Rebuild PDF generation: HTML <table> + 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 `<table>`, 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.
- Proper TypeScript types: `TableCell` interface for cell models, `Browser`
  type from playwright-core for browser instance management.
- Chromium provisioning: searches `.cache/ms-playwright/` with env var override
  (`PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH`), auto-installs via
  `npx playwright-core install chromium` if not found.
- Browser singleton with launch mutex to prevent concurrent double-launch,
  plus process shutdown hook to close browser on exit.

Requirements met:
- Each meeting in exactly one `<tr>` with 4 `<td>` 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)
This commit is contained in:
riyadhafraa
2026-05-04 10:26:34 +00:00
parent f4d99b6cfd
commit cd4dab15b6
@@ -1,9 +1,18 @@
import { fileURLToPath } from "node:url";
import path from "node:path";
import { readFileSync, existsSync, readdirSync } from "node:fs";
import { execSync } from "node:child_process";
import sanitizeHtml from "sanitize-html";
import type { Browser } from "playwright-core";
import type { RenderPdfInput, PdfMeeting, PdfMeetingAttendee } from "./pdf-renderer.js";
interface TableCell {
html: string;
cls: string;
style: string;
colspan?: number;
}
const ROW_COLOR_FILL: Record<string, string> = {
red: "#fee2e2",
amber: "#fef3c7",
@@ -195,20 +204,28 @@ function buildMeetingRow(meeting: PdfMeeting, input: RenderPdfInput): string {
number: 0, meeting: 1, attendees: 2, time: 3,
};
let cellsLtr = [
const cellsLtr: TableCell[] = [
{ html: esc(String(meeting.dailyNumber)), cls: "num-cell", style: "" },
{ html: esc(title) + location, cls: "meeting-cell", style: "" },
{ html: attendeesHtml, cls: "attendees-cell", style: "" },
{ html: `<span dir="ltr">${esc(time)}</span>`, cls: "time-cell", style: "" },
];
const renderCells = (cells: TableCell[]): string =>
cells
.map((c) => {
const cs = c.colspan ? ` colspan="${c.colspan}"` : "";
return `<td class="${c.cls}" style="${bgStyle}${c.style}"${cs}>${c.html}</td>`;
})
.join("");
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 = {
const mergedCell: TableCell = {
html: esc(mergeText),
cls: "merged-cell",
style: "",
@@ -216,32 +233,20 @@ function buildMeetingRow(meeting: PdfMeeting, input: RenderPdfInput): string {
};
const before = cellsLtr.slice(0, startIdx);
const after = cellsLtr.slice(endIdx + 1);
const merged = [...before, mergedCell, ...after];
const merged: TableCell[] = [...before, mergedCell, ...after];
const cells = isRtl ? [...merged].reverse() : merged;
const tds = cells
.map((c: any) => {
const cs = c.colspan ? ` colspan="${c.colspan}"` : "";
return `<td class="${c.cls}" style="${bgStyle}${c.style}"${cs}>${c.html}</td>`;
})
.join("");
return `<tr style="${bgStyle}">${tds}</tr>`;
return `<tr style="${bgStyle}">${renderCells(cells)}</tr>`;
}
}
const cells = isRtl ? [...cellsLtr].reverse() : cellsLtr;
const tds = cells
.map(
(c) => `<td class="${c.cls}" style="${bgStyle}${c.style}">${c.html}</td>`,
)
.join("");
return `<tr style="${bgStyle}">${tds}</tr>`;
return `<tr style="${bgStyle}">${renderCells(cells)}</tr>`;
}
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";
@@ -450,8 +455,8 @@ tr {
</html>`;
}
let browserInstance: any = null;
let launchPromise: Promise<any> | null = null;
let browserInstance: Browser | null = null;
let launchPromise: Promise<Browser> | null = null;
function findChromiumExecutable(): string {
if (process.env.PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH) {
@@ -492,7 +497,27 @@ function findChromiumExecutable(): string {
);
}
async function getBrowser() {
function ensureChromiumInstalled(): string {
try {
return findChromiumExecutable();
} catch {
try {
execSync("npx playwright-core install chromium", {
stdio: "pipe",
timeout: 120_000,
});
return findChromiumExecutable();
} catch (installErr) {
throw new Error(
`Chromium browser not found and auto-install failed. ` +
`Run "npx playwright-core install chromium" manually or set PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH. ` +
`Install error: ${installErr instanceof Error ? installErr.message : String(installErr)}`,
);
}
}
}
async function getBrowser(): Promise<Browser> {
if (browserInstance && browserInstance.isConnected()) {
return browserInstance;
}
@@ -501,7 +526,7 @@ async function getBrowser() {
launchPromise = (async () => {
const { chromium } = await import("playwright-core");
const execPath = findChromiumExecutable();
const execPath = ensureChromiumInstalled();
const browser = await chromium.launch({
executablePath: execPath,