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:
@@ -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"
|
||||
/>
|
||||
|
||||
Reference in New Issue
Block a user