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:
@@ -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",
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
Reference in New Issue
Block a user