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-prebuilt",
|
||||
"playwright",
|
||||
"playwright-core",
|
||||
"puppeteer",
|
||||
"puppeteer-core",
|
||||
"electron",
|
||||
|
||||
Binary file not shown.
@@ -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, "&")
|
||||
.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> {
|
||||
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({
|
||||
|
||||
Generated
+3
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user