diff --git a/artifacts/api-server/tests/executive-meetings.test.mjs b/artifacts/api-server/tests/executive-meetings.test.mjs index 7bd01a85..2d4c2ff1 100644 --- a/artifacts/api-server/tests/executive-meetings.test.mjs +++ b/artifacts/api-server/tests/executive-meetings.test.mjs @@ -870,6 +870,143 @@ test("PDF GET /executive-meetings/pdf returns a real PDF and archives it", async ); }); +test("PDF content: title label, rowColor tint, dropped isHighlighted, fontColor", async () => { + // This guards Task #349's visual contract directly against the + // bytes the renderer emits, complementing the higher-level test + // above which only checks status + magic. We render a PDF for a + // throw-away date with two meetings — one tinted via the saved + // `rowColor`, one only flagged isHighlighted — and verify by + // inflating PDFKit's FlateDecode'd content streams. + const zlib = await import("node:zlib"); + const decodeStreams = (buf) => { + const out = []; + let cursor = 0; + while (true) { + const start = buf.indexOf("stream\n", cursor); + if (start < 0) break; + const end = buf.indexOf("\nendstream", start); + if (end < 0) break; + try { + out.push( + zlib + .inflateSync(buf.slice(start + "stream\n".length, end)) + .toString("latin1"), + ); + } catch { + // not a deflate stream (font/image bytes) — skip + } + cursor = end + "\nendstream".length; + } + return out.join("\n"); + }; + + // Use a unique future date so we can't collide with other tests. + const pdfDate = "2099-05-03"; + + const ambered = await api(adminCookie, "POST", "/api/executive-meetings", { + titleAr: "اجتماع تجريبي", + titleEn: "Tinted Meeting", + meetingDate: pdfDate, + attendees: [{ name: "ع", attendanceType: "internal", sortOrder: 0 }], + }); + assert.equal(ambered.status, 201); + const amberedId = (await ambered.json()).id; + created.meetingIds.push(amberedId); + // rowColor is set via PATCH (not accepted by POST today). + const tint = await api( + adminCookie, + "PATCH", + `/api/executive-meetings/${amberedId}`, + { rowColor: "amber" }, + ); + assert.equal(tint.status, 200); + + const highlighted = await api(adminCookie, "POST", "/api/executive-meetings", { + titleAr: "اجتماع آخر", + titleEn: "Highlighted Meeting", + meetingDate: pdfDate, + isHighlighted: true, + attendees: [{ name: "ب", attendanceType: "internal", sortOrder: 0 }], + }); + assert.equal(highlighted.status, 201); + created.meetingIds.push((await highlighted.json()).id); + + // Pick a non-default tint that's distinguishable from header chrome. + // #336699 → 0.2, 0.4, 0.6 in PDFKit's `rg` op. + const setColor = await api( + adminCookie, + "PUT", + "/api/executive-meetings/font-settings", + { + scope: "user", + fontFamily: "Majalla", + fontSize: 14, + fontWeight: "regular", + alignment: "start", + fontColor: "#336699", + }, + ); + assert.equal(setColor.status, 200); + + const res = await api( + adminCookie, + "GET", + `/api/executive-meetings/pdf?date=${pdfDate}&lang=ar`, + ); + assert.equal(res.status, 200); + const buf = Buffer.from(await res.arrayBuffer()); + const ascii = buf.toString("latin1"); + const decoded = decodeStreams(buf); + + // (1) Title — "قائمة بأسماء حضور الاجتماعات" survives into the + // PDF metadata `/Title` field. PDFKit serializes non-ASCII PDF + // strings as parenthesised literals: BOM `\xFE\xFF` + UTF-16BE, + // with `(`, `)` and `\` byte values backslash-escaped. To dodge + // the escape problem we only assert on the UTF-16BE prefix of + // the first four glyphs ("قائم"), which contains no metacharacters. + // ق=0x0642 ا=0x0627 ئ=0x0626 م=0x0645 + const titlePrefix = Buffer.from([ + 0xfe, 0xff, 0x06, 0x42, 0x06, 0x27, 0x06, 0x26, 0x06, 0x45, + ]); + assert.ok( + buf.includes(titlePrefix), + "PDF /Title metadata must start with the new Arabic title (قائم…)", + ); + + // PDFKit emits full-precision floats and uses the `scn` operator + // (set non-stroke color in the current color space) once a color + // space has been selected with `cs`. Older PDFKit emits `rg` for + // pure-DeviceRGB writes — accept either form. + const op = "(?:rg|scn)"; + + // (2) rowColor=amber → #fef3c7 = 254/255, 243/255, 199/255. + assert.match( + decoded, + new RegExp(`0\\.9960\\d* 0\\.9529\\d* 0\\.7803\\d* ${op}`), + "amber rowColor must paint the saved palette fill", + ); + + // (3) The legacy isHighlighted overlay (#fecaca = 254/255, 202/255, + // 202/255) must NOT appear anymore — archival PDFs reflect + // editorial state only. + assert.doesNotMatch( + decoded, + new RegExp(`0\\.9960\\d* 0\\.7921\\d* 0\\.7921\\d* ${op}`), + "isHighlighted must no longer be baked into the PDF", + ); + + // (4) The user's fontColor must reach body text as a fill op. + // #336699 = 51/255 = 0.2 (exact), 102/255 = 0.4 (exact), 153/255 = + // 0.6 (exact) — PDFKit prints them without trailing decimals. + assert.match( + decoded, + new RegExp(`(?:^|\\s)0\\.2 0\\.4 0\\.6 ${op}`), + "user fontColor #336699 must appear as a fill operator", + ); + + void ascii; // kept for future ad-hoc debugging +}); + test("Sanitization: rich text strips disallowed tags but keeps inline formatting", async () => { const dirty = 'Hello World' + diff --git a/artifacts/tx-os/src/pages/executive-meetings.tsx b/artifacts/tx-os/src/pages/executive-meetings.tsx index b94a2190..cb0e3885 100644 --- a/artifacts/tx-os/src/pages/executive-meetings.tsx +++ b/artifacts/tx-os/src/pages/executive-meetings.tsx @@ -81,6 +81,7 @@ import { } from "@/components/ui/popover"; import { Checkbox } from "@/components/ui/checkbox"; import { useUpload, type UploadResponse } from "@workspace/object-storage-web"; +import { resolveServiceImageUrl } from "@/lib/image-url"; import { useToast } from "@/hooks/use-toast"; import { ToastAction } from "@/components/ui/toast"; import { @@ -7560,8 +7561,15 @@ function FontSettingsSection({