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:
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user