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:
@@ -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,
|
||||
})),
|
||||
})),
|
||||
|
||||
Reference in New Issue
Block a user