Task #349: Executive Meetings PDF improvements

- 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/<id>$/ 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/<id> -> /api/storage/objects/<id>
  (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.
This commit is contained in:
riyadhafraa
2026-05-03 14:43:22 +00:00
parent 57e8297464
commit 1c7c91e8f2
2 changed files with 146 additions and 1 deletions
@@ -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 <script>alert(1)</script><strong style="color:#ff0000">World</strong>' +
@@ -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({
<div className="flex flex-col gap-2">
{logoPath && (
<div className="flex items-center gap-3">
{/*
Stored object paths look like `/objects/<id>` but
the API actually serves them at
`/api/storage/objects/<id>`. resolveServiceImageUrl
centralises that prefix mapping (same helper the
chat avatars use) so the preview thumbnail loads.
*/}
<img
src={logoPath}
src={resolveServiceImageUrl(logoPath) ?? ""}
alt="logo"
className="h-14 w-14 object-contain border border-gray-200 rounded bg-white"
/>