WW-Enterprises

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

CheckFailure it prevents
Await document.fonts.ready, then assert the family loadedIntermittent fallback typeface
Self-host font filesCold start losing the font race
Verify Presentation Forms coverage and the embedding licenceMissing glyphs, licence exposure
Per-field dir, dir="auto" on user data, <bdi> on mixed runsReversed names and stray punctuation
Numerals chosen per field via Intl.NumberFormatWrong digit system on a legal figure
Logical properties, with an explicit do-not-mirror listBroken padding, mirrored logos, reversed IBANs
break-inside: avoid, repeating table headers, @page marginsOrphaned signature blocks and headerless tables
Fonts and direction inlined into header and footer templatesUnstyled page furniture
Stream the buffer, persist metadata onlyAn avoidable data protection problem
A golden test record with mixed script, a plus sign and slashesEvery 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.