Files
TX/artifacts/api-server/build.mjs
T
Riyadh 7c5a9748a2 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)
2026-05-04 10:24:08 +00:00

144 lines
4.2 KiB
JavaScript

import { createRequire } from "node:module";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { build as esbuild } from "esbuild";
import esbuildPluginPino from "esbuild-plugin-pino";
import { rm, cp } from "node:fs/promises";
// Plugins (e.g. 'esbuild-plugin-pino') may use `require` to resolve dependencies
globalThis.require = createRequire(import.meta.url);
const artifactDir = path.dirname(fileURLToPath(import.meta.url));
async function buildAll() {
const distDir = path.resolve(artifactDir, "dist");
await rm(distDir, { recursive: true, force: true });
await esbuild({
entryPoints: [path.resolve(artifactDir, "src/index.ts")],
platform: "node",
bundle: true,
format: "esm",
outdir: distDir,
outExtension: { ".js": ".mjs" },
logLevel: "info",
// Some packages may not be bundleable, so we externalize them, we can add more here as needed.
// Some of the packages below may not be imported or installed, but we're adding them in case they are in the future.
// Examples of unbundleable packages:
// - uses native modules and loads them dynamically (e.g. sharp)
// - use path traversal to read files (e.g. @google-cloud/secret-manager loads sibling .proto files)
external: [
"*.node",
"sharp",
"better-sqlite3",
"sqlite3",
"canvas",
"bcrypt",
"argon2",
"fsevents",
"re2",
"farmhash",
"xxhash-addon",
"bufferutil",
"utf-8-validate",
"ssh2",
"cpu-features",
"dtrace-provider",
"isolated-vm",
"lightningcss",
"pg-native",
"oracledb",
"mongodb-client-encryption",
"nodemailer",
"handlebars",
"knex",
"typeorm",
"protobufjs",
"onnxruntime-node",
"@tensorflow/*",
"@prisma/client",
"@mikro-orm/*",
"@grpc/*",
"@swc/*",
"@aws-sdk/*",
"@azure/*",
"@opentelemetry/*",
"@google-cloud/*",
"@google/*",
"googleapis",
"firebase-admin",
"@parcel/watcher",
"@sentry/profiling-node",
"@tree-sitter/*",
"aws-sdk",
"classic-level",
"dd-trace",
"ffi-napi",
"grpc",
"hiredis",
"kerberos",
"leveldown",
"miniflare",
"mysql2",
"newrelic",
"odbc",
"piscina",
"realm",
"ref-napi",
"rocksdb",
"sass-embedded",
"sequelize",
"serialport",
"snappy",
"tinypool",
"usb",
"workerd",
"wrangler",
"zeromq",
"zeromq-prebuilt",
"playwright",
"playwright-core",
"puppeteer",
"puppeteer-core",
"electron",
],
sourcemap: "linked",
plugins: [
// pino relies on workers to handle logging, instead of externalizing it we use a plugin to handle it
esbuildPluginPino({ transports: ["pino-pretty"] })
],
// Make sure packages that are cjs only (e.g. express) but are bundled continue to work in our esm output file
banner: {
js: `import { createRequire as __bannerCrReq } from 'node:module';
import __bannerPath from 'node:path';
import __bannerUrl from 'node:url';
globalThis.require = __bannerCrReq(import.meta.url);
globalThis.__filename = __bannerUrl.fileURLToPath(import.meta.url);
globalThis.__dirname = __bannerPath.dirname(globalThis.__filename);
`,
},
});
}
// Copy PDFKit's built-in font metric files (Helvetica.afm etc.) to
// dist/data/. PDFKit reads these at runtime via
// `fs.readFileSync(__dirname + "/data/Helvetica.afm")`, which esbuild
// cannot statically follow — so we ship them as plain assets next to
// the bundled output. The Arabic/Latin schedule fonts live in
// artifacts/api-server/assets/fonts/ and are read directly from there.
async function copyRuntimeAssets() {
const distDir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "dist");
const requireFn = createRequire(import.meta.url);
const pdfkitMain = requireFn.resolve("pdfkit");
const pdfkitDataDir = path.resolve(path.dirname(pdfkitMain), "data");
await cp(pdfkitDataDir, path.join(distDir, "data"), { recursive: true });
}
buildAll()
.then(copyRuntimeAssets)
.catch((err) => {
console.error(err);
process.exit(1);
});