diff --git a/artifacts/api-server/assets/fonts/DejaVuSans-Bold.ttf b/artifacts/api-server/assets/fonts/DejaVuSans-Bold.ttf new file mode 100644 index 00000000..426812b9 Binary files /dev/null and b/artifacts/api-server/assets/fonts/DejaVuSans-Bold.ttf differ diff --git a/artifacts/api-server/assets/fonts/DejaVuSans.ttf b/artifacts/api-server/assets/fonts/DejaVuSans.ttf new file mode 100644 index 00000000..fb0bd945 Binary files /dev/null and b/artifacts/api-server/assets/fonts/DejaVuSans.ttf differ diff --git a/artifacts/api-server/assets/fonts/NotoNaskhArabic-Bold.ttf b/artifacts/api-server/assets/fonts/NotoNaskhArabic-Bold.ttf new file mode 100644 index 00000000..c30a9dfb Binary files /dev/null and b/artifacts/api-server/assets/fonts/NotoNaskhArabic-Bold.ttf differ diff --git a/artifacts/api-server/assets/fonts/NotoNaskhArabic-Regular.ttf b/artifacts/api-server/assets/fonts/NotoNaskhArabic-Regular.ttf new file mode 100644 index 00000000..e81ad4ef Binary files /dev/null and b/artifacts/api-server/assets/fonts/NotoNaskhArabic-Regular.ttf differ diff --git a/artifacts/api-server/assets/fonts/NotoSansArabic-Bold.ttf b/artifacts/api-server/assets/fonts/NotoSansArabic-Bold.ttf new file mode 100644 index 00000000..2bec9624 Binary files /dev/null and b/artifacts/api-server/assets/fonts/NotoSansArabic-Bold.ttf differ diff --git a/artifacts/api-server/assets/fonts/NotoSansArabic-Regular.ttf b/artifacts/api-server/assets/fonts/NotoSansArabic-Regular.ttf new file mode 100644 index 00000000..72c9228c Binary files /dev/null and b/artifacts/api-server/assets/fonts/NotoSansArabic-Regular.ttf differ diff --git a/artifacts/api-server/build.mjs b/artifacts/api-server/build.mjs index 86ebf7fa..a78f62ee 100644 --- a/artifacts/api-server/build.mjs +++ b/artifacts/api-server/build.mjs @@ -3,7 +3,7 @@ import path from "node:path"; import { fileURLToPath } from "node:url"; import { build as esbuild } from "esbuild"; import esbuildPluginPino from "esbuild-plugin-pino"; -import { rm } from "node:fs/promises"; +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); @@ -120,7 +120,23 @@ globalThis.__dirname = __bannerPath.dirname(globalThis.__filename); }); } -buildAll().catch((err) => { - console.error(err); - process.exit(1); -}); +// 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); + }); diff --git a/artifacts/api-server/package.json b/artifacts/api-server/package.json index 61b23dae..6b590436 100644 --- a/artifacts/api-server/package.json +++ b/artifacts/api-server/package.json @@ -12,9 +12,11 @@ }, "dependencies": { "@google-cloud/storage": "^7.19.0", + "@swc/helpers": "^0.5.21", "@workspace/api-zod": "workspace:*", "@workspace/db": "workspace:*", "bcryptjs": "^3.0.3", + "bidi-js": "^1.0.3", "connect-pg-simple": "^10.0.0", "cookie-parser": "^1.4.7", "cors": "^2", @@ -22,6 +24,7 @@ "express": "^5", "express-session": "^1.19.0", "google-auth-library": "^10.6.2", + "pdfkit": "^0.18.0", "pino": "^9", "pino-http": "^10", "sanitize-html": "^2.17.3", @@ -36,6 +39,7 @@ "@types/express": "^5.0.6", "@types/express-session": "^1.19.0", "@types/node": "catalog:", + "@types/pdfkit": "^0.17.6", "@types/sanitize-html": "^2.16.1", "esbuild": "^0.27.3", "esbuild-plugin-pino": "^2.3.3", diff --git a/artifacts/api-server/src/lib/pdf-renderer.ts b/artifacts/api-server/src/lib/pdf-renderer.ts new file mode 100644 index 00000000..aab778ba --- /dev/null +++ b/artifacts/api-server/src/lib/pdf-renderer.ts @@ -0,0 +1,657 @@ +import { fileURLToPath } from "node:url"; +import path from "node:path"; +import { readFileSync, existsSync } from "node:fs"; +import bidiFactory from "bidi-js"; +import PDFDocument from "pdfkit"; + +import sanitizeHtml from "sanitize-html"; + +const bidi = bidiFactory(); + +export type PdfFontPrefs = { + fontFamily: string; + fontSize: number; + fontWeight: "regular" | "bold"; + alignment: "start" | "center"; +}; + +export type PdfMeetingAttendee = { + name: string; + title: string | null; + attendanceType?: string | null; +}; + +export type PdfMeeting = { + dailyNumber: number; + titleAr: string; + titleEn: string | null; + startTime: string | null; + endTime: string | null; + location: string | null; + isHighlighted: number; + attendees: PdfMeetingAttendee[]; +}; + +export type RenderPdfInput = { + date: string; + lang: "ar" | "en"; + font: PdfFontPrefs; + meetings: PdfMeeting[]; + labels: { + title: string; + no: string; + meeting: string; + attendees: string; + time: string; + none: string; + }; +}; + +// Resolve fonts relative to the bundled dist file (esbuild rewrites +// __dirname via the build banner so this works in both dev and prod). +function fontsDir(): string { + const here = + typeof __dirname === "string" + ? __dirname + : path.dirname(fileURLToPath(import.meta.url)); + // The fonts are shipped as raw assets next to dist/ — see + // artifacts/api-server/assets/fonts. From dist/ we go up one level. + 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(", ")}`, + ); +} + +// All font files bundled in artifacts/api-server/assets/fonts. We only +// load the ones each render needs, but list every file here so the +// build step (and font-family mapping below) stays declarative. +const FONT_FILES = { + naskhRegular: "NotoNaskhArabic-Regular.ttf", + naskhBold: "NotoNaskhArabic-Bold.ttf", + arabicSansRegular: "NotoSansArabic-Regular.ttf", + arabicSansBold: "NotoSansArabic-Bold.ttf", + latinRegular: "DejaVuSans.ttf", + latinBold: "DejaVuSans-Bold.ttf", +} as const; + +type FontFileKey = keyof typeof FONT_FILES; + +// Map every supported user-facing font family to a concrete pair of +// bundled fonts (one for Arabic glyphs, one for Latin glyphs). Naskh +// families (Noto Naskh Arabic, Amiri, system default) use the Naskh +// face; sans families (Cairo, Tajawal) use Noto Sans Arabic — visually +// the closest sans-serif Arabic typeface we can ship without bundling +// every requested vendor font. Latin glyphs always render in DejaVu +// Sans so that mixed-script cells share consistent metrics. +type FamilyMapping = { + arabicRegular: FontFileKey; + arabicBold: FontFileKey; + latinRegular: FontFileKey; + latinBold: FontFileKey; +}; + +const FAMILY_MAP: Record = { + system: { + arabicRegular: "naskhRegular", + arabicBold: "naskhBold", + latinRegular: "latinRegular", + latinBold: "latinBold", + }, + Cairo: { + arabicRegular: "arabicSansRegular", + arabicBold: "arabicSansBold", + latinRegular: "latinRegular", + latinBold: "latinBold", + }, + Tajawal: { + arabicRegular: "arabicSansRegular", + arabicBold: "arabicSansBold", + latinRegular: "latinRegular", + latinBold: "latinBold", + }, + "Noto Naskh Arabic": { + arabicRegular: "naskhRegular", + arabicBold: "naskhBold", + latinRegular: "latinRegular", + latinBold: "latinBold", + }, + Amiri: { + arabicRegular: "naskhRegular", + arabicBold: "naskhBold", + latinRegular: "latinRegular", + latinBold: "latinBold", + }, +}; + +function familyMappingFor(family: string): FamilyMapping { + return FAMILY_MAP[family] ?? FAMILY_MAP.system; +} + +const ARABIC_FONT_NAMES: ReadonlyArray = [ + "Cairo", + "Tajawal", + "Noto Naskh Arabic", + "Amiri", +]; + +// Bytes are loaded lazily on first render and reused. +let cachedFontBytes: Partial> = {}; +function loadFontBytes(keys: ReadonlyArray): Record { + const dir = fontsDir(); + const out = {} as Record; + for (const k of keys) { + if (!cachedFontBytes[k]) { + cachedFontBytes[k] = readFileSync(path.join(dir, FONT_FILES[k])); + } + out[k] = cachedFontBytes[k] as Buffer; + } + return out; +} + +// Fast Arabic-script test (Arabic, Arabic Supplement, Arabic Extended-A, +// Arabic Presentation Forms-A/B). We use it to decide which bundled font +// covers a given character so mixed Arabic/Latin cells can be rendered +// without missing-glyph boxes. +function isArabicChar(ch: string): boolean { + const c = ch.codePointAt(0) ?? 0; + return ( + (c >= 0x0600 && c <= 0x06ff) || + (c >= 0x0750 && c <= 0x077f) || + (c >= 0x08a0 && c <= 0x08ff) || + (c >= 0xfb50 && c <= 0xfdff) || + (c >= 0xfe70 && c <= 0xfeff) + ); +} + +function isLatinChar(ch: string): boolean { + const c = ch.codePointAt(0) ?? 0; + // Latin, digits, common punctuation, Latin-1 supplement. + return ( + (c >= 0x0020 && c <= 0x024f) || + (c >= 0x2000 && c <= 0x206f) || + (c >= 0x2070 && c <= 0x209f) + ); +} + +type Run = { text: string; isArabic: boolean }; + +// Split a string into runs of consecutive same-script characters so we +// can pick the Arabic font for Arabic glyphs and the Latin font for the +// rest. Whitespace sticks to the surrounding run to avoid a stream of +// 1-char runs. +function splitRunsByScript(text: string): Run[] { + const runs: Run[] = []; + let current: Run | null = null; + for (const ch of text) { + const arabic = isArabicChar(ch); + const latin = isLatinChar(ch); + if (!arabic && !latin) { + // Glyph that neither font may cover — bias toward whatever the + // current run is so we don't fragment unnecessarily. + if (current) { + current.text += ch; + continue; + } + } + const want = arabic; + if (!current || current.isArabic !== want) { + current = { text: ch, isArabic: want }; + runs.push(current); + } else { + current.text += ch; + } + } + return runs; +} + +// Apply Unicode Bidi to a string and return runs in *visual* order (which +// is the order PDFKit will paint them along the line). Without this, mixed +// Arabic/Latin/digit cells look wrong (e.g. "10:00 - 11:00" inside an +// otherwise-RTL line gets digit-reversed by the renderer). +function visualOrderRuns(text: string, baseDirection: "ltr" | "rtl"): Run[] { + if (!text) return []; + const embeddingLevels = bidi.getEmbeddingLevels(text, baseDirection); + const reordered = bidi.getReorderedString(text, embeddingLevels); + return splitRunsByScript(reordered); +} + +function fontKeyFor( + isArabic: boolean, + weight: "regular" | "bold", + mapping: FamilyMapping, +): FontFileKey { + if (isArabic) { + return weight === "bold" ? mapping.arabicBold : mapping.arabicRegular; + } + return weight === "bold" ? mapping.latinBold : mapping.latinRegular; +} + +function alignment( + font: PdfFontPrefs, + isRtl: boolean, +): "left" | "center" | "right" { + if (font.alignment === "center") return "center"; + // "start" maps to the writing-direction start. + return isRtl ? "right" : "left"; +} + +function htmlToPlain(input: string | null | undefined): string { + if (!input) return ""; + // Strip every tag so we render plain text — table cells already have + // their own font/weight/size from the user's font settings, and PDFKit + // doesn't render arbitrary inline HTML. We DO keep
as a newline + // because attendees often use line breaks. + const withBreaks = input + .replace(/<\s*br\s*\/?>/gi, "\n") + .replace(/<\/(p|div|li)>/gi, "\n") + .replace(/
  • /gi, "• "); + const sanitized = sanitizeHtml(withBreaks, { + allowedTags: [], + allowedAttributes: {}, + }); + // Decode common entities that sanitize-html leaves intact when no tags + // are kept (it returns the inner text). + 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; + return new Intl.DateTimeFormat(lang === "ar" ? "ar" : "en", { + hour: "numeric", + minute: "2-digit", + hour12: true, + // Force Latin digits even for Arabic locale so the Time column + // renders consistently across the schedule and the PDF — matches + // i18nFormatTime in the frontend. + numberingSystem: "latn", + } as Intl.DateTimeFormatOptions).format(parsed); + }; + if (startTime && endTime) return `${fmt(startTime)} – ${fmt(endTime)}`; + if (startTime) return fmt(startTime); + return "—"; +} + +type DrawOpts = { + x: number; + y: number; + width: number; + fontSize: number; + weight: "regular" | "bold"; + align: "left" | "center" | "right"; + baseDirection: "ltr" | "rtl"; + mapping: FamilyMapping; +}; + +// Draw a single line of text using mixed Arabic/Latin runs (in visual +// order). We measure each run with the right font and lay them out +// left-to-right starting from the resolved alignment. Returns the height +// consumed by this line (including a small line-gap). +function drawMixedLine( + doc: PDFKit.PDFDocument, + text: string, + opts: DrawOpts, +): number { + const runs = visualOrderRuns(text, opts.baseDirection); + if (runs.length === 0) { + return opts.fontSize * 1.2; + } + // Measure full line width. + let totalWidth = 0; + const measured = runs.map((r) => { + const fk = fontKeyFor(r.isArabic, opts.weight, opts.mapping); + doc.font(fk).fontSize(opts.fontSize); + const w = doc.widthOfString(r.text); + totalWidth += w; + return { ...r, width: w, fontKey: fk }; + }); + const overflow = totalWidth - opts.width; + let cursorX = opts.x; + if (opts.align === "center") { + cursorX = opts.x + Math.max(0, (opts.width - totalWidth) / 2); + } else if (opts.align === "right") { + cursorX = opts.x + Math.max(0, opts.width - totalWidth); + } + // If we overflow horizontally, fall back to wrapping via PDFKit's own + // text engine on the first run. Cells in the schedule rarely overflow + // because column widths are generous, but Arabic titles can be long. + if (overflow > 4) { + return drawWrappingLine(doc, text, opts); + } + for (const r of measured) { + doc.font(r.fontKey).fontSize(opts.fontSize).fillColor("black"); + doc.text(r.text, cursorX, opts.y, { + lineBreak: false, + width: r.width + 1, + }); + cursorX += r.width; + } + return opts.fontSize * 1.25; +} + +function drawWrappingLine( + doc: PDFKit.PDFDocument, + text: string, + opts: DrawOpts, +): number { + // Determine the dominant script for the wrapping pass. Without true + // bidi line-breaking we pick the "majority" script font to wrap with; + // this keeps shaping intact for the dominant direction at the cost of + // a slight metric mismatch on the minority runs. + const arabicChars = [...text].filter(isArabicChar).length; + const dominantArabic = arabicChars * 2 > text.length; + const fk = fontKeyFor(dominantArabic, opts.weight, opts.mapping); + doc.font(fk).fontSize(opts.fontSize).fillColor("black"); + // For Arabic text, run bidi on the whole string so the font sees runs + // already in visual order — this matches what drawMixedLine does for + // single-line cells. + const reordered = bidi.getReorderedString( + text, + bidi.getEmbeddingLevels(text, opts.baseDirection), + ); + const before = doc.y; + doc.text(reordered, opts.x, opts.y, { + width: opts.width, + align: opts.align, + lineBreak: true, + }); + return Math.max(opts.fontSize * 1.25, doc.y - before + 2); +} + +export async function renderSchedulePdf(input: RenderPdfInput): Promise { + const isRtl = input.lang === "ar"; + const baseDir: "ltr" | "rtl" = isRtl ? "rtl" : "ltr"; + const doc = new PDFDocument({ + size: "A4", + layout: "portrait", + margin: 36, // ~12.7 mm — matches the screen print page's @page margin closely + info: { + Title: `${input.labels.title} — ${input.date}`, + Producer: "Tx OS Executive Meetings", + }, + }); + const mapping = familyMappingFor(input.font.fontFamily); + // Load only the four files this family needs (deduped by key). + const neededKeys = Array.from( + new Set([ + mapping.arabicRegular, + mapping.arabicBold, + mapping.latinRegular, + mapping.latinBold, + ]), + ); + const bytes = loadFontBytes(neededKeys); + for (const k of neededKeys) { + doc.registerFont(k, bytes[k]); + } + + const chunks: Buffer[] = []; + doc.on("data", (c: Buffer) => chunks.push(c)); + const done = new Promise((resolve, reject) => { + doc.on("end", resolve); + doc.on("error", reject); + }); + + // Header — show the schedule title and the date on its own line. + const titleSize = Math.min(28, input.font.fontSize + 10); + drawMixedLine(doc, input.labels.title, { + x: doc.page.margins.left, + y: doc.y, + width: doc.page.width - doc.page.margins.left - doc.page.margins.right, + fontSize: titleSize, + weight: "bold", + align: "center", + baseDirection: baseDir, + mapping, + }); + doc.moveDown(0.4); + drawMixedLine(doc, input.date, { + x: doc.page.margins.left, + y: doc.y, + width: doc.page.width - doc.page.margins.left - doc.page.margins.right, + fontSize: Math.max(11, Math.round(input.font.fontSize * 0.85)), + weight: "regular", + align: "center", + baseDirection: baseDir, + mapping, + }); + doc.moveDown(0.8); + + if (input.meetings.length === 0) { + drawMixedLine(doc, input.labels.none, { + x: doc.page.margins.left, + y: doc.y, + width: doc.page.width - doc.page.margins.left - doc.page.margins.right, + fontSize: input.font.fontSize, + weight: input.font.fontWeight, + align: "center", + baseDirection: baseDir, + mapping, + }); + doc.end(); + await done; + return Buffer.concat(chunks); + } + + // ---- Table layout ---- + // Mirror the print page's column widths (6 / 30 / 44 / 20). + const tableX = doc.page.margins.left; + const tableWidth = doc.page.width - doc.page.margins.left - doc.page.margins.right; + const colWidths = [ + Math.round(tableWidth * 0.06), + Math.round(tableWidth * 0.3), + Math.round(tableWidth * 0.44), + 0, // last col absorbs rounding + ]; + colWidths[3] = tableWidth - colWidths[0] - colWidths[1] - colWidths[2]; + const headerLabels = isRtl + ? // RTL: visually right-to-left ordering matches the schedule's + // Arabic layout (#, Meeting, Attendees, Time becomes Time first). + [input.labels.time, input.labels.attendees, input.labels.meeting, input.labels.no] + : [input.labels.no, input.labels.meeting, input.labels.attendees, input.labels.time]; + const headerWidths = isRtl ? [...colWidths].reverse() : colWidths; + + const cellPadX = 6; + const cellPadY = 5; + const lineHeight = input.font.fontSize * 1.25; + + function drawHeader(): number { + const headerHeight = lineHeight + cellPadY * 2; + let cx = tableX; + const headerY = doc.y; + doc.save(); + doc.rect(tableX, headerY, tableWidth, headerHeight).fill("#0B1E3F"); + doc.restore(); + for (let i = 0; i < headerLabels.length; i++) { + const label = headerLabels[i]; + const arabicChars = [...label].filter(isArabicChar).length; + const dominantArabic = arabicChars * 2 > label.length; + const fk = fontKeyFor(dominantArabic, "bold", mapping); + doc.font(fk).fontSize(input.font.fontSize).fillColor("white"); + const reordered = bidi.getReorderedString( + label, + bidi.getEmbeddingLevels(label, baseDir), + ); + doc.text(reordered, cx + cellPadX, headerY + cellPadY, { + width: headerWidths[i] - cellPadX * 2, + align: "center", + lineBreak: false, + }); + cx += headerWidths[i]; + } + doc.fillColor("black"); + doc.y = headerY + headerHeight; + return headerHeight; + } + + // Render the table header on page 1. + const startY = doc.y; + const headerHeight = drawHeader(); + doc.y = startY + headerHeight; + + // ---- Rows ---- + for (const meeting of input.meetings) { + const cellsLogical: { text: string; align: "left" | "center" | "right" }[] = [ + { text: String(meeting.dailyNumber), align: "center" }, + { + text: htmlToPlain(isRtl ? meeting.titleAr : meeting.titleEn || meeting.titleAr) + + (meeting.location ? `\n${meeting.location}` : ""), + align: alignment(input.font, isRtl), + }, + { + text: + meeting.attendees.length === 0 + ? "—" + : meeting.attendees + .map((a, idx) => { + const name = htmlToPlain(a.name); + const t = a.title?.trim(); + return `${idx + 1}- ${name}${t ? ` (${t})` : ""}`; + }) + .join("\n"), + align: alignment(input.font, isRtl), + }, + { + text: formatTimeRange(meeting.startTime, meeting.endTime, input.lang), + align: "center", + }, + ]; + const cells = isRtl ? [...cellsLogical].reverse() : cellsLogical; + const widths = isRtl ? [...colWidths].reverse() : colWidths; + + // Measure the row height by laying out each cell to a temporary + // y-cursor and taking the max. We then redraw at the final position. + const probe = doc; + const probeY = probe.y; + let rowHeight = lineHeight + cellPadY * 2; + const cellHeights: number[] = []; + let cx = tableX; + for (let i = 0; i < cells.length; i++) { + const c = cells[i]; + const startProbeY = probeY + cellPadY; + probe.y = startProbeY; + // Reuse drawWrappingLine to compute height — drop the actual draw + // by using a temporary off-screen page would be ideal, but PDFKit + // doesn't expose an undo. We instead measure by counting expected + // wraps at the dominant font. + const lines = c.text.split("\n"); + let h = 0; + const arabicChars = [...c.text].filter(isArabicChar).length; + const dominantArabic = arabicChars * 2 > c.text.length; + const fk = fontKeyFor(dominantArabic, input.font.fontWeight, mapping); + doc.font(fk).fontSize(input.font.fontSize); + for (const line of lines) { + if (!line) { + h += lineHeight; + continue; + } + const w = doc.widthOfString(line); + const wraps = Math.max(1, Math.ceil(w / (widths[i] - cellPadX * 2))); + h += wraps * lineHeight; + } + const finalH = h + cellPadY * 2; + cellHeights.push(finalH); + if (finalH > rowHeight) rowHeight = finalH; + cx += widths[i]; + } + + // Page break check. + if (probeY + rowHeight > doc.page.height - doc.page.margins.bottom) { + doc.addPage(); + drawHeader(); + } + + const rowTop = doc.y; + if (meeting.isHighlighted) { + doc.save(); + doc.rect(tableX, rowTop, tableWidth, rowHeight).fill("#fecaca"); + doc.restore(); + } + + // Cell borders. + doc.save(); + doc.lineWidth(0.5).strokeColor("#d1d5db"); + doc.rect(tableX, rowTop, tableWidth, rowHeight).stroke(); + let bx = tableX; + for (let i = 0; i < cells.length - 1; i++) { + bx += widths[i]; + doc + .moveTo(bx, rowTop) + .lineTo(bx, rowTop + rowHeight) + .stroke(); + } + doc.restore(); + + // Draw cell contents. + cx = tableX; + for (let i = 0; i < cells.length; i++) { + const c = cells[i]; + const cellW = widths[i] - cellPadX * 2; + const lines = c.text.split("\n"); + let y = rowTop + cellPadY; + for (const line of lines) { + if (!line) { + y += lineHeight; + continue; + } + const used = drawWrappingLine(doc, line, { + x: cx + cellPadX, + y, + width: cellW, + fontSize: input.font.fontSize, + weight: input.font.fontWeight, + align: c.align, + baseDirection: baseDir, + mapping, + }); + y += used; + } + cx += widths[i]; + } + doc.y = rowTop + rowHeight; + } + + // Footer — generation timestamp and font info. + doc.moveDown(0.6); + const footer = `Generated ${new Date().toISOString()} · font=${input.font.fontFamily} ${input.font.fontSize}px ${input.font.fontWeight}`; + doc + .font(mapping.latinRegular) + .fontSize(8) + .fillColor("#6b7280") + .text(footer, doc.page.margins.left, doc.y, { + width: doc.page.width - doc.page.margins.left - doc.page.margins.right, + align: "left", + }); + + doc.end(); + await done; + return Buffer.concat(chunks); +} + +// Keep the explicit "is this an arabic-leaning family?" check exposed for +// tests / future logic that wants to know the user's font choice. +export function fontFamilyIsArabic(name: string): boolean { + return ARABIC_FONT_NAMES.includes(name); +} diff --git a/artifacts/api-server/src/routes/executive-meetings.ts b/artifacts/api-server/src/routes/executive-meetings.ts index 95026cda..9598a31a 100644 --- a/artifacts/api-server/src/routes/executive-meetings.ts +++ b/artifacts/api-server/src/routes/executive-meetings.ts @@ -34,6 +34,8 @@ import { } from "../middlewares/auth"; import { sanitizeRichText } from "../lib/sanitize"; import { emitExecutiveMeetingsDayChanged } from "../lib/realtime"; +import { renderSchedulePdf, type PdfFontPrefs } from "../lib/pdf-renderer"; +import { ObjectStorageService } from "../lib/objectStorage"; import { recordExecutiveMeetingNotifications, broadcastExecutiveMeetingNotifications, @@ -2164,6 +2166,206 @@ router.get( }, ); +// ===================================================================== +// PDF GENERATION (server-side) +// ===================================================================== + +// Resolve the user's effective font preferences exactly the way the +// frontend does: user-scope row wins, otherwise global, otherwise the +// schema defaults. +async function resolveFontPrefsForUser(userId: number): Promise { + const rows = await db + .select() + .from(executiveMeetingFontSettingsTable) + .where( + or( + eq(executiveMeetingFontSettingsTable.scope, "global"), + and( + eq(executiveMeetingFontSettingsTable.scope, "user"), + eq(executiveMeetingFontSettingsTable.userId, userId), + ), + ), + ); + const userRow = rows.find((r) => r.scope === "user" && r.userId === userId); + const globalRow = rows.find((r) => r.scope === "global"); + const eff = userRow ?? globalRow; + return { + fontFamily: eff?.fontFamily ?? "system", + fontSize: eff?.fontSize ?? 14, + fontWeight: (eff?.fontWeight as PdfFontPrefs["fontWeight"]) ?? "regular", + alignment: (eff?.alignment as PdfFontPrefs["alignment"]) ?? "start", + }; +} + +// Upload bytes to object storage by signing a PUT URL and streaming the +// buffer through `fetch`. Returns the canonical /objects/ path that +// GET /api/storage/objects/* knows how to serve. +async function uploadPdfToStorage(buf: Buffer): Promise { + const objectStorage = new ObjectStorageService(); + const uploadUrl = await objectStorage.getObjectEntityUploadURL(); + const putRes = await fetch(uploadUrl, { + method: "PUT", + body: buf, + headers: { + "Content-Type": "application/pdf", + "Content-Length": String(buf.byteLength), + }, + }); + if (!putRes.ok) { + const body = await putRes.text().catch(() => ""); + throw new Error( + `Failed to upload PDF to object storage (${putRes.status}): ${body.slice(0, 200)}`, + ); + } + // Storage returns the signed URL we PUT to — normalize back to + // /objects/ so we can serve it via GET /api/storage/objects/*. + return objectStorage.normalizeObjectEntityPath(uploadUrl.split("?")[0]!); +} + +router.get( + "/executive-meetings/pdf", + requireExecutiveAccess, + async (req, res): Promise => { + const dateRaw = typeof req.query.date === "string" ? req.query.date.trim() : ""; + if (!dateRaw || !DATE_RE_LOCAL.test(dateRaw)) { + res + .status(400) + .json({ error: "Invalid 'date' (expected YYYY-MM-DD)", code: "bad_date" }); + return; + } + const lang: "ar" | "en" = + typeof req.query.lang === "string" && req.query.lang === "en" ? "en" : "ar"; + const userId = req.session.userId!; + + const meetings = await db + .select() + .from(executiveMeetingsTable) + .where(eq(executiveMeetingsTable.meetingDate, dateRaw)) + .orderBy(asc(executiveMeetingsTable.dailyNumber)); + let attendeesByMeeting = new Map(); + if (meetings.length > 0) { + const ids = meetings.map((m) => m.id); + const attendees = await db + .select() + .from(executiveMeetingAttendeesTable) + .where(inArray(executiveMeetingAttendeesTable.meetingId, ids)) + .orderBy( + asc(executiveMeetingAttendeesTable.meetingId), + asc(executiveMeetingAttendeesTable.sortOrder), + ); + for (const a of attendees) { + const arr = attendeesByMeeting.get(a.meetingId) ?? []; + arr.push(a); + attendeesByMeeting.set(a.meetingId, arr); + } + } + + const font = await resolveFontPrefsForUser(userId); + const labels = + lang === "ar" + ? { + title: "جدول الاجتماعات التنفيذية", + no: "م", + meeting: "الاجتماع", + attendees: "الحضور", + time: "الوقت", + none: "لا توجد اجتماعات في هذا اليوم.", + } + : { + title: "Executive Meetings Schedule", + no: "#", + meeting: "Meeting", + attendees: "Attendees", + time: "Time", + none: "No meetings on this day.", + }; + + let pdf: Buffer; + try { + pdf = await renderSchedulePdf({ + date: dateRaw, + lang, + font, + labels, + meetings: meetings.map((m) => ({ + dailyNumber: m.dailyNumber, + titleAr: m.titleAr, + titleEn: m.titleEn, + startTime: m.startTime, + endTime: m.endTime, + location: m.location, + isHighlighted: m.isHighlighted ?? 0, + attendees: (attendeesByMeeting.get(m.id) ?? []).map((a) => ({ + name: a.name, + title: a.title, + attendanceType: a.attendanceType, + })), + })), + }); + } catch (err) { + // Fail loudly so callers see a real error instead of a corrupt PDF. + console.error("[executive-meetings] PDF render failed", err); + res.status(500).json({ + error: "Failed to generate PDF", + code: "pdf_render_failed", + detail: err instanceof Error ? err.message : String(err), + }); + return; + } + + // Upload + archive. If storage upload fails (e.g. sidecar offline) + // we still serve the PDF inline so the user is never blocked, but + // we record the archive with a synthetic path so the failure is + // visible in the audit trail. + let storagePath = `inline:${dateRaw}`; + try { + storagePath = await uploadPdfToStorage(pdf); + } catch (err) { + console.error("[executive-meetings] PDF upload failed; serving inline", err); + } + + try { + await db.transaction(async (tx) => { + const [{ value: maxV }] = await tx + .select({ + value: sql`coalesce(max(${executiveMeetingPdfArchivesTable.version}), 0)`, + }) + .from(executiveMeetingPdfArchivesTable) + .where(eq(executiveMeetingPdfArchivesTable.archiveDate, dateRaw)); + const insertValues: typeof executiveMeetingPdfArchivesTable.$inferInsert = { + archiveDate: dateRaw, + filePath: storagePath, + version: (maxV ?? 0) + 1, + byteSize: pdf.byteLength, + generatedBy: userId, + }; + const [row] = await tx + .insert(executiveMeetingPdfArchivesTable) + .values(insertValues) + .returning(); + await logAudit(tx, { + action: "create", + entityType: "pdf_archive", + entityId: row!.id, + newValue: row!, + performedBy: userId, + }); + }); + } catch (err) { + console.error("[executive-meetings] PDF archive insert failed", err); + } + + res.setHeader("Content-Type", "application/pdf"); + res.setHeader( + "Content-Disposition", + `attachment; filename="executive-meetings-${dateRaw}.pdf"`, + ); + res.setHeader("Content-Length", String(pdf.byteLength)); + res.setHeader("Cache-Control", "no-store"); + res.end(pdf); + }, +); + // ===================================================================== // PDF ARCHIVES // ===================================================================== diff --git a/artifacts/api-server/src/types/bidi-js.d.ts b/artifacts/api-server/src/types/bidi-js.d.ts new file mode 100644 index 00000000..0aefca5c --- /dev/null +++ b/artifacts/api-server/src/types/bidi-js.d.ts @@ -0,0 +1,21 @@ +declare module "bidi-js" { + export interface BidiEmbeddingLevels { + levels: Uint8Array; + paragraphs: Array<{ start: number; end: number; level: 0 | 1 }>; + } + + export interface BidiApi { + getEmbeddingLevels(text: string, baseDirection?: "ltr" | "rtl" | "auto"): BidiEmbeddingLevels; + getReorderedString(text: string, embeddingLevels: BidiEmbeddingLevels): string; + getReorderedIndices(text: string, embeddingLevels: BidiEmbeddingLevels): number[]; + getReorderSegments( + text: string, + embeddingLevels: BidiEmbeddingLevels, + ): Array<[number, number]>; + getBidiCharType(char: string): number; + getBidiCharTypeName(char: string): string; + getMirroredCharacter(char: string): string | null; + } + + export default function bidiFactory(): BidiApi; +} diff --git a/artifacts/api-server/tests/executive-meetings.test.mjs b/artifacts/api-server/tests/executive-meetings.test.mjs index 372bb9c7..db2a1814 100644 --- a/artifacts/api-server/tests/executive-meetings.test.mjs +++ b/artifacts/api-server/tests/executive-meetings.test.mjs @@ -539,6 +539,157 @@ test("PDF archives: POST creates a snapshot and GET returns it", async () => { assert.ok(archives.some((a) => a.id === archive.id)); }); +test("PDF GET /executive-meetings/pdf returns a real PDF and archives it", async () => { + // Create a meeting with mixed Arabic+Latin attendees + a time range so + // the renderer's bidi/font-segmentation path is exercised. + const create = await api(adminCookie, "POST", "/api/executive-meetings", { + titleAr: "اجتماع الميزانية ‪Q2 2026‬", + titleEn: "Q2 2026 Budget Review", + meetingDate: today, + startTime: "10:00", + endTime: "11:30", + location: "قاعة كبرى", + attendees: [ + { name: "أحمد العلي", title: "المدير التنفيذي", + attendanceType: "internal", sortOrder: 0 }, + { name: "John Smith", title: "CFO", + attendanceType: "external", sortOrder: 1 }, + ], + }); + assert.equal(create.status, 201); + const meeting = await create.json(); + created.meetingIds.push(meeting.id); + + // Bad date → 400 + const bad = await api(adminCookie, "GET", + "/api/executive-meetings/pdf?date=not-a-date"); + assert.equal(bad.status, 400); + + // Coordinator without executive role is denied — auth gate matches the + // rest of the executive-meetings module. + const noAuth = await fetch(`${API_BASE}/api/executive-meetings/pdf?date=${today}`); + assert.equal(noAuth.status, 401); + + // Happy path: real PDF response with non-trivial body. + const res = await api(adminCookie, "GET", + `/api/executive-meetings/pdf?date=${today}&lang=ar`); + assert.equal(res.status, 200); + assert.match( + res.headers.get("content-type") || "", + /^application\/pdf/, + "must serve application/pdf", + ); + assert.match( + res.headers.get("content-disposition") || "", + new RegExp(`attachment;\\s*filename="executive-meetings-${today}\\.pdf"`), + "filename should include the requested date", + ); + const buf = Buffer.from(await res.arrayBuffer()); + assert.ok(buf.length > 1000, `PDF should be >1KB, got ${buf.length}`); + assert.equal( + buf.subarray(0, 4).toString("ascii"), + "%PDF", + "body must start with PDF magic bytes", + ); + + // The download must have created an archive row recorded against the + // generating user with byteSize matching the served body. + const list = await api( + adminCookie, + "GET", + `/api/executive-meetings/pdf-archives?date=${today}`, + ); + assert.equal(list.status, 200); + const archives = (await list.json()).archives ?? []; + const generated = archives.find( + (a) => typeof a.byteSize === "number" && a.byteSize === buf.length, + ); + assert.ok( + generated, + "PDF download must produce an archive row with the served byte_size", + ); + assert.ok(generated.generatedBy, "generatedBy must be recorded"); + assert.ok( + typeof generated.filePath === "string" && generated.filePath.length > 0, + "filePath/storage_url must be non-empty", + ); + + // Empty-day handling: another date with no meetings still returns a + // valid PDF (with a "no meetings" message). + const empty = await api(adminCookie, "GET", + `/api/executive-meetings/pdf?date=2099-01-01&lang=en`); + assert.equal(empty.status, 200); + const emptyBuf = Buffer.from(await empty.arrayBuffer()); + assert.equal(emptyBuf.subarray(0, 4).toString("ascii"), "%PDF"); + + // Font-family must affect which font is embedded in the PDF. We render + // the same day twice with different families and assert that: + // 1) Both downloads succeed (200 + %PDF magic). + // 2) The Sans family ("Cairo") embeds NotoSansArabic, while the Naskh + // default ("Noto Naskh Arabic") embeds NotoNaskhArabic. The font's + // PostScript name appears verbatim in the PDF font dictionary. + const setSans = await api( + adminCookie, + "PUT", + "/api/executive-meetings/font-settings", + { + scope: "user", + fontFamily: "Cairo", + fontSize: 16, + fontWeight: "bold", + alignment: "center", + }, + ); + assert.equal(setSans.status, 200); + const sansRes = await api( + adminCookie, + "GET", + `/api/executive-meetings/pdf?date=${today}&lang=ar`, + ); + assert.equal(sansRes.status, 200); + const sansBuf = Buffer.from(await sansRes.arrayBuffer()); + assert.equal(sansBuf.subarray(0, 4).toString("ascii"), "%PDF"); + const sansAscii = sansBuf.toString("latin1"); + assert.ok( + /NotoSansArabic/i.test(sansAscii), + "Cairo family must embed NotoSansArabic in the PDF", + ); + assert.ok( + !/NotoNaskhArabic/i.test(sansAscii), + "Cairo family must NOT embed NotoNaskhArabic", + ); + + const setNaskh = await api( + adminCookie, + "PUT", + "/api/executive-meetings/font-settings", + { + scope: "user", + fontFamily: "Noto Naskh Arabic", + fontSize: 14, + fontWeight: "regular", + alignment: "start", + }, + ); + assert.equal(setNaskh.status, 200); + const naskhRes = await api( + adminCookie, + "GET", + `/api/executive-meetings/pdf?date=${today}&lang=ar`, + ); + assert.equal(naskhRes.status, 200); + const naskhBuf = Buffer.from(await naskhRes.arrayBuffer()); + const naskhAscii = naskhBuf.toString("latin1"); + assert.ok( + /NotoNaskhArabic/i.test(naskhAscii), + "Noto Naskh Arabic family must embed NotoNaskhArabic in the PDF", + ); + assert.ok( + !/NotoSansArabic/i.test(naskhAscii), + "Noto Naskh Arabic family must NOT embed NotoSansArabic", + ); +}); + test("Sanitization: rich text strips disallowed tags but keeps inline formatting", async () => { const dirty = 'Hello World' + diff --git a/artifacts/tx-os/public/opengraph.jpg b/artifacts/tx-os/public/opengraph.jpg index c3ff5c90..0e6b3fdd 100644 Binary files a/artifacts/tx-os/public/opengraph.jpg and b/artifacts/tx-os/public/opengraph.jpg differ diff --git a/artifacts/tx-os/src/locales/ar.json b/artifacts/tx-os/src/locales/ar.json index 923347e0..f21cc9af 100644 --- a/artifacts/tx-os/src/locales/ar.json +++ b/artifacts/tx-os/src/locales/ar.json @@ -1075,18 +1075,16 @@ "loading": "جارٍ التحميل…" }, "pdf": { - "heading": "تصدير / طباعة", - "intro": "تستخدم هذه الواجهة طابعة المتصفح لإنتاج نسخة PDF من جدول اليوم. اختر التاريخ ثم اضغط طباعة.", - "print": "طباعة وأرشفة", - "printOnly": "طباعة فقط", - "openSchedule": "عرض الجدول", + "heading": "تصدير PDF", + "intro": "يتم توليد ملف PDF حقيقي لجدول اليوم على الخادم باستخدام إعدادات الخط المحفوظة. كل عملية تنزيل تتم أرشفتها.", + "downloadPdf": "تنزيل PDF", + "downloadHint": "يُولَّد ملف PDF حسب تفضيلات الخط (النوع، الحجم، الوزن، المحاذاة) مع تشكيل عربي صحيح من اليمين لليسار.", + "downloaded": "بدأ تنزيل ملف PDF", "selectDate": "تاريخ الجدول", - "archiveSnapshot": "أرشفة نسخة", - "archived": "تمت أرشفة النسخة", - "archivedAndPrinted": "تمت أرشفة النسخة وفتح نافذة الطباعة", - "archivesHeading": "النسخ المؤرشفة", - "noArchives": "لا توجد نسخ مؤرشفة لهذا التاريخ بعد.", - "openArchive": "فتح" + "archivesHeading": "ملفات PDF المؤرشفة", + "noArchives": "لا توجد ملفات PDF مؤرشفة لهذا التاريخ بعد.", + "downloadArchive": "تنزيل", + "legacyArchive": "نسخة قديمة" }, "fontSettingsPage": { "heading": "إعدادات الخط", diff --git a/artifacts/tx-os/src/locales/en.json b/artifacts/tx-os/src/locales/en.json index f8dfe3ab..30c22510 100644 --- a/artifacts/tx-os/src/locales/en.json +++ b/artifacts/tx-os/src/locales/en.json @@ -1000,18 +1000,16 @@ } }, "pdf": { - "heading": "Print / Export", - "intro": "This view uses the browser print dialog to produce a PDF of the day's schedule. Pick a date and click Print.", - "print": "Print + archive", - "printOnly": "Print only", - "openSchedule": "Open schedule", + "heading": "PDF Export", + "intro": "Generates a real PDF of the day's schedule on the server using your saved font settings. Each download is archived.", + "downloadPdf": "Download PDF", + "downloadHint": "The PDF is generated using your font preferences (family, size, weight, alignment) with proper Arabic shaping.", + "downloaded": "PDF download started", "selectDate": "Schedule date", - "archiveSnapshot": "Archive snapshot", - "archived": "Snapshot archived", - "archivedAndPrinted": "Snapshot archived — print dialog opened", - "archivesHeading": "Archived snapshots", - "noArchives": "No snapshots archived for this date yet.", - "openArchive": "Open" + "archivesHeading": "Archived PDFs", + "noArchives": "No PDFs archived for this date yet.", + "downloadArchive": "Download", + "legacyArchive": "Legacy snapshot" }, "print": { "title": "Executive Meetings Schedule", diff --git a/artifacts/tx-os/src/pages/executive-meetings.tsx b/artifacts/tx-os/src/pages/executive-meetings.tsx index 6d9ae0f5..c11a7aad 100644 --- a/artifacts/tx-os/src/pages/executive-meetings.tsx +++ b/artifacts/tx-os/src/pages/executive-meetings.tsx @@ -28,7 +28,6 @@ import { Plus, Pencil, Trash2, - Printer, Check, X, ChevronUp, @@ -225,6 +224,9 @@ type PdfArchive = { archiveDate: string; filePath: string; version: number; + // Bytes recorded by the server-side generator. Null on legacy rows + // that were created before we wrote the PDF to object storage. + byteSize: number | null; generatedBy: number | null; generatedAt: string; generator: { @@ -234,6 +236,18 @@ type PdfArchive = { } | null; }; +function formatBytesForDisplay(bytes: number): string { + if (!Number.isFinite(bytes) || bytes <= 0) return "0 B"; + const units = ["B", "KB", "MB"]; + let value = bytes; + let unitIdx = 0; + while (value >= 1024 && unitIdx < units.length - 1) { + value /= 1024; + unitIdx += 1; + } + return `${value < 10 && unitIdx > 0 ? value.toFixed(1) : Math.round(value)} ${units[unitIdx]}`; +} + const SECTIONS = [ { key: "schedule", icon: CalendarClock }, { key: "manage", icon: ListChecks }, @@ -4899,22 +4913,51 @@ function PdfSection({ enabled: !!date, }); - function openPrint() { - const url = `/executive-meetings/print?date=${date}&lang=${lang}`; - window.open(url, "_blank", "noopener"); + // Stream a binary response from the API to the browser's download + // stack. Throws with the server's error message on a non-2xx so the + // toast surfaces a useful reason instead of a silent failure. + async function downloadBinary(url: string, filename: string): Promise { + const res = await fetch(url, { credentials: "include" }); + if (!res.ok) { + let message = `HTTP ${res.status}`; + try { + const j = (await res.json()) as { error?: string }; + if (j.error) message = j.error; + } catch { + /* response wasn't JSON — keep the HTTP status */ + } + throw new Error(message); + } + const blob = await res.blob(); + const objectUrl = URL.createObjectURL(blob); + try { + const a = document.createElement("a"); + a.href = objectUrl; + a.download = filename; + a.rel = "noopener"; + document.body.appendChild(a); + a.click(); + a.remove(); + } finally { + // Free the blob URL after the click — Safari needs a tick before + // it actually starts the download, so defer slightly. + setTimeout(() => URL.revokeObjectURL(objectUrl), 4000); + } } - async function archiveSnapshot() { + async function downloadPdf() { + if (!date) return; setBusy(true); try { - await apiJson(`/api/executive-meetings/pdf-archives`, { - method: "POST", - body: { archiveDate: date }, - }); - toast({ title: t("executiveMeetings.pdf.archived") }); + const url = `/api/executive-meetings/pdf?date=${encodeURIComponent( + date, + )}&lang=${encodeURIComponent(lang)}`; + await downloadBinary(url, `executive-meetings-${date}.pdf`); + // Refresh archives so the row created by the GET appears. await qc.invalidateQueries({ queryKey: ["/api/executive-meetings/pdf-archives", date], }); + toast({ title: t("executiveMeetings.pdf.downloaded") }); } catch (err) { const msg = err instanceof Error ? err.message : String(err); toast({ title: msg, variant: "destructive" }); @@ -4923,25 +4966,20 @@ function PdfSection({ } } - async function archiveAndPrint() { - if (!date) return; - setBusy(true); + async function downloadArchive(archive: PdfArchive) { + if (!archive.filePath?.startsWith("/objects/")) return; try { - await apiJson(`/api/executive-meetings/pdf-archives`, { - method: "POST", - body: { archiveDate: date }, - }); - await qc.invalidateQueries({ - queryKey: ["/api/executive-meetings/pdf-archives", date], - }); - const url = `/executive-meetings/print?date=${date}&lang=${lang}`; - window.open(url, "_blank", "noopener"); - toast({ title: t("executiveMeetings.pdf.archivedAndPrinted") }); + // /objects/ resolves to GET /api/storage/objects/ on the + // server. The route is auth-gated behind the same session, so + // credentials must be included. + const url = `/api${archive.filePath.replace(/^\/objects/, "/storage/objects")}`; + await downloadBinary( + url, + `executive-meetings-${archive.archiveDate}-v${archive.version}.pdf`, + ); } catch (err) { const msg = err instanceof Error ? err.message : String(err); toast({ title: msg, variant: "destructive" }); - } finally { - setBusy(false); } } @@ -4963,37 +5001,17 @@ function PdfSection({
    - -

    - {t("executiveMeetings.pdf.openSchedule")} + {t("executiveMeetings.pdf.downloadHint")}

    @@ -5033,20 +5051,28 @@ function PdfSection({
    - - - {a.filePath} - + {a.filePath?.startsWith("/objects/") ? ( + + ) : ( + + {t("executiveMeetings.pdf.legacyArchive")} + + )} + {typeof a.byteSize === "number" ? ( + + {formatBytesForDisplay(a.byteSize)} + + ) : null}
  • ); diff --git a/lib/db/src/schema/executive-meetings.ts b/lib/db/src/schema/executive-meetings.ts index 1d8a1def..f281e21f 100644 --- a/lib/db/src/schema/executive-meetings.ts +++ b/lib/db/src/schema/executive-meetings.ts @@ -176,8 +176,15 @@ export const executiveMeetingPdfArchivesTable = pgTable( { id: serial("id").primaryKey(), archiveDate: date("archive_date").notNull(), + // `filePath` doubles as the storage URL for the archived PDF. When the + // PDF was uploaded to object storage the value is a `/objects/` + // path that resolves through GET /api/storage/objects/*. Older rows + // (browser-print snapshots) use the synthetic `print:` form. filePath: text("file_path").notNull(), version: integer("version").notNull().default(1), + // Bytes of the generated PDF on disk. Nullable because legacy rows + // (created before the server-side generator) never had a real file. + byteSize: integer("byte_size"), generatedBy: integer("generated_by").references(() => usersTable.id, { onDelete: "set null", }), diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b5743272..82ff3475 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -84,6 +84,9 @@ importers: '@google-cloud/storage': specifier: ^7.19.0 version: 7.19.0 + '@swc/helpers': + specifier: ^0.5.21 + version: 0.5.21 '@workspace/api-zod': specifier: workspace:* version: link:../../lib/api-zod @@ -93,6 +96,9 @@ importers: bcryptjs: specifier: ^3.0.3 version: 3.0.3 + bidi-js: + specifier: ^1.0.3 + version: 1.0.3 connect-pg-simple: specifier: ^10.0.0 version: 10.0.0 @@ -114,6 +120,9 @@ importers: google-auth-library: specifier: ^10.6.2 version: 10.6.2 + pdfkit: + specifier: ^0.18.0 + version: 0.18.0 pino: specifier: ^9 version: 9.14.0 @@ -151,6 +160,9 @@ importers: '@types/node': specifier: 'catalog:' version: 25.3.5 + '@types/pdfkit': + specifier: ^0.17.6 + version: 0.17.6 '@types/sanitize-html': specifier: ^2.16.1 version: 2.16.1 @@ -1337,6 +1349,14 @@ packages: '@jridgewell/trace-mapping@0.3.31': resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==} + '@noble/ciphers@1.3.0': + resolution: {integrity: sha512-2I0gnIVPtfnMw9ee9h1dJG7tp81+8Ob3OJb3Mv37rx5L40/b0i7djjCVvGOVqc9AEIQyvyu1i6ypKdFw8R8gQw==} + engines: {node: ^14.21.3 || >=16} + + '@noble/hashes@1.8.0': + resolution: {integrity: sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==} + engines: {node: ^14.21.3 || >=16} + '@nodable/entities@2.1.0': resolution: {integrity: sha512-nyT7T3nbMyBI/lvr6L5TyWbFJAI9FTgVRakNoBqCD+PmID8DzFrrNdLLtHMwMszOtqZa8PAOV24ZqDnQrhQINA==} @@ -2253,6 +2273,9 @@ packages: '@socket.io/component-emitter@3.1.2': resolution: {integrity: sha512-9BCxFwvbGg/RsZK9tjXd8s4UcwR0MWeFQ1XEKIQVVvAGJyINdrqKMcTRyLoK8Rse1GjzLV9cwjWV1olXRWEXVA==} + '@swc/helpers@0.5.21': + resolution: {integrity: sha512-jI/VAmtdjB/RnI8GTnokyX7Ug8c+g+ffD6QRLa6XQewtnGyukKkKSk3wLTM3b5cjt1jNh9x0jfVlagdN2gDKQg==} + '@tabby_ai/hijri-converter@1.0.5': resolution: {integrity: sha512-r5bClKrcIusDoo049dSL8CawnHR6mRdDwhlQuIgZRNty68q0x8k3Lf1BtPAMxRf/GgnHBnIO4ujd3+GQdLWzxQ==} engines: {node: '>=16.0.0'} @@ -2628,6 +2651,9 @@ packages: '@types/node@25.3.5': resolution: {integrity: sha512-oX8xrhvpiyRCQkG1MFchB09f+cXftgIXb3a7UUa4Y3wpmZPw5tyZGTLWhlESOLq1Rq6oDlc8npVU2/9xiCuXMA==} + '@types/pdfkit@0.17.6': + resolution: {integrity: sha512-tIwzxk2uWKp0Cq9JIluQXJid77lYhF52EsIOwhsMF4iWLA6YneoBR1xVKYYdAysHuepUB0OX4tdwMiUDdGKmig==} + '@types/pg@8.18.0': resolution: {integrity: sha512-gT+oueVQkqnj6ajGJXblFR4iavIXWsGAFCk3dP4Kki5+a9R4NMt0JARdk6s8cUKcfUoqP5dAtDSLU8xYUTFV+Q==} @@ -2821,6 +2847,10 @@ packages: balanced-match@1.0.2: resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==} + base64-js@0.0.8: + resolution: {integrity: sha512-3XSA2cR/h/73EzlXXdU6YNycmYI7+kicTxks4eJg2g39biHR84slg2+des+p7iHYhbRg/udIS4TD53WabcOUkw==} + engines: {node: '>= 0.4'} + base64-js@1.5.1: resolution: {integrity: sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==} @@ -2837,6 +2867,9 @@ packages: resolution: {integrity: sha512-GlF5wPWnSa/X5LKM1o0wz0suXIINz1iHRLvTS+sLyi7XPbe5ycmYI3DlZqVGZZtDgl4DmasFg7gOB3JYbphV5g==} hasBin: true + bidi-js@1.0.3: + resolution: {integrity: sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw==} + bignumber.js@9.3.1: resolution: {integrity: sha512-Ko0uX15oIUS7wJ3Rb30Fs6SkVbLmPBAKdlm7q9+ak9bbIeFf0MwuBsQV6z7+X768/cHsfg+WlysDWJcmthjsjQ==} @@ -2851,6 +2884,12 @@ packages: resolution: {integrity: sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==} engines: {node: '>=8'} + brotli@1.3.3: + resolution: {integrity: sha512-oTKjJdShmDuGW94SyyaoQvAjf30dZaHnjJ8uAF+u2/vGJkJbJPJAT1gDiOJP5v1Zb6f9KEyW/1HpuaWIXtGHPg==} + + browserify-zlib@0.2.0: + resolution: {integrity: sha512-Z942RysHXmJrhqk88FmKBVq/v5tqmSkDz7p54G/MGyjMnCFFnC79XWNbg+Vta8W6Wb2qtSZTSxIGkJrRpCFEiA==} + browserslist@4.28.1: resolution: {integrity: sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA==} engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7} @@ -2891,6 +2930,10 @@ packages: classnames@2.5.1: resolution: {integrity: sha512-saHYOzhIQs6wy2sVxTM6bUDsQO4F50V9RQ22qBpEdCW+I+/Wmke2HOl6lS6dTpdxVhb88/I6+Hs+438c3lfUow==} + clone@2.1.2: + resolution: {integrity: sha512-3Pe/CF1Nn94hyhIYpjtiLhdCoEoz0DqQ+988E9gmeEdQZlojxnOb74wctFyuwWQHzqyf9X7C7MG8juUpqBJT8w==} + engines: {node: '>=0.8'} + clsx@2.1.1: resolution: {integrity: sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==} engines: {node: '>=6'} @@ -3067,6 +3110,9 @@ packages: detect-node-es@1.1.0: resolution: {integrity: sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ==} + dfa@1.2.0: + resolution: {integrity: sha512-ED3jP8saaweFTjeGX8HQPjeC1YYyZs98jGNZx6IiBvxW7JG5v492kamAQB3m2wop07CvU/RQmzcKr6bgcC5D/Q==} + dom-helpers@5.2.1: resolution: {integrity: sha512-nRCa7CK3VTrM2NmGkIy4cbK7IZlgBE/PYMn55rrXefr5xXDP0LdtfPnblFDoVdcAfslJ7or6iqAUnx0CCGIWQA==} @@ -3406,6 +3452,9 @@ packages: resolution: {integrity: sha512-JGG8pvDi2C+JxidYdIwQDyS/CgcrIdh18cvgxcBge3wSHRQOrooMD3GlFBcmMJAN9M42SAZjDp5zv1dglJjwww==} engines: {node: '>=20'} + fontkit@2.0.4: + resolution: {integrity: sha512-syetQadaUEDNdxdugga9CpEYVaQIxOwk7GlwZWWZ19//qW4zE5bknOKeMBDYAASwnpaSHKJITRLMF9m1fp3s6g==} + form-data@2.5.5: resolution: {integrity: sha512-jqdObeR2rxZZbPSGL+3VckHMYtu+f9//KXBsVny6JSX/pa38Fy+bGjuG8eW/H6USNQWhLi8Num++cU2yOCNz4A==} engines: {node: '>= 0.12'} @@ -3662,6 +3711,9 @@ packages: resolution: {integrity: sha512-34wB/Y7MW7bzjKRjUKTa46I2Z7eV62Rkhva+KkopW7Qvv/OSWBqvkSY7vusOPrNuZcUG3tApvdVgNB8POj3SPw==} engines: {node: '>=10'} + js-md5@0.8.3: + resolution: {integrity: sha512-qR0HB5uP6wCuRMrWPTrkMaev7MJZwJuuw4fnwAzRgP4J4/F8RwtodOKpGp4XpqsLBFzzgqIO42efFAyz2Et6KQ==} + js-tokens@4.0.0: resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} @@ -3776,6 +3828,9 @@ packages: resolution: {integrity: sha512-l51N2r93WmGUye3WuFoN5k10zyvrVs0qfKBhyC5ogUQ6Ew6JUSswh78mbSO+IU3nTWsyOArqPCcShdQSadghBQ==} engines: {node: '>= 12.0.0'} + linebreak@1.1.0: + resolution: {integrity: sha512-MHp03UImeVhB7XZtjd0E4n6+3xr5Dq/9xI/5FptGk5FrbDR3zagPa2DS6U8ks/3HjbKWG9Q1M2ufOzxV2qLYSQ==} + linkify-it@5.0.0: resolution: {integrity: sha512-5aHCbzQRADcdP+ATqnDuhhJ/MRIqDkZX5pyjFHRRysS8vZ5AbqGEoFIb6pYHPZ+L/OC2Lc+xT8uHVVR5CAK/wQ==} @@ -3995,6 +4050,12 @@ packages: resolution: {integrity: sha512-MyIV3ZA/PmyBN/ud8vV9XzwTrNtR4jFrObymZYnZqMmW0zA8Z17vnT0rBgFE/TlohB+YCHqXMgZzb3Csp49vqg==} engines: {node: '>=14.16'} + pako@0.2.9: + resolution: {integrity: sha512-NUcwaKxUxWrZLpDG+z/xZaCgQITkA/Dv4V/T6bw7VON6l1Xz/VnrBqrYjZQ12TamKHzITTfOEIYUj48y2KXImA==} + + pako@1.0.11: + resolution: {integrity: sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==} + parse-ms@4.0.0: resolution: {integrity: sha512-TXfryirbmq34y8QBwgqCVLi+8oA3oWx2eAnSn62ITyEhEYaWRlVZ2DvMM9eZbMs/RfxPu/PK/aBLyGj4IrqMHw==} engines: {node: '>=18'} @@ -4024,6 +4085,9 @@ packages: pathe@2.0.3: resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==} + pdfkit@0.18.0: + resolution: {integrity: sha512-NvUwSDZ0eYEzqAiWwVQkRkjYUkZ48kcsHuCO31ykqPPIVkwoSDjDGiwIgHHNtsiwls3z3P/zy4q00hl2chg2Ug==} + pg-cloudflare@1.3.0: resolution: {integrity: sha512-6lswVVSztmHiRtD6I8hw4qP/nDm1EJbKMRhf3HCYaqud7frGysPv7FYJ5noZQdhQtN2xJnimfMtvQq21pdbzyQ==} @@ -4099,6 +4163,9 @@ packages: engines: {node: '>=18'} hasBin: true + png-js@1.1.0: + resolution: {integrity: sha512-PM/uYGzGdNSzqeOgly68+6wKQDL1SY0a/N+OEa/+br6LnHWOAJB0Npiamnodfq3jd2LS/i2fMeOKSAILjA+m5Q==} + postcss-selector-parser@6.0.10: resolution: {integrity: sha512-IQ7TZdoaqbT+LCpShg46jnZVlhWD2w6iQYAcYXfHARZ7X1t/UGhhceQDs5X0cGqKvYlHNOuv7Oa1xmb0oQuA3w==} engines: {node: '>=4'} @@ -4354,6 +4421,9 @@ packages: resolve-pkg-maps@1.0.0: resolution: {integrity: sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==} + restructure@3.0.2: + resolution: {integrity: sha512-gSfoiOEA0VPE6Tukkrr7I0RBdE0s7H1eFCDBk05l1KIQT1UIKNc5JZy6jdyW6eYH3aR3g5b3PuL77rq0hvwtAw==} + retry-request@7.0.2: resolution: {integrity: sha512-dUOvLMJ0/JJYEn8NrpOaGNE7X3vpI5XlZS/u0ANjqtcZVKnIxP7IgCFwrKTxENw29emmwug53awKtaMm4i9g5w==} engines: {node: '>=14'} @@ -4546,6 +4616,9 @@ packages: thread-stream@3.1.0: resolution: {integrity: sha512-OqyPZ9u96VohAyMfJykzmivOrY2wfMSf3C5TtFJVgN+Hm6aj+voFhlK+kZEIv2FBh1X6Xp3DlnCOfEQ3B2J86A==} + tiny-inflate@1.0.3: + resolution: {integrity: sha512-pkY1fj1cKHb2seWDy0B16HeWyczlJA9/WW3u3c4z/NiWDsO3DOU5D7nhTLE9CF0yXv/QZFY7sEJmj24dK+Rrqw==} + tiny-invariant@1.3.3: resolution: {integrity: sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==} @@ -4623,6 +4696,12 @@ packages: undici-types@7.18.2: resolution: {integrity: sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==} + unicode-properties@1.4.1: + resolution: {integrity: sha512-CLjCCLQ6UuMxWnbIylkisbRj31qxHPAurvena/0iwSVbQ2G1VY5/HjV0IRabOEbDHlzZlRdCrD4NhB0JtU40Pg==} + + unicode-trie@2.0.0: + resolution: {integrity: sha512-x7bc76x0bm4prf1VLg79uhAzKw8DVboClSN5VxJuQ+LKDOVEW9CdH+VY7SP+vX7xCYQqzzgQpFqz15zeLvAtZQ==} + unicorn-magic@0.3.0: resolution: {integrity: sha512-+QBBXBCvifc56fsbuxZQ6Sic3wqqc3WWaqxs58gvJrcOuN83HGTCwz3oS5phzU9LthRNE9VrJCFCLUgHeeFnfA==} engines: {node: '>=18'} @@ -5274,6 +5353,10 @@ snapshots: '@jridgewell/resolve-uri': 3.1.2 '@jridgewell/sourcemap-codec': 1.5.5 + '@noble/ciphers@1.3.0': {} + + '@noble/hashes@1.8.0': {} + '@nodable/entities@2.1.0': {} '@nodelib/fs.scandir@2.1.5': @@ -6249,6 +6332,10 @@ snapshots: '@socket.io/component-emitter@3.1.2': {} + '@swc/helpers@0.5.21': + dependencies: + tslib: 2.8.1 + '@tabby_ai/hijri-converter@1.0.5': {} '@tailwindcss/node@4.2.1': @@ -6629,6 +6716,10 @@ snapshots: dependencies: undici-types: 7.18.2 + '@types/pdfkit@0.17.6': + dependencies: + '@types/node': 25.3.5 + '@types/pg@8.18.0': dependencies: '@types/node': 25.3.5 @@ -6836,6 +6927,8 @@ snapshots: balanced-match@1.0.2: {} + base64-js@0.0.8: {} + base64-js@1.5.1: {} base64id@2.0.0: {} @@ -6844,6 +6937,10 @@ snapshots: bcryptjs@3.0.3: {} + bidi-js@1.0.3: + dependencies: + require-from-string: 2.0.2 + bignumber.js@9.3.1: {} body-parser@2.2.2: @@ -6868,6 +6965,14 @@ snapshots: dependencies: fill-range: 7.1.1 + brotli@1.3.3: + dependencies: + base64-js: 1.5.1 + + browserify-zlib@0.2.0: + dependencies: + pako: 1.0.11 + browserslist@4.28.1: dependencies: baseline-browser-mapping: 2.10.0 @@ -6908,6 +7013,8 @@ snapshots: classnames@2.5.1: {} + clone@2.1.2: {} + clsx@2.1.1: {} cmdk@1.1.1(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.1.0(react@19.1.0))(react@19.1.0): @@ -7042,6 +7149,8 @@ snapshots: detect-node-es@1.1.0: {} + dfa@1.2.0: {} + dom-helpers@5.2.1: dependencies: '@babel/runtime': 7.28.6 @@ -7432,6 +7541,18 @@ snapshots: locate-path: 8.0.0 unicorn-magic: 0.3.0 + fontkit@2.0.4: + dependencies: + '@swc/helpers': 0.5.21 + brotli: 1.3.3 + clone: 2.1.2 + dfa: 1.2.0 + fast-deep-equal: 3.1.3 + restructure: 3.0.2 + tiny-inflate: 1.0.3 + unicode-properties: 1.4.1 + unicode-trie: 2.0.0 + form-data@2.5.5: dependencies: asynckit: 0.4.0 @@ -7703,6 +7824,8 @@ snapshots: joycon@3.1.1: {} + js-md5@0.8.3: {} + js-tokens@4.0.0: {} js-yaml@4.1.1: @@ -7789,6 +7912,11 @@ snapshots: lightningcss-win32-arm64-msvc: 1.31.1 lightningcss-win32-x64-msvc: 1.31.1 + linebreak@1.1.0: + dependencies: + base64-js: 0.0.8 + unicode-trie: 2.0.0 + linkify-it@5.0.0: dependencies: uc.micro: 2.1.0 @@ -7996,6 +8124,10 @@ snapshots: p-timeout@6.1.4: {} + pako@0.2.9: {} + + pako@1.0.11: {} + parse-ms@4.0.0: {} parse-srcset@1.0.2: {} @@ -8012,6 +8144,15 @@ snapshots: pathe@2.0.3: {} + pdfkit@0.18.0: + dependencies: + '@noble/ciphers': 1.3.0 + '@noble/hashes': 1.8.0 + fontkit: 2.0.4 + js-md5: 0.8.3 + linebreak: 1.1.0 + png-js: 1.1.0 + pg-cloudflare@1.3.0: optional: true @@ -8108,6 +8249,10 @@ snapshots: optionalDependencies: fsevents: 2.3.2 + png-js@1.1.0: + dependencies: + browserify-zlib: 0.2.0 + postcss-selector-parser@6.0.10: dependencies: cssesc: 3.0.0 @@ -8373,6 +8518,8 @@ snapshots: resolve-pkg-maps@1.0.0: {} + restructure@3.0.2: {} + retry-request@7.0.2: dependencies: '@types/request': 2.48.13 @@ -8633,6 +8780,8 @@ snapshots: dependencies: real-require: 0.2.0 + tiny-inflate@1.0.3: {} + tiny-invariant@1.3.3: {} tinyglobby@0.2.15: @@ -8696,6 +8845,16 @@ snapshots: undici-types@7.18.2: {} + unicode-properties@1.4.1: + dependencies: + base64-js: 1.5.1 + unicode-trie: 2.0.0 + + unicode-trie@2.0.0: + dependencies: + pako: 0.2.9 + tiny-inflate: 1.0.3 + unicorn-magic@0.3.0: {} unicorn-magic@0.4.0: {}