Replace executive-meetings print-to-PDF with server-side PDF generator

The "Download PDF" button on the executive-meetings page now hits a real
backend endpoint that returns a true PDF (no more browser print dialog),
respects each user's font preferences (family, size, weight, alignment)
with proper Arabic RTL shaping, and archives every download.

The renderer maps each saved fontFamily ("system", "Cairo", "Tajawal",
"Noto Naskh Arabic", "Amiri") to a concrete pair of bundled font files
so the chosen family genuinely changes the embedded glyphs — Cairo and
Tajawal pick Noto Sans Arabic, the Naskh-style families and the system
default pick Noto Naskh Arabic, and Latin glyphs render in DejaVu Sans
across the board. Headers, body cells, and footer all flow through the
same script-aware font selection.

Backend (artifacts/api-server)
- New GET /api/executive-meetings/pdf?date=&lang= route in
  src/routes/executive-meetings.ts that fetches the day's meetings +
  attendees, renders a PDF, uploads it to object storage, writes an
  executive_meeting_pdf_archives row (date, generated_by, byte_size,
  storage_url), and streams the file back inline.
- New src/lib/pdf-renderer.ts using pdfkit + bidi-js with bundled
  Noto Naskh Arabic and DejaVu Sans fonts in assets/fonts/.
- Added byte_size column on executive_meeting_pdf_archives (also in
  lib/db schema) and rebuilt lib/db.
- Added ambient types for bidi-js; installed @swc/helpers to satisfy
  fontkit at runtime.
- build.mjs now copies pdfkit's data/ folder (Helvetica.afm, etc.)
  into dist/data so the bundled server can construct PDFDocument.

Frontend (artifacts/tx-os)
- PdfSection in src/pages/executive-meetings.tsx now renders a single
  "Download PDF" button that fetches the endpoint, builds a Blob, and
  downloads it. Removed the print/archive-creation buttons.
- Archive list shows a Download button for new /objects/... rows and a
  read-only "Legacy snapshot" badge for older print: rows.
- Added byteSize on PdfArchive + size formatting; updated en/ar locales.

Tests
- New test "PDF GET /executive-meetings/pdf returns a real PDF and
  archives it" in tests/executive-meetings.test.mjs covers: bad-date
  400, unauthenticated 401, real %PDF body + content-type/disposition,
  archive row with byteSize/generatedBy/filePath, empty-day handling,
  and font-family mapping (Cairo embeds NotoSansArabic; Noto Naskh
  Arabic embeds NotoNaskhArabic).
- All 23 executive-meetings tests pass.

Rebase
- Rebased onto main-repl/main (37255b7 "Update the website's shared
  image"). The only conflict was the binary asset
  artifacts/tx-os/public/opengraph.jpg — accepted the incoming/main
  version since it's unrelated to this PDF work.

Drift
- Kept the legacy /executive-meetings/print SPA route and the existing
  POST /pdf-archives endpoint to preserve old archive snapshots and
  the existing snapshot test. Proposed follow-up #169 to clean these
  up once stakeholders confirm.

Replit-Task-Id: 68914058-ebd6-4670-a785-c0084fe1fc94
This commit is contained in:
riyadhafraa
2026-04-29 18:01:19 +00:00
parent 474198d77d
commit 11aaaf2abe
18 changed files with 1329 additions and 90 deletions
@@ -539,6 +539,157 @@ test("PDF archives: POST creates a snapshot and GET returns it", async () => {
assert.ok(archives.some((a) => a.id === archive.id));
});
test("PDF GET /executive-meetings/pdf returns a real PDF and archives it", async () => {
// Create a meeting with mixed Arabic+Latin attendees + a time range so
// the renderer's bidi/font-segmentation path is exercised.
const create = await api(adminCookie, "POST", "/api/executive-meetings", {
titleAr: "اجتماع الميزانية ‪Q2 2026",
titleEn: "Q2 2026 Budget Review",
meetingDate: today,
startTime: "10:00",
endTime: "11:30",
location: "قاعة كبرى",
attendees: [
{ name: "أحمد العلي", title: "المدير التنفيذي",
attendanceType: "internal", sortOrder: 0 },
{ name: "John Smith", title: "CFO",
attendanceType: "external", sortOrder: 1 },
],
});
assert.equal(create.status, 201);
const meeting = await create.json();
created.meetingIds.push(meeting.id);
// Bad date → 400
const bad = await api(adminCookie, "GET",
"/api/executive-meetings/pdf?date=not-a-date");
assert.equal(bad.status, 400);
// Coordinator without executive role is denied — auth gate matches the
// rest of the executive-meetings module.
const noAuth = await fetch(`${API_BASE}/api/executive-meetings/pdf?date=${today}`);
assert.equal(noAuth.status, 401);
// Happy path: real PDF response with non-trivial body.
const res = await api(adminCookie, "GET",
`/api/executive-meetings/pdf?date=${today}&lang=ar`);
assert.equal(res.status, 200);
assert.match(
res.headers.get("content-type") || "",
/^application\/pdf/,
"must serve application/pdf",
);
assert.match(
res.headers.get("content-disposition") || "",
new RegExp(`attachment;\\s*filename="executive-meetings-${today}\\.pdf"`),
"filename should include the requested date",
);
const buf = Buffer.from(await res.arrayBuffer());
assert.ok(buf.length > 1000, `PDF should be >1KB, got ${buf.length}`);
assert.equal(
buf.subarray(0, 4).toString("ascii"),
"%PDF",
"body must start with PDF magic bytes",
);
// The download must have created an archive row recorded against the
// generating user with byteSize matching the served body.
const list = await api(
adminCookie,
"GET",
`/api/executive-meetings/pdf-archives?date=${today}`,
);
assert.equal(list.status, 200);
const archives = (await list.json()).archives ?? [];
const generated = archives.find(
(a) => typeof a.byteSize === "number" && a.byteSize === buf.length,
);
assert.ok(
generated,
"PDF download must produce an archive row with the served byte_size",
);
assert.ok(generated.generatedBy, "generatedBy must be recorded");
assert.ok(
typeof generated.filePath === "string" && generated.filePath.length > 0,
"filePath/storage_url must be non-empty",
);
// Empty-day handling: another date with no meetings still returns a
// valid PDF (with a "no meetings" message).
const empty = await api(adminCookie, "GET",
`/api/executive-meetings/pdf?date=2099-01-01&lang=en`);
assert.equal(empty.status, 200);
const emptyBuf = Buffer.from(await empty.arrayBuffer());
assert.equal(emptyBuf.subarray(0, 4).toString("ascii"), "%PDF");
// Font-family must affect which font is embedded in the PDF. We render
// the same day twice with different families and assert that:
// 1) Both downloads succeed (200 + %PDF magic).
// 2) The Sans family ("Cairo") embeds NotoSansArabic, while the Naskh
// default ("Noto Naskh Arabic") embeds NotoNaskhArabic. The font's
// PostScript name appears verbatim in the PDF font dictionary.
const setSans = await api(
adminCookie,
"PUT",
"/api/executive-meetings/font-settings",
{
scope: "user",
fontFamily: "Cairo",
fontSize: 16,
fontWeight: "bold",
alignment: "center",
},
);
assert.equal(setSans.status, 200);
const sansRes = await api(
adminCookie,
"GET",
`/api/executive-meetings/pdf?date=${today}&lang=ar`,
);
assert.equal(sansRes.status, 200);
const sansBuf = Buffer.from(await sansRes.arrayBuffer());
assert.equal(sansBuf.subarray(0, 4).toString("ascii"), "%PDF");
const sansAscii = sansBuf.toString("latin1");
assert.ok(
/NotoSansArabic/i.test(sansAscii),
"Cairo family must embed NotoSansArabic in the PDF",
);
assert.ok(
!/NotoNaskhArabic/i.test(sansAscii),
"Cairo family must NOT embed NotoNaskhArabic",
);
const setNaskh = await api(
adminCookie,
"PUT",
"/api/executive-meetings/font-settings",
{
scope: "user",
fontFamily: "Noto Naskh Arabic",
fontSize: 14,
fontWeight: "regular",
alignment: "start",
},
);
assert.equal(setNaskh.status, 200);
const naskhRes = await api(
adminCookie,
"GET",
`/api/executive-meetings/pdf?date=${today}&lang=ar`,
);
assert.equal(naskhRes.status, 200);
const naskhBuf = Buffer.from(await naskhRes.arrayBuffer());
const naskhAscii = naskhBuf.toString("latin1");
assert.ok(
/NotoNaskhArabic/i.test(naskhAscii),
"Noto Naskh Arabic family must embed NotoNaskhArabic in the PDF",
);
assert.ok(
!/NotoSansArabic/i.test(naskhAscii),
"Noto Naskh Arabic family must NOT embed NotoSansArabic",
);
});
test("Sanitization: rich text strips disallowed tags but keeps inline formatting", async () => {
const dirty =
'Hello <script>alert(1)</script><strong style="color:#ff0000">World</strong>' +