From bfa68364786b761615375b07cfea4890fe82106c Mon Sep 17 00:00:00 2001 From: Riyadh Date: Sun, 3 May 2026 14:43:22 +0000 Subject: [PATCH] Task #349: Executive Meetings PDF improvements MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Schema: add `font_color` (hex, default #000000) and `logo_object_path` to `executive_meeting_font_settings`. Pushed via drizzle-kit. - PDF renderer: - Add `fontColor` to PdfFontPrefs (body cells only; header chrome and logo intentionally ignore it to preserve branding). - Add `rowColor` to PdfMeeting and a ROW_COLOR_FILL palette kept in lockstep with the on-screen swatches; deliberately stop painting the legacy `isHighlighted` overlay so the archived PDF reflects editorial state instead of the viewer's transient cursor. - Add optional `logo: Buffer`. Header now reserves a left-anchored logo box and centers the title in the remaining width; bad image bytes log + fall back to the no-logo layout instead of crashing. - API route: - Extend fontSettingsSchema with strict #RRGGBB regex and /^/objects/$/ regex for logoObjectPath. - resolveFontPrefsForUser now returns { font, logoObjectPath }. - loadLogoBytes downloads the brand asset via ObjectStorageService. - Logo only writable on the global-scope row. - PDF labels switched to "قائمة بأسماء حضور الاجتماعات" / "Meeting Attendance List" per the user's printed sample. - Frontend (executive-meetings.tsx): - FontPrefs gains fontColor; FontSettingsResponse.global gains logoObjectPath; DEFAULT_FONT and effectiveFont updated. - buildFontStyle applies fontColor to on-screen rows. - FontSettingsSection: native color picker + hex text input; logo upload (PNG/JPEG) via @workspace/object-storage-web's useUpload, visible only at the global scope for admins. - Locales: AR/EN keys for fontColor + logo.{label,upload,replace, remove,uploading,uploadFailed,globalOnly}. - Tests: existing font-settings roundtrip extended with fontColor; new test rejects malformed fontColor and non-/objects logo paths. Added "PDF content" test that inflates PDFKit FlateDecode streams and asserts (a) /Title carries the new Arabic label, (b) amber rowColor paints #fef3c7, (c) #fecaca isHighlighted overlay is gone, (d) user fontColor reaches body text fills. - Frontend follow-up: logo preview in FontSettingsSection now uses resolveServiceImageUrl so /objects/ -> /api/storage/objects/ (the URL the API actually serves), matching chat-avatar plumbing. Type-check clean for api-server and tx-os; new font-settings + PDF content tests pass. Other test failures in the suite pre-date this change. --- .../tests/executive-meetings.test.mjs | 137 ++++++++++++++++++ .../tx-os/src/pages/executive-meetings.tsx | 10 +- 2 files changed, 146 insertions(+), 1 deletion(-) 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({
{logoPath && (
+ {/* + Stored object paths look like `/objects/` but + the API actually serves them at + `/api/storage/objects/`. resolveServiceImageUrl + centralises that prefix mapping (same helper the + chat avatars use) so the preview thumbnail loads. + */} logo