Files
TX/artifacts/api-server/src/lib/pdf-renderer.ts
T
riyadhafraa 328d867975 Improve PDF schedule layout for better readability and alignment
Adjust PDF rendering to ensure all meeting details (number, title, attendees, time) are contained within a single row, improve vertical alignment to the top, and set correct text alignment for RTL content.

Replit-Commit-Author: Agent
Replit-Commit-Session-Id: 77cfe984-2c65-4152-bb7a-0df28274fe66
Replit-Commit-Checkpoint-Type: full_checkpoint
Replit-Commit-Event-Id: 5cba573f-cf8f-4641-b2db-92b5b87be569
Replit-Commit-Screenshot-Url: https://storage.googleapis.com/screenshot-production-us-central1/c3c252e4-c83d-40ca-9fff-99a3ea60701e/77cfe984-2c65-4152-bb7a-0df28274fe66/Zh9QhRk
Replit-Helium-Checkpoint-Created: true
2026-05-04 09:59:03 +00:00

1035 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 = 2;
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: isRtl ? "right" : "left",
},
{
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: isRtl ? "right" : "left",
},
{
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");
let y = rowTop + cellPadY;
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);
}