36cb2ca86e
Modify attendee numbering logic to reset at each subheading, ensuring proper sequence and display across different views and PDF outputs.
683 lines
22 KiB
TypeScript
683 lines
22 KiB
TypeScript
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;
|
||
};
|
||
|
||
const FAMILY_MAP: Record<string, FamilyMapping> = {
|
||
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<string> = [
|
||
"Cairo",
|
||
"Tajawal",
|
||
"Noto Naskh Arabic",
|
||
"Amiri",
|
||
];
|
||
|
||
// 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;
|
||
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<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);
|
||
}
|