2158aa97dd
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.
946 lines
34 KiB
TypeScript
946 lines
34 KiB
TypeScript
import { fileURLToPath } from "node:url";
|
|
import path from "node:path";
|
|
import { readFileSync, existsSync } from "node:fs";
|
|
import bidiFactory from "bidi-js";
|
|
import PDFDocument from "pdfkit";
|
|
// CommonJS module — pulled in via createRequire so the TS->ESM build
|
|
// doesn't try to statically resolve a default export that doesn't exist.
|
|
import { createRequire } from "node:module";
|
|
|
|
import sanitizeHtml from "sanitize-html";
|
|
|
|
const bidi = bidiFactory();
|
|
|
|
const reshaperRequire = createRequire(import.meta.url);
|
|
const { ArabicShaper } = reshaperRequire("arabic-persian-reshaper") as {
|
|
ArabicShaper: { convertArabic: (input: string) => string };
|
|
};
|
|
|
|
// Convert base Arabic letters (U+0600..U+06FF) to their contextual
|
|
// presentation forms (Arabic Presentation Forms-A/B).
|
|
//
|
|
// HISTORICAL NOTE: this used to run unconditionally before drawing,
|
|
// because the original PDFKit (without fontkit GSUB features) had no
|
|
// shaper. The current renderer hands raw Unicode + ARABIC_FEATURES to
|
|
// pdfkit so fontkit performs shaping AND visual reorder itself; doing
|
|
// both this pre-shape AND the in-pdfkit shaping double-processes the
|
|
// string and renders it visually mirrored. The function is retained
|
|
// only as a public export for downstream callers (tests, ad-hoc
|
|
// scripts) that explicitly want pre-shaped output. The renderer no
|
|
// longer calls it.
|
|
export function shapeArabic(text: string): string {
|
|
if (!text) return text;
|
|
// Fast skip: nothing to shape if there are no base-form Arabic chars.
|
|
// Covers Arabic (0600-06FF), Arabic Supplement (0750-077F), and
|
|
// Arabic Extended-A (08A0-08FF) so non-standard Arabic-script letters
|
|
// also trigger the shaping pass.
|
|
let hasBaseArabic = false;
|
|
for (const ch of text) {
|
|
const c = ch.codePointAt(0) ?? 0;
|
|
if (
|
|
(c >= 0x0600 && c <= 0x06ff) ||
|
|
(c >= 0x0750 && c <= 0x077f) ||
|
|
(c >= 0x08a0 && c <= 0x08ff)
|
|
) {
|
|
hasBaseArabic = true;
|
|
break;
|
|
}
|
|
}
|
|
if (!hasBaseArabic) return text;
|
|
return ArabicShaper.convertArabic(text);
|
|
}
|
|
|
|
export type PdfFontPrefs = {
|
|
fontFamily: string;
|
|
fontSize: number;
|
|
fontWeight: "regular" | "bold";
|
|
alignment: "start" | "center";
|
|
// Body-text fill; header chrome ignores this.
|
|
fontColor: string;
|
|
};
|
|
|
|
// Keep in sync with ROW_COLOR_OPTIONS in tx-os executive-meetings.tsx.
|
|
const ROW_COLOR_FILL: Record<string, string> = {
|
|
red: "#fee2e2",
|
|
amber: "#fef3c7",
|
|
green: "#dcfce7",
|
|
blue: "#dbeafe",
|
|
violet: "#ede9fe",
|
|
gray: "#f3f4f6",
|
|
};
|
|
|
|
export type PdfMeetingAttendee = {
|
|
name: string;
|
|
title: string | null;
|
|
attendanceType?: string | null;
|
|
// "person" (default) or "subheading". The renderer skips subheadings
|
|
// for the running attendee number and prints them as "— label —".
|
|
// Typed as a plain string (rather than the literal union) because the
|
|
// DB column is just a varchar; the renderer normalises unknown values
|
|
// back to "person" at the comparison site.
|
|
kind?: string | null;
|
|
};
|
|
|
|
export type PdfMeeting = {
|
|
dailyNumber: number;
|
|
titleAr: string;
|
|
titleEn: string | null;
|
|
startTime: string | null;
|
|
endTime: string | null;
|
|
location: string | null;
|
|
// Legacy field; ignored by renderer (use rowColor instead).
|
|
isHighlighted?: number;
|
|
// Palette key from ROW_COLOR_FILL; unknown/null = no tint.
|
|
rowColor?: string | null;
|
|
attendees: PdfMeetingAttendee[];
|
|
};
|
|
|
|
export type RenderPdfInput = {
|
|
date: string;
|
|
lang: "ar" | "en";
|
|
font: PdfFontPrefs;
|
|
meetings: PdfMeeting[];
|
|
labels: {
|
|
title: string;
|
|
no: string;
|
|
meeting: string;
|
|
attendees: string;
|
|
time: string;
|
|
none: string;
|
|
recordedBy: string;
|
|
};
|
|
// PNG/JPEG bytes for the top-left header logo; undefined = no logo.
|
|
logo?: Buffer | null;
|
|
};
|
|
|
|
// Resolve fonts relative to the bundled dist file (esbuild rewrites
|
|
// __dirname via the build banner so this works in both dev and prod).
|
|
function fontsDir(): string {
|
|
const here =
|
|
typeof __dirname === "string"
|
|
? __dirname
|
|
: path.dirname(fileURLToPath(import.meta.url));
|
|
// The fonts are shipped as raw assets next to dist/ — see
|
|
// artifacts/api-server/assets/fonts. From dist/ we go up one level.
|
|
const candidates = [
|
|
path.resolve(here, "..", "assets", "fonts"),
|
|
path.resolve(here, "assets", "fonts"),
|
|
path.resolve(here, "..", "..", "assets", "fonts"),
|
|
];
|
|
for (const c of candidates) {
|
|
if (existsSync(path.join(c, "NotoNaskhArabic-Regular.ttf"))) return c;
|
|
}
|
|
throw new Error(
|
|
`Could not locate bundled PDF fonts. Tried: ${candidates.join(", ")}`,
|
|
);
|
|
}
|
|
|
|
// All font files bundled in artifacts/api-server/assets/fonts. We only
|
|
// load the ones each render needs, but list every file here so the
|
|
// build step (and font-family mapping below) stays declarative.
|
|
const FONT_FILES = {
|
|
naskhRegular: "NotoNaskhArabic-Regular.ttf",
|
|
naskhBold: "NotoNaskhArabic-Bold.ttf",
|
|
arabicSansRegular: "NotoSansArabic-Regular.ttf",
|
|
arabicSansBold: "NotoSansArabic-Bold.ttf",
|
|
latinRegular: "DejaVuSans.ttf",
|
|
latinBold: "DejaVuSans-Bold.ttf",
|
|
} as const;
|
|
|
|
type FontFileKey = keyof typeof FONT_FILES;
|
|
|
|
// Map every supported user-facing font family to a concrete pair of
|
|
// bundled fonts (one for Arabic glyphs, one for Latin glyphs). Naskh
|
|
// families (Noto Naskh Arabic, Amiri, system default) use the Naskh
|
|
// face; sans families (Cairo, Tajawal) use Noto Sans Arabic — visually
|
|
// the closest sans-serif Arabic typeface we can ship without bundling
|
|
// every requested vendor font. Latin glyphs always render in DejaVu
|
|
// Sans so that mixed-script cells share consistent metrics.
|
|
type FamilyMapping = {
|
|
arabicRegular: FontFileKey;
|
|
arabicBold: FontFileKey;
|
|
latinRegular: FontFileKey;
|
|
latinBold: FontFileKey;
|
|
};
|
|
|
|
// Web fonts (DIN Next LT Arabic, Tajawal, Helvetica Neue LT Arabic,
|
|
// Helvetica Neue, Majalla) are not bundled into the PDF renderer
|
|
// because we do not ship their font files server-side. Instead we map
|
|
// each web family to the closest visual stand-in already bundled here:
|
|
// sans-serif Arabic families render with NotoSansArabic; serif/Naskh
|
|
// stand-ins use NotoNaskhArabic; pure-Latin families (Helvetica Neue)
|
|
// fall back to the Naskh pair so Arabic glyphs in mixed strings still
|
|
// render correctly. This keeps PDFs readable without ballooning the
|
|
// server bundle.
|
|
const FAMILY_MAP: Record<string, FamilyMapping> = {
|
|
system: {
|
|
arabicRegular: "naskhRegular",
|
|
arabicBold: "naskhBold",
|
|
latinRegular: "latinRegular",
|
|
latinBold: "latinBold",
|
|
},
|
|
"DIN Next LT Arabic": {
|
|
arabicRegular: "arabicSansRegular",
|
|
arabicBold: "arabicSansBold",
|
|
latinRegular: "latinRegular",
|
|
latinBold: "latinBold",
|
|
},
|
|
Tajawal: {
|
|
arabicRegular: "arabicSansRegular",
|
|
arabicBold: "arabicSansBold",
|
|
latinRegular: "latinRegular",
|
|
latinBold: "latinBold",
|
|
},
|
|
"Helvetica Neue LT Arabic": {
|
|
arabicRegular: "arabicSansRegular",
|
|
arabicBold: "arabicSansBold",
|
|
latinRegular: "latinRegular",
|
|
latinBold: "latinBold",
|
|
},
|
|
"Helvetica Neue": {
|
|
arabicRegular: "naskhRegular",
|
|
arabicBold: "naskhBold",
|
|
latinRegular: "latinRegular",
|
|
latinBold: "latinBold",
|
|
},
|
|
Majalla: {
|
|
arabicRegular: "naskhRegular",
|
|
arabicBold: "naskhBold",
|
|
latinRegular: "latinRegular",
|
|
latinBold: "latinBold",
|
|
},
|
|
};
|
|
|
|
function familyMappingFor(family: string): FamilyMapping {
|
|
return FAMILY_MAP[family] ?? FAMILY_MAP.system;
|
|
}
|
|
|
|
// Family names that primarily target Arabic script. Used by
|
|
// `fontFamilyIsArabic` for downstream callers that branch on
|
|
// script — kept aligned with FAMILY_MAP. "Helvetica Neue" (the Latin
|
|
// face) is intentionally excluded; the others all ship Arabic glyphs.
|
|
const ARABIC_FONT_NAMES: ReadonlyArray<string> = [
|
|
"DIN Next LT Arabic",
|
|
"Tajawal",
|
|
"Helvetica Neue LT Arabic",
|
|
"Majalla",
|
|
];
|
|
|
|
// Bytes are loaded lazily on first render and reused.
|
|
let cachedFontBytes: Partial<Record<FontFileKey, Buffer>> = {};
|
|
function loadFontBytes(keys: ReadonlyArray<FontFileKey>): Record<FontFileKey, Buffer> {
|
|
const dir = fontsDir();
|
|
const out = {} as Record<FontFileKey, Buffer>;
|
|
for (const k of keys) {
|
|
if (!cachedFontBytes[k]) {
|
|
cachedFontBytes[k] = readFileSync(path.join(dir, FONT_FILES[k]));
|
|
}
|
|
out[k] = cachedFontBytes[k] as Buffer;
|
|
}
|
|
return out;
|
|
}
|
|
|
|
// Fast Arabic-script test (Arabic, Arabic Supplement, Arabic Extended-A,
|
|
// Arabic Presentation Forms-A/B). We use it to decide which bundled font
|
|
// covers a given character so mixed Arabic/Latin cells can be rendered
|
|
// without missing-glyph boxes.
|
|
function isArabicChar(ch: string): boolean {
|
|
const c = ch.codePointAt(0) ?? 0;
|
|
return (
|
|
(c >= 0x0600 && c <= 0x06ff) ||
|
|
(c >= 0x0750 && c <= 0x077f) ||
|
|
(c >= 0x08a0 && c <= 0x08ff) ||
|
|
(c >= 0xfb50 && c <= 0xfdff) ||
|
|
(c >= 0xfe70 && c <= 0xfeff)
|
|
);
|
|
}
|
|
|
|
function isLatinChar(ch: string): boolean {
|
|
const c = ch.codePointAt(0) ?? 0;
|
|
// Latin, digits, common punctuation, Latin-1 supplement.
|
|
return (
|
|
(c >= 0x0020 && c <= 0x024f) ||
|
|
(c >= 0x2000 && c <= 0x206f) ||
|
|
(c >= 0x2070 && c <= 0x209f)
|
|
);
|
|
}
|
|
|
|
type Run = { text: string; isArabic: boolean };
|
|
|
|
// OpenType feature tags fontkit needs to (a) shape Arabic into contextual
|
|
// joining forms and (b) reorder Arabic glyphs visually right-to-left
|
|
// inside a single LTR draw call. We pass these via PDFKit's `features`
|
|
// option whenever we draw a run that contains Arabic — pdfkit forwards
|
|
// them straight to fontkit's GSUB layout. Crucially, this makes pdfkit
|
|
// the SOLE owner of shaping + RTL reorder; if we also pre-shape or
|
|
// pre-reorder the string ourselves, fontkit applies a second pass and
|
|
// the result is visually mirrored (the bug this module used to ship).
|
|
const ARABIC_FEATURES: PDFKit.Mixins.OpenTypeFeatures[] = [
|
|
"rtla", // right-to-left alternates: flips digits and mirrored glyphs
|
|
"rclt", // required contextual alternates (Arabic joining)
|
|
"calt", // contextual alternates
|
|
"liga", // standard ligatures (lam-alef, etc.)
|
|
"init",
|
|
"medi",
|
|
"fina",
|
|
"isol",
|
|
];
|
|
|
|
// Split a string into runs of consecutive same-script characters so we
|
|
// can pick the Arabic font for Arabic glyphs and the Latin font for the
|
|
// rest. Whitespace sticks to the surrounding run to avoid a stream of
|
|
// 1-char runs.
|
|
function splitRunsByScript(text: string): Run[] {
|
|
const runs: Run[] = [];
|
|
let current: Run | null = null;
|
|
for (const ch of text) {
|
|
const arabic = isArabicChar(ch);
|
|
const latin = isLatinChar(ch);
|
|
if (!arabic && !latin) {
|
|
// Glyph that neither font may cover — bias toward whatever the
|
|
// current run is so we don't fragment unnecessarily.
|
|
if (current) {
|
|
current.text += ch;
|
|
continue;
|
|
}
|
|
}
|
|
const want = arabic;
|
|
if (!current || current.isArabic !== want) {
|
|
current = { text: ch, isArabic: want };
|
|
runs.push(current);
|
|
} else {
|
|
current.text += ch;
|
|
}
|
|
}
|
|
return runs;
|
|
}
|
|
|
|
// Build draw-order runs for a single line of mixed Arabic/Latin text.
|
|
//
|
|
// Returns an array of runs whose ORDER matches the visual left-to-right
|
|
// painting order. For each run, `text` is the *raw, logical-order*
|
|
// substring (Arabic in U+06xx code points, NOT pre-shaped presentation
|
|
// forms) so that pdfkit/fontkit can run shaping + intra-run RTL reorder
|
|
// itself via the `features` option when we draw.
|
|
//
|
|
// Algorithm:
|
|
// 1. Compute Unicode Bidi embedding levels on the raw text.
|
|
// 2. Walk the text in LOGICAL order, slicing at every level boundary.
|
|
// Each slice is internally consistent direction-wise, so fontkit can
|
|
// reorder it correctly with `rtla` features.
|
|
// 3. If the paragraph base direction is RTL, REVERSE the run array.
|
|
// For RTL bases the visual L-to-R sequence is the logical sequence
|
|
// read backwards (with each LTR-level slice keeping its own LTR
|
|
// internal order — which is exactly what we want).
|
|
function visualOrderRuns(text: string, baseDirection: "ltr" | "rtl"): Run[] {
|
|
if (!text) return [];
|
|
const levels = bidi.getEmbeddingLevels(text, baseDirection);
|
|
const lvlArr = levels.levels as ArrayLike<number>;
|
|
// bidi-js returns one level per code unit — text and lvlArr share length.
|
|
const out: Run[] = [];
|
|
let i = 0;
|
|
const n = text.length;
|
|
while (i < n) {
|
|
const startLevel = lvlArr[i];
|
|
let j = i + 1;
|
|
while (j < n && lvlArr[j] === startLevel) j++;
|
|
const slice = text.slice(i, j);
|
|
// Detect Arabic content for font + features selection. A level-1+
|
|
// odd run is RTL but might still be punctuation/digits; only true
|
|
// Arabic chars need the Arabic font and shaping features.
|
|
const hasArabic = [...slice].some(isArabicChar);
|
|
out.push({ text: slice, isArabic: hasArabic });
|
|
i = j;
|
|
}
|
|
if (baseDirection === "rtl") out.reverse();
|
|
return out;
|
|
}
|
|
|
|
// Returns the pdfkit `features` option appropriate for a run. We want
|
|
// shaping ON for any run containing Arabic glyphs and OFF otherwise so
|
|
// Latin runs (e.g. "9:00 — 10:00") don't get treated as RTL.
|
|
function featuresFor(
|
|
isArabic: boolean,
|
|
): PDFKit.Mixins.OpenTypeFeatures[] | undefined {
|
|
return isArabic ? ARABIC_FEATURES : undefined;
|
|
}
|
|
|
|
function fontKeyFor(
|
|
isArabic: boolean,
|
|
weight: "regular" | "bold",
|
|
mapping: FamilyMapping,
|
|
): FontFileKey {
|
|
if (isArabic) {
|
|
return weight === "bold" ? mapping.arabicBold : mapping.arabicRegular;
|
|
}
|
|
return weight === "bold" ? mapping.latinBold : mapping.latinRegular;
|
|
}
|
|
|
|
function alignment(
|
|
font: PdfFontPrefs,
|
|
isRtl: boolean,
|
|
): "left" | "center" | "right" {
|
|
if (font.alignment === "center") return "center";
|
|
// "start" maps to the writing-direction start.
|
|
return isRtl ? "right" : "left";
|
|
}
|
|
|
|
function htmlToPlain(input: string | null | undefined): string {
|
|
if (!input) return "";
|
|
// Strip every tag so we render plain text — table cells already have
|
|
// their own font/weight/size from the user's font settings, and PDFKit
|
|
// doesn't render arbitrary inline HTML. We DO keep <br> as a newline
|
|
// because attendees often use line breaks.
|
|
const withBreaks = input
|
|
.replace(/<\s*br\s*\/?>/gi, "\n")
|
|
.replace(/<\/(p|div|li)>/gi, "\n")
|
|
.replace(/<li>/gi, "• ");
|
|
const sanitized = sanitizeHtml(withBreaks, {
|
|
allowedTags: [],
|
|
allowedAttributes: {},
|
|
});
|
|
// Decode common entities that sanitize-html leaves intact when no tags
|
|
// are kept (it returns the inner text).
|
|
return sanitized
|
|
.replace(/ /g, " ")
|
|
.replace(/&/g, "&")
|
|
.replace(/</g, "<")
|
|
.replace(/>/g, ">")
|
|
.replace(/"/g, '"')
|
|
.replace(/'/g, "'")
|
|
.replace(/[ \t]+\n/g, "\n")
|
|
.replace(/\n{3,}/g, "\n\n")
|
|
.trim();
|
|
}
|
|
|
|
function formatTimeRange(
|
|
startTime: string | null,
|
|
endTime: string | null,
|
|
lang: "ar" | "en",
|
|
): string {
|
|
const fmt = (t: string): string => {
|
|
const hhmm = t.slice(0, 5);
|
|
const parsed = new Date(`1970-01-01T${hhmm}:00`);
|
|
if (Number.isNaN(parsed.getTime())) return hhmm;
|
|
// 12-hour with NO AM/PM (en) or ص/م (ar) suffix — matches the
|
|
// on-screen schedule. We `formatToParts` and drop the `dayPeriod`,
|
|
// then trim trailing/leading whitespace literals (Intl may use
|
|
// U+00A0 / U+202F — String.prototype.trim handles them all).
|
|
// Force Latin digits via -u-nu-latn so the PDF Time column renders
|
|
// consistently regardless of language.
|
|
const locale = lang === "ar" ? "ar-u-nu-latn" : "en-US-u-nu-latn";
|
|
const parts = new Intl.DateTimeFormat(locale, {
|
|
hour: "numeric",
|
|
minute: "2-digit",
|
|
hour12: true,
|
|
numberingSystem: "latn",
|
|
} as Intl.DateTimeFormatOptions).formatToParts(parsed);
|
|
return parts
|
|
.filter((p) => p.type !== "dayPeriod")
|
|
.map((p) => p.value)
|
|
.join("")
|
|
.trim();
|
|
};
|
|
// 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;
|
|
width: number;
|
|
fontSize: number;
|
|
weight: "regular" | "bold";
|
|
align: "left" | "center" | "right";
|
|
baseDirection: "ltr" | "rtl";
|
|
mapping: FamilyMapping;
|
|
// Optional hex fill; header callers omit it to keep brand colors.
|
|
color?: string;
|
|
};
|
|
|
|
// Draw a single line of text using mixed Arabic/Latin runs (in visual
|
|
// order). We measure each run with the right font and lay them out
|
|
// left-to-right starting from the resolved alignment. Returns the height
|
|
// consumed by this line (including a small line-gap).
|
|
function drawMixedLine(
|
|
doc: PDFKit.PDFDocument,
|
|
text: string,
|
|
opts: DrawOpts,
|
|
): number {
|
|
const runs = visualOrderRuns(text, opts.baseDirection);
|
|
if (runs.length === 0) {
|
|
return opts.fontSize * 1.2;
|
|
}
|
|
// Measure full line width — feature flags must match the draw call so
|
|
// ligatures + RTL alternates produce the same advance width.
|
|
let totalWidth = 0;
|
|
const measured = runs.map((r) => {
|
|
const fk = fontKeyFor(r.isArabic, opts.weight, opts.mapping);
|
|
const features = featuresFor(r.isArabic);
|
|
doc.font(fk).fontSize(opts.fontSize);
|
|
const w = doc.widthOfString(r.text, features ? { features } : {});
|
|
totalWidth += w;
|
|
return { ...r, width: w, fontKey: fk, features };
|
|
});
|
|
const overflow = totalWidth - opts.width;
|
|
let cursorX = opts.x;
|
|
if (opts.align === "center") {
|
|
cursorX = opts.x + Math.max(0, (opts.width - totalWidth) / 2);
|
|
} else if (opts.align === "right") {
|
|
cursorX = opts.x + Math.max(0, opts.width - totalWidth);
|
|
}
|
|
// If we overflow horizontally, fall back to wrapping via PDFKit's own
|
|
// text engine on the first run. Cells in the schedule rarely overflow
|
|
// because column widths are generous, but Arabic titles can be long.
|
|
if (overflow > 4) {
|
|
return drawWrappingLine(doc, text, opts);
|
|
}
|
|
for (const r of measured) {
|
|
doc.font(r.fontKey).fontSize(opts.fontSize).fillColor(opts.color ?? "black");
|
|
doc.text(r.text, cursorX, opts.y, {
|
|
lineBreak: false,
|
|
width: r.width + 1,
|
|
...(r.features ? { features: [...r.features] } : {}),
|
|
});
|
|
cursorX += r.width;
|
|
}
|
|
return opts.fontSize * 1.25;
|
|
}
|
|
|
|
function drawWrappingLine(
|
|
doc: PDFKit.PDFDocument,
|
|
text: string,
|
|
opts: DrawOpts,
|
|
): number {
|
|
// Determine the dominant script for the wrapping pass. Without true
|
|
// bidi line-breaking we pick the "majority" script font to wrap with;
|
|
// this keeps shaping intact for the dominant direction at the cost of
|
|
// a slight metric mismatch on the minority runs.
|
|
const arabicChars = [...text].filter(isArabicChar).length;
|
|
const dominantArabic = arabicChars * 2 > text.length;
|
|
const fk = fontKeyFor(dominantArabic, opts.weight, opts.mapping);
|
|
doc.font(fk).fontSize(opts.fontSize).fillColor(opts.color ?? "black");
|
|
// Hand the RAW (logical-order) string to pdfkit and let fontkit do
|
|
// both Arabic shaping and intra-line RTL reorder via the `features`
|
|
// option. Pre-shaping here would double-process the string and
|
|
// produce visually-mirrored output (the bug we shipped previously).
|
|
const features = featuresFor(dominantArabic);
|
|
const before = doc.y;
|
|
doc.text(text, opts.x, opts.y, {
|
|
width: opts.width,
|
|
align: opts.align,
|
|
lineBreak: true,
|
|
// Tighten the inter-line gap pdfkit applies inside wrapped paragraphs.
|
|
// Default is the font's natural lineGap; setting -1 saves ~1pt per
|
|
// wrapped line which matters when fitting a full day on one page.
|
|
lineGap: -1,
|
|
...(features ? { features } : {}),
|
|
});
|
|
return Math.max(opts.fontSize * 1.25, doc.y - before + 2);
|
|
}
|
|
|
|
export async function renderSchedulePdf(input: RenderPdfInput): Promise<Buffer> {
|
|
const isRtl = input.lang === "ar";
|
|
const baseDir: "ltr" | "rtl" = isRtl ? "rtl" : "ltr";
|
|
const doc = new PDFDocument({
|
|
size: "A4",
|
|
layout: "portrait",
|
|
margin: 24, // ~8.5mm — tightened from 36 so a typical day fits on one A4 page
|
|
info: {
|
|
Title: `${input.labels.title} — ${input.date}`,
|
|
Producer: "Tx OS Executive Meetings",
|
|
},
|
|
});
|
|
const mapping = familyMappingFor(input.font.fontFamily);
|
|
// Load only the four files this family needs (deduped by key).
|
|
const neededKeys = Array.from(
|
|
new Set<FontFileKey>([
|
|
mapping.arabicRegular,
|
|
mapping.arabicBold,
|
|
mapping.latinRegular,
|
|
mapping.latinBold,
|
|
]),
|
|
);
|
|
const bytes = loadFontBytes(neededKeys);
|
|
for (const k of neededKeys) {
|
|
doc.registerFont(k, bytes[k]);
|
|
}
|
|
|
|
const chunks: Buffer[] = [];
|
|
doc.on("data", (c: Buffer) => chunks.push(c));
|
|
const done = new Promise<void>((resolve, reject) => {
|
|
doc.on("end", resolve);
|
|
doc.on("error", reject);
|
|
});
|
|
|
|
// Header: logo (if any) on the left, centered title, date below.
|
|
// Title size capped tighter (22 vs 28) so the header band doesn't eat
|
|
// a row of table space — keeps a typical day on one page.
|
|
const titleSize = Math.min(22, input.font.fontSize + 6);
|
|
const headerLeftEdge = doc.page.margins.left;
|
|
const headerWidth = doc.page.width - doc.page.margins.left - doc.page.margins.right;
|
|
const headerTopY = doc.y;
|
|
const logoBoxSize = titleSize * 1.8;
|
|
let titleX = headerLeftEdge;
|
|
let titleWidth = headerWidth;
|
|
if (input.logo && input.logo.length > 0) {
|
|
try {
|
|
doc.image(input.logo, headerLeftEdge, headerTopY, {
|
|
fit: [logoBoxSize, logoBoxSize],
|
|
});
|
|
// Symmetric reservation keeps the title geometrically centered.
|
|
const gap = 12;
|
|
titleX = headerLeftEdge + logoBoxSize + gap;
|
|
titleWidth = headerWidth - 2 * (logoBoxSize + gap);
|
|
if (titleWidth < headerWidth * 0.4) {
|
|
// Fallback: too narrow for symmetric reservation.
|
|
titleX = headerLeftEdge + logoBoxSize + gap;
|
|
titleWidth = headerWidth - logoBoxSize - gap;
|
|
}
|
|
// Reset doc.y; image() may have advanced it.
|
|
doc.y = headerTopY;
|
|
} catch (err) {
|
|
// Bad image bytes: log and render without the logo.
|
|
console.warn("[pdf-renderer] failed to embed logo, continuing without", err);
|
|
}
|
|
}
|
|
drawMixedLine(doc, input.labels.title, {
|
|
x: titleX,
|
|
y: headerTopY,
|
|
width: titleWidth,
|
|
fontSize: titleSize,
|
|
weight: "bold",
|
|
align: "center",
|
|
baseDirection: baseDir,
|
|
mapping,
|
|
});
|
|
doc.y = headerTopY + Math.max(titleSize * 1.25, input.logo ? logoBoxSize : 0);
|
|
doc.moveDown(0.2);
|
|
doc.moveDown(0.4);
|
|
|
|
if (input.meetings.length === 0) {
|
|
drawMixedLine(doc, input.labels.none, {
|
|
x: doc.page.margins.left,
|
|
y: doc.y,
|
|
width: doc.page.width - doc.page.margins.left - doc.page.margins.right,
|
|
fontSize: input.font.fontSize,
|
|
weight: input.font.fontWeight,
|
|
align: "center",
|
|
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;
|
|
const tableWidth = doc.page.width - doc.page.margins.left - doc.page.margins.right;
|
|
// Column widths re-balanced for the inline-attendees layout: meeting
|
|
// titles can be long and need more room (the previous 30% forced 6-line
|
|
// wraps for typical titles), while attendees now flow as one paragraph
|
|
// and don't need 44%. Mirrors the reference PDF's proportions.
|
|
const colWidths = [
|
|
Math.round(tableWidth * 0.06),
|
|
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
|
|
? // RTL: visually right-to-left ordering matches the schedule's
|
|
// Arabic layout (#, Meeting, Attendees, Time becomes Time first).
|
|
[input.labels.time, input.labels.attendees, input.labels.meeting, input.labels.no]
|
|
: [input.labels.no, input.labels.meeting, input.labels.attendees, input.labels.time];
|
|
const headerWidths = isRtl ? [...colWidths].reverse() : colWidths;
|
|
|
|
const cellPadX = 6;
|
|
// Tightened from 5→3 to keep the table compact (we aim to fit a typical
|
|
// day on one page, mirroring the user's reference layout).
|
|
const cellPadY = 3;
|
|
const lineHeight = input.font.fontSize * 1.25;
|
|
|
|
function drawHeader(): number {
|
|
const headerHeight = lineHeight + cellPadY * 2;
|
|
let cx = tableX;
|
|
const headerY = doc.y;
|
|
doc.save();
|
|
doc.rect(tableX, headerY, tableWidth, headerHeight).fill("#0B1E3F");
|
|
doc.restore();
|
|
for (let i = 0; i < headerLabels.length; i++) {
|
|
const label = headerLabels[i];
|
|
const arabicChars = [...label].filter(isArabicChar).length;
|
|
const dominantArabic = arabicChars * 2 > label.length;
|
|
const fk = fontKeyFor(dominantArabic, "bold", mapping);
|
|
doc.font(fk).fontSize(input.font.fontSize).fillColor("white");
|
|
// Raw label + Arabic features → fontkit shapes & reorders.
|
|
const features = featuresFor(dominantArabic);
|
|
doc.text(label, cx + cellPadX, headerY + cellPadY, {
|
|
width: headerWidths[i] - cellPadX * 2,
|
|
align: "center",
|
|
lineBreak: false,
|
|
...(features ? { features } : {}),
|
|
});
|
|
cx += headerWidths[i];
|
|
}
|
|
doc.fillColor("black");
|
|
doc.y = headerY + headerHeight;
|
|
return headerHeight;
|
|
}
|
|
|
|
// Render the table header on page 1.
|
|
const startY = doc.y;
|
|
const headerHeight = drawHeader();
|
|
doc.y = startY + headerHeight;
|
|
|
|
// ---- Rows ----
|
|
for (const meeting of input.meetings) {
|
|
const cellsLogical: { text: string; align: "left" | "center" | "right" }[] = [
|
|
{ text: String(meeting.dailyNumber), align: "center" },
|
|
{
|
|
text: htmlToPlain(isRtl ? meeting.titleAr : meeting.titleEn || meeting.titleAr) +
|
|
(meeting.location ? `\n${meeting.location}` : ""),
|
|
align: alignment(input.font, isRtl),
|
|
},
|
|
{
|
|
text:
|
|
meeting.attendees.length === 0
|
|
? "—"
|
|
: (() => {
|
|
// Walking person counter — each subheading starts a
|
|
// fresh numbered section, so persons restart at 1- after
|
|
// every subheading. Same logic as the on-screen
|
|
// AttendeeFlow and the client print page.
|
|
let personIdx = 0;
|
|
return meeting.attendees
|
|
.map((a) => {
|
|
const isSub = (a.kind ?? "person") === "subheading";
|
|
const name = htmlToPlain(a.name);
|
|
if (isSub) {
|
|
// Reset the in-section counter so the next person
|
|
// starts at 1.
|
|
personIdx = 0;
|
|
// Brackets keep subheadings visually distinct in the
|
|
// monospace plain-text PDF cell where bold/colour
|
|
// can't carry. Adapter prefix is non-numeric so a
|
|
// human reader can scan it as "section label".
|
|
return `— ${name} —`;
|
|
}
|
|
personIdx += 1;
|
|
const t = a.title?.trim();
|
|
return `${personIdx}- ${name}${t ? ` (${t})` : ""}`;
|
|
})
|
|
// Inline join: attendees flow as one paragraph per cell
|
|
// (wrapped naturally by pdfkit) instead of one-per-line.
|
|
// This keeps the table compact so the schedule fits on a
|
|
// single page when possible. Subheadings still get their
|
|
// own line via the leading "\n" below.
|
|
.join(" ");
|
|
})(),
|
|
align: alignment(input.font, isRtl),
|
|
},
|
|
{
|
|
text: formatTimeRange(meeting.startTime, meeting.endTime, input.lang),
|
|
align: "center",
|
|
},
|
|
];
|
|
const cells = isRtl ? [...cellsLogical].reverse() : cellsLogical;
|
|
const widths = isRtl ? [...colWidths].reverse() : colWidths;
|
|
|
|
// Measure the row height by laying out each cell to a temporary
|
|
// y-cursor and taking the max. We then redraw at the final position.
|
|
const probe = doc;
|
|
const probeY = probe.y;
|
|
let rowHeight = lineHeight + cellPadY * 2;
|
|
const cellHeights: number[] = [];
|
|
let cx = tableX;
|
|
for (let i = 0; i < cells.length; i++) {
|
|
const c = cells[i];
|
|
const startProbeY = probeY + cellPadY;
|
|
probe.y = startProbeY;
|
|
// Use PDFKit's own layout engine (heightOfString) so the probe
|
|
// reserves EXACTLY as much vertical space as the eventual draw
|
|
// call will consume — passing the same font, features, width,
|
|
// and align as drawWrappingLine. A heuristic ceil(w/availableW)
|
|
// probe under-counts whenever pdfkit's word-break engine wraps
|
|
// sooner than a simple division would, causing rows to overlap.
|
|
const lines = c.text.split("\n");
|
|
let h = 0;
|
|
const arabicChars = [...c.text].filter(isArabicChar).length;
|
|
const dominantArabic = arabicChars * 2 > c.text.length;
|
|
const fk = fontKeyFor(dominantArabic, input.font.fontWeight, mapping);
|
|
const features = featuresFor(dominantArabic);
|
|
doc.font(fk).fontSize(input.font.fontSize);
|
|
const measureOpts: PDFKit.Mixins.TextOptions = {
|
|
width: widths[i] - cellPadX * 2,
|
|
align: c.align,
|
|
lineBreak: true,
|
|
// Mirror drawWrappingLine's lineGap so the probe matches the draw.
|
|
lineGap: -1,
|
|
...(features ? { features } : {}),
|
|
};
|
|
for (const line of lines) {
|
|
if (!line) {
|
|
h += lineHeight;
|
|
continue;
|
|
}
|
|
// heightOfString returns pdfkit's exact rendered height for the
|
|
// wrapped paragraph (including its internal line gaps). Use it
|
|
// directly — rounding up to whole lineHeight units inflates rows
|
|
// by half a line and leaves visible empty space below cells.
|
|
// The +2 mirrors drawWrappingLine's per-line padding fallback so
|
|
// multi-line cells never get under-reserved.
|
|
const measured = doc.heightOfString(line, measureOpts);
|
|
h += Math.max(lineHeight, measured) + 2;
|
|
}
|
|
const finalH = h + cellPadY * 2;
|
|
cellHeights.push(finalH);
|
|
if (finalH > rowHeight) rowHeight = finalH;
|
|
cx += widths[i];
|
|
}
|
|
|
|
// 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();
|
|
}
|
|
|
|
const rowTop = doc.y;
|
|
// Saved per-row tint (#288); isHighlighted is not painted.
|
|
if (meeting.rowColor) {
|
|
const fill = ROW_COLOR_FILL[meeting.rowColor];
|
|
if (fill) {
|
|
doc.save();
|
|
doc.rect(tableX, rowTop, tableWidth, rowHeight).fill(fill);
|
|
doc.restore();
|
|
}
|
|
}
|
|
|
|
// Cell borders.
|
|
doc.save();
|
|
doc.lineWidth(0.5).strokeColor("#d1d5db");
|
|
doc.rect(tableX, rowTop, tableWidth, rowHeight).stroke();
|
|
let bx = tableX;
|
|
for (let i = 0; i < cells.length - 1; i++) {
|
|
bx += widths[i];
|
|
doc
|
|
.moveTo(bx, rowTop)
|
|
.lineTo(bx, rowTop + rowHeight)
|
|
.stroke();
|
|
}
|
|
doc.restore();
|
|
|
|
// Draw cell contents.
|
|
cx = tableX;
|
|
for (let i = 0; i < cells.length; i++) {
|
|
const c = cells[i];
|
|
const cellW = widths[i] - cellPadX * 2;
|
|
const lines = c.text.split("\n");
|
|
let y = rowTop + cellPadY;
|
|
for (const line of lines) {
|
|
if (!line) {
|
|
y += lineHeight;
|
|
continue;
|
|
}
|
|
const used = drawWrappingLine(doc, line, {
|
|
x: cx + cellPadX,
|
|
y,
|
|
width: cellW,
|
|
fontSize: input.font.fontSize,
|
|
weight: input.font.fontWeight,
|
|
align: c.align,
|
|
baseDirection: baseDir,
|
|
mapping,
|
|
color: input.font.fontColor,
|
|
});
|
|
y += used;
|
|
}
|
|
cx += widths[i];
|
|
}
|
|
doc.y = rowTop + rowHeight;
|
|
}
|
|
|
|
// 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;
|
|
return Buffer.concat(chunks);
|
|
}
|
|
|
|
// Keep the explicit "is this an arabic-leaning family?" check exposed for
|
|
// tests / future logic that wants to know the user's font choice.
|
|
export function fontFamilyIsArabic(name: string): boolean {
|
|
return ARABIC_FONT_NAMES.includes(name);
|
|
}
|