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
@@ -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) => {