From 4018373bb068056ae3cb71737a16880f6ff87433 Mon Sep 17 00:00:00 2001 From: Riyadh Date: Sun, 3 May 2026 15:26:38 +0000 Subject: [PATCH] Fix Arabic text rendering in generated PDFs Add Arabic text shaping functionality to convert base characters to their contextual presentation forms before bidi reordering and PDF rendering. Includes a new regression test to verify correct shaping by asserting the presence of presentation form codepoints in the PDF's embedded ToUnicode CMap. --- artifacts/api-server/package.json | 1 + artifacts/api-server/src/lib/pdf-renderer.ts | 66 +++++++++++++++--- .../tests/executive-meetings.test.mjs | 67 +++++++++++++++++++ pnpm-lock.yaml | 8 +++ 4 files changed, 132 insertions(+), 10 deletions(-) diff --git a/artifacts/api-server/package.json b/artifacts/api-server/package.json index 785a7137..9fc60a56 100644 --- a/artifacts/api-server/package.json +++ b/artifacts/api-server/package.json @@ -16,6 +16,7 @@ "@swc/helpers": "^0.5.21", "@workspace/api-zod": "workspace:*", "@workspace/db": "workspace:*", + "arabic-persian-reshaper": "1.0.1", "bcryptjs": "^3.0.3", "bidi-js": "^1.0.3", "connect-pg-simple": "^10.0.0", diff --git a/artifacts/api-server/src/lib/pdf-renderer.ts b/artifacts/api-server/src/lib/pdf-renderer.ts index a2d2e894..fd2246bf 100644 --- a/artifacts/api-server/src/lib/pdf-renderer.ts +++ b/artifacts/api-server/src/lib/pdf-renderer.ts @@ -3,11 +3,51 @@ import path from "node:path"; import { readFileSync, existsSync } from "node:fs"; import bidiFactory from "bidi-js"; import PDFDocument from "pdfkit"; +// CommonJS module — pulled in via createRequire so the TS->ESM build +// doesn't try to statically resolve a default export that doesn't exist. +import { createRequire } from "node:module"; import sanitizeHtml from "sanitize-html"; const bidi = bidiFactory(); +const reshaperRequire = createRequire(import.meta.url); +const { ArabicShaper } = reshaperRequire("arabic-persian-reshaper") as { + ArabicShaper: { convertArabic: (input: string) => string }; +}; + +// Convert base Arabic letters (U+0600..U+06FF) to their contextual +// presentation forms (Arabic Presentation Forms-A/B). PDFKit has no +// built-in OpenType layout / HarfBuzz, so without this every Arabic +// character renders in its isolated form and — combined with the bidi +// visual reorder — the printed text looks both disconnected and +// reversed (e.g. "الاجتماعات" prints as "تاعامتجالا"). Must run BEFORE +// `bidi.getReorderedString`: shaping needs the original logical order +// to know each letter's neighbours, and the resulting presentation +// forms are still classified as RTL so bidi reorders them correctly. +// Idempotent on text that contains only presentation forms / Latin. +export function shapeArabic(text: string): string { + if (!text) return text; + // Fast skip: nothing to shape if there are no base-form Arabic chars. + // Covers Arabic (0600-06FF), Arabic Supplement (0750-077F), and + // Arabic Extended-A (08A0-08FF) so non-standard Arabic-script letters + // also trigger the shaping pass. + let hasBaseArabic = false; + for (const ch of text) { + const c = ch.codePointAt(0) ?? 0; + if ( + (c >= 0x0600 && c <= 0x06ff) || + (c >= 0x0750 && c <= 0x077f) || + (c >= 0x08a0 && c <= 0x08ff) + ) { + hasBaseArabic = true; + break; + } + } + if (!hasBaseArabic) return text; + return ArabicShaper.convertArabic(text); +} + export type PdfFontPrefs = { fontFamily: string; fontSize: number; @@ -259,8 +299,9 @@ function splitRunsByScript(text: string): Run[] { // otherwise-RTL line gets digit-reversed by the renderer). function visualOrderRuns(text: string, baseDirection: "ltr" | "rtl"): Run[] { if (!text) return []; - const embeddingLevels = bidi.getEmbeddingLevels(text, baseDirection); - const reordered = bidi.getReorderedString(text, embeddingLevels); + const shaped = shapeArabic(text); + const embeddingLevels = bidi.getEmbeddingLevels(shaped, baseDirection); + const reordered = bidi.getReorderedString(shaped, embeddingLevels); return splitRunsByScript(reordered); } @@ -417,12 +458,13 @@ function drawWrappingLine( const dominantArabic = arabicChars * 2 > text.length; const fk = fontKeyFor(dominantArabic, opts.weight, opts.mapping); doc.font(fk).fontSize(opts.fontSize).fillColor(opts.color ?? "black"); - // For Arabic text, run bidi on the whole string so the font sees runs - // already in visual order — this matches what drawMixedLine does for - // single-line cells. + // For Arabic text, shape contextual joining forms first, then run bidi + // so the font sees runs already in visual order — this matches what + // drawMixedLine does for single-line cells. + const shaped = shapeArabic(text); const reordered = bidi.getReorderedString( - text, - bidi.getEmbeddingLevels(text, opts.baseDirection), + shaped, + bidi.getEmbeddingLevels(shaped, opts.baseDirection), ); const before = doc.y; doc.text(reordered, opts.x, opts.y, { @@ -571,9 +613,10 @@ export async function renderSchedulePdf(input: RenderPdfInput): Promise const dominantArabic = arabicChars * 2 > label.length; const fk = fontKeyFor(dominantArabic, "bold", mapping); doc.font(fk).fontSize(input.font.fontSize).fillColor("white"); + const shapedLabel = shapeArabic(label); const reordered = bidi.getReorderedString( - label, - bidi.getEmbeddingLevels(label, baseDir), + shapedLabel, + bidi.getEmbeddingLevels(shapedLabel, baseDir), ); doc.text(reordered, cx + cellPadX, headerY + cellPadY, { width: headerWidths[i] - cellPadX * 2, @@ -667,7 +710,10 @@ export async function renderSchedulePdf(input: RenderPdfInput): Promise h += lineHeight; continue; } - const w = doc.widthOfString(line); + // Measure with shaped text — presentation forms are typically + // narrower than chained isolated glyphs, so probing the raw + // string would over-count wraps and reserve excess row height. + const w = doc.widthOfString(shapeArabic(line)); const wraps = Math.max(1, Math.ceil(w / (widths[i] - cellPadX * 2))); h += wraps * lineHeight; } diff --git a/artifacts/api-server/tests/executive-meetings.test.mjs b/artifacts/api-server/tests/executive-meetings.test.mjs index 81f4f78c..06ccd15d 100644 --- a/artifacts/api-server/tests/executive-meetings.test.mjs +++ b/artifacts/api-server/tests/executive-meetings.test.mjs @@ -1024,6 +1024,73 @@ test("PDF content: title label, rowColor tint, dropped isHighlighted, fontColor" void ascii; }); +test("PDF Arabic shaping: title renders in connected presentation forms (not isolated)", async () => { + // Regression for the bug where Arabic in the printed PDF appeared as + // disconnected, visually-reversed letters (e.g. "الاجتماعات" → + // "تاعامتجالا"). PDFKit has no built-in HarfBuzz, so we now run a + // pre-shaping pass that converts base Arabic letters (U+0600..U+06FF) + // to their contextual presentation forms (FE80..FEFF, plus FEFB + // lam-alef ligature) BEFORE handing text to bidi + PDFKit. We assert + // the rendered PDF contains presentation-form codepoints in its + // embedded ToUnicode CMap (proves shaped glyphs were drawn). + + // End-to-end: render a real PDF and assert the inflated streams + // (which include the embedded ToUnicode CMap) reference at least one + // Arabic Presentation Forms-B codepoint. Without shaping the CMap + // would only contain base U+06xx hex values. + const shapedDate = "2099-05-04"; + const shapedMeeting = await api(adminCookie, "POST", "/api/executive-meetings", { + titleAr: "اجتماع الاختبار", + titleEn: "Shaping Smoke Test", + meetingDate: shapedDate, + attendees: [ + { name: "محمد عبدالله", attendanceType: "internal", sortOrder: 0 }, + ], + }); + assert.equal(shapedMeeting.status, 201); + created.meetingIds.push((await shapedMeeting.json()).id); + + const res = await api( + adminCookie, + "GET", + `/api/executive-meetings/pdf?date=${shapedDate}&lang=ar`, + ); + assert.equal(res.status, 200); + const buf = Buffer.from(await res.arrayBuffer()); + + // Inflate every FlateDecode stream and concat — same trick as the + // existing PDF content test. ToUnicode CMaps are inflate-able and + // contain pairs like ` `, so a regex over the joined + // payload is enough. + let cursor = 0; + const decoded = []; + while (true) { + const start = buf.indexOf("stream\n", cursor); + if (start < 0) break; + const end = buf.indexOf("\nendstream", start); + if (end < 0) break; + try { + decoded.push( + zlib + .inflateSync(buf.slice(start + "stream\n".length, end)) + .toString("latin1"), + ); + } catch { + // not a deflate stream + } + cursor = end + "\nendstream".length; + } + const allDecoded = decoded.join("\n"); + // Match any FE80..FEFF or FB50..FDFF hex literal — these are the + // Arabic Presentation Forms ranges. Without shaping none would appear. + const presFormsRegex = /<(?:FE[89A-F][0-9A-F]|FEF[0-9A-F]|FE[7][0-9A-F]|FB[5-9A-F][0-9A-F]|FC[0-9A-F]{2}|FD[0-9A-F]{2})>/i; + assert.match( + allDecoded, + presFormsRegex, + "rendered PDF must reference Arabic Presentation Forms codepoints (proves shaping ran before PDFKit)", + ); +}); + test("PDF embeds the brand logo when set in global font settings", async () => { // Build a real 1x1 RGB PNG (png-js rejects most base64 fixtures). const u32 = (n) => { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 1fa51f2c..05a0c324 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -93,6 +93,9 @@ importers: '@workspace/db': specifier: workspace:* version: link:../../lib/db + arabic-persian-reshaper: + specifier: 1.0.1 + version: 1.0.1 bcryptjs: specifier: ^3.0.3 version: 3.0.3 @@ -2838,6 +2841,9 @@ packages: resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==} engines: {node: '>=8'} + arabic-persian-reshaper@1.0.1: + resolution: {integrity: sha512-VYBjkhz6o4W1Xt4mD2LAReljJpLSw5CUZMqSBDIQRvFgUSlTKEYghapgBWvkeMWF4W+KF3Fm+/z8EywJU4PBeg==} + argparse@2.0.1: resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==} @@ -6932,6 +6938,8 @@ snapshots: ansi-regex@5.0.1: {} + arabic-persian-reshaper@1.0.1: {} + argparse@2.0.1: {} aria-hidden@1.2.6: