From f4d99b6cfdc347c01ef552c3cd04effd686c75cc Mon Sep 17 00:00:00 2001 From: riyadhafraa <49757212-riyadhafraa@users.noreply.replit.com> Date: Mon, 4 May 2026 10:24:08 +0000 Subject: [PATCH] Rebuild PDF generation: HTML + Playwright replaces PDFKit manual drawing Task #372: Replaced the manual PDFKit coordinate-based PDF renderer with an HTML table-based approach using Playwright's headless Chromium. Key changes: - Created `pdf-html-renderer.ts` with `buildScheduleHtml()` (generates full HTML with embedded base64 fonts, proper `
`, RTL/LTR support, row colors, merged cells, attendees formatting) and `htmlToPdfBuffer()` (renders HTML to PDF via Playwright Chromium). - Updated `pdf-renderer.ts`: `renderSchedulePdf()` now delegates to the new HTML renderer via dynamic import. Legacy PDFKit code preserved as `renderSchedulePdf_LEGACY()` for fallback reference. - Added `playwright-core` dependency and configured esbuild externals. - Chromium binary discovery: searches `.cache/ms-playwright/` directories with env var override (`PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH`). Browser instance is cached as singleton with launch mutex to prevent concurrent double-launch. - Process shutdown hook closes browser on exit. Requirements met: - Each meeting in exactly one `` with 4 ``; + }) + .join(""); + return `${tds}`; + } + } + + const cells = isRtl ? [...cellsLtr].reverse() : cellsLtr; + const tds = cells + .map( + (c) => ``, + ) + .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) => ``) + .join(""); + + const bodyRows = input.meetings.length === 0 + ? `` + : input.meetings.map((m) => buildMeetingRow(m, input)).join(""); + + const longDate = formatLongDate(input.date); + + return ` + + + + + + + + + +
` cells - No text outside table, attendees ~3 per line inline - Time always in its column, `page-break-inside: avoid`, `table-layout: fixed` - Matches reference design (blue header, centered text, DIN Next LT Arabic) --- artifacts/api-server/build.mjs | 1 + .../api-server/executive-meetings-final.pdf | Bin 25457 -> 0 bytes artifacts/api-server/package.json | 1 + .../api-server/src/lib/pdf-html-renderer.ts | 565 ++++++++++++++++++ artifacts/api-server/src/lib/pdf-renderer.ts | 5 + pnpm-lock.yaml | 3 + replit.md | 4 +- 7 files changed, 578 insertions(+), 1 deletion(-) delete mode 100644 artifacts/api-server/executive-meetings-final.pdf create mode 100644 artifacts/api-server/src/lib/pdf-html-renderer.ts diff --git a/artifacts/api-server/build.mjs b/artifacts/api-server/build.mjs index a78f62ee..00f82bd1 100644 --- a/artifacts/api-server/build.mjs +++ b/artifacts/api-server/build.mjs @@ -97,6 +97,7 @@ async function buildAll() { "zeromq", "zeromq-prebuilt", "playwright", + "playwright-core", "puppeteer", "puppeteer-core", "electron", diff --git a/artifacts/api-server/executive-meetings-final.pdf b/artifacts/api-server/executive-meetings-final.pdf deleted file mode 100644 index b5c4f56e79f122f5e7d732dbbfaefef1b1f25b65..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 25457 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 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}
    ${c.html}
    ${esc(label)}
    ${esc(input.labels.none)}
    + + + + + ${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).