PDF: inline attendees, tighter layout — fit a typical day on one page

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.
This commit is contained in:
riyadhafraa
2026-05-03 19:11:49 +00:00
parent e0824bc6a0
commit 26a869c0eb
2 changed files with 34 additions and 18 deletions
+34 -18
View File
@@ -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<Buffer>
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<Buffer>
});
// 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<Buffer>
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<Buffer>
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<Buffer>
// 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<Buffer>
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<Buffer>
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<Buffer>
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<Buffer>
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);