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