บทความ

สร้าง PDF ภาษาไทยใน Laravel ยังไง ให้ฟอนต์ไม่เพี้ยนและมีเลขหน้า

ให้ Chrome เป็นคนพิมพ์ PDF ผ่าน spatie/laravel-pdf สระกับวรรณยุกต์ไทยจะวางถูกที่และตัดบรรทัดตรงรอยคำ ฝังฟอนต์ Sarabun ไว้ใน view แล้วใส่หัวกระดาษกับ "หน้า X จาก Y" ได้ทุกหน้า

เผยแพร่
เวลาอ่าน
7 นาที

ใบเสนอราคา ใบแจ้งหนี้ ใบเสร็จ รายงานประจำเดือน งานในระบบของ SME แทบทุกงานจบที่ปุ่ม "ดาวน์โหลด PDF" ที่ผ่านมาเรามักเริ่มจาก barryvdh/laravel-dompdf เพราะติดตั้งด้วย composer แล้วใช้ได้เลย หรือ mPDF ที่มีตัวเลือกเรื่องภาษาไทยให้ตั้งเยอะกว่า

ลองพิมพ์ภาษาไทยด้วย dompdf ครั้งแรกจะได้เครื่องหมาย ? เต็มหน้า เพราะฟอนต์มาตรฐานของ PDF ไม่มีตัวอักษรไทย ส่วน DejaVu ที่ dompdf ติดตั้งมาให้ ก็ไม่มีอักษรไทยเหมือนกัน ออกมาเป็นกล่องสี่เหลี่ยม พอใส่ฟอนต์ไทยเข้าไปเองแล้ว ตัวอักษรขึ้นครบก็จริง แต่ยังมีอีกสองเรื่องที่แก้ไม่ได้

dompdf วางวรรณยุกต์ทับสระ และตัดบรรทัดไม่เป็น

ฟอนต์ไทยรุ่นใหม่เก็บตำแหน่งของวรรณยุกต์ไว้ในตาราง GPOS ของฟอนต์ ถ้าคำนั้นมีสระบนอยู่แล้ว เช่น "ที่" หรือ "หนี้" วรรณยุกต์ต้องขยับขึ้นไปอยู่เหนือสระ โปรแกรมที่วาดตัวอักษรต้องอ่านตารางนี้ (เรียกว่า text shaping) แต่ dompdf ยังไม่อ่าน วรรณยุกต์จึงวางทับสระ มีคนแจ้งไว้ตรง ๆ กับฟอนต์ Noto Sans Thai และงาน shaping ของภาษาที่ซับซ้อนก็ยังเป็น issue ที่เปิดอยู่

อีกเรื่องคือภาษาไทยไม่เว้นวรรคระหว่างคำ dompdf ตัดบรรทัดได้แค่ตรงช่องว่าง ประโยคยาว ๆ จึงยาวทะลุกรอบออกไป ภาพนี้คือข้อความเดียวกัน ฟอนต์ Sarabun ตัวเดียวกัน พิมพ์ด้วย dompdf 3.1 เทียบกับ Chrome

dompdf กับ Chrome พิมพ์ข้อความไทยชุดเดียวกัน ข้างบน dompdf วางวรรณยุกต์ทับสระใน "ที่นี่" "ปั้น" "กี่ปี่" และปล่อยบรรทัดสุดท้ายยาวทะลุกรอบ ข้างล่าง Chrome วางถูกที่และตัดบรรทัดตรงรอยคำ

mPDF ทำได้ดีกว่าในเรื่องนี้ ถ้าเปิด OTL mPDF จะอ่านตาราง GPOS และตัดบรรทัดภาษาไทยด้วยพจนานุกรมได้ แต่ CSS ที่ mPDF รองรับยังจำกัด ไม่มี flexbox และ position ใช้ได้กับ element ชั้นบนสุดใต้ <body> เท่านั้น เอกสารที่จัดหน้าซับซ้อนจึงต้องวางด้วยตารางเป็นหลัก

ให้ Chrome เป็นคนพิมพ์ PDF

Chrome วาดตัวอักษรด้วย HarfBuzz ซึ่งอ่านตาราง GPOS ของฟอนต์ และหาจุดตัดบรรทัดด้วย ICU ที่มีพจนานุกรมคำไทยไว้หารอยคำ หน้าเว็บภาษาไทยที่เราเปิดดูทุกวันออกมาถูกก็เพราะสองตัวนี้ ถ้าให้ Chrome พิมพ์ PDF จาก HTML เราจะได้ภาษาไทยแบบเดียวกับที่เห็นในเบราว์เซอร์ ได้ flexbox, grid และ CSS ที่คุ้นมือมาด้วย

ฝั่ง PHP มีแพ็กเกจของ Spatie สองตัวที่ทำงานนี้

  • spatie/browsershot สั่ง Chrome ผ่าน Puppeteer ให้แปลง HTML เป็น PDF หรือรูป
  • spatie/laravel-pdf ห่อ Browsershot ไว้อีกชั้นให้ใช้กับ Blade view, response และ disk ของ Laravel ได้สะดวก เวอร์ชัน 2 สลับตัวพิมพ์ (driver) ได้ ทั้ง Browsershot, Gotenberg, Cloudflare, Chrome PHP, WeasyPrint และ dompdf

โพสต์นี้ใช้ laravel-pdf 2.14 กับ Browsershot 5.4 ส่วนท้ายโพสต์มีวิธีใช้ Browsershot ตรง ๆ สำหรับโปรเจกต์ที่ไม่อยากเพิ่มอีกชั้น

ติดตั้ง laravel-pdf กับ Browsershot

laravel-pdf ต้องการ PHP 8.2 กับ Laravel 11 ขึ้นไป ส่วน Browsershot ต้องการ Node 22 ขึ้นไปและ Puppeteer 23 ขึ้นไป Browsershot ไม่ได้ติดมากับ laravel-pdf เวอร์ชัน 2 ต้อง require เพิ่มเอง

composer require spatie/laravel-pdf spatie/browsershot
npm install puppeteer
npx puppeteer browsers install chrome-headless-shell

บรรทัดสุดท้ายโหลด Chrome รุ่น headless ที่ Puppeteer ใช้พิมพ์ ถ้าข้ามไป ตอนสั่งพิมพ์ครั้งแรกจะเจอ error Could not find chrome-headless-shell

เก็บฟอนต์ไทยไว้ในโปรเจกต์

เลือกฟอนต์ไทยจาก Google Fonts ได้หลายตัว เช่น Sarabun, Noto Sans Thai หรือ IBM Plex Sans Thai ทั้งสามตัวใช้สัญญาอนุญาต SIL Open Font License ฝังลง PDF ที่ส่งให้ลูกค้าได้ ตัวอย่างนี้ใช้ Sarabun ดาวน์โหลดไฟล์ Sarabun-Regular.ttf กับ Sarabun-Bold.ttf มาไว้ที่ resources/fonts

แล้วทำ partial ที่ประกาศ @font-face จากไฟล์นั้น โดยอ่านไฟล์มาแปลงเป็น base64 ฝังไว้ใน CSS ตรง ๆ

<style>
    @font-face {
        font-family: 'Sarabun';
        font-weight: 400;
        src: url('data:font/ttf;base64,{{ base64_encode(file_get_contents(resource_path('fonts/Sarabun-Regular.ttf'))) }}') format('truetype');
    }

    @font-face {
        font-family: 'Sarabun';
        font-weight: 700;
        src: url('data:font/ttf;base64,{{ base64_encode(file_get_contents(resource_path('fonts/Sarabun-Bold.ttf'))) }}') format('truetype');
    }
</style>

ตัวเนื้อหาจะโหลดฟอนต์จาก Google Fonts ก็ได้ แต่ header กับ footer โหลดไม่ได้ (รายละเอียดอยู่ในหัวข้อถัดไป) ใช้ partial ตัวเดียวกันทั้งสามที่ไปเลยง่ายกว่า และไม่ต้องพึ่งอินเทอร์เน็ตตอนพิมพ์

เขียนใบแจ้งหนี้เป็น Blade view

view ของ PDF ก็คือหน้า HTML ธรรมดาหน้าหนึ่ง ใช้ flexbox จัดที่อยู่สองฝั่งได้ และ <thead> ของตารางจะขึ้นซ้ำให้เองทุกหน้าที่ตารางยาวต่อไป

วันที่ในเอกสารไทยมักต้องเป็น พ.ศ. ตัวอย่างนี้ใช้ ->thaidate() จากแพ็กเกจ thaidate ส่วนยอดรวมที่ต้องเขียนเป็นตัวหนังสือ "…บาทถ้วน" ใช้ฟังก์ชัน baht_text() จากโพสต์แสดงค่าเงินบาทเป็นภาษาไทย จะใช้ตัวอื่นที่มีอยู่ในโปรเจกต์แล้วก็ได้

<!DOCTYPE html>
<html lang="th">
<head>
    <meta charset="utf-8">
    @include('pdf.fonts')
    <style>
        body { font-family: 'Sarabun', sans-serif; font-size: 14px; line-height: 1.6; color: #222; }
        h1 { font-size: 22px; margin: 0 0 4px; }
        .parties { display: flex; justify-content: space-between; margin: 16px 0; }
        table { width: 100%; border-collapse: collapse; }
        th, td { padding: 6px 8px; border-bottom: 1px solid #ddd; }
        th { background: #f3f3f3; text-align: left; }
        .num { text-align: right; white-space: nowrap; }
        tr { break-inside: avoid; }
        .total td { font-weight: 700; border-bottom: 2px solid #222; }
    </style>
</head>
<body>
    <h1>ใบแจ้งหนี้</h1>
    <div>เลขที่ {{ $invoice->number }} · วันที่ {{ $invoice->issued_at->thaidate('j F Y') }}</div>

    <div class="parties">
        <div>
            <strong>บริษัท ตัวอย่าง จำกัด</strong><br>
            99/9 ถนนตัวอย่าง แขวงตัวอย่าง เขตตัวอย่าง กรุงเทพฯ 10000<br>
            [email protected]
        </div>
        <div>
            <strong>ลูกค้า</strong><br>
            {{ $invoice->customer_name }}<br>
            {{ $invoice->customer_email }}
        </div>
    </div>

    <table>
        <thead>
            <tr>
                <th>#</th>
                <th>รายการ</th>
                <th class="num">จำนวน</th>
                <th class="num">ราคาต่อหน่วย</th>
                <th class="num">รวม</th>
            </tr>
        </thead>
        <tbody>
            @foreach ($invoice->items as $item)
                <tr>
                    <td>{{ $loop->iteration }}</td>
                    <td>{{ $item->name }}</td>
                    <td class="num">{{ $item->quantity }}</td>
                    <td class="num">{{ number_format($item->price, 2) }}</td>
                    <td class="num">{{ number_format($item->quantity * $item->price, 2) }}</td>
                </tr>
            @endforeach
            <tr class="total">
                <td colspan="4">รวมทั้งสิ้น ({{ baht_text($invoice->total) }})</td>
                <td class="num">{{ number_format($invoice->total, 2) }}</td>
            </tr>
        </tbody>
    </table>

    <p>หมายเหตุ: กรุณาชำระเงินภายในวันที่ {{ $invoice->due_at->thaidate('j F Y') }} โอนเข้าบัญชีบริษัทตามที่แจ้งไว้ในใบเสนอราคา หากมีข้อสงสัยเกี่ยวกับรายการในใบแจ้งหนี้ฉบับนี้ ติดต่อฝ่ายบัญชีได้ทางอีเมลด้านบนในวันและเวลาทำการ</p>
</body>
</html>

tr { break-inside: avoid; } บอก Chrome ไม่ให้ผ่าแถวไหนครึ่งหนึ่งตรงรอยต่อหน้า

ใส่หัวกระดาษและ "หน้า X จาก Y"

Chrome มีช่องให้ใส่ HTML ของหัวกระดาษ (header) กับท้ายกระดาษ (footer) แยกจากเนื้อหา แล้ววางซ้ำให้ทุกหน้า ใน HTML สองชิ้นนี้ ถ้า tag ไหนมี class ตามรายการนี้ Chrome จะเติมค่าลงไปให้ตอนพิมพ์

  • date วันที่พิมพ์
  • title ชื่อเอกสารจาก <title>
  • url ที่อยู่ของหน้า
  • pageNumber เลขหน้าปัจจุบัน
  • totalPages จำนวนหน้าทั้งหมด

laravel-pdf มี Blade directive @pageNumber กับ @totalPages ที่เขียน <span class="pageNumber"></span> และ <span class="totalPages"></span> ให้ เราจึงเขียน footer สั้น ๆ ได้แบบนี้

@include('pdf.fonts')

<div style="font-family: 'Sarabun'; font-size: 10px; margin: 0 15mm; flex: 1; display: flex; justify-content: space-between; border-bottom: 1px solid #ccc; padding-bottom: 4px;">
    <strong>บริษัท ตัวอย่าง จำกัด</strong>
    <span>ใบแจ้งหนี้เลขที่ {{ $invoice->number }}</span>
</div>

ทุกอย่างใน header กับ footer เขียนเป็น inline style และ include ฟอนต์ซ้ำทุกไฟล์ เหตุผลอยู่ในหัวข้อถัดไป

สั่งพิมพ์จาก controller

Pdf::view() รับ view กับข้อมูลแบบเดียวกับ view() ปกติ headerView() กับ footerView() รับ view ของหัวและท้ายกระดาษ format() เลือกขนาดกระดาษ และ margins() รับระยะขอบ บน ขวา ล่าง ซ้าย หน่วยเป็นมิลลิเมตร

<?php

namespace App\Http\Controllers;

use App\Models\Invoice;
use Spatie\LaravelPdf\Enums\Format;
use Spatie\LaravelPdf\Facades\Pdf;
use Spatie\LaravelPdf\PdfBuilder;

class InvoicePdfController extends Controller
{
    public function __invoke(Invoice $invoice): PdfBuilder
    {
        return Pdf::view('pdf.invoice', ['invoice' => $invoice])
            ->headerView('pdf.header', ['invoice' => $invoice])
            ->footerView('pdf.footer')
            ->format(Format::A4)
            ->margins(25, 15, 20, 15)
            ->name("{$invoice->number}.pdf")
            ->download();
    }
}

return ตัว builder ออกไปจาก controller ได้เลย laravel-pdf สร้าง response ให้ ถ้าไม่เรียก download() เบราว์เซอร์จะเปิด PDF ในแท็บแทนการดาวน์โหลด ส่วน name() เป็นชื่อไฟล์ตอนผู้ใช้กดบันทึก

ใบแจ้งหนี้ 2 หน้า ภาษาไทยด้วยฟอนต์ Sarabun มีหัวกระดาษและเลขหน้า ผลจาก view ข้างบน หัวกระดาษกับ "หน้า 1 จาก 2" ขึ้นทุกหน้า หัวตารางขึ้นซ้ำบนหน้า 2 วันที่เป็น พ.ศ. และยอดรวมเขียนเป็นตัวหนังสือ

ถ้าอยากเก็บไฟล์ไว้แทนการส่งให้ผู้ใช้ เรียก save() ใส่ path ในเครื่อง หรือเลือก disk ก่อนด้วย disk() ใบแจ้งหนี้ที่ต้องแนบไปกับอีเมลก็แนบได้จาก builder ตัวเดียวกัน ก่อนส่งจริงลองดักอีเมลไว้ดูในเครื่องก่อนก็ได้

Pdf::view('pdf.invoice', ['invoice' => $invoice])
    ->format(Format::A4)
    ->disk('local')
    ->save("invoices/{$invoice->number}.pdf");

Browsershot เปิด Chrome ใหม่ทุกครั้งที่พิมพ์ ใบแจ้งหนี้ 2 หน้านี้บนเครื่องผมใช้ราวครึ่งวินาที บนเซิร์ฟเวอร์ที่ CPU น้อยกว่าอาจนานกว่านั้น งานที่สร้างทีละหลายไฟล์ เช่นออกใบแจ้งหนี้ทั้งเดือน ส่งไปทำใน queue ด้วย saveQueued() จะไม่ทำให้หน้าเว็บค้าง

Chrome วาด header กับ footer ในหน้าเอกสารแยกอีกหน้าหนึ่ง แล้วเอา HTML ของเราไปใส่ในกล่องที่สูงเท่ากับ margin บนและล่าง ผลคือ header ไม่เห็น CSS หรือฟอนต์ที่เนื้อหาประกาศไว้เลย ทั้ง เอกสารของ laravel-pdf และของ Gotenberg เตือนเรื่องนี้ไว้ ลองพลาดทีละข้อจะได้แบบนี้

ท้ายกระดาษที่พิมพ์ผิดสามแบบ เทียบกับแบบที่ถูก ไม่ตั้ง font-size เลขหน้าเหลือเป็นจุดเล็ก ๆ margin ล่างไม่พอ footer ทับแถวสุดท้าย ไม่ใส่ฟอนต์ Chrome ใช้ฟอนต์สำรองของเครื่อง

สรุปเป็นข้อที่ต้องทำในทุก header และ footer

  • ใส่ฟอนต์ใน header และ footer เอง CSS ใน <head> ของเนื้อหาไม่ข้ามมาถึง ถ้าไม่ประกาศ Chrome จะหยิบฟอนต์ไทยตัวไหนก็ได้ที่มีในเครื่อง บนเซิร์ฟเวอร์ที่ไม่มีฟอนต์ไทยเลยจะได้กล่องสี่เหลี่ยม
  • ห้ามอ้างไฟล์จากข้างนอก รูปจาก URL ไม่ขึ้นใน header (มีคนรายงานไว้ใน issue ของ Puppeteer) ตอนเขียนโพสต์นี้ผมลองใส่ลิงก์ Google Fonts หรือ @font-face ที่ชี้ไป URL ไว้ใน footer กับ Chrome 155 ผลคือพิมพ์ไม่ออกเลย ได้ error Printing failed ฟอนต์กับโลโก้จึงต้องฝังเป็น base64 หรือใส่ <svg> ลงไปตรง ๆ
  • ตั้ง font-size เอง ถ้าไม่ตั้ง ตัวอักษรใน header จะเล็กจนอ่านไม่ออก
  • เว้น margin บนล่างให้พอ กล่อง header สูงเท่ากับ margin บน และ footer สูงเท่ากับ margin ล่าง ถ้าไม่ตั้ง margin เลย footer จะทับเนื้อหา
  • ใส่ flex: 1 ให้กล่องนอกสุด Chrome วาง HTML ของเราไว้ในกล่องที่เป็น display: flex ถ้าไม่ใส่ กล่องจะหดเหลือแค่กว้างเท่าข้อความ ชื่อบริษัทกับเลขที่ใบแจ้งหนี้จะติดกันอยู่ทางซ้าย ส่วน margin: 0 15mm ทำให้ขอบซ้ายขวาตรงกับเนื้อหา

ใช้ Browsershot ตรง ๆ ก็ได้

ถ้าโปรเจกต์ไม่ใช่ Laravel หรือมี Browsershot อยู่แล้ว ใช้ showBrowserHeaderAndFooter() เปิดช่อง header กับ footer แล้วส่ง HTML เข้าไปด้วย headerHtml() กับ footerHtml() ตรงนี้ไม่มี @pageNumber ให้ใช้ เขียน <span class="pageNumber"> เอง

use Spatie\Browsershot\Browsershot;

$fonts = view('pdf.fonts')->render();

Browsershot::html(view('pdf.invoice', ['invoice' => $invoice])->render())
    ->format('A4')
    ->margins(25, 15, 20, 15)
    ->showBackground()
    ->showBrowserHeaderAndFooter()
    ->headerHtml(view('pdf.header', ['invoice' => $invoice])->render())
    ->footerHtml($fonts.'<div style="font-family: Sarabun; font-size: 10px; margin: 0 15mm; flex: 1; text-align: right;">หน้า <span class="pageNumber"></span> จาก <span class="totalPages"></span></div>')
    ->save(storage_path("app/invoices/{$invoice->number}.pdf"));

ถ้าเรียก showBrowserHeaderAndFooter() แต่ไม่ส่ง headerHtml() Chrome จะใส่หัวกระดาษของตัวเองเป็นวันที่พิมพ์มาให้ อยากได้แค่ footer ให้เรียก hideHeader() ด้วย ส่วน showBackground() ทำให้สีพื้นของหัวตารางพิมพ์ออกมา laravel-pdf เรียกสองอย่างนี้ให้อยู่แล้ว

เซิร์ฟเวอร์ต้องมี Node กับ Chrome

ข้อเสียของทางนี้คือเซิร์ฟเวอร์ต้องรัน Node และ Chrome ได้ ทางที่เลือกได้ขึ้นกับว่าเราใช้ hosting แบบไหน

  • VPS เช่น Ubuntu ติดตั้ง Node, Puppeteer, Chrome และไลบรารีของระบบที่ Chrome ต้องใช้ตามคำสั่งในเอกสาร Browsershot ได้เลย ถ้ารันเป็น root หรือใน Docker แล้ว Chrome เปิดไม่ขึ้น ลองตั้ง LARAVEL_PDF_NO_SANDBOX=true
  • Shared hosting ที่ใช้ cPanel หรือ DirectAdmin ปกติเราไม่มีสิทธิ์ติดตั้ง Chrome หรือรันโปรแกรมแบบนี้ ลองถามผู้ให้บริการก่อน ถ้าไม่ได้ ให้ Chrome ไปอยู่ที่อื่นแทน ด้วยสองทางข้างล่าง
  • Gotenberg เป็นเซิร์ฟเวอร์แปลง PDF ที่มี Chromium อยู่ข้างใน รันด้วย Docker บนเครื่องไหนก็ได้ที่แอปเรียกถึง แล้วตั้ง driver เป็น gotenberg ถ้าใช้ทางนี้ header กับ footer ต้องเป็น HTML เต็มหน้า มี <html> <head> <body> และ Gotenberg บอกว่า header กับ footer ใช้ได้เฉพาะฟอนต์ที่ติดตั้งไว้ใน image ลองพิมพ์ภาษาไทยดูก่อนใช้จริง
  • Cloudflare Browser Run ให้ Chrome ของ Cloudflare พิมพ์ผ่าน API ไม่ต้องมีอะไรบนเซิร์ฟเวอร์เลย ตั้ง driver เป็น cloudflare พร้อม API token กับ account ID แผน Workers Free ใช้ browser ได้วันละ 10 นาที พอสำหรับเอกสารวันละไม่กี่ฉบับ
LARAVEL_PDF_DRIVER=gotenberg
GOTENBERG_URL=http://localhost:3000

แล้วเปิด Gotenberg ไว้บนเครื่องที่มี Docker

docker run --rm -p 3000:3000 gotenberg/gotenberg:8

เปลี่ยน driver แล้ว view ของเนื้อหากับโค้ดใน controller ยังใช้ชุดเดิมได้ (header กับ footer ของ Gotenberg ต้องปรับเป็น HTML เต็มหน้าตามที่บอกไว้) ถ้าวันหนึ่งย้ายจาก shared hosting ไป VPS ก็แค่เปลี่ยน .env กลับมาเป็น browsershot

อ่านต่อ

เมธอดทั้งหมด เช่นแนวนอนด้วย landscape() ขึ้นหน้าใหม่ด้วย @pageBreak หรือใส่รหัสผ่านให้ไฟล์ อยู่ในเอกสาร laravel-pdf ส่วนตัวเลือกของ Browsershot ดูได้ที่เอกสารสร้าง PDF ของ Browsershot

อ่านต่อ

ล่าสุด

ดูทั้งหมด →