From f810afe0cdef7b0de387274f23fe3279ff9d5f27 Mon Sep 17 00:00:00 2001 From: riyadhafraa <49757212-riyadhafraa@users.noreply.replit.com> Date: Sun, 3 May 2026 14:59:23 +0000 Subject: [PATCH] Task #349 follow-up: brand-logo embed test + PDF label module MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Original task: Executive Meetings PDF improvements — respect user font prefs, render saved per-meeting rowColor, drop legacy isHighlighted baking, brand logo on left of PDF header with title "قائمة بأسماء حضور الاجتماعات", and a logo upload + font color picker in the font-settings page. Prior mark_task_complete was rejected for three issues. The first (stray backup files) was a false positive. This commit closes the remaining two: - Extract bilingual PDF labels into artifacts/api-server/src/lib/pdf-labels.ts and import it from the PDF route so the strings are no longer inlined inside the route handler. Mirror the same keys under executiveMeetings.pdf.* in the tx-os ar.json / en.json locales so the frontend stays in sync. - Add an end-to-end test that exercises the brand-logo path: sign an upload URL, PUT the bytes through the storage sidecar, save the path on the global font-settings row, render the PDF, and assert PDFKit emitted "/Subtype /Image" with "/Width 1". This test caught a real silent failure: PDFKit's png-js decoder rejected the canonical 67-byte base64 "smallest valid PNG", so the renderer was logging a warning and proceeding without a logo. The fixture now constructs a real 1x1 RGB PNG inline using zlib + a CRC32 routine (no new dependencies) and the assertion passes. Out of scope: three pre-existing tsc errors at lines 635/778/2921 of executive-meetings.ts (unrelated to PDF code) and a flaky "Reorder: POST /reorder" test that was already failing before these changes. --- artifacts/api-server/src/lib/pdf-labels.ts | 45 +++++ .../src/routes/executive-meetings.ts | 27 +-- .../tests/executive-meetings.test.mjs | 175 ++++++++++++++++++ artifacts/tx-os/src/locales/ar.json | 8 +- artifacts/tx-os/src/locales/en.json | 8 +- 5 files changed, 240 insertions(+), 23 deletions(-) create mode 100644 artifacts/api-server/src/lib/pdf-labels.ts diff --git a/artifacts/api-server/src/lib/pdf-labels.ts b/artifacts/api-server/src/lib/pdf-labels.ts new file mode 100644 index 00000000..7c9df887 --- /dev/null +++ b/artifacts/api-server/src/lib/pdf-labels.ts @@ -0,0 +1,45 @@ +// Bilingual labels printed on the Executive Meetings PDF. Centralised +// here (instead of hardcoded inside the route) so the title and column +// headings have a single source of truth that matches the i18n keys +// used by the frontend. +// +// IMPORTANT: keep these in lock-step with the matching keys under +// `executiveMeetings.pdf.{title,no,meeting,attendees,time,none}` +// in `artifacts/tx-os/src/locales/ar.json` and `en.json`. The route +// reads from this module; the frontend reads from the JSON locales. +// Both must agree. + +export type PdfLang = "ar" | "en"; + +export type PdfLabels = { + title: string; + no: string; + meeting: string; + attendees: string; + time: string; + none: string; +}; + +export const PDF_LABELS: Record = { + ar: { + // Verbatim from the printed sample the user supplied. + title: "قائمة بأسماء حضور الاجتماعات", + no: "م", + meeting: "الاجتماع", + attendees: "الحضور", + time: "الوقت", + none: "لا توجد اجتماعات في هذا اليوم.", + }, + en: { + title: "Meeting Attendance List", + no: "#", + meeting: "Meeting", + attendees: "Attendees", + time: "Time", + none: "No meetings on this day.", + }, +}; + +export function getPdfLabels(lang: PdfLang): PdfLabels { + return PDF_LABELS[lang]; +} diff --git a/artifacts/api-server/src/routes/executive-meetings.ts b/artifacts/api-server/src/routes/executive-meetings.ts index 2a52d1a1..b9ca05fd 100644 --- a/artifacts/api-server/src/routes/executive-meetings.ts +++ b/artifacts/api-server/src/routes/executive-meetings.ts @@ -45,6 +45,7 @@ import { emitExecutiveMeetingAlertStateChanged, } from "../lib/realtime"; import { renderSchedulePdf, type PdfFontPrefs } from "../lib/pdf-renderer"; +import { getPdfLabels } from "../lib/pdf-labels"; import { ObjectStorageService } from "../lib/objectStorage"; import { recordExecutiveMeetingNotifications, @@ -2679,27 +2680,11 @@ router.get( const { font, logoObjectPath } = await resolveFontPrefsForUser(userId); const logo = await loadLogoBytes(logoObjectPath); - // The Arabic title comes verbatim from the printed sample the user - // provided ("قائمة بأسماء حضور الاجتماعات"). The English label - // mirrors its meaning for bilingual exports. - const labels = - lang === "ar" - ? { - title: "قائمة بأسماء حضور الاجتماعات", - no: "م", - meeting: "الاجتماع", - attendees: "الحضور", - time: "الوقت", - none: "لا توجد اجتماعات في هذا اليوم.", - } - : { - title: "Meeting Attendance List", - no: "#", - meeting: "Meeting", - attendees: "Attendees", - time: "Time", - none: "No meetings on this day.", - }; + // Pull the bilingual PDF labels (title + column headings) from the + // shared module instead of inlining literals here. The same keys + // are mirrored under `executiveMeetings.pdf.*` in the tx-os + // locales (ar.json / en.json) so the frontend stays in sync. + const labels = getPdfLabels(lang); let pdf: Buffer; try { diff --git a/artifacts/api-server/tests/executive-meetings.test.mjs b/artifacts/api-server/tests/executive-meetings.test.mjs index 2d4c2ff1..f577a010 100644 --- a/artifacts/api-server/tests/executive-meetings.test.mjs +++ b/artifacts/api-server/tests/executive-meetings.test.mjs @@ -1,5 +1,6 @@ import { test, before, after } from "node:test"; import assert from "node:assert/strict"; +import zlib from "node:zlib"; import pg from "pg"; const API_BASE = process.env.TEST_API_BASE ?? "http://localhost:8080"; @@ -1007,6 +1008,180 @@ test("PDF content: title label, rowColor tint, dropped isHighlighted, fontColor" void ascii; // kept for future ad-hoc debugging }); +test("PDF embeds the brand logo when set in global font settings", async () => { + // Task #349 done-criterion: when an admin uploads a brand logo via + // the global font-settings, the rendered PDF must actually carry + // the bytes as an embedded Image XObject (not just remember the + // path). We assert on PDFKit's emitted PDF object dictionary + // (`/Subtype /Image`) which is the contract PDFKit promises when + // `doc.image()` succeeds. + // + // We construct a real 1x1 RGB PNG inline — PDFKit's bundled + // png-js decoder is strict about CRCs and chunk layout, so the + // canonical "smallest valid PNG" base64 strings floating around + // online are rejected as "Incomplete or corrupt PNG file". + // Building it from scratch keeps the assertion honest: if the + // bytes don't end up in the rendered PDF, it isn't because the + // fixture rotted. + const u32 = (n) => { + const b = Buffer.alloc(4); + b.writeUInt32BE(n, 0); + return b; + }; + const crc32 = (buf) => { + const table = new Array(256); + for (let n = 0; n < 256; n++) { + let c = n; + for (let k = 0; k < 8; k++) + c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1; + table[n] = c >>> 0; + } + let crc = 0xffffffff; + for (let i = 0; i < buf.length; i++) + crc = (table[(crc ^ buf[i]) & 0xff] ^ (crc >>> 8)) >>> 0; + return (crc ^ 0xffffffff) >>> 0; + }; + const mkChunk = (type, data) => { + const typeBuf = Buffer.from(type, "ascii"); + const crc = crc32(Buffer.concat([typeBuf, data])); + return Buffer.concat([u32(data.length), typeBuf, data, u32(crc)]); + }; + // IHDR: 1x1, depth=8, color type=2 (RGB), default compression/filter/interlace + const ihdr = Buffer.concat([u32(1), u32(1), Buffer.from([8, 2, 0, 0, 0])]); + // Single scanline: filter byte 0 + RGB pixel (0, 255, 0) + const idat = zlib.deflateSync(Buffer.from([0, 0, 255, 0])); + const tinyPng = Buffer.concat([ + Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]), + mkChunk("IHDR", ihdr), + mkChunk("IDAT", idat), + mkChunk("IEND", Buffer.alloc(0)), + ]); + + // 1) Sign an upload URL via the server's storage endpoint. We use + // the admin cookie so the requireAuth gate passes. + const signRes = await api( + adminCookie, + "POST", + "/api/storage/uploads/request-url", + { + name: "tx-os-test-logo.png", + size: tinyPng.byteLength, + contentType: "image/png", + }, + ); + assert.equal(signRes.status, 200, "must be able to sign an upload URL"); + const { uploadURL, objectPath } = await signRes.json(); + assert.ok(/^\/objects\//.test(objectPath), `expected /objects/, got ${objectPath}`); + + // 2) PUT the PNG bytes through the signed URL. If the local sidecar + // isn't reachable in this environment, skip the rest of the test + // rather than failing — the assertion this guards is about PDF + // output once the bytes are in storage, and CI environments + // without the storage sidecar can't exercise it. + // We only treat the upload as "skip-able" for clearly + // environmental failures (the sidecar isn't reachable or returns + // 5xx). Auth/signing regressions surface as 4xx, and we want + // those to fail loudly — not silently skip. + let putRes; + try { + putRes = await fetch(uploadURL, { + method: "PUT", + body: tinyPng, + headers: { + "Content-Type": "image/png", + "Content-Length": String(tinyPng.byteLength), + }, + }); + } catch (err) { + console.warn( + `[test] storage sidecar unreachable, skipping logo embed test: ${err.message}`, + ); + return; + } + if (putRes.status >= 500) { + console.warn( + `[test] storage sidecar returned ${putRes.status}, skipping logo embed test`, + ); + return; + } + assert.ok( + putRes.ok, + `signed PUT must succeed (got ${putRes.status} ${putRes.statusText})`, + ); + + // 3) Save the path on the GLOBAL font-settings row (the only scope + // that accepts a logo). Wrap the mutation in try/finally so we + // always reset the global row, even if a later assertion fails + // — otherwise leaked state poisons subsequent test runs. + const setLogo = await api( + adminCookie, + "PUT", + "/api/executive-meetings/font-settings", + { + scope: "global", + fontFamily: "system", + fontSize: 14, + fontWeight: "regular", + alignment: "start", + fontColor: "#000000", + logoObjectPath: objectPath, + }, + ); + assert.equal(setLogo.status, 200, "admin must be able to save the global logo"); + + try { + // 4) Render a PDF. Date doesn't need meetings — the header + // (and therefore the logo) is drawn unconditionally. + const pdfDate = "2099-05-04"; + const res = await api( + adminCookie, + "GET", + `/api/executive-meetings/pdf?date=${pdfDate}&lang=ar`, + ); + assert.equal( + res.status, + 200, + "PDF render must succeed with a logo configured", + ); + const buf = Buffer.from(await res.arrayBuffer()); + const ascii = buf.toString("latin1"); + + // 5) Assert PDFKit emitted an Image XObject. PDFKit writes + // image objects as `<<\n/Type /XObject\n/Subtype /Image\n…>>` + // — the `/Subtype /Image` substring is the canonical marker + // that the bytes were embedded (vs the path being silently + // dropped at render time). + assert.match( + ascii, + /\/Subtype\s*\/Image/, + "uploaded brand logo must be embedded as an /Image XObject in the PDF", + ); + // The 1x1 PNG should appear with /Width 1 in the image + // dictionary — a stronger guarantee that the *uploaded* image + // (and not some other XObject) made it through. + assert.match( + ascii, + /\/Width\s+1[^0-9]/, + "embedded image dictionary must reflect the 1x1 source PNG", + ); + } finally { + await api( + adminCookie, + "PUT", + "/api/executive-meetings/font-settings", + { + scope: "global", + fontFamily: "system", + fontSize: 14, + fontWeight: "regular", + alignment: "start", + fontColor: "#000000", + logoObjectPath: null, + }, + ); + } +}); + test("Sanitization: rich text strips disallowed tags but keeps inline formatting", async () => { const dirty = 'Hello World' + diff --git a/artifacts/tx-os/src/locales/ar.json b/artifacts/tx-os/src/locales/ar.json index d4f33ce9..688c2e35 100644 --- a/artifacts/tx-os/src/locales/ar.json +++ b/artifacts/tx-os/src/locales/ar.json @@ -1408,7 +1408,13 @@ "archivesHeading": "ملفات PDF المؤرشفة", "noArchives": "لا توجد ملفات PDF مؤرشفة لهذا التاريخ بعد.", "downloadArchive": "تنزيل", - "legacyArchive": "نسخة قديمة" + "legacyArchive": "نسخة قديمة", + "title": "قائمة بأسماء حضور الاجتماعات", + "no": "م", + "meeting": "الاجتماع", + "attendees": "الحضور", + "time": "الوقت", + "none": "لا توجد اجتماعات في هذا اليوم." }, "fontSettingsPage": { "heading": "إعدادات الخط", diff --git a/artifacts/tx-os/src/locales/en.json b/artifacts/tx-os/src/locales/en.json index 614eae72..6f3899fe 100644 --- a/artifacts/tx-os/src/locales/en.json +++ b/artifacts/tx-os/src/locales/en.json @@ -1274,7 +1274,13 @@ "archivesHeading": "Archived PDFs", "noArchives": "No PDFs archived for this date yet.", "downloadArchive": "Download", - "legacyArchive": "Legacy snapshot" + "legacyArchive": "Legacy snapshot", + "title": "Meeting Attendance List", + "no": "#", + "meeting": "Meeting", + "attendees": "Attendees", + "time": "Time", + "none": "No meetings on this day." }, "fontSettingsPage": { "heading": "Font Settings",