From 2158aa97dd45b5767f7f1db470ece2dca95569f8 Mon Sep 17 00:00:00 2001 From: Riyadh Date: Sun, 3 May 2026 19:27:11 +0000 Subject: [PATCH] Task #351: Match Executive Meetings PDF to user's reference layout MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit User compared the rendered PDF against their reference (rrr1) and flagged three concrete differences. This change addresses all three: 1. Time format — formatTimeRange now joins start/end with U+2011 NON-BREAKING HYPHEN (no surrounding spaces): "9:40‑9:50" instead of "9:40 – 9:50". The non-breaking hyphen looks identical to ASCII "-" and prevents PDFKit from wrapping the range across two lines in the narrow Time column. 2. Date placement — removed the ISO date that was printed under the header title. Added a new bottom-aligned footer block on the leading edge (right for AR, left for EN) that prints: • "مقيد" / "Recorded by" (bold) • Long-form date "3 May 2026" (always Latin, English month name even on the AR PDF, mirroring the reference) The date row is forced to baseDirection="ltr" so RTL bidi doesn't visually reorder the runs into "May 2026 3" on the AR PDF. formatLongDate parses the ISO route param in UTC to avoid host-tz day drift. The footer is extracted into a drawFooter() helper and called in BOTH the empty-day and populated-day code paths so it always renders. 3. Title weight — title was already calling drawMixedLine with weight:"bold" but added explicit confirmation; column proportions tweaked (6/36/39/19) so the wider Time column doesn't force range wrapping while keeping enough room for Meeting/Attendees content. Other touches: - Added `recordedBy` to PdfLabels type + RenderPdfInput.labels type and to ar.json/en.json executiveMeetings.pdf blocks. - Page-break check reserves footer height so the last row never overlaps the bottom footer. Verified: PDF Arabic shaping regression test passes; live AR/EN PDFs match the reference; one-page-fit still holds for typical days. Pre-existing tsc errors in executive-meetings.ts and unrelated test suite failures are not caused by this work. --- artifacts/api-server/src/lib/pdf-labels.ts | 3 + artifacts/api-server/src/lib/pdf-renderer.ts | 103 ++++++++++++++----- artifacts/tx-os/src/locales/ar.json | 3 +- artifacts/tx-os/src/locales/en.json | 3 +- 4 files changed, 83 insertions(+), 29 deletions(-) diff --git a/artifacts/api-server/src/lib/pdf-labels.ts b/artifacts/api-server/src/lib/pdf-labels.ts index 7c9df887..70b4d554 100644 --- a/artifacts/api-server/src/lib/pdf-labels.ts +++ b/artifacts/api-server/src/lib/pdf-labels.ts @@ -18,6 +18,7 @@ export type PdfLabels = { attendees: string; time: string; none: string; + recordedBy: string; }; export const PDF_LABELS: Record = { @@ -29,6 +30,7 @@ export const PDF_LABELS: Record = { attendees: "الحضور", time: "الوقت", none: "لا توجد اجتماعات في هذا اليوم.", + recordedBy: "مقيد", }, en: { title: "Meeting Attendance List", @@ -37,6 +39,7 @@ export const PDF_LABELS: Record = { attendees: "Attendees", time: "Time", none: "No meetings on this day.", + recordedBy: "Recorded by", }, }; diff --git a/artifacts/api-server/src/lib/pdf-renderer.ts b/artifacts/api-server/src/lib/pdf-renderer.ts index 5dc71ab8..d22f9121 100644 --- a/artifacts/api-server/src/lib/pdf-renderer.ts +++ b/artifacts/api-server/src/lib/pdf-renderer.ts @@ -107,6 +107,7 @@ export type RenderPdfInput = { attendees: string; time: string; none: string; + recordedBy: string; }; // PNG/JPEG bytes for the top-left header logo; undefined = no logo. logo?: Buffer | null; @@ -440,11 +441,33 @@ function formatTimeRange( .join("") .trim(); }; - if (startTime && endTime) return `${fmt(startTime)} – ${fmt(endTime)}`; + // U+2011 NON-BREAKING HYPHEN keeps the time range on a single line in + // the narrow Time column. It renders identically to an ASCII hyphen. + if (startTime && endTime) return `${fmt(startTime)}\u2011${fmt(endTime)}`; if (startTime) return fmt(startTime); return "—"; } +// Long-form date for the footer block ("3 May 2026"). We always emit +// Latin digits and an English month name — even on the AR PDF — to mirror +// the user's reference layout (rrr1) and keep DIN Next LT Arabic +// rendering consistent across locales. +function formatLongDate(iso: string): string { + // `iso` is the YYYY-MM-DD route param; build a UTC date so the day + // doesn't drift by host timezone. + const m = /^(\d{4})-(\d{2})-(\d{2})$/.exec(iso); + if (!m) return iso; + const [, y, mo, d] = m; + const dt = new Date(Date.UTC(Number(y), Number(mo) - 1, Number(d))); + if (Number.isNaN(dt.getTime())) return iso; + return new Intl.DateTimeFormat("en-GB", { + day: "numeric", + month: "long", + year: "numeric", + timeZone: "UTC", + }).format(dt); +} + type DrawOpts = { x: number; y: number; @@ -616,16 +639,6 @@ export async function renderSchedulePdf(input: RenderPdfInput): Promise }); doc.y = headerTopY + Math.max(titleSize * 1.25, input.logo ? logoBoxSize : 0); doc.moveDown(0.2); - drawMixedLine(doc, input.date, { - x: doc.page.margins.left, - y: doc.y, - width: doc.page.width - doc.page.margins.left - doc.page.margins.right, - fontSize: Math.max(11, Math.round(input.font.fontSize * 0.85)), - weight: "regular", - align: "center", - baseDirection: baseDir, - mapping, - }); doc.moveDown(0.4); if (input.meetings.length === 0) { @@ -639,11 +652,46 @@ export async function renderSchedulePdf(input: RenderPdfInput): Promise baseDirection: baseDir, mapping, }); + drawFooter(); doc.end(); await done; return Buffer.concat(chunks); } + function drawFooter(): void { + const longDate = formatLongDate(input.date); + const labelSize = Math.max(11, Math.round(input.font.fontSize * 0.95)); + const dateSize = Math.max(11, Math.round(input.font.fontSize * 0.95)); + const footerHeight = labelSize * 1.4 + dateSize * 1.4; + const footerWidth = + doc.page.width - doc.page.margins.left - doc.page.margins.right; + const footerY = doc.page.height - doc.page.margins.bottom - footerHeight; + const footerAlign: "left" | "right" = baseDir === "rtl" ? "right" : "left"; + drawMixedLine(doc, input.labels.recordedBy, { + x: doc.page.margins.left, + y: footerY, + width: footerWidth, + fontSize: labelSize, + weight: "bold", + align: footerAlign, + baseDirection: baseDir, + mapping, + }); + drawMixedLine(doc, longDate, { + x: doc.page.margins.left, + y: footerY + labelSize * 1.4, + width: footerWidth, + fontSize: dateSize, + weight: "regular", + align: footerAlign, + // Force LTR for the date — the long-form date is always Latin + // ("3 May 2026"), so an RTL base direction would visually reorder + // the runs ("May 2026 3"). Alignment still mirrors the locale. + baseDirection: "ltr", + mapping, + }); + } + // ---- Table layout ---- // Mirror the print page's column widths (6 / 30 / 44 / 20). const tableX = doc.page.margins.left; @@ -654,9 +702,11 @@ export async function renderSchedulePdf(input: RenderPdfInput): Promise // and don't need 44%. Mirrors the reference PDF's proportions. const colWidths = [ Math.round(tableWidth * 0.06), - Math.round(tableWidth * 0.38), - Math.round(tableWidth * 0.4), - 0, // last col absorbs rounding + Math.round(tableWidth * 0.36), + Math.round(tableWidth * 0.39), + 0, // last col (Time) absorbs rounding ≈ 19% — wide enough to keep + // a `HH:MM-HH:MM` range on a single line in both languages + // without stealing room from the Attendees column. ]; colWidths[3] = tableWidth - colWidths[0] - colWidths[1] - colWidths[2]; const headerLabels = isRtl @@ -811,8 +861,14 @@ export async function renderSchedulePdf(input: RenderPdfInput): Promise cx += widths[i]; } - // Page break check. - if (probeY + rowHeight > doc.page.height - doc.page.margins.bottom) { + // Page break check. Reserve `footerReserve` so the bottom "Recorded + // by" / date footer never overlaps the last row. + const footerReserve = + Math.max(11, Math.round(input.font.fontSize * 0.95)) * 1.4 * 2 + 6; + if ( + probeY + rowHeight > + doc.page.height - doc.page.margins.bottom - footerReserve + ) { doc.addPage(); drawHeader(); } @@ -872,17 +928,10 @@ export async function renderSchedulePdf(input: RenderPdfInput): Promise doc.y = rowTop + rowHeight; } - // Footer — generation timestamp and font info. - doc.moveDown(0.6); - const footer = `Generated ${new Date().toISOString()} · font=${input.font.fontFamily} ${input.font.fontSize}px ${input.font.fontWeight}`; - doc - .font(mapping.latinRegular) - .fontSize(8) - .fillColor("#6b7280") - .text(footer, doc.page.margins.left, doc.y, { - width: doc.page.width - doc.page.margins.left - doc.page.margins.right, - align: "left", - }); + // Footer — "Recorded by" label + long-form date, anchored near the + // bottom of the (last) page on the leading edge (right for RTL, left + // for LTR) to mirror the user's reference layout (rrr1). + drawFooter(); doc.end(); await done; diff --git a/artifacts/tx-os/src/locales/ar.json b/artifacts/tx-os/src/locales/ar.json index 688c2e35..08b83e6a 100644 --- a/artifacts/tx-os/src/locales/ar.json +++ b/artifacts/tx-os/src/locales/ar.json @@ -1414,7 +1414,8 @@ "meeting": "الاجتماع", "attendees": "الحضور", "time": "الوقت", - "none": "لا توجد اجتماعات في هذا اليوم." + "none": "لا توجد اجتماعات في هذا اليوم.", + "recordedBy": "مقيد" }, "fontSettingsPage": { "heading": "إعدادات الخط", diff --git a/artifacts/tx-os/src/locales/en.json b/artifacts/tx-os/src/locales/en.json index 6f3899fe..269464fb 100644 --- a/artifacts/tx-os/src/locales/en.json +++ b/artifacts/tx-os/src/locales/en.json @@ -1280,7 +1280,8 @@ "meeting": "Meeting", "attendees": "Attendees", "time": "Time", - "none": "No meetings on this day." + "none": "No meetings on this day.", + "recordedBy": "Recorded by" }, "fontSettingsPage": { "heading": "Font Settings",