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:
@@ -97,6 +97,7 @@ async function buildAll() {
|
|||||||
"zeromq",
|
"zeromq",
|
||||||
"zeromq-prebuilt",
|
"zeromq-prebuilt",
|
||||||
"playwright",
|
"playwright",
|
||||||
|
"playwright-core",
|
||||||
"puppeteer",
|
"puppeteer",
|
||||||
"puppeteer-core",
|
"puppeteer-core",
|
||||||
"electron",
|
"electron",
|
||||||
|
|||||||
Binary file not shown.
@@ -30,6 +30,7 @@
|
|||||||
"pdfkit": "^0.18.0",
|
"pdfkit": "^0.18.0",
|
||||||
"pino": "^9",
|
"pino": "^9",
|
||||||
"pino-http": "^10",
|
"pino-http": "^10",
|
||||||
|
"playwright-core": "^1.59.1",
|
||||||
"sanitize-html": "^2.17.3",
|
"sanitize-html": "^2.17.3",
|
||||||
"socket.io": "^4.8.3",
|
"socket.io": "^4.8.3",
|
||||||
"zod": "catalog:"
|
"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, "&")
|
||||||
|
.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(/<li>/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) =>
|
||||||
|
`<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> {
|
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 isRtl = input.lang === "ar";
|
||||||
const baseDir: "ltr" | "rtl" = isRtl ? "rtl" : "ltr";
|
const baseDir: "ltr" | "rtl" = isRtl ? "rtl" : "ltr";
|
||||||
const doc = new PDFDocument({
|
const doc = new PDFDocument({
|
||||||
|
|||||||
Generated
+3
@@ -135,6 +135,9 @@ importers:
|
|||||||
pino-http:
|
pino-http:
|
||||||
specifier: ^10
|
specifier: ^10
|
||||||
version: 10.5.0
|
version: 10.5.0
|
||||||
|
playwright-core:
|
||||||
|
specifier: ^1.59.1
|
||||||
|
version: 1.59.1
|
||||||
sanitize-html:
|
sanitize-html:
|
||||||
specifier: ^2.17.3
|
specifier: ^2.17.3
|
||||||
version: 2.17.3
|
version: 2.17.3
|
||||||
|
|||||||
@@ -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/api-server/tests`.
|
||||||
Do not make changes to the folder `artifacts/tx-os/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 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/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/components/executive-meetings/upcoming-meeting-alert.tsx`.
|
||||||
Do not make changes to the file `artifacts/tx-os/src/pages/executive-meetings.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.
|
- **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.
|
- **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.
|
- **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 `<table>` rendered by Playwright's headless Chromium (`pdf-html-renderer.ts`). This replaced the manual PDFKit drawing approach to guarantee each meeting occupies exactly one `<tr>` row with 4 `<td>` 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.
|
- **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).
|
- **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).
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user