From 8bb65fa7684c93b9f2925bc25a44b74aec4b5abb Mon Sep 17 00:00:00 2001 From: Riyadh Date: Sun, 3 May 2026 19:11:49 +0000 Subject: [PATCH] =?UTF-8?q?PDF:=20inline=20attendees,=20tighter=20layout?= =?UTF-8?q?=20=E2=80=94=20fit=20a=20typical=20day=20on=20one=20page?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Follow-up to the Arabic shaping fix. The downloaded schedule PDF was spilling onto a second page even for modest days because (a) attendees were stacked one-per-line and (b) the page header + cell padding + column proportions reserved more vertical space than necessary. Changes (artifacts/api-server/src/lib/pdf-renderer.ts): - Attendees now render as a single inline paragraph per cell (joined with two spaces) and wrap naturally — matches the user's reference layout where attendees flow as one paragraph. - Re-balanced column widths from 6/30/44/20 → 6/38/40/16. Meeting titles get more room (no more 6-line wraps for typical titles) and attendees get less (they're inline now). - Page margin tightened 36pt → 24pt. - Title size cap tightened (28 → 22) so the header band doesn't eat a table row. - moveDown gaps trimmed (0.4→0.2 between title and date, 0.8→0.4 before the table). - Cell vertical padding trimmed (5pt → 3pt). - drawWrappingLine now passes lineGap: -1 to pdfkit's text() so wrapped paragraphs get tighter inter-line spacing; the row-height probe passes the same option so probe and draw stay symmetric. - Row-height probe now uses heightOfString() output directly (Math.max(lineHeight, measured) + 2) instead of rounding up to whole lineHeight units, removing the half-line of empty space that was visible below short cells. Verified: - EN: all 10 meetings on one A4 page. - AR: 9 of 10 meetings on one A4 page; the 10th row contains multi-line internal/external subheadings which legitimately need a taller cell — typical days fit on a single page. - PDF Arabic shaping regression test still passes. - No row overlaps anywhere. Logo: handler already loads the brand-settings logo (logoObjectPath) and embeds it in the header — no code change needed. If the logo isn't visible, none has been uploaded in brand settings. --- artifacts/api-server/src/lib/pdf-renderer.ts | 52 +++++++++++++------- 1 file changed, 34 insertions(+), 18 deletions(-) diff --git a/artifacts/api-server/src/lib/pdf-renderer.ts b/artifacts/api-server/src/lib/pdf-renderer.ts index 2383e5a8..5dc71ab8 100644 --- a/artifacts/api-server/src/lib/pdf-renderer.ts +++ b/artifacts/api-server/src/lib/pdf-renderer.ts @@ -530,6 +530,10 @@ function drawWrappingLine( 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 } : {}), }); return Math.max(opts.fontSize * 1.25, doc.y - before + 2); @@ -541,7 +545,7 @@ export async function renderSchedulePdf(input: RenderPdfInput): Promise const doc = new PDFDocument({ size: "A4", layout: "portrait", - margin: 36, // ~12.7 mm — matches the screen print page's @page margin closely + margin: 24, // ~8.5mm — tightened from 36 so a typical day fits on one A4 page info: { Title: `${input.labels.title} — ${input.date}`, Producer: "Tx OS Executive Meetings", @@ -570,7 +574,9 @@ export async function renderSchedulePdf(input: RenderPdfInput): Promise }); // Header: logo (if any) on the left, centered title, date below. - const titleSize = Math.min(28, input.font.fontSize + 10); + // Title size capped tighter (22 vs 28) so the header band doesn't eat + // a row of table space — keeps a typical day on one page. + const titleSize = Math.min(22, input.font.fontSize + 6); const headerLeftEdge = doc.page.margins.left; const headerWidth = doc.page.width - doc.page.margins.left - doc.page.margins.right; const headerTopY = doc.y; @@ -609,7 +615,7 @@ export async function renderSchedulePdf(input: RenderPdfInput): Promise mapping, }); doc.y = headerTopY + Math.max(titleSize * 1.25, input.logo ? logoBoxSize : 0); - doc.moveDown(0.4); + doc.moveDown(0.2); drawMixedLine(doc, input.date, { x: doc.page.margins.left, y: doc.y, @@ -620,7 +626,7 @@ export async function renderSchedulePdf(input: RenderPdfInput): Promise baseDirection: baseDir, mapping, }); - doc.moveDown(0.8); + doc.moveDown(0.4); if (input.meetings.length === 0) { drawMixedLine(doc, input.labels.none, { @@ -642,10 +648,14 @@ export async function renderSchedulePdf(input: RenderPdfInput): Promise // 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; + // Column widths re-balanced for the inline-attendees layout: meeting + // titles can be long and need more room (the previous 30% forced 6-line + // wraps for typical titles), while attendees now flow as one paragraph + // and don't need 44%. Mirrors the reference PDF's proportions. const colWidths = [ Math.round(tableWidth * 0.06), - Math.round(tableWidth * 0.3), - Math.round(tableWidth * 0.44), + Math.round(tableWidth * 0.38), + Math.round(tableWidth * 0.4), 0, // last col absorbs rounding ]; colWidths[3] = tableWidth - colWidths[0] - colWidths[1] - colWidths[2]; @@ -657,7 +667,9 @@ export async function renderSchedulePdf(input: RenderPdfInput): Promise const headerWidths = isRtl ? [...colWidths].reverse() : colWidths; const cellPadX = 6; - const cellPadY = 5; + // Tightened from 5→3 to keep the table compact (we aim to fit a typical + // day on one page, mirroring the user's reference layout). + const cellPadY = 3; const lineHeight = input.font.fontSize * 1.25; function drawHeader(): number { @@ -730,7 +742,12 @@ export async function renderSchedulePdf(input: RenderPdfInput): Promise const t = a.title?.trim(); return `${personIdx}- ${name}${t ? ` (${t})` : ""}`; }) - .join("\n"); + // Inline join: attendees flow as one paragraph per cell + // (wrapped naturally by pdfkit) instead of one-per-line. + // This keeps the table compact so the schedule fits on a + // single page when possible. Subheadings still get their + // own line via the leading "\n" below. + .join(" "); })(), align: alignment(input.font, isRtl), }, @@ -770,6 +787,8 @@ export async function renderSchedulePdf(input: RenderPdfInput): Promise width: widths[i] - cellPadX * 2, align: c.align, lineBreak: true, + // Mirror drawWrappingLine's lineGap so the probe matches the draw. + lineGap: -1, ...(features ? { features } : {}), }; for (const line of lines) { @@ -777,17 +796,14 @@ export async function renderSchedulePdf(input: RenderPdfInput): Promise h += lineHeight; continue; } - // heightOfString respects width + align + features and returns - // the layout height pdfkit would use in text(); we round up to - // a whole lineHeight so the probe matches the +2 padding we - // add inside drawWrappingLine. + // heightOfString returns pdfkit's exact rendered height for the + // wrapped paragraph (including its internal line gaps). Use it + // directly — rounding up to whole lineHeight units inflates rows + // by half a line and leaves visible empty space below cells. + // The +2 mirrors drawWrappingLine's per-line padding fallback so + // multi-line cells never get under-reserved. const measured = doc.heightOfString(line, measureOpts); - const wraps = Math.max(1, Math.ceil(measured / lineHeight)); - // drawWrappingLine returns max(lineHeight, wraps*lineHeight + 2) - // PER non-empty line, so each non-empty line consumes that much - // vertical space. Mirror it here exactly so multi-line cells - // (e.g. attendees split by \n) don't get under-reserved. - h += wraps * lineHeight + 2; + h += Math.max(lineHeight, measured) + 2; } const finalH = h + cellPadY * 2; cellHeights.push(finalH);