57e8297464
- Schema: add `font_color` (hex, default #000000) and `logo_object_path` to `executive_meeting_font_settings`. Pushed via drizzle-kit. - PDF renderer: - Add `fontColor` to PdfFontPrefs (body cells only; header chrome and logo intentionally ignore it to preserve branding). - Add `rowColor` to PdfMeeting and a ROW_COLOR_FILL palette kept in lockstep with the on-screen swatches; deliberately stop painting the legacy `isHighlighted` overlay so the archived PDF reflects editorial state instead of the viewer's transient cursor. - Add optional `logo: Buffer`. Header now reserves a left-anchored logo box and centers the title in the remaining width; bad image bytes log + fall back to the no-logo layout instead of crashing. - API route: - Extend fontSettingsSchema with strict #RRGGBB regex and /^/objects/<id>$/ regex for logoObjectPath. - resolveFontPrefsForUser now returns { font, logoObjectPath }. - loadLogoBytes downloads the brand asset via ObjectStorageService. - Logo only writable on the global-scope row. - PDF labels switched to "قائمة بأسماء حضور الاجتماعات" / "Meeting Attendance List" per the user's printed sample. - Frontend (executive-meetings.tsx): - FontPrefs gains fontColor; FontSettingsResponse.global gains logoObjectPath; DEFAULT_FONT and effectiveFont updated. - buildFontStyle applies fontColor to on-screen rows. - FontSettingsSection: native color picker + hex text input; logo upload (PNG/JPEG) via @workspace/object-storage-web's useUpload, visible only at the global scope for admins. - Locales: AR/EN keys for fontColor + logo.{label,upload,replace, remove,uploading,uploadFailed,globalOnly}. - Tests: existing font-settings roundtrip extended with fontColor; new test rejects malformed fontColor and non-/objects logo paths. Type-check clean for api-server and tx-os; new font-settings tests pass. Other test failures in the suite pre-date this change.
711 lines
23 KiB
Plaintext
711 lines
23 KiB
Plaintext
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;
|
||
// "person" (default) or "subheading". The renderer skips subheadings
|
||
// for the running attendee number and prints them as "— label —".
|
||
// Typed as a plain string (rather than the literal union) because the
|
||
// DB column is just a varchar; the renderer normalises unknown values
|
||
// back to "person" at the comparison site.
|
||
kind?: 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;
|
||
};
|
||
|
||
// Web fonts (DIN Next LT Arabic, Tajawal, Helvetica Neue LT Arabic,
|
||
// Helvetica Neue, Majalla) are not bundled into the PDF renderer
|
||
// because we do not ship their font files server-side. Instead we map
|
||
// each web family to the closest visual stand-in already bundled here:
|
||
// sans-serif Arabic families render with NotoSansArabic; serif/Naskh
|
||
// stand-ins use NotoNaskhArabic; pure-Latin families (Helvetica Neue)
|
||
// fall back to the Naskh pair so Arabic glyphs in mixed strings still
|
||
// render correctly. This keeps PDFs readable without ballooning the
|
||
// server bundle.
|
||
const FAMILY_MAP: Record<string, FamilyMapping> = {
|
||
system: {
|
||
arabicRegular: "naskhRegular",
|
||
arabicBold: "naskhBold",
|
||
latinRegular: "latinRegular",
|
||
latinBold: "latinBold",
|
||
},
|
||
"DIN Next LT Arabic": {
|
||
arabicRegular: "arabicSansRegular",
|
||
arabicBold: "arabicSansBold",
|
||
latinRegular: "latinRegular",
|
||
latinBold: "latinBold",
|
||
},
|
||
Tajawal: {
|
||
arabicRegular: "arabicSansRegular",
|
||
arabicBold: "arabicSansBold",
|
||
latinRegular: "latinRegular",
|
||
latinBold: "latinBold",
|
||
},
|
||
"Helvetica Neue LT Arabic": {
|
||
arabicRegular: "arabicSansRegular",
|
||
arabicBold: "arabicSansBold",
|
||
latinRegular: "latinRegular",
|
||
latinBold: "latinBold",
|
||
},
|
||
"Helvetica Neue": {
|
||
arabicRegular: "naskhRegular",
|
||
arabicBold: "naskhBold",
|
||
latinRegular: "latinRegular",
|
||
latinBold: "latinBold",
|
||
},
|
||
Majalla: {
|
||
arabicRegular: "naskhRegular",
|
||
arabicBold: "naskhBold",
|
||
latinRegular: "latinRegular",
|
||
latinBold: "latinBold",
|
||
},
|
||
};
|
||
|
||
function familyMappingFor(family: string): FamilyMapping {
|
||
return FAMILY_MAP[family] ?? FAMILY_MAP.system;
|
||
}
|
||
|
||
// Family names that primarily target Arabic script. Used by
|
||
// `fontFamilyIsArabic` for downstream callers that branch on
|
||
// script — kept aligned with FAMILY_MAP. "Helvetica Neue" (the Latin
|
||
// face) is intentionally excluded; the others all ship Arabic glyphs.
|
||
const ARABIC_FONT_NAMES: ReadonlyArray<string> = [
|
||
"DIN Next LT Arabic",
|
||
"Tajawal",
|
||
"Helvetica Neue LT Arabic",
|
||
"Majalla",
|
||
];
|
||
|
||
// Bytes are loaded lazily on first render and reused.
|
||
let cachedFontBytes: Partial<Record<FontFileKey, Buffer>> = {};
|
||
function loadFontBytes(keys: ReadonlyArray<FontFileKey>): Record<FontFileKey, Buffer> {
|
||
const dir = fontsDir();
|
||
const out = {} as Record<FontFileKey, Buffer>;
|
||
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 <br> 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(/<li>/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;
|
||
// 12-hour with NO AM/PM (en) or ص/م (ar) suffix — matches the
|
||
// on-screen schedule. We `formatToParts` and drop the `dayPeriod`,
|
||
// then trim trailing/leading whitespace literals (Intl may use
|
||
// U+00A0 / U+202F — String.prototype.trim handles them all).
|
||
// Force Latin digits via -u-nu-latn so the PDF Time column renders
|
||
// consistently regardless of language.
|
||
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 "—";
|
||
}
|
||
|
||
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<Buffer> {
|
||
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<FontFileKey>([
|
||
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<void>((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
|
||
? "—"
|
||
: (() => {
|
||
// Walking person counter — each subheading starts a
|
||
// fresh numbered section, so persons restart at 1- after
|
||
// every subheading. Same logic as the on-screen
|
||
// AttendeeFlow and the client print page.
|
||
let personIdx = 0;
|
||
return meeting.attendees
|
||
.map((a) => {
|
||
const isSub = (a.kind ?? "person") === "subheading";
|
||
const name = htmlToPlain(a.name);
|
||
if (isSub) {
|
||
// Reset the in-section counter so the next person
|
||
// starts at 1.
|
||
personIdx = 0;
|
||
// Brackets keep subheadings visually distinct in the
|
||
// monospace plain-text PDF cell where bold/colour
|
||
// can't carry. Adapter prefix is non-numeric so a
|
||
// human reader can scan it as "section label".
|
||
return `— ${name} —`;
|
||
}
|
||
personIdx += 1;
|
||
const t = a.title?.trim();
|
||
return `${personIdx}- ${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);
|
||
}
|