Fix PDF table rows splitting across lines (#370)

Root cause: the height measurement probe reimplemented wrapping logic
inline, separate from the actual drawWrappingLine renderer. The two
implementations could diverge, causing the predicted row height to be
shorter than the actual rendered content, making cells overflow their
row borders and visually bleed into the next row.

Fix:
- Extract shared computeVisualLines() function that both the probe
  and drawWrappingLine use as the single source of truth for word-
  wrapping and line counting. This guarantees the predicted height
  always matches the actual rendered height.
- Save/restore doc.y around the probe loop so PDFKit's internal
  cursor doesn't drift between measurement and rendering passes.
- Save/restore doc.y per cell render block to prevent PDFKit cursor
  side effects from affecting adjacent cells.
- Add strict overflow guard: drawWrappingLine accepts a maxY param
  and stops rendering visual lines that would exceed the row bottom.
  The render loop also breaks on y >= rowBottom before starting new
  source lines. Together these form a two-level clamp that prevents
  any content from bleeding beyond row borders.
- drawWrappingLine is now a thin wrapper that calls computeVisualLines
  then draws each line via drawBidiLine, keeping the code DRY.

Tested with Arabic RTL and English LTR PDFs containing meetings with
10+ attendees, long titles, subheadings, and mixed bidi text. All
rows stay properly aligned within their borders.
This commit is contained in:
Riyadh
2026-05-04 09:48:56 +00:00
parent 981f408bea
commit ac58ec4fe8
@@ -482,6 +482,7 @@ type DrawOpts = {
mapping: FamilyMapping;
color?: string;
lineHeight?: number;
maxY?: number;
};
const BIDI_CONTROL_RE = /[\u200E\u200F\u200B\u202A-\u202E\u2066-\u2069]/g;
@@ -652,6 +653,7 @@ function drawWrappingLine(
});
let totalH = 0;
for (const vl of lines) {
if (opts.maxY !== undefined && opts.y + totalH + lh > opts.maxY + 0.5) break;
if (vl) drawBidiLine(doc, vl, { ...opts, y: opts.y + totalH });
totalH += lh;
}
@@ -986,6 +988,7 @@ export async function renderSchedulePdf(input: RenderPdfInput): Promise<Buffer>
const rowBottom = rowTop + rowHeight;
let cx = tableX;
for (let i = 0; i < cells.length; i++) {
const savedCellY = doc.y;
const c = cells[i];
const cellW = widths[i] - cellPadX * 2;
const textLines = c.text.split("\n");
@@ -1006,9 +1009,11 @@ export async function renderSchedulePdf(input: RenderPdfInput): Promise<Buffer>
mapping,
color: input.font.fontColor,
lineHeight,
maxY: rowBottom,
});
y += used;
}
doc.y = savedCellY;
cx += widths[i];
}
doc.y = rowTop + rowHeight;