Task #351: Match Executive Meetings PDF to user's reference layout

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.
This commit is contained in:
Riyadh
2026-05-03 19:27:11 +00:00
parent 8bb65fa768
commit 2158aa97dd
4 changed files with 83 additions and 29 deletions
@@ -18,6 +18,7 @@ export type PdfLabels = {
attendees: string;
time: string;
none: string;
recordedBy: string;
};
export const PDF_LABELS: Record<PdfLang, PdfLabels> = {
@@ -29,6 +30,7 @@ export const PDF_LABELS: Record<PdfLang, PdfLabels> = {
attendees: "الحضور",
time: "الوقت",
none: "لا توجد اجتماعات في هذا اليوم.",
recordedBy: "مقيد",
},
en: {
title: "Meeting Attendance List",
@@ -37,6 +39,7 @@ export const PDF_LABELS: Record<PdfLang, PdfLabels> = {
attendees: "Attendees",
time: "Time",
none: "No meetings on this day.",
recordedBy: "Recorded by",
},
};
+76 -27
View File
@@ -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<Buffer>
});
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<Buffer>
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<Buffer>
// 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<Buffer>
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<Buffer>
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;
+2 -1
View File
@@ -1414,7 +1414,8 @@
"meeting": "الاجتماع",
"attendees": "الحضور",
"time": "الوقت",
"none": "لا توجد اجتماعات في هذا اليوم."
"none": "لا توجد اجتماعات في هذا اليوم.",
"recordedBy": "مقيد"
},
"fontSettingsPage": {
"heading": "إعدادات الخط",
+2 -1
View File
@@ -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",