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.
- 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 `<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:
Riyadh
2026-05-04 10:24:08 +00:00
parent 044126ccb0
commit 7c5a9748a2
6 changed files with 575 additions and 0 deletions
+1
View File
@@ -97,6 +97,7 @@ async function buildAll() {
"zeromq",
"zeromq-prebuilt",
"playwright",
"playwright-core",
"puppeteer",
"puppeteer-core",
"electron",
Binary file not shown.
+1
View File
@@ -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:"
@@ -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<string, string> = {
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<string, { regular: string; bold: string }> = {
"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<string, { regular: string; bold: string }> = {};
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, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;");
}
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(/<li>/gi, "• ");
const sanitized = sanitizeHtml(withBreaks, {
allowedTags: [],
allowedAttributes: {},
});
return sanitized
.replace(/&nbsp;/g, " ")
.replace(/&amp;/g, "&")
.replace(/&lt;/g, "<")
.replace(/&gt;/g, ">")
.replace(/&quot;/g, '"')
.replace(/&#39;/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) =>
`<div class="attendee-line">${group.map((s) => `<span class="attendee-item">${esc(s)}</span>`).join(" ")}</div>`,
)
.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 ? `<br/><span style="font-size:90%">${esc(meeting.location)}</span>` : "";
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<string, number> = {
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: `<span dir="ltr">${esc(time)}</span>`, 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 `<td class="${c.cls}" style="${bgStyle}${c.style}"${cs}>${c.html}</td>`;
})
.join("");
return `<tr style="${bgStyle}">${tds}</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>`;
}
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
? `<img src="data:image/png;base64,${input.logo.toString("base64")}" style="height:50px;width:auto;" />`
: "";
const headerCells = headerLabels
.map((label) => `<th>${esc(label)}</th>`)
.join("");
const bodyRows = input.meetings.length === 0
? `<tr><td colspan="4" style="text-align:center;padding:20px;color:#666;">${esc(input.labels.none)}</td></tr>`
: input.meetings.map((m) => buildMeetingRow(m, input)).join("");
const longDate = formatLongDate(input.date);
return `<!DOCTYPE html>
<html lang="${input.lang}" dir="${dir}">
<head>
<meta charset="utf-8"/>
<style>
@font-face {
font-family: '${fontFaceName}';
src: url(data:font/truetype;base64,${fontData.regular}) format('truetype');
font-weight: normal;
}
@font-face {
font-family: '${fontFaceName}';
src: url(data:font/truetype;base64,${fontData.bold}) format('truetype');
font-weight: bold;
}
@page {
size: A4 portrait;
margin: 15mm 10mm 20mm 10mm;
}
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: '${fontFaceName}', sans-serif;
font-size: ${fontSize}px;
font-weight: ${fontWeight};
color: ${fontColor};
direction: ${dir};
line-height: 1.05;
-webkit-print-color-adjust: exact;
print-color-adjust: exact;
}
.page-header {
text-align: center;
margin-bottom: 8px;
position: relative;
}
.page-header .logo {
position: absolute;
${isRtl ? "right" : "left"}: 0;
top: 0;
}
.page-header h1 {
font-size: ${Math.min(22, fontSize + 6)}px;
font-weight: bold;
color: #000;
margin: 0;
padding: 8px 0;
}
table {
width: 100%;
border-collapse: collapse;
table-layout: fixed;
border-spacing: 0;
}
col:nth-child(1) { width: ${colWidths[0]}; }
col:nth-child(2) { width: ${colWidths[1]}; }
col:nth-child(3) { width: ${colWidths[2]}; }
col:nth-child(4) { width: ${colWidths[3]}; }
thead th {
background-color: #0B1E3F;
color: white;
font-weight: bold;
text-align: center;
padding: 3px 4px;
font-size: ${fontSize}px;
line-height: 1.2;
border: none;
}
tbody td {
padding: 2px 4px;
vertical-align: middle;
text-align: center;
border: 0.5px solid #d1d5db;
font-size: ${fontSize}px;
line-height: 1.05;
word-wrap: break-word;
overflow-wrap: break-word;
}
tr {
page-break-inside: avoid;
break-inside: avoid;
}
.num-cell {
text-align: center;
vertical-align: middle;
}
.meeting-cell {
text-align: center;
vertical-align: middle;
}
.attendees-cell {
text-align: center;
vertical-align: middle;
}
.time-cell {
text-align: center;
vertical-align: middle;
direction: ltr;
white-space: nowrap;
}
.merged-cell {
text-align: center;
vertical-align: middle;
}
.attendee-line {
white-space: nowrap;
direction: ${dir};
unicode-bidi: isolate;
}
.attendee-item {
white-space: nowrap;
unicode-bidi: isolate;
}
.page-footer {
position: fixed;
bottom: 0;
${isRtl ? "right" : "left"}: 0;
width: 100%;
font-size: ${Math.max(11, Math.round(fontSize * 0.95))}px;
text-align: ${isRtl ? "right" : "left"};
padding-top: 8px;
}
.page-footer .recorded-by {
font-weight: bold;
}
.page-footer .date-line {
margin-top: 2px;
}
</style>
</head>
<body>
<div class="page-header">
${logoHtml ? `<div class="logo">${logoHtml}</div>` : ""}
<h1>${esc(input.labels.title)}</h1>
</div>
<table>
<colgroup>
<col/><col/><col/><col/>
</colgroup>
<thead>
<tr>${headerCells}</tr>
</thead>
<tbody>
${bodyRows}
</tbody>
</table>
<div class="page-footer">
<div class="recorded-by">${esc(input.labels.recordedBy)}</div>
<div class="date-line">${esc(longDate)}</div>
</div>
</body>
</html>`;
}
let browserInstance: any = null;
let launchPromise: Promise<any> | 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<Buffer> {
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<Buffer> {
const html = buildScheduleHtml(input);
return htmlToPdfBuffer(html);
}
@@ -661,6 +661,11 @@ function drawWrappingLine(
}
export async function renderSchedulePdf(input: RenderPdfInput): Promise<Buffer> {
const { renderSchedulePdfHtml } = await import("./pdf-html-renderer.js");
return renderSchedulePdfHtml(input);
}
async function renderSchedulePdf_LEGACY(input: RenderPdfInput): Promise<Buffer> {
const isRtl = input.lang === "ar";
const baseDir: "ltr" | "rtl" = isRtl ? "rtl" : "ltr";
const doc = new PDFDocument({
+3
View File
@@ -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