W-Enterprises/Insights/Arabic PDFs in headless Chrome
Field notes / 6 August 2026
Arabic and English PDFs in headless Chrome: the seven things that break.
Rendering a bilingual document to PDF sounds like a solved problem until the first Arabic name comes out reversed, the first PDF renders in Times New Roman, and the first invoice total appears in the wrong numerals. None of these are hard once you have met them. All of them cost a day the first time.
Written from production experience building a bilingual document platform serving the Saudi market. Nothing here is client code. It is the general technique.
Why use a browser at all
There are dedicated PDF libraries. They are excellent at drawing boxes and terrible at Arabic. Arabic script requires contextual shaping, where a letter takes a different glyph depending on whether it starts, sits inside, or ends a word, plus ligatures, plus bidirectional reordering when Latin script or numbers appear in the same line. Implementing that correctly is a multi-year project, and browsers have already done it.
So the pragmatic answer for bilingual documents is to lay out in HTML and CSS, let the browser's text engine do the shaping and the bidi algorithm, and print to PDF. In Node that means Puppeteer driving headless Chromium.
The cost is that you inherit a browser's asynchrony. Which is where the first and worst problem comes from.
1. The font loading race
This is the single most common failure and the most confusing, because it is intermittent. The page loads, you call page.pdf(), and roughly one document in five comes out in a fallback font, with the Arabic either in a system face or as boxes.
The cause is that page.goto() resolving on networkidle0 does not mean fonts have been parsed and applied. Web fonts load asynchronously and the print snapshot can happen first.
The fix is to wait on the font loading API explicitly, inside the page:
await page.goto(url, { waitUntil: 'networkidle0' });
// Do not trust networkidle for fonts.
await page.evaluate(async () => {
await document.fonts.ready;
});
const pdf = await page.pdf({ format: 'A4', printBackground: true });
Two hardening steps beyond that. First, self-host the font files rather than pulling them from a CDN. A serverless container with a cold DNS cache and a 200 millisecond font fetch will lose the race more often than a local file will. Second, assert rather than hope. In a document pipeline that generates legal paperwork, an assertion is cheap:
const ok = await page.evaluate((family) =>
document.fonts.check(`12px "${family}"`), 'IBM Plex Sans Arabic');
if (!ok) throw new Error('Arabic font not loaded, refusing to render');
Failing loudly and retrying costs a second. Shipping an employment contract in the wrong typeface costs a client relationship.
2. Choosing a font that actually covers Arabic
Many popular families ship an Arabic subset that covers the basic block and nothing else. Documents then look correct until someone's name uses a character outside it, and a single glyph silently falls back to a different face with different metrics, which shifts the line.
What to check before committing to a family:
- Coverage of Arabic Presentation Forms, not only the base block.
- Tashkeel, the diacritic marks, positioning correctly rather than colliding with the letter.
- Both Arabic-Indic and Latin digit sets present, so a mixed document does not need two families.
- A licence that permits embedding in a generated PDF. This is a real constraint and it is routinely ignored.
Set the stack so Latin and Arabic each get a face chosen deliberately, rather than leaving it to fallback order:
:root {
--latin: 'Inter', system-ui, sans-serif;
--arabic: 'IBM Plex Sans Arabic', 'Noto Naskh Arabic', serif;
}
body { font-family: var(--latin); }
:lang(ar), [dir="rtl"] { font-family: var(--arabic); }
3. Bidirectional text, and why the name came out backwards
The Unicode bidirectional algorithm is good. It gets one class of case wrong from the browser's point of view, because it cannot know your intent: a run of neutral characters between text of opposing direction. Brackets, slashes, hyphens and spaces are all neutral, so a string like an Arabic company name followed by (Ltd), or a reference number with slashes, can reorder in ways that look broken.
Three rules that resolve almost all of it:
- Mark direction on the element, not the page. Every field that holds user data gets an explicit
dir, based on the language of that field, not the language of the document. - Use
dir="auto"for content you do not control. It infers direction from the first strong character, which is right far more often than a hardcoded guess. - Isolate mixed runs. The
<bdi>element exists exactly for this: it stops a name from reordering the punctuation around it.
<!-- Fragile: the parenthesis can jump ends -->
<td>{{employerName}} (Employer)</td>
<!-- Correct: the name is isolated from its surroundings -->
<td><bdi dir="auto">{{employerName}}</bdi> (Employer)</td>
The test case worth keeping in the suite is a single record containing an Arabic personal name, a Latin company suffix, a phone number with a plus sign, and a date with slashes. If that renders correctly in both directions, most things will.
4. Numerals are a policy decision, not a rendering detail
Arabic documents may use Arabic-Indic digits or Latin digits, and which one is correct depends on the document type and the reader, not on the language tag. A salary figure on a payslip and an invoice total submitted to a tax authority frequently want different answers, in the same language, on the same page.
The mistake is to make this a CSS concern and let it apply globally. It should be an explicit per-field choice, driven by data:
// Explicit, per field, not a global CSS switch
const fmt = (n, numerals) => new Intl.NumberFormat(
numerals === 'arab' ? 'ar-SA-u-nu-arab' : 'ar-SA-u-nu-latn',
{ minimumFractionDigits: 2 }
).format(n);
fmt(17500.5, 'latn'); // 17,500.50
fmt(17500.5, 'arab'); // ١٧٬٥٠٠٫٥٠
Two things that bite. Machine-readable payloads should almost always stay in Latin digits regardless of what the rendered document shows, because downstream parsers rarely expect otherwise. And the Arabic decimal separator and thousands separator are distinct characters from the Latin ones, so string comparison between a rendered figure and a stored figure will fail even when both are correct.
5. RTL layout is mirroring, not flipping
Setting direction: rtl reverses inline flow. It does not move your padding, and it does not know that some things should never mirror.
Use logical properties throughout and the layout mirrors for free:
/* Breaks under RTL */
.field { margin-left: 1rem; border-left: 2px solid; text-align: left; }
/* Mirrors correctly */
.field { margin-inline-start: 1rem; border-inline-start: 2px solid; text-align: start; }
Then keep a short list of things that must not mirror, because mirroring them is wrong rather than merely unusual:
- Logos and signature images.
- Charts with a time axis, where left to right is a convention about time, not about reading order.
- Phone numbers, IBANs, VAT numbers and reference codes, which are inherently left to right sequences. Wrap them in
<span dir="ltr">. - Code samples and file paths.
6. Page breaks in a document nobody scrolls
A screen has one long page. A PDF has many, and a signature block orphaned onto a page of its own makes a formal document look amateur.
.signature-block,
.terms-clause,
table tr { break-inside: avoid; }
thead { display: table-header-group; } /* repeat headers on each page */
tfoot { display: table-footer-group; }
.section-heading { break-after: avoid; } /* never a heading alone at the foot */
Two more that matter for real documents. Chromium honours @page margins, so set them there rather than adding body padding, which will otherwise apply only to the first page. And headers and footers passed through Puppeteer's headerTemplate and footerTemplate render in a separate context that does not inherit your stylesheet, so any font or direction they need has to be inlined into that template.
7. Serverless, cold starts and not writing to disk
Running headless Chromium in a serverless container adds two constraints.
The first is size and cold start. A full Puppeteer install carries its own Chromium and will not fit comfortably in a small function image. The usual arrangement is puppeteer-core paired with a compiled Chromium build packaged for the platform, with a single browser instance reused across invocations while the container is warm and a fresh page per request. The instance is the expensive object. The page is not.
The second is that the moment a document contains personal data, writing the PDF to disk creates a data protection problem you did not need. Under the Saudi Personal Data Protection Law, and equally under South Africa's POPIA or the GDPR, the cheapest posture is to hold nothing:
// Generate and stream. Nothing touches disk, nothing is stored.
const buffer = await page.pdf({ format: 'A4', printBackground: true });
await page.close();
res.setHeader('Content-Type', 'application/pdf');
res.setHeader('Content-Disposition', 'inline; filename="document.pdf"');
res.end(buffer);
If a generation audit trail is required, and compliance teams usually ask for one, log metadata only. Who generated which template, for which record, at what time. Never the field values. That gives auditors what they need and keeps the sensitive payload out of storage entirely.
A checklist worth keeping
| Check | Failure it prevents |
|---|---|
Await document.fonts.ready, then assert the family loaded | Intermittent fallback typeface |
| Self-host font files | Cold start losing the font race |
| Verify Presentation Forms coverage and the embedding licence | Missing glyphs, licence exposure |
Per-field dir, dir="auto" on user data, <bdi> on mixed runs | Reversed names and stray punctuation |
Numerals chosen per field via Intl.NumberFormat | Wrong digit system on a legal figure |
| Logical properties, with an explicit do-not-mirror list | Broken padding, mirrored logos, reversed IBANs |
break-inside: avoid, repeating table headers, @page margins | Orphaned signature blocks and headerless tables |
| Fonts and direction inlined into header and footer templates | Unstyled page furniture |
| Stream the buffer, persist metadata only | An avoidable data protection problem |
| A golden test record with mixed script, a plus sign and slashes | Every regression above, caught in CI |
The part that is not technical
Everything above is a day's work each, once. The reason bilingual document projects overrun is rarely any single item on the list. It is that they are usually discovered one at a time, in production, in front of a client who is looking at a contract with their own name rendered backwards.
If that is the position you are in, the fastest route out is usually not to rewrite the renderer. It is to fix them in the order above, because they compound: fonts before shaping, shaping before layout, layout before pagination.
Working on bilingual documents?
This came out of building a multi-tenant bilingual document platform now running in production in the Saudi market. If you are hitting any of the above, or building something similar, the fastest thing is usually a short conversation.