Improve PDF generation for meeting tables with better text handling

Update PDF rendering logic to correctly handle bidirectional text, cell merging, and text wrapping for meeting tables.

Replit-Commit-Author: Agent
Replit-Commit-Session-Id: 77cfe984-2c65-4152-bb7a-0df28274fe66
Replit-Commit-Checkpoint-Type: full_checkpoint
Replit-Commit-Event-Id: 36ae937b-a1fc-4edb-8901-b07ed7713220
Replit-Commit-Screenshot-Url: https://storage.googleapis.com/screenshot-production-us-central1/c3c252e4-c83d-40ca-9fff-99a3ea60701e/77cfe984-2c65-4152-bb7a-0df28274fe66/Zh9QhRk
Replit-Helium-Checkpoint-Created: true
This commit is contained in:
riyadhafraa
2026-05-04 09:24:37 +00:00
parent d47c5cf5d3
commit 771cfb5611
4 changed files with 354 additions and 73 deletions
+200 -70
View File
@@ -88,11 +88,12 @@ export type PdfMeeting = {
startTime: string | null;
endTime: string | null;
location: string | null;
// Legacy field; ignored by renderer (use rowColor instead).
isHighlighted?: number;
// Palette key from ROW_COLOR_FILL; unknown/null = no tint.
rowColor?: string | null;
attendees: PdfMeetingAttendee[];
mergeStartColumn?: string | null;
mergeEndColumn?: string | null;
mergeText?: string | null;
};
export type RenderPdfInput = {
@@ -483,10 +484,65 @@ type DrawOpts = {
lineHeight?: number;
};
// 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).
const BIDI_CONTROL_RE = /[\u200E\u200F\u200B\u202A-\u202E\u2066-\u2069]/g;
function stripBidiControls(s: string): string {
return s.replace(BIDI_CONTROL_RE, "");
}
function measureBidiLineWidth(
doc: PDFKit.PDFDocument,
text: string,
opts: Pick<DrawOpts, "fontSize" | "weight" | "baseDirection" | "mapping">,
): number {
const runs = visualOrderRuns(text, opts.baseDirection);
let w = 0;
for (const r of runs) {
const clean = stripBidiControls(r.text);
if (!clean) continue;
const fk = fontKeyFor(r.isArabic, opts.weight, opts.mapping);
const features = featuresFor(r.isArabic);
doc.font(fk).fontSize(opts.fontSize);
w += doc.widthOfString(clean, features ? { features } : {});
}
return w;
}
function drawBidiLine(
doc: PDFKit.PDFDocument,
text: string,
opts: DrawOpts,
): void {
const runs = visualOrderRuns(text, opts.baseDirection);
if (runs.length === 0) return;
let totalWidth = 0;
const measured: { clean: string; isArabic: boolean; width: number; fontKey: string; features: PDFKit.Mixins.OpenTypeFeatures[] | undefined }[] = [];
for (const r of runs) {
const clean = stripBidiControls(r.text);
if (!clean) continue;
const fk = fontKeyFor(r.isArabic, opts.weight, opts.mapping);
const features = featuresFor(r.isArabic);
doc.font(fk).fontSize(opts.fontSize);
const w = doc.widthOfString(clean, features ? { features } : {});
totalWidth += w;
measured.push({ clean, isArabic: r.isArabic, width: w, fontKey: fk, features });
}
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);
}
for (const r of measured) {
doc.font(r.fontKey).fontSize(opts.fontSize).fillColor(opts.color ?? "black");
doc.text(r.clean, cursorX, opts.y, {
lineBreak: false,
width: r.width + 1,
...(r.features ? { features: [...r.features] } : {}),
});
cursorX += r.width;
}
}
function drawMixedLine(
doc: PDFKit.PDFDocument,
text: string,
@@ -497,8 +553,6 @@ function drawMixedLine(
if (runs.length === 0) {
return lh;
}
// Measure full line width — feature flags must match the draw call so
// ligatures + RTL alternates produce the same advance width.
let totalWidth = 0;
const measured = runs.map((r) => {
const fk = fontKeyFor(r.isArabic, opts.weight, opts.mapping);
@@ -508,19 +562,15 @@ function drawMixedLine(
totalWidth += w;
return { ...r, width: w, fontKey: fk, features };
});
const overflow = totalWidth - opts.width;
if (totalWidth > opts.width + 4) {
return drawWrappingLine(doc, text, opts);
}
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(opts.color ?? "black");
doc.text(r.text, cursorX, opts.y, {
@@ -538,32 +588,60 @@ function drawWrappingLine(
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(opts.color ?? "black");
// Hand the RAW (logical-order) string to pdfkit and let fontkit do
// both Arabic shaping and intra-line RTL reorder via the `features`
// option. Pre-shaping here would double-process the string and
// produce visually-mirrored output (the bug we shipped previously).
const features = featuresFor(dominantArabic);
const before = doc.y;
doc.text(text, opts.x, opts.y, {
width: opts.width,
align: opts.align,
lineBreak: true,
// Tighten the inter-line gap pdfkit applies inside wrapped paragraphs.
// Default is the font's natural lineGap; setting -1 saves ~1pt per
// wrapped line which matters when fitting a full day on one page.
lineGap: -1,
...(features ? { features } : {}),
});
const wrapLh = opts.lineHeight ?? opts.fontSize * 1.2;
return Math.max(wrapLh, doc.y - before);
const lh = opts.lineHeight ?? opts.fontSize * 1.2;
if (!text.trim()) return lh;
const words = text.split(/\s+/).filter((w) => w.length > 0);
if (words.length === 0) return lh;
const mOpts = {
fontSize: opts.fontSize,
weight: opts.weight,
baseDirection: opts.baseDirection,
mapping: opts.mapping,
};
const hardBreak = (word: string): string[] => {
const ww = measureBidiLineWidth(doc, word, mOpts);
if (ww <= opts.width) return [word];
const chars = [...word];
const pieces: string[] = [];
let cur = "";
for (const ch of chars) {
const test = cur + ch;
if (cur && measureBidiLineWidth(doc, test, mOpts) > opts.width) {
pieces.push(cur);
cur = ch;
} else {
cur = test;
}
}
if (cur) pieces.push(cur);
return pieces;
};
const visualLines: string[] = [];
let current = "";
for (let i = 0; i < words.length; i++) {
const ww = measureBidiLineWidth(doc, words[i], mOpts);
if (ww > opts.width) {
if (current) { visualLines.push(current); current = ""; }
for (const piece of hardBreak(words[i])) visualLines.push(piece);
continue;
}
const test = current ? current + " " + words[i] : words[i];
if (measureBidiLineWidth(doc, test, mOpts) > opts.width) {
visualLines.push(current);
current = words[i];
} else {
current = test;
}
}
if (current) visualLines.push(current);
if (visualLines.length === 0) return lh;
let totalH = 0;
for (const vl of visualLines) {
drawBidiLine(doc, vl, { ...opts, y: opts.y + totalH });
totalH += lh;
}
return Math.max(lh, totalH);
}
export async function renderSchedulePdf(input: RenderPdfInput): Promise<Buffer> {
@@ -757,9 +835,13 @@ export async function renderSchedulePdf(input: RenderPdfInput): Promise<Buffer>
const headerHeight = drawHeader();
doc.y = startY + headerHeight;
const MERGE_COL_INDEX: Record<string, number> = {
number: 0, meeting: 1, attendees: 2, time: 3,
};
// ---- Rows ----
for (const meeting of input.meetings) {
const cellsLogical: { text: string; align: "left" | "center" | "right" }[] = [
let cellsLogical: { text: string; align: "left" | "center" | "right" }[] = [
{ text: String(meeting.dailyNumber), align: "center" },
{
text: htmlToPlain(isRtl ? meeting.titleAr : meeting.titleEn || meeting.titleAr) +
@@ -771,28 +853,31 @@ export async function renderSchedulePdf(input: RenderPdfInput): Promise<Buffer>
meeting.attendees.length === 0
? "—"
: (() => {
const ATTENDEES_PER_LINE = 3;
let personIdx = 0;
const parts: string[] = [];
let currentGroup: string[] = [];
const flushGroup = () => {
if (currentGroup.length === 0) return;
for (let i = 0; i < currentGroup.length; i += ATTENDEES_PER_LINE) {
parts.push(currentGroup.slice(i, i + ATTENDEES_PER_LINE).join(" "));
}
currentGroup = [];
};
for (const a of meeting.attendees) {
const isSub = (a.kind ?? "person") === "subheading";
const name = htmlToPlain(a.name);
if (isSub) {
if (currentGroup.length > 0) {
parts.push(currentGroup.join(" "));
}
flushGroup();
personIdx = 0;
parts.push(`${name}`);
currentGroup = [];
} else {
personIdx += 1;
const t = a.title?.trim();
currentGroup.push(`-${personIdx} ${name}${t ? ` (${t})` : ""}`);
currentGroup.push(`\u202A${personIdx}-\u202C ${name}${t ? ` (${t})` : ""}`);
}
}
if (currentGroup.length > 0) {
parts.push(currentGroup.join(" "));
}
flushGroup();
return parts.join("\n");
})(),
align: "center",
@@ -802,8 +887,29 @@ export async function renderSchedulePdf(input: RenderPdfInput): Promise<Buffer>
align: "center",
},
];
let cellWidths = [...colWidths];
if (meeting.mergeStartColumn && meeting.mergeEndColumn) {
const startIdx = MERGE_COL_INDEX[meeting.mergeStartColumn];
const endIdx = MERGE_COL_INDEX[meeting.mergeEndColumn];
if (startIdx !== undefined && endIdx !== undefined && startIdx <= endIdx) {
const mergedWidth = cellWidths.slice(startIdx, endIdx + 1).reduce((a, b) => a + b, 0);
const mergedCell = { text: meeting.mergeText ? htmlToPlain(meeting.mergeText) : "", align: "center" as const };
cellsLogical = [
...cellsLogical.slice(0, startIdx),
mergedCell,
...cellsLogical.slice(endIdx + 1),
];
cellWidths = [
...cellWidths.slice(0, startIdx),
mergedWidth,
...cellWidths.slice(endIdx + 1),
];
}
}
const cells = isRtl ? [...cellsLogical].reverse() : cellsLogical;
const widths = isRtl ? [...colWidths].reverse() : colWidths;
const widths = isRtl ? [...cellWidths].reverse() : cellWidths;
// 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.
@@ -812,16 +918,16 @@ export async function renderSchedulePdf(input: RenderPdfInput): Promise<Buffer>
let rowHeight = lineHeight + cellPadY * 2;
const cellHeights: number[] = [];
let cx = tableX;
const probeWrapOpts = {
fontSize: input.font.fontSize,
weight: input.font.fontWeight,
baseDirection: baseDir,
mapping,
};
for (let i = 0; i < cells.length; i++) {
const c = cells[i];
const startProbeY = probeY + cellPadY;
probe.y = startProbeY;
// Use PDFKit's own layout engine (heightOfString) so the probe
// reserves EXACTLY as much vertical space as the eventual draw
// call will consume — passing the same font, features, width,
// and align as drawWrappingLine. A heuristic ceil(w/availableW)
// probe under-counts whenever pdfkit's word-break engine wraps
// sooner than a simple division would, causing rows to overlap.
const lines = c.text.split("\n");
let h = 0;
const cellW = widths[i] - cellPadX * 2;
@@ -830,19 +936,43 @@ export async function renderSchedulePdf(input: RenderPdfInput): Promise<Buffer>
h += lineHeight;
continue;
}
const lineArabicChars = [...line].filter(isArabicChar).length;
const lineDominantArabic = lineArabicChars * 2 > line.length;
const lineFk = fontKeyFor(lineDominantArabic, input.font.fontWeight, mapping);
const lineFeatures = featuresFor(lineDominantArabic);
doc.font(lineFk).fontSize(input.font.fontSize);
const measured = doc.heightOfString(line, {
width: cellW,
align: c.align,
lineBreak: true,
lineGap: -1,
...(lineFeatures ? { features: lineFeatures } : {}),
});
h += Math.max(lineHeight, measured);
const singleW = measureBidiLineWidth(doc, line, probeWrapOpts);
if (singleW <= cellW) {
h += lineHeight;
} else {
const wds = line.split(/\s+/).filter((w: string) => w.length > 0);
if (wds.length === 0) { h += lineHeight; continue; }
let wrapCount = 0;
let cur = "";
for (let wi = 0; wi < wds.length; wi++) {
const wordW = measureBidiLineWidth(doc, wds[wi], probeWrapOpts);
if (wordW > cellW) {
if (cur) { wrapCount++; cur = ""; }
const chars = [...wds[wi]];
let piece = "";
for (const ch of chars) {
const test = piece + ch;
if (piece && measureBidiLineWidth(doc, test, probeWrapOpts) > cellW) {
wrapCount++;
piece = ch;
} else {
piece = test;
}
}
if (piece) { wrapCount++; }
continue;
}
const test = cur ? cur + " " + wds[wi] : wds[wi];
if (measureBidiLineWidth(doc, test, probeWrapOpts) > cellW) {
wrapCount++;
cur = wds[wi];
} else {
cur = test;
}
}
if (cur) wrapCount++;
h += Math.max(1, wrapCount) * lineHeight;
}
}
const finalH = h + cellPadY * 2;
cellHeights.push(finalH);
@@ -2686,14 +2686,14 @@ router.get(
startTime: m.startTime,
endTime: m.endTime,
location: m.location,
// Forward saved tint; isHighlighted is intentionally dropped.
rowColor: m.rowColor,
mergeStartColumn: m.mergeStartColumn,
mergeEndColumn: m.mergeEndColumn,
mergeText: m.mergeText,
attendees: (attendeesByMeeting.get(m.id) ?? []).map((a) => ({
name: a.name,
title: a.title,
attendanceType: a.attendanceType,
// Forward the row kind so the renderer can render subheadings
// as labels rather than numbered attendees.
kind: a.kind,
})),
})),