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 b5c4f56e..00000000 Binary files a/artifacts/api-server/executive-meetings-final.pdf and /dev/null differ 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).