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