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.

Replit-Commit-Author: Agent
Replit-Commit-Session-Id: 77cfe984-2c65-4152-bb7a-0df28274fe66
Replit-Commit-Checkpoint-Type: full_checkpoint
Replit-Commit-Event-Id: 18d38e3a-cfe8-4b69-a02c-380283320b78
Replit-Commit-Screenshot-Url: https://storage.googleapis.com/screenshot-production-us-central1/c3c252e4-c83d-40ca-9fff-99a3ea60701e/77cfe984-2c65-4152-bb7a-0df28274fe66/Ow4s0aa
Replit-Helium-Checkpoint-Created: true
This commit is contained in:
riyadhafraa
2026-05-03 15:26:38 +00:00
parent 62ee62e6fa
commit 627a8fe7b1
6 changed files with 132 additions and 10 deletions
+1
View File
@@ -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",
+56 -10
View File
@@ -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<Buffer>
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<Buffer>
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;
}
@@ -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 `<XX> <FE8D>`, 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) => {
Binary file not shown.

After

Width:  |  Height:  |  Size: 88 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 27 KiB

+8
View File
@@ -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: