92324eca60
The "Download PDF" button on the executive-meetings page now hits a real
backend endpoint that returns a true PDF (no more browser print dialog),
respects each user's font preferences (family, size, weight, alignment)
with proper Arabic RTL shaping, and archives every download.
The renderer maps each saved fontFamily ("system", "Cairo", "Tajawal",
"Noto Naskh Arabic", "Amiri") to a concrete pair of bundled font files
so the chosen family genuinely changes the embedded glyphs — Cairo and
Tajawal pick Noto Sans Arabic, the Naskh-style families and the system
default pick Noto Naskh Arabic, and Latin glyphs render in DejaVu Sans
across the board. Headers, body cells, and footer all flow through the
same script-aware font selection.
Backend (artifacts/api-server)
- New GET /api/executive-meetings/pdf?date=&lang= route in
src/routes/executive-meetings.ts that fetches the day's meetings +
attendees, renders a PDF, uploads it to object storage, writes an
executive_meeting_pdf_archives row (date, generated_by, byte_size,
storage_url), and streams the file back inline.
- New src/lib/pdf-renderer.ts using pdfkit + bidi-js with bundled
Noto Naskh Arabic and DejaVu Sans fonts in assets/fonts/.
- Added byte_size column on executive_meeting_pdf_archives (also in
lib/db schema) and rebuilt lib/db.
- Added ambient types for bidi-js; installed @swc/helpers to satisfy
fontkit at runtime.
- build.mjs now copies pdfkit's data/ folder (Helvetica.afm, etc.)
into dist/data so the bundled server can construct PDFDocument.
Frontend (artifacts/tx-os)
- PdfSection in src/pages/executive-meetings.tsx now renders a single
"Download PDF" button that fetches the endpoint, builds a Blob, and
downloads it. Removed the print/archive-creation buttons.
- Archive list shows a Download button for new /objects/... rows and a
read-only "Legacy snapshot" badge for older print: rows.
- Added byteSize on PdfArchive + size formatting; updated en/ar locales.
Tests
- New test "PDF GET /executive-meetings/pdf returns a real PDF and
archives it" in tests/executive-meetings.test.mjs covers: bad-date
400, unauthenticated 401, real %PDF body + content-type/disposition,
archive row with byteSize/generatedBy/filePath, empty-day handling,
and font-family mapping (Cairo embeds NotoSansArabic; Noto Naskh
Arabic embeds NotoNaskhArabic).
- All 23 executive-meetings tests pass.
Rebase
- Rebased onto main-repl/main (13d82b8 "Update the website's shared
image"). The only conflict was the binary asset
artifacts/tx-os/public/opengraph.jpg — accepted the incoming/main
version since it's unrelated to this PDF work.
Drift
- Kept the legacy /executive-meetings/print SPA route and the existing
POST /pdf-archives endpoint to preserve old archive snapshots and
the existing snapshot test. Proposed follow-up #169 to clean these
up once stakeholders confirm.
143 lines
4.2 KiB
JavaScript
143 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",
|
|
"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);
|
|
});
|