RTL languages in PDFs: Arabic, Hebrew, and Urdu without the headaches
If you've ever tried to generate a PDF invoice in Arabic and ended up with disconnected letters, numbers on the wrong side, or a layout that looks like someone shuffled the words, you know this problem. Right-to-left (RTL) text looks simple from the outside — "just flip the direction, right?" — but underneath it's one of the harder things to get right in document generation.
This post walks through what actually breaks when you render Arabic, Hebrew, or Urdu in PDFs, and how to fix it without rewriting your renderer.
Why RTL in PDFs is hard
PDFs don't have a concept of "text direction" the way HTML does. A PDF stores glyphs at absolute positions. Whatever you draw, in whatever order, lands exactly where you put it. That means every RTL concern — reordering, shaping, mirroring — has to be handled before the glyphs hit the page.
Three things typically go wrong:
- Bidi reordering: A string like
"Order #1234 طلب"has logical order (what you store) and visual order (what the user sees). Unicode's Bidi Algorithm (UAX #9) decides how to flip runs. Most low-level PDF libraries skip this entirely. - Shaping: Arabic and Urdu letters change form depending on their neighbors (initial, medial, final, isolated). Without shaping, you get the isolated form of every letter — readable only to a very patient person.
- Font coverage: Many default PDF fonts (Helvetica, Times) have zero RTL glyph coverage. You need a font with the right Unicode blocks and OpenType shaping tables (GSUB/GPOS).
The naive approach, and why it fails
Here's what a lot of people start with using pdf-lib:
tsimport { PDFDocument, StandardFonts } from "pdf-lib"; const doc = await PDFDocument.create(); const page = doc.addPage(); const font = await doc.embedFont(StandardFonts.Helvetica); page.drawText("مرحبا بالعالم", { x: 50, y: 500, font, size: 24 }); await doc.save();
Result: a page with a row of boxes (.notdef glyphs) because Helvetica has no Arabic coverage. Switch to an Arabic font and you'll get isolated glyphs with no shaping. Add bidi text ("Hello مرحبا") and the Arabic will render in logical order instead of being flipped into its visual place.
Doing it manually: the pieces you need
If you want to stick with a low-level library, you need a shaping engine and a bidi algorithm in the pipeline. The real-world stack looks like this:
- HarfBuzz (via
harfbuzzjsor native bindings) for shaping - bidi-js or unicode-bidirectional for the Bidi algorithm
- A font with proper RTL support — Noto Sans Arabic, Amiri, Noto Nastaliq Urdu, Noto Sans Hebrew
A minimal working example:
tsimport { PDFDocument } from "pdf-lib"; import fontkit from "@pdf-lib/fontkit"; import bidiFactory from "bidi-js"; import { readFileSync } from "node:fs"; const bidi = bidiFactory(); const doc = await PDFDocument.create(); doc.registerFontkit(fontkit); const fontBytes = readFileSync("./NotoSansArabic-Regular.ttf"); const font = await doc.embedFont(fontBytes, { subset: true }); const logical = "Invoice #1234 — فاتورة رقم ١٢٣٤"; const embeddingLevels = bidi.getEmbeddingLevels(logical, "rtl"); const visual = bidi.getReorderedString(logical, embeddingLevels); const page = doc.addPage(); page.drawText(visual, { x: 50, y: 500, font, size: 18 }); await doc.save();
This handles bidi reordering, but shaping is still missing — pdf-lib + fontkit will apply basic OpenType features, but for complex Urdu Nastaliq or heavily ligated Arabic you'll want HarfBuzz in the loop. That means pre-shaping the text and drawing glyph IDs directly, which pdf-lib doesn't support cleanly. At that point most teams either switch to a browser-based renderer (Puppeteer + Chromium handles bidi and shaping natively) or move to a managed service.
The browser-rendering approach
Chromium's text stack handles bidi and shaping correctly out of the box. If you render your PDF from HTML, you get RTL mostly for free:
html<!doctype html> <html lang="ar" dir="rtl"> <head> <meta charset="utf-8" /> <style> @font-face { font-family: "Noto Arabic"; src: url("/fonts/NotoSansArabic-Regular.ttf") format("truetype"); } body { font-family: "Noto Arabic", sans-serif; } </style> </head> <body> <h1>فاتورة رقم 1234</h1> <p>Hello مرحبا — mixed content works.</p> </body> </html>
Set dir="rtl" on the root (or on specific elements for mixed documents), use lang so the shaper picks the right rules, and embed a font with proper coverage. Then run it through Puppeteer or Playwright.
The catch: you now own a headless Chromium process in production. Memory, cold starts,