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);