EST. 2026 — Notes from real server work SYSADMIN · SECURITY · AI · DIRECTADMIN Issue 01
THE SYSADMIN ALMANAC
Home / Linux / Generating Thai PDF Quotes with Chromium and the Pitfalls I Hit
Linux

Generating Thai PDF Quotes with Chromium and the Pitfalls I Hit

by Ton-Or ·31 Aug 2026·9 min read
Generating Thai PDF Quotes with Chromium and the Pitfalls I Hit

Generating PDFs from HTML sounds like half a day's work, but in practice it falls over on all the surrounding details — a browser that cannot be found, Thai fonts that turn into boxes, filenames with forbidden characters, and document numbers with slashes inside them.

I was building a document generation system for a project that needed to issue quotations and delivery notes for multiple companies in a group, each with its own letterhead, colors, fonts, and document numbering format, all using different templates. The requirement was to click one button and get an A4 PDF that looked exactly the same printed as it did on screen. I chose the most direct approach: have the same Chromium that renders the web page print it to PDF. It sounds simple, but I lost several days to the surrounding pitfalls, so I collected them here.

All company names, customer names, document numbering formats, and filenames in this article are examples I made up. The system structure and code are real.

Why I chose browser printing instead of drawing PDFs myself

There are plenty of libraries that generate PDFs directly, and they are much faster. But when the requirements include Thai documents with expanding and shrinking tables, four different letterheads, Thai fonts that correctly position vowels above and below consonants, and layouts that customers can adjust themselves, it becomes a job of writing an entire layout engine. With HTML and CSS, I already get all of that for free, and most importantly, the web preview and the generated file use the same code. They cannot diverge because they use the same blade template.

One document's pathbladeTemplate per company->Temporary HTMLFonts embedded->Chromiumfull build->page.pdfA4 + page numbers->Output fileSanitized filename

First pitfall: Executable doesn't exist, even though Chromium is installed

I hit this on the first run.

browserType.launch: Executable doesn't exist at
C:\Users\<user>\AppData\Local\ms-playwright\chromium_headless_shell-1217\chrome-win\chrome-headless-shell.exe
Looks like Playwright was just installed or updated.
Please run the following command to download new browsers:
    npx playwright install

The confusing part was that Chromium really was installed on the machine and worked normally for other tasks. I checked the folders and found chromium-1187, but Playwright was looking for chromium_headless_shell-1217, which was missing.

The reason is that newer Playwright versions split the browser into two builds: the full browser and a headless shell with the UI removed to make it lighter and faster. When I call launch() with the default headless setting, it always looks for the headless shell first. If the browser was installed with an older Playwright version, or only the full build was installed, that shell will not be there, and it will not fall back to the full browser either.

chrome-headless-shell

What Playwright looks for by default

  • Smaller and faster to start
  • Separate download, different revision from the full build
  • Often missing on machines upgraded across versions
  • If missing, launch fails immediately with no fallback
chromium full build

What is actually installed on the machine

  • Located at chromium-<rev>/chrome-win/chrome.exe
  • Produces exactly the same rendering
  • Can be selected directly with executablePath
  • Slightly heavier, but that does not affect document generation

My fix was to stop letting it choose automatically. I wrote a function to find an installed full build and point executablePath directly at it.

// tools/render.cjs — หา chromium ตัวเต็มที่มีจริง แล้วชี้ให้ launch()
const fs   = require('fs');
const path = require('path');
const { chromium } = require(process.env.PW_PKG || 'playwright');

// เครื่องนี้เคยลงข้ามรุ่นมาหลายรอบ เลยไล่ทีละ revision ที่เป็นไปได้
const REVS = ['1223', '1217', '1208', '1187'];

function fullChromeExe() {
  const root = process.env.PLAYWRIGHT_BROWSERS_PATH
            || path.join(process.env.LOCALAPPDATA || '', 'ms-playwright');
  for (const rev of REVS) {
    for (const rel of [`chromium-${rev}/chrome-win/chrome.exe`,      // windows
                       `chromium-${rev}/chrome-linux/chrome`]) {      // linux
      const p = path.join(root, rel);
      if (fs.existsSync(p)) return p;
    }
  }
  return null;   // ไม่เจอก็ปล่อยให้ playwright จัดการเอง แล้วค่อยอ่าน error
}

async function launch() {
  const exe = fullChromeExe();
  if (!exe) console.warn('[WARN] ไม่พบ chromium ตัวเต็ม จะปล่อยให้ playwright เลือกเอง');
  return chromium.launch({
    headless: true,
    ...(exe ? { executablePath: exe } : {}),
    args: ['--no-sandbox', '--font-render-hinting=none'],  // hinting=none ให้ตัวอักษรคมเท่ากันทุกเครื่อง
  });
}
Do not fix this by running npx playwright install in production

The error message recommends reinstalling the browser, which is fine on a development machine. But on a server with controlled versions, reinstalling means downloading another browser build weighing several hundred megabytes, and it may pull a new revision that renders differently from the one tested. I chose to point to the existing browser instead, so I could ensure every document really came from the same browser build.

On the Python side, it is the same problem with the same fix.

# tools/render.py — เวอร์ชัน Python ปัญหาเดียวกัน วิธีแก้เดียวกัน
import os
from pathlib import Path
from playwright.sync_api import sync_playwright

REVS = ["1223", "1217", "1208", "1187"]

def full_chrome() -> str | None:
    root = Path(os.environ.get("PLAYWRIGHT_BROWSERS_PATH")
                or Path(os.environ["LOCALAPPDATA"]) / "ms-playwright")
    for rev in REVS:
        for rel in (f"chromium-{rev}/chrome-win/chrome.exe",
                    f"chromium-{rev}/chrome-linux/chrome"):
            p = root / rel
            if p.is_file():
                return str(p)
    return None

def render(html_path: str, out_pdf: str) -> None:
    with sync_playwright() as pw:
        exe = full_chrome()
        browser = pw.chromium.launch(executable_path=exe) if exe else pw.chromium.launch()
        page = browser.new_page()
        page.goto(Path(html_path).as_uri(), wait_until="networkidle")
        page.emulate_media(media="print")          # ให้ @media print มีผลจริง
        page.pdf(path=out_pdf, format="A4", print_background=True,
                 margin={"top": "12mm", "bottom": "14mm", "left": "12mm", "right": "12mm"})
        browser.close()

Setting up A4 and page breaks without splitting table rows

By default, page.pdf() does not print backgrounds. I have to enable printBackground myself, or all the colored table headers disappear, and I also have to call emulateMedia('print') first, or the CSS in @media print has no effect.

.sheet height = hA4 height: 1122 px (297mm at 96dpi)h ≤ 1122scale = 1Fits on one pageh greater than 1122scale = 1122 / hShrink by up to 40%Minimum 0.600.60 still does not fitscale = 1Page breaks + page numbersWhen h exceeds 1122 / 0.60Always await document.fonts.ready before measuring h
The height of .sheet determines whether to scale it down first. If it exceeds A4, shrink it by no more than 40%, and if it still does not fit at the minimum scale, allow actual page breaks with page numbers.
// เรนเดอร์หนึ่งใบ: ลองย่อให้จบหน้าเดียวก่อน ถ้าเกินจริงค่อยปล่อยให้แบ่งหน้า + ใส่เลขหน้า
const page = await (await launch()).newPage();
await page.goto('file://' + htmlPath, { waitUntil: 'networkidle' });
await page.emulateMedia({ media: 'print' });
await page.evaluate(() => document.fonts.ready);        // รอฟอนต์โหลดจบก่อนวัดความสูง

const A4H = 1122;                                       // 297mm ที่ 96dpi
const h   = await page.$eval('.sheet', el => el.scrollHeight);
const fit = h <= A4H ? 1 : Math.max(0.60, A4H / h);     // ย่อได้ไม่เกิน 40%

const opts = {
  path: outPdf, format: 'A4', printBackground: true, scale: Number(fit.toFixed(3)),
  margin: { top: '12mm', bottom: '14mm', left: '12mm', right: '12mm' },
};

if (fit === 0.60 && h > A4H / 0.60) {                   // ย่อสุดแล้วยังไม่พอ = ต้องแบ่งหน้าจริง
  opts.scale = 1;
  opts.displayHeaderFooter = true;
  opts.headerTemplate = '<div></div>';                  // ต้องใส่ ไม่งั้นมันโชว์หัวเริ่มต้นของ chrome
  opts.footerTemplate =
    '<div style="width:100%;font-size:9px;padding:0 12mm;text-align:right;color:#555">'
    + 'หน้า <span class="pageNumber"></span> / <span class="totalPages"></span></div>';
}
await page.pdf(opts);

The CSS also has to tell the browser where breaks are forbidden, or it will split item rows halfway through a line.

@page { size: A4; margin: 0; }

.sheet        { width: 210mm; }
thead         { display: table-header-group; }   /* หัวตารางซ้ำทุกหน้าอัตโนมัติ */
tfoot         { display: table-footer-group; }
tr, .no-break { break-inside: avoid; }           /* ห้ามตัดกลางแถว */
.page-break   { break-before: page; }
.sign-block   { break-inside: avoid; }           /* บล็อกลายเซ็นห้ามหลุดไปคนละหน้ากับยอดรวม */

I want to stress the Thai font issue in particular. During local development, everything looked good because the fonts could be fetched from the internet. On a server with no internet access, or a connection slower than the timeout, every Thai character in the document turned into an empty box. The fix is to include the fonts with the page itself, reference local files through @font-face, and always wait for document.fonts.ready before printing. Do not rely on the internet when generating documents.

Sanitize Thai filenames before writing to disk

The system builds filenames from the document number plus the customer name, which is Thai text entered by users. That means it could contain anything. Windows forbids \ / : * ? " < > | in filenames, along with control characters with codes 0 through 31, and there are a few more rules that people often forget.

<?php
// app/Services/FileName.php — ทำให้ชื่อไฟล์ปลอดภัยทั้งบน Windows และ Linux
final class FileName
{
    // ชื่อสงวนของ Windows ใช้เป็นชื่อไฟล์ไม่ได้แม้จะมีนามสกุลต่อท้าย
    private const RESERVED = ['CON','PRN','AUX','NUL',
        'COM1','COM2','COM3','COM4','COM5','COM6','COM7','COM8','COM9',
        'LPT1','LPT2','LPT3','LPT4','LPT5','LPT6','LPT7','LPT8','LPT9'];

    public static function safe(string $name, string $ext = 'pdf'): string
    {
        // 1) อักขระต้องห้ามของ Windows + อักขระควบคุม -> ขีดล่าง
        $name = preg_replace('/[\\\\\/:\*\?"<>\|\x00-\x1F]/u', '_', $name);

        // 2) ยุบช่องว่างซ้ำ แล้วตัดหัวท้าย (ภาษาไทยเก็บไว้ตามเดิม ไม่ต้องถอดเป็นอังกฤษ)
        $name = trim(preg_replace('/\s+/u', ' ', $name));

        // 3) Windows ตัด จุด กับ ช่องว่าง ท้ายชื่อทิ้งเงียบๆ -> ชื่อเพี้ยนจากที่ตั้งใจ
        $name = rtrim($name, ". \t");

        // 4) ชื่อสงวน -> เติมขีดล่างกัน
        if (in_array(mb_strtoupper($name), self::RESERVED, true)) {
            $name = '_' . $name;
        }

        // 5) จำกัดความยาว "เป็นไบต์" เพราะไทยตัวละ 3 ไบต์ ext4 จำกัดที่ 255 ไบต์
        while (strlen($name) > 200) {
            $name = mb_substr($name, 0, mb_strlen($name) - 1);   // ตัดทีละอักขระ ไม่ตัดกลาง UTF-8
        }

        return ($name === '' ? 'document' : $name) . '.' . $ext;
    }

    /** ส่งดาวน์โหลดให้ชื่อไทยไม่กลายเป็นขยะบนเบราว์เซอร์เก่า */
    public static function disposition(string $filename): string
    {
        $ascii = preg_replace('/[^\x20-\x7E]/', '_', $filename);   // ชื่อสำรองแบบ ASCII
        return sprintf('attachment; filename="%s"; filename*=UTF-8\'\'%s',
            str_replace('"', '', $ascii), rawurlencode($filename));
    }
}

Rule 5 is the one that caught me. At first, I used mb_substr to truncate names to 200 characters, but long Thai filenames still failed to write on Linux because ext4 counts bytes, not characters. One Thai character takes 3 bytes, so a 200-character name becomes 600 bytes, well beyond the 255 limit. I had to keep trimming until the byte length fit, and ordinary substr must not be used because it can cut through a UTF-8 character and leave a garbled name.

Document numbering: tokens and a counter table

Each company in the group needs its own document numbering format, and most importantly, the running numbers must be separate, not shared across companies. I stored the format as a token string in the company record and used one central counter table.

-- ตัวนับกลาง หนึ่งแถว = หนึ่งบริษัท x หนึ่งชนิดเอกสาร x หนึ่งช่วงรีเซ็ต
CREATE TABLE series_counters (
  skey     VARCHAR(64) NOT NULL,   -- เช่น S1|QT|202608
  last_no  INT UNSIGNED NOT NULL DEFAULT 0,
  UNIQUE KEY uk_skey (skey)
) ENGINE=InnoDB;
<?php
// app/Services/DocNumber.php — สร้างเลขเอกสารตามรูปแบบของแต่ละบริษัท
final class DocNumber
{
    /** {T}=ชนิด · {YYYY}/{YY}=ปี ค.ศ. · {BE}/{BY}=ปี พ.ศ. · {MM}=เดือน · {R}/{R3}/{R4}=เลขรัน */
    public static function nextFor(Company $self, string $type, ?string $date = null): string
    {
        $ts  = strtotime($date ?? 'now');
        $fmt = $self->doc_format ?: '{T}{YYYY}{MM}{R4}';

        // ช่วงรีเซ็ต: มี {MM} = รายเดือน · มีปีอย่างเดียว = รายปี · ไม่มีเลย = รันต่อเนื่องไม่รีเซ็ต
        $period = str_contains($fmt, '{MM}')
            ? date('Ym', $ts)
            : (preg_match('/\{(YYYY|YY|BE|BY)\}/', $fmt) ? date('Y', $ts) : 'ALL');

        $skey = sprintf('S%d|%s|%s', $self->id, $type, $period);

        // นับแบบ atomic ใน transaction เดียว กันสองคนกดพร้อมกันแล้วได้เลขซ้ำ
        $no = DB::transaction(function () use ($skey) {
            DB::statement('INSERT INTO series_counters (skey, last_no) VALUES (?, 1)
                           ON DUPLICATE KEY UPDATE last_no = last_no + 1', [$skey]);
            return (int) DB::selectOne('SELECT last_no FROM series_counters WHERE skey = ?', [$skey])->last_no;
        });

        return strtr($fmt, [
            '{T}'    => $type,
            '{YYYY}' => date('Y', $ts),          '{YY}' => date('y', $ts),
            '{BE}'   => (int) date('Y', $ts) + 543,
            '{BY}'   => substr((string) ((int) date('Y', $ts) + 543), 2),
            '{MM}'   => date('m', $ts),
            '{R}'    => (string) $no,
            '{R3}'   => str_pad((string) $no, 3, '0', STR_PAD_LEFT),
            '{R4}'   => str_pad((string) $no, 4, '0', STR_PAD_LEFT),
        ]);
    }
}

The example system uses four formats in practice. The first company can leave the setting blank and get the default {T}{YYYY}{MM}{R4}, producing QT2026080004. The second uses {T}-AA{BY}{MM}-{R3}, producing QT-AA6908-001. The third uses {T}.BB.{BY}{MM}.{R3}, producing QT.BB.6908.001. The fourth uses {T}-{BE}{MM}-CC{R3}, producing QT-256908-CC001. All four have completely separate counters because skey starts with the company ID.

4 formatsDocument numbering formats in the group
1 tableCentral counters shared by all companies
9 charactersForbidden characters in filenames
255 bytesFilename limit on ext4
A4Paper size enforced in page.pdf
0.60Minimum scale before allowing page breaks

A real case: a single slash in a document number broke paths throughout the system

When the system went live, one company requested the format from its existing paper documents: {T}/{BE}/{MM}/{R3}, which produced numbers like QT/2569/08/001. The system generated the number normally and saved it to the database normally, but as soon as I clicked to generate the PDF, the file simply disappeared. At first, there was not even an error to inspect.

The cause was straightforward. The filename was QT/2569/08/001.pdf, and on Linux, a slash is not a filename character: it is a directory separator. The system therefore interpreted it as an instruction to write a file named 001.pdf inside the nonexistent folder QT/2569/08/. Sometimes it left junk directories behind; sometimes the write failed silently. On top of that, the URL linking to the file also broke because the slashes caused the route to interpret the path incorrectly.

Sanitizing is a safety net, not a fix for the root cause

The sanitizer above already catches slashes, so the filename becomes QT_2569_08_001.pdf and can be written successfully. But users would wonder why the number in the document and the filename did not match, so I also blocked it at the source: forbid filename-invalid characters in the document numbering format itself. Validate them when saving the settings. Anyone who wants separators can use hyphens or dots instead of slashes.

For companies that had already issued numbers in the invalid format, I used this fixed sequence of steps.

  1. First, set a new format with no forbidden characters for that company.
  2. Delete that company's counters using the condition skey LIKE 'S{id}|%'. Do not delete anyone else's.
  3. Iterate through that company's documents by id, oldest to newest, and call nextFor again for each one.
  4. If a company previously issued numbers through the old system, seed last_no to continue from the previous value and avoid duplicate numbers.
  5. Delete old PDF files whose names no longer match the new numbers, then render the whole set again.

The last issue that wasted half a day

When testing, I like sending POST requests with curl because it is quick. It turned out that Thai data sent through curl in Git Bash on Windows became garbled every time. I spent a long time hunting for a bug in the encoding code before discovering that the code was fine. The problem was the shell mishandling UTF-8 when passing arguments.

The short version is: do not use curl in Git Bash to test Thai data. Test through a real browser, or create test data through tinker or a seeder instead, to avoid spending time chasing a bug that does not exist.

My takeaway from this project is that letting the browser print PDFs is the most worthwhile approach for Thai documents with complex layouts, but the cost is not in rendering. It is in pinning the browser version, embedding fonts, and cleaning user input before turning it into filenames. Anyone using this structure can adjust the margins and minimum scale to suit their own forms. If I have left anything out or made a typo, I will come back and fix it later.

Want us to set this up?

Get help with server setup, configuration, migration, or troubleshooting. Save time for the work that matters.

Get a quote

Hit something like this? Stuck somewhere?

Come trade notes on the LinuxThai board

→ Continue on the board

Comments 0

No comments yet — be the first