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"; import sanitizeHtml from "sanitize-html"; const bidi = bidiFactory(); export type PdfFontPrefs = { fontFamily: string; fontSize: number; fontWeight: "regular" | "bold"; alignment: "start" | "center"; }; 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; 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; }; }; // 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; }; const FAMILY_MAP: Record = { system: { arabicRegular: "naskhRegular", arabicBold: "naskhBold", latinRegular: "latinRegular", latinBold: "latinBold", }, Cairo: { arabicRegular: "arabicSansRegular", arabicBold: "arabicSansBold", latinRegular: "latinRegular", latinBold: "latinBold", }, Tajawal: { arabicRegular: "arabicSansRegular", arabicBold: "arabicSansBold", latinRegular: "latinRegular", latinBold: "latinBold", }, "Noto Naskh Arabic": { arabicRegular: "naskhRegular", arabicBold: "naskhBold", latinRegular: "latinRegular", latinBold: "latinBold", }, Amiri: { arabicRegular: "naskhRegular", arabicBold: "naskhBold", latinRegular: "latinRegular", latinBold: "latinBold", }, }; function familyMappingFor(family: string): FamilyMapping { return FAMILY_MAP[family] ?? FAMILY_MAP.system; } const ARABIC_FONT_NAMES: ReadonlyArray = [ "Cairo", "Tajawal", "Noto Naskh Arabic", "Amiri", ]; // Bytes are loaded lazily on first render and reused. let cachedFontBytes: Partial> = {}; function loadFontBytes(keys: ReadonlyArray): Record { const dir = fontsDir(); const out = {} as Record; 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 }; // 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; } // Apply Unicode Bidi to a string and return runs in *visual* order (which // is the order PDFKit will paint them along the line). Without this, mixed // Arabic/Latin/digit cells look wrong (e.g. "10:00 - 11:00" inside an // otherwise-RTL line gets digit-reversed by the renderer). function visualOrderRuns(text: string, baseDirection: "ltr" | "rtl"): Run[] { if (!text) return []; const embeddingLevels = bidi.getEmbeddingLevels(text, baseDirection); const reordered = bidi.getReorderedString(text, embeddingLevels); return splitRunsByScript(reordered); } 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
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(/
  • /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; return new Intl.DateTimeFormat(lang === "ar" ? "ar" : "en", { hour: "numeric", minute: "2-digit", hour12: true, // Force Latin digits even for Arabic locale so the Time column // renders consistently across the schedule and the PDF — matches // i18nFormatTime in the frontend. numberingSystem: "latn", } as Intl.DateTimeFormatOptions).format(parsed); }; if (startTime && endTime) return `${fmt(startTime)} – ${fmt(endTime)}`; if (startTime) return fmt(startTime); return "—"; } type DrawOpts = { x: number; y: number; width: number; fontSize: number; weight: "regular" | "bold"; align: "left" | "center" | "right"; baseDirection: "ltr" | "rtl"; mapping: FamilyMapping; }; // 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. let totalWidth = 0; const measured = runs.map((r) => { const fk = fontKeyFor(r.isArabic, opts.weight, opts.mapping); doc.font(fk).fontSize(opts.fontSize); const w = doc.widthOfString(r.text); totalWidth += w; return { ...r, width: w, fontKey: fk }; }); 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("black"); doc.text(r.text, cursorX, opts.y, { lineBreak: false, width: r.width + 1, }); 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("black"); // For Arabic text, run bidi on the whole string so the font sees runs // already in visual order — this matches what drawMixedLine does for // single-line cells. const reordered = bidi.getReorderedString( text, bidi.getEmbeddingLevels(text, opts.baseDirection), ); const before = doc.y; doc.text(reordered, opts.x, opts.y, { width: opts.width, align: opts.align, lineBreak: true, }); return Math.max(opts.fontSize * 1.25, doc.y - before + 2); } export async function renderSchedulePdf(input: RenderPdfInput): Promise { const isRtl = input.lang === "ar"; const baseDir: "ltr" | "rtl" = isRtl ? "rtl" : "ltr"; const doc = new PDFDocument({ size: "A4", layout: "portrait", margin: 36, // ~12.7 mm — matches the screen print page's @page margin closely 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([ 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((resolve, reject) => { doc.on("end", resolve); doc.on("error", reject); }); // Header — show the schedule title and the date on its own line. const titleSize = Math.min(28, input.font.fontSize + 10); drawMixedLine(doc, input.labels.title, { x: doc.page.margins.left, y: doc.y, width: doc.page.width - doc.page.margins.left - doc.page.margins.right, fontSize: titleSize, weight: "bold", align: "center", baseDirection: baseDir, mapping, }); doc.moveDown(0.4); drawMixedLine(doc, input.date, { x: doc.page.margins.left, y: doc.y, width: doc.page.width - doc.page.margins.left - doc.page.margins.right, fontSize: Math.max(11, Math.round(input.font.fontSize * 0.85)), weight: "regular", align: "center", baseDirection: baseDir, mapping, }); doc.moveDown(0.8); 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, }); doc.end(); await done; return Buffer.concat(chunks); } // ---- 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; const colWidths = [ Math.round(tableWidth * 0.06), Math.round(tableWidth * 0.3), Math.round(tableWidth * 0.44), 0, // last col absorbs rounding ]; 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; const cellPadY = 5; 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"); const reordered = bidi.getReorderedString( label, bidi.getEmbeddingLevels(label, baseDir), ); doc.text(reordered, cx + cellPadX, headerY + cellPadY, { width: headerWidths[i] - cellPadX * 2, align: "center", lineBreak: false, }); 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})` : ""}`; }) .join("\n"); })(), 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; // Reuse drawWrappingLine to compute height — drop the actual draw // by using a temporary off-screen page would be ideal, but PDFKit // doesn't expose an undo. We instead measure by counting expected // wraps at the dominant font. 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); doc.font(fk).fontSize(input.font.fontSize); for (const line of lines) { if (!line) { h += lineHeight; continue; } const w = doc.widthOfString(line); const wraps = Math.max(1, Math.ceil(w / (widths[i] - cellPadX * 2))); h += wraps * lineHeight; } const finalH = h + cellPadY * 2; cellHeights.push(finalH); if (finalH > rowHeight) rowHeight = finalH; cx += widths[i]; } // Page break check. if (probeY + rowHeight > doc.page.height - doc.page.margins.bottom) { doc.addPage(); drawHeader(); } const rowTop = doc.y; if (meeting.isHighlighted) { doc.save(); doc.rect(tableX, rowTop, tableWidth, rowHeight).fill("#fecaca"); 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, }); y += used; } cx += widths[i]; } doc.y = rowTop + rowHeight; } // Footer — generation timestamp and font info. doc.moveDown(0.6); const footer = `Generated ${new Date().toISOString()} · font=${input.font.fontFamily} ${input.font.fontSize}px ${input.font.fontWeight}`; doc .font(mapping.latinRegular) .fontSize(8) .fillColor("#6b7280") .text(footer, doc.page.margins.left, doc.y, { width: doc.page.width - doc.page.margins.left - doc.page.margins.right, align: "left", }); 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); }