}V$LrPq)G3SbDN7dfFBjdGdR1*zst?~OR(va57q|!DpCX!_2B&(%dD7@K4PwqYSSJjWT2j8uoDkM!
zf1rop|Ig@BjM87}(sk7B7xwM@JX}t{Ev9$Wvx4*%!G|*K}kp`=x;q}*ZmY0(iR6wEE%b-E9>mV##w>)f<$H>Cv
ze}iY?HYmdN&>&hF6s=(>%=%7RK?*QAD=&)@RHU6aOu*7AQG{)@hOgTcVLOa5E5O%I
z9;R_=g(|?a(`8`UB(ywwy;96NMGH5GM%2O(uN(&RUs1H*r}Wc0&^I9^StVFq?LV=T
z=0H;PVY@`Ru97LjZSj-9L8
zrC(_At}33})Yt61+nL}QLfC=8`+Uw$dD_%UgooI1gOeJnXDdrl%k_+c4-y22t{Oojvz2jz@ZIdinH-50r9+{^Rc9>2Ee(Rh(il%%$GTDenrZ;4dehAz>nXu{#;
z!aTyu;K}g3{4X!5l&lx01IeRPHTlK;Iz^@0@>wVC0f5VY&f!nLw=Safor(RcU!WO(
z!FB(WL-=orU7APwjoAIFH1nEr6!}zDCR=0*JeYdkTd9$;yJlQviFq*H1MgP_*
z^&dz7i6^fg1Y_iG7ir39tkOD8b}&%9F6m(YGe-UsJPZ#DFmw#x34(&sU#l@?UC;ZY
z9`tvmiysd6o50ymK6hOUjaB;FQ~{bkp|r7+9V|q+p8T7Z;ot5y_|#$f!>{sZ@T3H9
zCtLfY9@c&|N_riHhWUIMn2i2*)5<8wtn0P?t7kZUU&hf?YfHH0klL0lkKKpV1m*Me
z?q7(j3A?~)<0Evqw>ykw!_)-5_fQRYRA|GtQ-m6;Exm+mI=izyvVuqSG$pqpv&=ME
z_v8f2@M&Ex7LoBu0al#O`iwc>NL*W)o=NH>_V3Z!cKG-ouSmL)+j@_dpPbq|dk`?J
zuO5foR615tZV9g-KTtgb6dWL-zWT+wyVQsiwe1`74nm>zBSlmu*W+ItlI?6t=g
zXnT2Suh%{ef;R@<$z9+t?scAFZtYGH_1LH3YvnK4GyoY_!1lHX@l_`x@1T_wJ6#tq
zs>@sKP#3S_`Mm+5dHcBK3NMh8u);))vtl`J(RHMC}8>q-<4G98|
z=oI(UZKcFpb0rUfwNd426sB82>Qr*n4TKV?g(lOv1aCi(DGJ%y@FlVG94?T6L{EqF
zrb39#5{*a~L?EYXjQ#PIe=LMSvb*91ki~b=QUYS*|{1y`yDRHvdO>B}Pc0m+S!m%F72__NP(vhQvM{_%i>m7YjDO2mPu
zmt%T>Js!}m3Bx4^f
zCHGfUjIN3Bl_$B&T>#(B;1a{Dv=(s;jMy}?&Z4EpQWK+q!ENl)TrGEv@za}>>*bAD
zyPR2U`e$5U7^%#98#Zzm^rTP0u_cjuW%X=lc?#Ahg{vnQ@P4LPyz=InH-$3?{wb6k
zll%c0j6J)+QI2moz*%(>G)tLZxiSON4CJ;8@}S`rzDTCxHuL`%XXd8)-^
zIbtwoc5UO38(|_~5=t;7u1*iUKrIGL4GaztFRfr}us+P?cxUHv@)y8`$gJ6=tijtU
zrh=pv^X0hRrxF49cR%1gebViKU}qup?X!q3WrY3pR{)ZFEc6v&wiI955%UO)BJwlL
zV+il4O_BHc9yqh_T1n$*p-yk2>)N`@TbTH29#Q^+{?6RFh@4ryGg|^_VkZWV3Pg7etgIbf%b@y5L9(ZO$3S&4
z<8x7rzSS)IDgo6Xh;0I>h}`=YtK~s#t_{%w0VrAI%HY5vc-UM#C*s#aeiqtRY
zryK%JeO0YN69$xnsuS3~g?WrzE2>0C=^Aoepm?(T>$%Yq@w@%?_hpMI`+~=gQMz^O9Mh@<;xbKnjt{1d4idJ_XnH`rPc(Bsle+JBaoqN
zOE|`8brvTohYQeKo)1HhkEFpUrHHi1MZk+;6T-+;M8%0`5WArGW}0#JqqzA`p1P$_tPIWyMXpkPG3Q#vy=%1H<&1$PVtO_ep0L
zGS)g4l!6FH%bg*ilJ~+^rSwzEek%J`rxsBZKeyoG=9FXiiTufvOCR0EAOuB;2qGu}
zVMSD)1b`Wg3bSMer}}4KW6P$wu3#zeMK(eAZg3&YbOzgVlfy=P=s#y!sSlV!7IoLk
z^1*`+A7@|VG#oGrxaN;8zQ@~-Jkl#!#`oA99A4_h9t&6lbC>0&
z!^i{eDQB>AeMl%fRIPp?gc{&xh+p!cPDZeOiT1Amm(w~b@AihR`83CL?#zM8LmeSK
z^1|@&)bxkW2{p-t1&cqO`0-oitBiGB9DkXx_p=Pr*N2P$bqxI5VE5Bl`126?*D>(x
zSO@Eyp7#{|HZ$#~VbN4t%hk@_*WOO>+W={JR1N-`0E>vyX5G=ALX3`Cdpg741lAuH
zzK@k?6y}$)vS+Giq}A-uwy>e?$I7~2p8xdkt26gXLi)m9XCGqS$AqBS!PByRvX2}F
z7uNz+a4lnhtkj`5HwKi~GSV#lSElAiJja?32KthgzL1GA{QF;$blS=k2+sM^=5GT1
z$Sc#vdAh4Ta8(x5-A`@XGLIFOJ-;~F*_4E_EPZ3&a68YOsnfarv3`RB)w#AB`(=Q1
z7I31HmM1%NlTQ3VUX0woVA5?
zNAI4T(}j{$1?ERU>+sv_wc96x5v=);&hi!V=q$jA
zL36L4qAR?!D4t`drVtHZ7G?*KxynHtQd|X
z_rEk!Hql9g>OwX;S?|3cbe(N{N&1Xjf2X9ugr%Iy(~Km8Y=E+QjmKK#1vtORT75Ze
zi8R*4N6#j?62p6P*Jr1d80}up8hF)nQcz6SXnH_CM_IRR8tD+k>_v`XzfM#eg9Z-?!42lDk}q>g}`?ABYS^;st0
zYMDMRY=>Sfp{@o&Tnq+$+qRK$pMwwO;JAZxyxkr^svi|&j}8EmLphrcYJdzap6UXV
zK1vAT+%zqU&O>#9FJp{@I()|lEe#bmR)4@jqX-Ip2~bziP6wpStL3rcpl)zVUhvER0@>tyyE>LCiZrqrI_5uuy5pVoxhr
zw_q9@1hQKNH{Lga+IPj)kV3f;^QWQqn%E`uwpyL9*3KpqyM8!K+%lW4I_sC#H7-@aa;n)7E163mm$;$$mC|jb*}g)e#0JNufzp6i
z8(lJ5fzfwyl?As9q5J!tgbQw-f~G$?S?zuS`gBbv)0cviY^D`z*QjheJFocNqp~%K
zU%7CP>gK)qT8L8?b-&L_h|?RYTRx8^Lp`{qpG&Y=4Xmw5PLW7?7@r
z84i-JaXSo^hA%hplHB?m+0q<=9o@Mnavy%wr8M|gN_0Z@2(o>1pUITvOBaOh!xmtN
z;*%2}a@3$_^{B{YE{52{+s#E5kOv&S*yf1XI5cESit>|Zc}Xh3NFr)p0bEd-gz=&87E2a^c+y^
z?YgpziyH{Z!N$^&o`Gx^g>Eb{$*OvE0uOeje*j;*TS~UNp%Ifv0rnZ43KF)S$%^U;
z_kOWStmxO?zikqGg058)Krx8gP$MSbemsDzM!YbA9;CxJ83Rx1_DOsKf2#@fPLT0&
zCCTk%m|Q2EA<_}b6^3s;I0I@L__(=M5fEe$-J?&o{2V3+65WdOSZ1Z;>02G~i*%#~
zkcnL|dU-$6O4X?!O69GN3mRh(m6o2vO3+<+4zZY>(ixTJ+IZnPK-at7>ExMV0LQ=J
zh_9mCdg}E*3;33eemMhuh{)CwF48eQgeJa_ZfmCGCG&BheG}t61cff!h&7%jtn7aZ
z9Y=F(kavE(b!SgviNnyIjM-j=dGRnUAu1$31hfl?eIXiOV~KAkvkxE)X7zb5AMc^!
zZdOQ5gAOR&*H|qT?#xe+v#dATsf_7*oLJzQjT{{-g>ehLHby6|W#bE=sxnwq5iqdCGE{aRi!9#@j
zK9)~U7L0Z1CM6e)wHcY7%wvTug2eWLjDAC6{Gj&q&>nV>B5zZ9dWdKgSXMyZOL({%
z_+aHuorN%cZCE!Buc=Zbodb$CJ>F?)Awkv3-hDkXnDsu^sl$71i>!@iU1H
zz4&G0@l&rn2r0+1J00)j*s-pDAHi{-Le=RJ@Nx*U++WE`OO+CvU)`B5(%KZHXszu4dnnZMZJ=<09zlw{y2>#sI6N|qM9r7i!~fs$2#
zBe=iY`SVzL*+1GLE06j8`k`bo3V+n0h(`T!tfCzH&wQ9a%Tq!t{eBH(
zlu+`&Uwc?N0*)B}+BO*_ML6L4s|}4(fMfsv+PplhVJQfY_5WvK_5I*8c&&-PCgc
diff --git a/artifacts/api-server/package.json b/artifacts/api-server/package.json
index 9fc60a56..813f29dc 100644
--- a/artifacts/api-server/package.json
+++ b/artifacts/api-server/package.json
@@ -30,6 +30,7 @@
"pdfkit": "^0.18.0",
"pino": "^9",
"pino-http": "^10",
+ "playwright-core": "^1.59.1",
"sanitize-html": "^2.17.3",
"socket.io": "^4.8.3",
"zod": "catalog:"
diff --git a/artifacts/api-server/src/lib/pdf-html-renderer.ts b/artifacts/api-server/src/lib/pdf-html-renderer.ts
new file mode 100644
index 00000000..0fef8f09
--- /dev/null
+++ b/artifacts/api-server/src/lib/pdf-html-renderer.ts
@@ -0,0 +1,565 @@
+import { fileURLToPath } from "node:url";
+import path from "node:path";
+import { readFileSync, existsSync, readdirSync } from "node:fs";
+import sanitizeHtml from "sanitize-html";
+import type { RenderPdfInput, PdfMeeting, PdfMeetingAttendee } from "./pdf-renderer.js";
+
+const ROW_COLOR_FILL: Record = {
+ red: "#fee2e2",
+ amber: "#fef3c7",
+ green: "#dcfce7",
+ blue: "#dbeafe",
+ violet: "#ede9fe",
+ gray: "#f3f4f6",
+};
+
+function fontsDir(): string {
+ const here =
+ typeof __dirname === "string"
+ ? __dirname
+ : path.dirname(fileURLToPath(import.meta.url));
+ 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(", ")}`,
+ );
+}
+
+const FONT_FILES: Record = {
+ "DIN Next LT Arabic": {
+ regular: "DINNextLTArabic-Regular.ttf",
+ bold: "DINNextLTArabic-Bold.ttf",
+ },
+ system: {
+ regular: "NotoNaskhArabic-Regular.ttf",
+ bold: "NotoNaskhArabic-Bold.ttf",
+ },
+ Tajawal: {
+ regular: "Tajawal-Regular.ttf",
+ bold: "Tajawal-Bold.ttf",
+ },
+ "Helvetica Neue LT Arabic": {
+ regular: "HelveticaNeueLTArabic-Regular.ttf",
+ bold: "HelveticaNeueLTArabic-Bold.ttf",
+ },
+ "Helvetica Neue": {
+ regular: "HelveticaNeue-Regular.ttf",
+ bold: "HelveticaNeue-Bold.ttf",
+ },
+ Majalla: {
+ regular: "Majalla-Regular.ttf",
+ bold: "Majalla-Bold.ttf",
+ },
+};
+
+let fontBase64Cache: Record = {};
+
+function getFontBase64(family: string): { regular: string; bold: string } {
+ if (fontBase64Cache[family]) return fontBase64Cache[family];
+ const dir = fontsDir();
+ const files = FONT_FILES[family] ?? FONT_FILES.system;
+ const regular = readFileSync(path.join(dir, files.regular)).toString("base64");
+ const bold = readFileSync(path.join(dir, files.bold)).toString("base64");
+ fontBase64Cache[family] = { regular, bold };
+ return fontBase64Cache[family];
+}
+
+function esc(s: string): string {
+ return s
+ .replace(/&/g, "&")
+ .replace(//g, ">")
+ .replace(/"/g, """);
+}
+
+function htmlToPlain(input: string | null | undefined): string {
+ if (!input) return "";
+ const withBreaks = input
+ .replace(/<\s*br\s*\/?>/gi, "\n")
+ .replace(/<\/(p|div|li)>/gi, "\n")
+ .replace(//gi, "• ");
+ const sanitized = sanitizeHtml(withBreaks, {
+ allowedTags: [],
+ allowedAttributes: {},
+ });
+ 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;
+ 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 "\u2014";
+}
+
+function formatLongDate(iso: string): string {
+ 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);
+}
+
+function buildAttendeesHtml(attendees: PdfMeetingAttendee[]): string {
+ if (attendees.length === 0) return "\u2014";
+ const PER_LINE = 3;
+ let personIdx = 0;
+ const lines: string[][] = [];
+ let currentGroup: string[] = [];
+
+ const flushGroup = () => {
+ if (currentGroup.length === 0) return;
+ for (let i = 0; i < currentGroup.length; i += PER_LINE) {
+ lines.push(currentGroup.slice(i, i + PER_LINE));
+ }
+ currentGroup = [];
+ };
+
+ for (const a of attendees) {
+ const isSub = (a.kind ?? "person") === "subheading";
+ const name = htmlToPlain(a.name);
+ if (isSub) {
+ flushGroup();
+ personIdx = 0;
+ lines.push([`\u2014 ${name} \u2014`]);
+ } else {
+ personIdx += 1;
+ const t = a.title?.trim();
+ currentGroup.push(`${personIdx}-${name}${t ? ` (${t})` : ""}`);
+ }
+ }
+ flushGroup();
+
+ return lines
+ .map(
+ (group) =>
+ `${group.map((s) => `${esc(s)}`).join(" ")}
`,
+ )
+ .join("");
+}
+
+function buildMeetingRow(meeting: PdfMeeting, input: RenderPdfInput): string {
+ const isRtl = input.lang === "ar";
+ const title = htmlToPlain(
+ isRtl ? meeting.titleAr : meeting.titleEn || meeting.titleAr,
+ );
+ const location = meeting.location ? `
${esc(meeting.location)}` : "";
+ const time = formatTimeRange(meeting.startTime, meeting.endTime, input.lang);
+ const attendeesHtml = buildAttendeesHtml(meeting.attendees);
+ const bgStyle = meeting.rowColor && ROW_COLOR_FILL[meeting.rowColor]
+ ? `background-color:${ROW_COLOR_FILL[meeting.rowColor]};`
+ : "";
+
+ const MERGE_COL_INDEX: Record = {
+ number: 0, meeting: 1, attendees: 2, time: 3,
+ };
+
+ let cellsLtr = [
+ { html: esc(String(meeting.dailyNumber)), cls: "num-cell", style: "" },
+ { html: esc(title) + location, cls: "meeting-cell", style: "" },
+ { html: attendeesHtml, cls: "attendees-cell", style: "" },
+ { html: `${esc(time)}`, cls: "time-cell", style: "" },
+ ];
+
+ 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 colspan = endIdx - startIdx + 1;
+ const mergeText = meeting.mergeText ? htmlToPlain(meeting.mergeText) : "";
+ const mergedCell = {
+ html: esc(mergeText),
+ cls: "merged-cell",
+ style: "",
+ colspan,
+ };
+ const before = cellsLtr.slice(0, startIdx);
+ const after = cellsLtr.slice(endIdx + 1);
+ const merged = [...before, mergedCell, ...after];
+
+ const cells = isRtl ? [...merged].reverse() : merged;
+ const tds = cells
+ .map((c: any) => {
+ const cs = c.colspan ? ` colspan="${c.colspan}"` : "";
+ return `${c.html} | `;
+ })
+ .join("");
+ return `${tds}
`;
+ }
+ }
+
+ const cells = isRtl ? [...cellsLtr].reverse() : cellsLtr;
+ const tds = cells
+ .map(
+ (c) => `${c.html} | `,
+ )
+ .join("");
+ return `${tds}
`;
+}
+
+export function buildScheduleHtml(input: RenderPdfInput): string {
+ const isRtl = input.lang === "ar";
+ const dir = isRtl ? "rtl" : "ltr";
+ const fontFamily = input.font.fontFamily === "system" ? "ScheduleFont" : input.font.fontFamily;
+ const fontSize = input.font.fontSize;
+ const fontWeight = input.font.fontWeight === "bold" ? "bold" : "normal";
+ const fontColor = input.font.fontColor || "#000000";
+
+ const fontData = getFontBase64(input.font.fontFamily);
+ const fontFaceName = "ScheduleFont";
+
+ const headerLabelsLtr = [input.labels.no, input.labels.meeting, input.labels.attendees, input.labels.time];
+ const headerLabels = isRtl ? [...headerLabelsLtr].reverse() : headerLabelsLtr;
+ const colWidthsLtr = ["8%", "30%", "42%", "20%"];
+ const colWidths = isRtl ? [...colWidthsLtr].reverse() : colWidthsLtr;
+
+ const logoHtml = input.logo && input.logo.length > 0
+ ? `
`
+ : "";
+
+ const headerCells = headerLabels
+ .map((label) => `${esc(label)} | `)
+ .join("");
+
+ const bodyRows = input.meetings.length === 0
+ ? `| ${esc(input.labels.none)} |
`
+ : input.meetings.map((m) => buildMeetingRow(m, input)).join("");
+
+ const longDate = formatLongDate(input.date);
+
+ return `
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ ${headerCells}
+
+
+ ${bodyRows}
+
+
+
+
+
+
+`;
+}
+
+let browserInstance: any = null;
+let launchPromise: Promise | null = null;
+
+function findChromiumExecutable(): string {
+ if (process.env.PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH) {
+ const envPath = process.env.PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH;
+ if (existsSync(envPath)) return envPath;
+ }
+
+ const here =
+ typeof __dirname === "string"
+ ? __dirname
+ : path.dirname(fileURLToPath(import.meta.url));
+ const workspaceRoot = path.resolve(here, "..", "..", "..", "..");
+
+ const searchRoots = Array.from(new Set([
+ workspaceRoot,
+ process.cwd(),
+ "/home/runner/workspace",
+ path.resolve(here, "..", ".."),
+ process.env.HOME ?? "",
+ ].filter(Boolean)));
+
+ for (const root of searchRoots) {
+ const cacheDir = path.join(root, ".cache", "ms-playwright");
+ if (!existsSync(cacheDir)) continue;
+
+ const entries = readdirSync(cacheDir).sort().reverse();
+ for (const entry of entries) {
+ if (!entry.startsWith("chromium")) continue;
+ const chromeCandidate = path.join(cacheDir, entry, "chrome-linux64", "chrome");
+ const shellCandidate = path.join(cacheDir, entry, "chrome-headless-shell-linux64", "headless_shell");
+ if (existsSync(chromeCandidate)) return chromeCandidate;
+ if (existsSync(shellCandidate)) return shellCandidate;
+ }
+ }
+
+ throw new Error(
+ `Could not find Chromium browser binary for PDF generation. Searched: ${searchRoots.map((r) => path.join(r, ".cache/ms-playwright")).join(", ")}. Set PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH to override.`,
+ );
+}
+
+async function getBrowser() {
+ if (browserInstance && browserInstance.isConnected()) {
+ return browserInstance;
+ }
+
+ if (launchPromise) return launchPromise;
+
+ launchPromise = (async () => {
+ const { chromium } = await import("playwright-core");
+ const execPath = findChromiumExecutable();
+
+ const browser = await chromium.launch({
+ executablePath: execPath,
+ headless: true,
+ args: [
+ "--no-sandbox",
+ "--disable-setuid-sandbox",
+ "--disable-dev-shm-usage",
+ "--disable-gpu",
+ "--font-render-hinting=none",
+ ],
+ });
+
+ browserInstance = browser;
+ launchPromise = null;
+ return browser;
+ })();
+
+ launchPromise.catch(() => { launchPromise = null; });
+
+ return launchPromise;
+}
+
+process.on("beforeExit", () => {
+ if (browserInstance) {
+ browserInstance.close().catch(() => {});
+ browserInstance = null;
+ }
+});
+
+export async function htmlToPdfBuffer(html: string): Promise {
+ const browser = await getBrowser();
+ const context = await browser.newContext();
+ const page = await context.newPage();
+
+ try {
+ await page.setContent(html, { waitUntil: "load" });
+ await page.waitForTimeout(200);
+
+ const pdfBuffer = await page.pdf({
+ format: "A4",
+ printBackground: true,
+ margin: {
+ top: "15mm",
+ bottom: "20mm",
+ left: "10mm",
+ right: "10mm",
+ },
+ preferCSSPageSize: true,
+ });
+
+ return Buffer.from(pdfBuffer);
+ } finally {
+ await context.close();
+ }
+}
+
+export async function renderSchedulePdfHtml(input: RenderPdfInput): Promise {
+ const html = buildScheduleHtml(input);
+ return htmlToPdfBuffer(html);
+}
diff --git a/artifacts/api-server/src/lib/pdf-renderer.ts b/artifacts/api-server/src/lib/pdf-renderer.ts
index 61daefcf..229f0526 100644
--- a/artifacts/api-server/src/lib/pdf-renderer.ts
+++ b/artifacts/api-server/src/lib/pdf-renderer.ts
@@ -661,6 +661,11 @@ function drawWrappingLine(
}
export async function renderSchedulePdf(input: RenderPdfInput): Promise {
+ const { renderSchedulePdfHtml } = await import("./pdf-html-renderer.js");
+ return renderSchedulePdfHtml(input);
+}
+
+async function renderSchedulePdf_LEGACY(input: RenderPdfInput): Promise {
const isRtl = input.lang === "ar";
const baseDir: "ltr" | "rtl" = isRtl ? "rtl" : "ltr";
const doc = new PDFDocument({
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 05a0c324..fa019a22 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -135,6 +135,9 @@ importers:
pino-http:
specifier: ^10
version: 10.5.0
+ playwright-core:
+ specifier: ^1.59.1
+ version: 1.59.1
sanitize-html:
specifier: ^2.17.3
version: 2.17.3
diff --git a/replit.md b/replit.md
index 082e045a..e0f1740b 100644
--- a/replit.md
+++ b/replit.md
@@ -11,7 +11,8 @@ Ask before making major changes.
Do not make changes to the folder `artifacts/api-server/tests`.
Do not make changes to the folder `artifacts/tx-os/tests`.
Do not make changes to the folder `lib/db/scripts`.
-Do not make changes to the file `artifacts/api-server/src/lib/pdf-renderer.ts`.
+Do not make changes to the file `artifacts/api-server/src/lib/pdf-renderer.ts` (legacy PDFKit code kept for fallback).
+Do not make changes to the file `artifacts/api-server/src/lib/pdf-html-renderer.ts` (HTML-table PDF renderer via Playwright).
Do not make changes to the file `artifacts/tx-os/src/App.tsx`.
Do not make changes to the file `artifacts/tx-os/src/components/executive-meetings/upcoming-meeting-alert.tsx`.
Do not make changes to the file `artifacts/tx-os/src/pages/executive-meetings.tsx`.
@@ -43,6 +44,7 @@ The project is structured as a pnpm monorepo.
- **Executive Meetings Module**: A comprehensive module with scheduling, CRUD operations for meetings, change requests, approvals, tasks, notifications, and an audit log. RBAC is enforced via five role sets (READ/MUTATE/APPROVE/REQUEST/ADMIN_AUDIT). All mutations are wrapped in database transactions to ensure data consistency and atomic audit logging.
- **Optimistic Locking**: Implemented for Executive Meeting postponements to prevent concurrent updates from silently overwriting changes, using `expectedUpdatedAt` and returning a 409 conflict on mismatch.
- **Upcoming Meeting Alert**: A global, draggable alert component appears when an Executive Meeting is within five minutes of starting, providing options to postpone, reschedule, or cancel the meeting.
+- **PDF Generation (HTML Table)**: Executive meetings PDFs are now generated via a real HTML `` rendered by Playwright's headless Chromium (`pdf-html-renderer.ts`). This replaced the manual PDFKit drawing approach to guarantee each meeting occupies exactly one `` row with 4 `| ` cells (number, title, attendees, time). The Chromium binary is auto-discovered from `.cache/ms-playwright/` or via `PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH` env var. The browser instance is cached (singleton with launch mutex) and closed on process exit. The legacy PDFKit renderer is preserved as `renderSchedulePdf_LEGACY` in `pdf-renderer.ts` for fallback reference.
- **Custom Editor Fonts**: The in-place rich-text cell editor (attendee names, meeting titles, etc.) ships a curated set of self-hosted Arabic + Latin font families (DIN Next LT Arabic, Tajawal, Helvetica Neue LT Arabic, Helvetica Neue, Majalla) declared in `artifacts/tx-os/src/custom-fonts.css` with `font-display: swap`. Files live under `artifacts/tx-os/public/fonts/`. The site default body font is **DIN Next LT Arabic** (`--app-font-sans` in `index.css`), and Google Fonts is no longer fetched at page load. The Executive Meetings **Font Settings** page exposes the same five families plus `system` as the user/global picker; the values are kept in lockstep with the backend Zod allowlist (`FONT_FAMILIES` in `routes/executive-meetings.ts`), the rich-text sanitizer's font-family allowlist (`FONT_NAME_PART` in `lib/sanitize.ts`), and the PDF renderer's family map (`FAMILY_MAP` in `lib/pdf-renderer.ts`). When adding or removing a family, update all four locations together. Note the intentional asymmetry of the `system` value: in the web UI it resolves to whatever the CSS default is (currently DIN Next LT Arabic, a sans family), while in server-side PDF rendering it maps to the bundled Naskh stand-in (`NotoNaskhArabic`) — PDFs are document-style output where Naskh is the more conventional Arabic body face, so this is by design.
- **Tab Quick-Add Attendees**: Pressing Tab inside an attendee name cell commits the current value and immediately opens a new pending attendee row right after, so users can keep typing names without using the mouse. Implemented via an `onTabNext` prop on `EditableCell` and a `chainStartAdd` prop on `AttendeeFlow` that bypasses the single-pending UI gate (the parent's state-level guard still prevents truly overlapping pendings).
|