Files
TX/artifacts/api-server/src/lib/pdf-renderer.ts
T
Riyadh 260716a33f Update PDF generation to match reference design and improve alignment
Revert PDF cell padding and text alignment to match the reference design, ensuring centered content and vertical alignment.
2026-05-04 10:06:22 +00:00

1037 lines
35 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;
isHighlighted?: number;
rowColor?: string | null;
attendees: PdfMeetingAttendee[];
mergeStartColumn?: string | null;
mergeEndColumn?: string | null;
mergeText?: string | null;
};
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",
dinNextRegular: "DINNextLTArabic-Regular.ttf",
dinNextBold: "DINNextLTArabic-Bold.ttf",
tajawalRegular: "Tajawal-Regular.ttf",
tajawalBold: "Tajawal-Bold.ttf",
helvNeueLTArRegular: "HelveticaNeueLTArabic-Regular.ttf",
helvNeueLTArBold: "HelveticaNeueLTArabic-Bold.ttf",
helvNeueRegular: "HelveticaNeue-Regular.ttf",
helvNeueBold: "HelveticaNeue-Bold.ttf",
majallaRegular: "Majalla-Regular.ttf",
majallaBold: "Majalla-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;
};
// Every user-facing font family now maps to its actual bundled font
// files. The user uploaded all font assets so the PDF renders with the
// exact typeface chosen in settings — no more Noto stand-ins.
const FAMILY_MAP: Record<string, FamilyMapping> = {
system: {
arabicRegular: "naskhRegular",
arabicBold: "naskhBold",
latinRegular: "latinRegular",
latinBold: "latinBold",
},
"DIN Next LT Arabic": {
arabicRegular: "dinNextRegular",
arabicBold: "dinNextBold",
latinRegular: "dinNextRegular",
latinBold: "dinNextBold",
},
Tajawal: {
arabicRegular: "tajawalRegular",
arabicBold: "tajawalBold",
latinRegular: "tajawalRegular",
latinBold: "tajawalBold",
},
"Helvetica Neue LT Arabic": {
arabicRegular: "helvNeueLTArRegular",
arabicBold: "helvNeueLTArBold",
latinRegular: "helvNeueLTArRegular",
latinBold: "helvNeueLTArBold",
},
"Helvetica Neue": {
arabicRegular: "naskhRegular",
arabicBold: "naskhBold",
latinRegular: "helvNeueRegular",
latinBold: "helvNeueBold",
},
Majalla: {
arabicRegular: "majallaRegular",
arabicBold: "majallaBold",
latinRegular: "majallaRegular",
latinBold: "majallaBold",
},
};
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(/&nbsp;/g, " ")
.replace(/&amp;/g, "&")
.replace(/&lt;/g, "<")
.replace(/&gt;/g, ">")
.replace(/&quot;/g, '"')
.replace(/&#39;/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();
};
if (startTime && endTime) return `${fmt(startTime)}-${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;
color?: string;
lineHeight?: number;
maxY?: number;
};
const BIDI_CONTROL_RE = /[\u200E\u200F\u200B\u202A-\u202E\u2066-\u2069]/g;
function stripBidiControls(s: string): string {
return s.replace(BIDI_CONTROL_RE, "");
}
function measureBidiLineWidth(
doc: PDFKit.PDFDocument,
text: string,
opts: Pick<DrawOpts, "fontSize" | "weight" | "baseDirection" | "mapping">,
): number {
const runs = visualOrderRuns(text, opts.baseDirection);
let w = 0;
for (const r of runs) {
const clean = stripBidiControls(r.text);
if (!clean) continue;
const fk = fontKeyFor(r.isArabic, opts.weight, opts.mapping);
const features = featuresFor(r.isArabic);
doc.font(fk).fontSize(opts.fontSize);
w += doc.widthOfString(clean, features ? { features } : {});
}
return w;
}
function drawBidiLine(
doc: PDFKit.PDFDocument,
text: string,
opts: DrawOpts,
): void {
const runs = visualOrderRuns(text, opts.baseDirection);
if (runs.length === 0) return;
let totalWidth = 0;
const measured: { clean: string; isArabic: boolean; width: number; fontKey: string; features: PDFKit.Mixins.OpenTypeFeatures[] | undefined }[] = [];
for (const r of runs) {
const clean = stripBidiControls(r.text);
if (!clean) continue;
const fk = fontKeyFor(r.isArabic, opts.weight, opts.mapping);
const features = featuresFor(r.isArabic);
doc.font(fk).fontSize(opts.fontSize);
const w = doc.widthOfString(clean, features ? { features } : {});
totalWidth += w;
measured.push({ clean, isArabic: r.isArabic, width: w, fontKey: fk, features });
}
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);
}
for (const r of measured) {
doc.font(r.fontKey).fontSize(opts.fontSize).fillColor(opts.color ?? "black");
doc.text(r.clean, cursorX, opts.y, {
lineBreak: false,
width: r.width + 1,
...(r.features ? { features: [...r.features] } : {}),
});
cursorX += r.width;
}
}
function drawMixedLine(
doc: PDFKit.PDFDocument,
text: string,
opts: DrawOpts,
): number {
const lh = opts.lineHeight ?? opts.fontSize * 1.2;
const runs = visualOrderRuns(text, opts.baseDirection);
if (runs.length === 0) {
return lh;
}
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 };
});
if (totalWidth > opts.width + 4) {
return drawWrappingLine(doc, text, opts);
}
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);
}
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 lh;
}
type WrapOpts = Pick<DrawOpts, "fontSize" | "weight" | "baseDirection" | "mapping"> & { width: number };
function computeVisualLines(
doc: PDFKit.PDFDocument,
text: string,
opts: WrapOpts,
): string[] {
if (!text.trim()) return [""];
const words = text.split(/\s+/).filter((w) => w.length > 0);
if (words.length === 0) return [""];
const mOpts = {
fontSize: opts.fontSize,
weight: opts.weight,
baseDirection: opts.baseDirection,
mapping: opts.mapping,
};
const hardBreak = (word: string): string[] => {
if (measureBidiLineWidth(doc, word, mOpts) <= opts.width) return [word];
const chars = [...word];
const pieces: string[] = [];
let cur = "";
for (const ch of chars) {
const test = cur + ch;
if (cur && measureBidiLineWidth(doc, test, mOpts) > opts.width) {
pieces.push(cur);
cur = ch;
} else {
cur = test;
}
}
if (cur) pieces.push(cur);
return pieces;
};
const result: string[] = [];
let current = "";
for (let i = 0; i < words.length; i++) {
if (measureBidiLineWidth(doc, words[i], mOpts) > opts.width) {
if (current) { result.push(current); current = ""; }
for (const piece of hardBreak(words[i])) result.push(piece);
continue;
}
const test = current ? current + " " + words[i] : words[i];
if (measureBidiLineWidth(doc, test, mOpts) > opts.width) {
result.push(current);
current = words[i];
} else {
current = test;
}
}
if (current) result.push(current);
return result.length > 0 ? result : [""];
}
function drawWrappingLine(
doc: PDFKit.PDFDocument,
text: string,
opts: DrawOpts,
): number {
const lh = opts.lineHeight ?? opts.fontSize * 1.2;
const lines = computeVisualLines(doc, text, {
fontSize: opts.fontSize,
weight: opts.weight,
baseDirection: opts.baseDirection,
mapping: opts.mapping,
width: opts.width,
});
let totalH = 0;
for (const vl of lines) {
if (opts.maxY !== undefined && opts.y + totalH + lh > opts.maxY + 0.5) break;
if (vl) drawBidiLine(doc, vl, { ...opts, y: opts.y + totalH });
totalH += lh;
}
return Math.max(lh, totalH);
}
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 * 2.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.2, input.logo ? logoBoxSize : 0);
doc.moveDown(0.3);
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 sz = Math.max(11, Math.round(input.font.fontSize * 0.95));
const lineStep = sz * 1.5;
const footerHeight = lineStep * 2 + 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";
// Temporarily zero-out the bottom margin AND save/restore doc.y so
// PDFKit never auto-paginates when we render near the page bottom.
const savedBottom = doc.page.margins.bottom;
const savedY = doc.y;
doc.page.margins.bottom = 0;
// --- Label line ("مقيد" / "Recorded by") ---
const labelIsArabic = [...input.labels.recordedBy].filter(isArabicChar).length * 2 > input.labels.recordedBy.length;
const labelFont = fontKeyFor(labelIsArabic, "bold", mapping);
const features = featuresFor(labelIsArabic);
doc.font(labelFont).fontSize(sz).fillColor("black");
doc.text(input.labels.recordedBy, doc.page.margins.left, footerY, {
width: footerWidth,
align: footerAlign,
lineBreak: false,
...(features ? { features } : {}),
});
// --- Date line ("3 May 2026") ---
// Reset doc.y BEFORE the second call so PDFKit doesn't think
// we've exceeded the page boundary.
doc.y = footerY;
const dateFont = fontKeyFor(false, "regular", mapping);
doc.font(dateFont).fontSize(sz).fillColor("black");
doc.text(longDate, doc.page.margins.left, footerY + lineStep, {
width: footerWidth,
align: footerAlign,
lineBreak: false,
});
doc.page.margins.bottom = savedBottom;
doc.y = savedY;
}
// ---- Table layout ----
const tableX = doc.page.margins.left;
const tableWidth = doc.page.width - doc.page.margins.left - doc.page.margins.right;
const colWidths = [
Math.round(tableWidth * 0.08),
Math.round(tableWidth * 0.30),
Math.round(tableWidth * 0.42),
0,
];
colWidths[3] = tableWidth - colWidths[0] - colWidths[1] - colWidths[2];
const headerLabels = isRtl
? [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 = 4;
const cellPadY = 0;
const lineHeight = input.font.fontSize * 1.05;
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;
const MERGE_COL_INDEX: Record<string, number> = {
number: 0, meeting: 1, attendees: 2, time: 3,
};
// ---- Rows ----
for (const meeting of input.meetings) {
let 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: "center",
},
{
text:
meeting.attendees.length === 0
? "—"
: (() => {
const ATTENDEES_PER_LINE = 3;
let personIdx = 0;
const parts: string[] = [];
let currentGroup: string[] = [];
const flushGroup = () => {
if (currentGroup.length === 0) return;
for (let i = 0; i < currentGroup.length; i += ATTENDEES_PER_LINE) {
parts.push(currentGroup.slice(i, i + ATTENDEES_PER_LINE).join(" "));
}
currentGroup = [];
};
for (const a of meeting.attendees) {
const isSub = (a.kind ?? "person") === "subheading";
const name = htmlToPlain(a.name);
if (isSub) {
flushGroup();
personIdx = 0;
parts.push(`${name}`);
} else {
personIdx += 1;
const t = a.title?.trim();
currentGroup.push(`\u202A${personIdx}-\u202C ${name}${t ? ` (${t})` : ""}`);
}
}
flushGroup();
return parts.join("\n");
})(),
align: "center",
},
{
text: formatTimeRange(meeting.startTime, meeting.endTime, input.lang),
align: "center",
},
];
let cellWidths = [...colWidths];
if (meeting.mergeStartColumn && meeting.mergeEndColumn) {
const startIdx = MERGE_COL_INDEX[meeting.mergeStartColumn];
const endIdx = MERGE_COL_INDEX[meeting.mergeEndColumn];
if (startIdx !== undefined && endIdx !== undefined && startIdx <= endIdx) {
const mergedWidth = cellWidths.slice(startIdx, endIdx + 1).reduce((a, b) => a + b, 0);
const mergedCell = { text: meeting.mergeText ? htmlToPlain(meeting.mergeText) : "", align: "center" as const };
cellsLogical = [
...cellsLogical.slice(0, startIdx),
mergedCell,
...cellsLogical.slice(endIdx + 1),
];
cellWidths = [
...cellWidths.slice(0, startIdx),
mergedWidth,
...cellWidths.slice(endIdx + 1),
];
}
}
const cells = isRtl ? [...cellsLogical].reverse() : cellsLogical;
const widths = isRtl ? [...cellWidths].reverse() : cellWidths;
const savedProbeY = doc.y;
const wrapOpts: WrapOpts = {
fontSize: input.font.fontSize,
weight: input.font.fontWeight,
baseDirection: baseDir,
mapping,
width: 0,
};
let rowHeight = lineHeight + cellPadY * 2;
const cellHeights: number[] = [];
for (let i = 0; i < cells.length; i++) {
const cellW = widths[i] - cellPadX * 2;
const textLines = cells[i].text.split("\n");
let h = 0;
for (const tl of textLines) {
if (!tl) { h += lineHeight; continue; }
const vLines = computeVisualLines(doc, tl, { ...wrapOpts, width: cellW });
h += vLines.length * lineHeight;
}
const finalH = h + cellPadY * 2;
cellHeights.push(finalH);
if (finalH > rowHeight) rowHeight = finalH;
}
doc.y = savedProbeY;
const footerReserve =
Math.max(11, Math.round(input.font.fontSize * 0.95)) * 1.4 * 2 + 6;
if (
savedProbeY + rowHeight >
doc.page.height - doc.page.margins.bottom - footerReserve
) {
doc.addPage();
drawHeader();
}
const rowTop = doc.y;
if (meeting.rowColor) {
const fill = ROW_COLOR_FILL[meeting.rowColor];
if (fill) {
doc.save();
doc.rect(tableX, rowTop, tableWidth, rowHeight).fill(fill);
doc.restore();
}
}
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();
const rowBottom = rowTop + rowHeight;
let cx = tableX;
for (let i = 0; i < cells.length; i++) {
const savedCellY = doc.y;
const c = cells[i];
const cellW = widths[i] - cellPadX * 2;
const textLines = c.text.split("\n");
const contentH = cellHeights[i] - cellPadY * 2;
const vOffset = Math.max(0, (rowHeight - cellPadY * 2 - contentH) / 2);
let y = rowTop + cellPadY + vOffset;
for (const tl of textLines) {
if (y >= rowBottom) break;
if (!tl) { y += lineHeight; continue; }
const used = drawWrappingLine(doc, tl, {
x: cx + cellPadX,
y,
width: cellW,
fontSize: input.font.fontSize,
weight: input.font.fontWeight,
align: c.align,
baseDirection: baseDir,
mapping,
color: input.font.fontColor,
lineHeight,
maxY: rowBottom,
});
y += used;
}
doc.y = savedCellY;
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);
}