diff --git a/src/lib/__tests__/doctor-report-pdf-core.test.ts b/src/lib/__tests__/doctor-report-pdf-core.test.ts index c20aac7ed..bc1566a9e 100644 --- a/src/lib/__tests__/doctor-report-pdf-core.test.ts +++ b/src/lib/__tests__/doctor-report-pdf-core.test.ts @@ -3,6 +3,7 @@ import { PDFParse } from "pdf-parse"; import { buildDoctorReportPdfDocument, renderDoctorReportPdfBytes, + sanitiseForPdf, DOCTOR_REPORT_VITAL_TYPES, DOCTOR_REPORT_TYPE_LABEL_KEYS, DOCTOR_REPORT_TYPE_UNIT_KEYS, @@ -183,6 +184,96 @@ describe("buildDoctorReportPdfDocument", () => { }); }); +// ── WinAnsi sanitiser ── the rendered Helvetica is WinAnsi-encoded; glyphs +// outside it (trend arrows, superscripts) resolve to the .notdef box at a +// fallback advance width and stretch the surrounding line. The sanitiser +// maps every offender the report can emit onto a WinAnsi-safe equivalent. +describe("sanitiseForPdf", () => { + it("maps trend arrows to ASCII equivalents", () => { + expect(sanitiseForPdf("↑")).toBe("^"); + expect(sanitiseForPdf("↓")).toBe("v"); + expect(sanitiseForPdf("→")).toBe("->"); + }); + + it("maps the superscript-two in kg/m² to a plain 2", () => { + expect(sanitiseForPdf("kg/m²")).toBe("kg/m2"); + }); + + it("leaves WinAnsi-safe glyphs (umlauts, ß, em-dash) untouched", () => { + expect(sanitiseForPdf("Müller — Größe")).toBe("Müller — Größe"); + }); + + it("renders the GLP-1 weight summary without any non-WinAnsi glyph", async () => { + const data = makeData({ + glp1: { + weightStartKg: 92, + weightEndKg: 86, + weightDeltaKg: -6, + medications: [], + sideEffects: [], + } as unknown as DoctorReportData["glp1"], + }); + const bytes = renderDoctorReportPdfBytes(data, { + t: getServerTranslator("de").t, + locale: "de", + now: FIXED_NOW, + }); + const text = await extractText(bytes); + // The arrow separator must have been folded to ASCII; no raw arrow in + // the document text layer. + expect(text).not.toMatch(/[↑↓→²]/); + expect(text).toContain("->"); + }); +}); + +// ── chart time axis ── the sparkline must anchor the trend in real time so a +// reader can tell whether the curve spans a week or a year. +describe("doctor-report sparkline time axis", () => { + it("prints the series start and end dates under the chart", async () => { + const data = makeData({ + measurements: { + WEIGHT: [ + { value: 82, measuredAt: "2026-02-05T08:00:00.000Z" }, + { value: 81, measuredAt: "2026-03-10T08:00:00.000Z" }, + { value: 79.5, measuredAt: "2026-04-28T08:00:00.000Z" }, + ], + }, + }); + const bytes = renderDoctorReportPdfBytes(data, { + t: getServerTranslator("de").t, + locale: "de", + includeCharts: true, + now: FIXED_NOW, + }); + const text = await extractText(bytes); + // dd.mm short labels (Berlin tz). First + last sample dates. + expect(text).toContain("05.02."); + expect(text).toContain("28.04."); + }); +}); + +// ── page-break discipline ── a long table must not orphan its heading or +// collide with the footer; the renderer paginates without throwing. +describe("doctor-report pagination", () => { + it("paginates a long compliance table across pages without throwing", () => { + const compliance: DoctorReportData["compliance"] = {}; + for (let i = 0; i < 60; i++) { + compliance[`Medication ${i}`] = { + total: 90, + taken: 80, + skipped: 5, + missed: 5, + }; + } + const doc = buildDoctorReportPdfDocument(makeData({ compliance }), { + t: getServerTranslator("de").t, + locale: "de", + now: FIXED_NOW, + }); + expect(doc.getNumberOfPages()).toBeGreaterThan(1); + }); +}); + // Audit-2026-05-07 / phase P0 / closes audit C-9: server-side PDF used a // stale type map and silently dropped body composition while the browser // PDF rendered them. These tests pin the contract. diff --git a/src/lib/doctor-report-pdf-core.ts b/src/lib/doctor-report-pdf-core.ts index 16f82bb61..c8969c458 100644 --- a/src/lib/doctor-report-pdf-core.ts +++ b/src/lib/doctor-report-pdf-core.ts @@ -18,6 +18,93 @@ import type { DoctorReportData } from "./doctor-report-data"; type T = (key: string, params?: Record) => string; +/** + * jsPDF's built-in Helvetica is WinAnsi-encoded. Latin-1 glyphs (umlauts, + * ß, em-/en-dash, typographic quotes, °, µ) carry correct metrics, but any + * code point outside WinAnsi resolves to the `.notdef` box at a single + * fallback advance width. The widths then disagree with the drawn glyph and + * the surrounding words stretch / shift — the visible "stretched line" bug. + * + * This sanitiser maps every glyph the report can emit that falls outside + * WinAnsi onto a WinAnsi-safe equivalent, applied centrally to every string + * just before it reaches `doc.text` / `doc.splitTextToSize` (see + * `patchPdfTextSanitiser`). The offenders that actually occur in the report + * are the trend arrows (↑ ↓ →) injected by `trendArrow()` + the `glp1WeightSummary` + * separator, and the superscript-two in "kg/m²". The mapping is exhaustive + * for those and degrades gracefully for any future stray symbol. + * + * Premium follow-up (documented, not a hotfix blocker): embed a Unicode TTF + * (e.g. DejaVuSans) via `doc.addFileToVFS` / `addFont` so the arrows and + * superscripts render as their true glyphs instead of ASCII equivalents. + */ +const WINANSI_REPLACEMENTS: Record = { + // Trend arrows → ASCII so the metrics match the drawn glyph. + "↑": "^", // ↑ up + "↓": "v", // ↓ down + "→": "->", // → right (also the glp1 weight separator) + "←": "<-", // ← + "↔": "<->", // ↔ + // Super-/subscripts used in "kg/m²". + "²": "2", // ² + "³": "3", // ³ + "¹": "1", // ¹ + // Defensive: a few maths/symbol glyphs that are outside WinAnsi but read + // fine as ASCII, in case a future string introduces them. + "≈": "~", // ≈ + "≤": "<=", // ≤ + "≥": ">=", // ≥ + "×": "x", // × + "€": "EUR", // € +}; + +const WINANSI_REPLACE_RE = new RegExp( + `[${Object.keys(WINANSI_REPLACEMENTS).join("")}]`, + "g", +); + +/** Map non-WinAnsi glyphs onto safe equivalents. Pure; exported for tests. */ +export function sanitiseForPdf(text: string): string { + return text.replace(WINANSI_REPLACE_RE, (ch) => WINANSI_REPLACEMENTS[ch] ?? ch); +} + +/** + * Patch `doc.text` + `doc.splitTextToSize` on a single jsPDF instance so + * every drawn string (direct text, wrapped paragraphs, AND `jspdf-autotable` + * cells — which route their content through `doc.text` too) passes through + * `sanitiseForPdf` first. One choke point instead of a sanitiser call at + * every site keeps the renderer readable and guarantees coverage. + */ +function patchPdfTextSanitiser(doc: jsPDF): void { + const clean = (value: unknown): unknown => + typeof value === "string" + ? sanitiseForPdf(value) + : Array.isArray(value) + ? value.map((v) => (typeof v === "string" ? sanitiseForPdf(v) : v)) + : value; + + const originalText = doc.text.bind(doc); + doc.text = function patchedText( + this: jsPDF, + ...args: Parameters + ): jsPDF { + const next = [...args] as unknown[]; + next[0] = clean(next[0]); + return originalText(...(next as Parameters)); + } as jsPDF["text"]; + + const originalSplit = doc.splitTextToSize.bind(doc); + doc.splitTextToSize = function patchedSplit( + this: jsPDF, + ...args: Parameters + ): ReturnType { + const next = [...args] as unknown[]; + next[0] = clean(next[0]); + return originalSplit( + ...(next as Parameters), + ); + } as jsPDF["splitTextToSize"]; +} + export interface DoctorReportRenderOptions { t: T; locale: Locale; @@ -234,10 +321,34 @@ function buildClinicalSummaryLines( return lines; } +/** A single charted reading. */ +type SparklinePoint = { value: number; measuredAt: string }; + +/** Label band (5 mm) + plot (16 mm) + axis-date line (4.5 mm) + trailing gap. */ +const SPARKLINE_HEIGHT = 5 + 16 + 4.5 + 2; + /** - * Draw a jsPDF-native trend sparkline (label + min/max ticks + polyline). - * Returns the new `y` cursor below the drawn chart. Vector-only — uses - * `doc.lines()`, no raster image, no native canvas module. + * Rough autoTable height estimate (mm) used by the page-break guards so a + * heading + its table do not orphan. The grid theme at fontSize 9 + + * cellPadding 3 renders header + each body row at ~9 mm. Deliberately a + * conservative over-estimate — a slightly early break is harmless, a missed + * one tears the module. + */ +function estimateTableHeight(rowCount: number): number { + const HEADER_MM = 9; + const ROW_MM = 9; + return HEADER_MM + rowCount * ROW_MM; +} + +/** + * Draw a jsPDF-native trend sparkline (label + min/max ticks + polyline + + * a time axis). Returns the new `y` cursor below the drawn chart. Vector-only + * — uses `doc.lines()`, no raster image, no native canvas module. + * + * Each point carries its `measuredAt` so the x-axis is anchored in real time: + * points are positioned by their timestamp across the report window, and the + * first/last dates are printed under the baseline. Without the axis a reader + * cannot tell whether the trend spans a week or a year. */ function drawSparkline( doc: jsPDF, @@ -246,20 +357,23 @@ function drawSparkline( y: number; width: number; label: string; - values: number[]; + points: SparklinePoint[]; num: FormatNum; unit: string; + dateShort: (iso: string) => string; }, ): number { - const { x, y, width, label, values, num, unit } = opts; + const { x, y, width, label, points, num, unit, dateShort } = opts; const chartHeight = 16; const labelHeight = 5; + const axisLabelHeight = 4.5; doc.setFontSize(9); doc.setFont("helvetica", "bold"); doc.setTextColor(30, 30, 30); doc.text(label, x, y + labelHeight - 1.5); + const values = points.map((p) => p.value); const min = Math.min(...values); const max = Math.max(...values); const range = max - min || 1; @@ -275,20 +389,36 @@ function drawSparkline( doc.setLineWidth(0.2); doc.line(x, chartBottom, x + chartWidth, chartBottom); - // Polyline points → jsPDF relative `lines()` deltas. - const stepX = values.length > 1 ? chartWidth / (values.length - 1) : 0; - const points = values.map((v, i) => ({ - px: x + i * stepX, - py: chartBottom - ((v - min) / range) * chartHeight, - })); + // X position is anchored in time: map each reading's timestamp across the + // [first, last] span. When every timestamp collapses to one instant fall + // back to even spacing so a same-day cluster still plots. + const times = points.map((p) => new Date(p.measuredAt).getTime()); + const tMin = Math.min(...times); + const tMax = Math.max(...times); + const tSpan = tMax - tMin; + const plotted = points.map((p, i) => { + const frac = + tSpan > 0 + ? (times[i] - tMin) / tSpan + : points.length > 1 + ? i / (points.length - 1) + : 0; + return { + px: x + frac * chartWidth, + py: chartBottom - ((p.value - min) / range) * chartHeight, + }; + }); doc.setDrawColor(80, 110, 200); doc.setLineWidth(0.4); const deltas: [number, number][] = []; - for (let i = 1; i < points.length; i++) { - deltas.push([points[i].px - points[i - 1].px, points[i].py - points[i - 1].py]); + for (let i = 1; i < plotted.length; i++) { + deltas.push([ + plotted[i].px - plotted[i - 1].px, + plotted[i].py - plotted[i - 1].py, + ]); } if (deltas.length > 0) { - doc.lines(deltas, points[0].px, points[0].py); + doc.lines(deltas, plotted[0].px, plotted[0].py); } // Min/max labels in the right gutter. @@ -299,7 +429,17 @@ function drawSparkline( doc.text(`${num(max, 1)}${unitSuffix}`, x + chartWidth + 2, chartTop + 2); doc.text(`${num(min, 1)}${unitSuffix}`, x + chartWidth + 2, chartBottom); - return chartBottom + 2; + // Time axis: first date left-aligned, last date right-aligned under the + // baseline so the trend's span is unambiguous. + const axisY = chartBottom + axisLabelHeight - 1; + const startLabel = dateShort(points[0]!.measuredAt); + const endLabel = dateShort(points[points.length - 1]!.measuredAt); + doc.text(startLabel, x, axisY); + if (endLabel !== startLabel) { + doc.text(endLabel, x + chartWidth, axisY, { align: "right" }); + } + + return chartBottom + axisLabelHeight + 2; } /** @@ -336,6 +476,9 @@ export function buildDoctorReportPdfDocument( }; const doc = new jsPDF({ orientation: "portrait", unit: "mm", format: "a4" }); + // Route every drawn string through the WinAnsi sanitiser so non-WinAnsi + // glyphs (trend arrows, superscripts) can never stretch a line. + patchPdfTextSanitiser(doc); // v1.7.0 — document metadata (PDF/A-leaning). jsPDF embeds the standard // Helvetica fonts and pulls no external resources; setting the document // properties + a deterministic creation date gives most practice systems @@ -348,9 +491,33 @@ export function buildDoctorReportPdfDocument( author: data.patient.fullName ?? data.patient.username ?? "HealthLog", }); const pageWidth = doc.internal.pageSize.getWidth(); + const pageHeight = doc.internal.pageSize.getHeight(); const margin = 20; + // Bottom safe area: the three-line footer disclaimer sits in the last + // ~14 mm; reserve it plus a small breathing gap so body content never + // collides with the footer or runs off the page. `contentMaxY` is the + // single source of truth every break check + autoTable bottom margin + // refers to — no more scattered `y > 240` magic numbers. + const FOOTER_HEIGHT = 16; + const bottomMargin = 6; + const contentMaxY = pageHeight - bottomMargin - FOOTER_HEIGHT; + const tableBottomMargin = bottomMargin + FOOTER_HEIGHT; let y = margin; + /** + * Page-break guard. Adds a page and resets the cursor to the top margin + * when the upcoming block (height `needed`, in mm) would not fit above + * `contentMaxY`. Call BEFORE drawing a module heading so a heading never + * gets orphaned at the bottom of a page. Returns the (possibly reset) y. + */ + const ensureSpace = (current: number, needed: number): number => { + if (current + needed > contentMaxY) { + doc.addPage(); + return margin; + } + return current; + }; + doc.setFontSize(22); doc.setFont("helvetica", "bold"); doc.setTextColor(30, 30, 30); @@ -449,6 +616,9 @@ export function buildDoctorReportPdfDocument( // physician a one-paragraph orientation before the detail tables. const summaryLines = buildClinicalSummaryLines(data, t, num); if (summaryLines.length > 0) { + // Keep the heading with at least its first line (heading 5 mm + one body + // line 4.5 mm + trailing gap). + y = ensureSpace(y, 5 + 4.5 + 4); doc.setFontSize(12); doc.setFont("helvetica", "bold"); doc.setTextColor(30, 30, 30); @@ -460,6 +630,10 @@ export function buildDoctorReportPdfDocument( for (const line of summaryLines) { const wrapped = doc.splitTextToSize(line, pageWidth - 2 * margin); for (const w of wrapped) { + if (y + 4.5 > contentMaxY) { + doc.addPage(); + y = margin; + } doc.text(w, margin, y); y += 4.5; } @@ -467,6 +641,8 @@ export function buildDoctorReportPdfDocument( y += 4; } + // Vitals heading — keep it with the start of its table. + y = ensureSpace(y, 6 + 18); doc.setFontSize(14); doc.setFont("helvetica", "bold"); doc.setTextColor(30, 30, 30); @@ -535,7 +711,12 @@ export function buildDoctorReportPdfDocument( fontStyle: "bold", }, alternateRowStyles: { fillColor: [252, 252, 252] }, - margin: { left: margin, right: margin }, + margin: { + left: margin, + right: margin, + top: margin, + bottom: tableBottomMargin, + }, }); y = (doc as jsPDF & { lastAutoTable: { finalY: number } }).lastAutoTable @@ -550,10 +731,8 @@ export function buildDoctorReportPdfDocument( (type) => (data.measurements[type]?.length ?? 0) >= 2, ); if (chartTypes.length > 0) { - if (y > 235) { - doc.addPage(); - y = margin; - } + // Keep the heading with at least the first chart. + y = ensureSpace(y, 6 + SPARKLINE_HEIGHT + 4); doc.setFontSize(12); doc.setFont("helvetica", "bold"); doc.setTextColor(30, 30, 30); @@ -561,19 +740,19 @@ export function buildDoctorReportPdfDocument( y += 6; for (const type of chartTypes) { const series = data.measurements[type] ?? []; - if (y > 250) { - doc.addPage(); - y = margin; - } + // A chart never tears across a page boundary — break before drawing + // it whole. + y = ensureSpace(y, SPARKLINE_HEIGHT + 4); const label = t(DOCTOR_REPORT_TYPE_LABEL_KEYS[type] ?? ""); y = drawSparkline(doc, { x: margin, y, width: pageWidth - 2 * margin, label, - values: series.map((p) => p.value), + points: series, num, unit: unitFor(type), + dateShort: (iso) => formatters.dateShort(iso), }); y += 4; } @@ -584,6 +763,7 @@ export function buildDoctorReportPdfDocument( const sysStat = data.stats.BLOOD_PRESSURE_SYS; const diaStat = data.stats.BLOOD_PRESSURE_DIA; if (sysStat && diaStat) { + y = ensureSpace(y, 5 + 8); doc.setFontSize(12); doc.setFont("helvetica", "bold"); doc.text(t("doctorReport.bpClassificationTitle"), margin, y); @@ -601,6 +781,7 @@ export function buildDoctorReportPdfDocument( } if (data.bmi) { + y = ensureSpace(y, 5 + 8); doc.setFontSize(12); doc.setFont("helvetica", "bold"); doc.text(t("doctorReport.bmiTitle"), margin, y); @@ -624,10 +805,7 @@ export function buildDoctorReportPdfDocument( (ctx) => data.glucoseStats?.[ctx], ); if (loggedGlucose.length > 0) { - if (y > 240) { - doc.addPage(); - y = margin; - } + y = ensureSpace(y, 5 + 5 + 3); doc.setFontSize(12); doc.setFont("helvetica", "bold"); doc.text(t("doctorReport.glucoseClassificationTitle"), margin, y); @@ -645,6 +823,10 @@ export function buildDoctorReportPdfDocument( : s.avg < range.min ? "doctorReport.glucoseBelowTarget" : "doctorReport.glucoseAboveTarget"; + if (y + 5 > contentMaxY) { + doc.addPage(); + y = margin; + } doc.text( t("doctorReport.glucoseRow", { label: t(GLUCOSE_LABEL_KEYS[ctx]), @@ -664,10 +846,9 @@ export function buildDoctorReportPdfDocument( const complianceEntries = Object.entries(data.compliance); if (complianceEntries.length > 0) { - if (y > 240) { - doc.addPage(); - y = margin; - } + // Keep the heading with the table header + first row (~6 mm heading + + // ~9 mm header + ~9 mm row). + y = ensureSpace(y, 6 + estimateTableHeight(Math.min(complianceEntries.length, 1))); doc.setFontSize(14); doc.setFont("helvetica", "bold"); @@ -713,7 +894,12 @@ export function buildDoctorReportPdfDocument( fontStyle: "bold", }, alternateRowStyles: { fillColor: [252, 252, 252] }, - margin: { left: margin, right: margin }, + margin: { + left: margin, + right: margin, + top: margin, + bottom: tableBottomMargin, + }, }); y = (doc as jsPDF & { lastAutoTable: { finalY: number } }).lastAutoTable @@ -726,10 +912,8 @@ export function buildDoctorReportPdfDocument( // current drug + dose, full titration history, weight curve over // the report window, side-effect frequency, and compliance %. if (data.glp1) { - if (y > 220) { - doc.addPage(); - y = margin; - } + // Keep the GLP-1 heading with its first content line. + y = ensureSpace(y, 6 + 6 + 5); doc.setFontSize(14); doc.setFont("helvetica", "bold"); @@ -756,10 +940,8 @@ export function buildDoctorReportPdfDocument( } for (const med of data.glp1.medications) { - if (y > 240) { - doc.addPage(); - y = margin; - } + // Keep the med name with at least its first detail line. + y = ensureSpace(y, 5 + 5); doc.setFontSize(11); doc.setFont("helvetica", "bold"); doc.text(med.name, margin, y); @@ -797,6 +979,9 @@ export function buildDoctorReportPdfDocument( `${num(dc.value, 2)} ${dc.unit}`, dc.note ?? "", ]); + // A titration history reads as one block — break before it if the + // whole table would not fit, and tell autoTable not to split a row. + y = ensureSpace(y, estimateTableHeight(historyRows.length)); autoTable(doc, { startY: y, head: [ @@ -808,6 +993,7 @@ export function buildDoctorReportPdfDocument( ], body: historyRows, theme: "grid", + rowPageBreak: "avoid", styles: { fontSize: 9, cellPadding: 3, @@ -821,7 +1007,12 @@ export function buildDoctorReportPdfDocument( fontStyle: "bold", }, alternateRowStyles: { fillColor: [252, 252, 252] }, - margin: { left: margin, right: margin }, + margin: { + left: margin, + right: margin, + top: margin, + bottom: tableBottomMargin, + }, }); y = (doc as jsPDF & { lastAutoTable: { finalY: number } }).lastAutoTable @@ -830,15 +1021,14 @@ export function buildDoctorReportPdfDocument( } if (data.glp1.sideEffects.length > 0) { - if (y > 240) { - doc.addPage(); - y = margin; - } + const seRows = data.glp1.sideEffects.map((s) => [s.tag, String(s.count)]); + // Keep the heading with the table; the side-effect tally reads as one + // block. + y = ensureSpace(y, 5 + estimateTableHeight(seRows.length)); doc.setFontSize(11); doc.setFont("helvetica", "bold"); doc.text(t("doctorReport.glp1SideEffectsTitle"), margin, y); y += 5; - const seRows = data.glp1.sideEffects.map((s) => [s.tag, String(s.count)]); autoTable(doc, { startY: y, head: [ @@ -846,6 +1036,7 @@ export function buildDoctorReportPdfDocument( ], body: seRows, theme: "grid", + rowPageBreak: "avoid", styles: { fontSize: 9, cellPadding: 3, @@ -859,7 +1050,12 @@ export function buildDoctorReportPdfDocument( fontStyle: "bold", }, alternateRowStyles: { fillColor: [252, 252, 252] }, - margin: { left: margin, right: margin }, + margin: { + left: margin, + right: margin, + top: margin, + bottom: tableBottomMargin, + }, }); y = (doc as jsPDF & { lastAutoTable: { finalY: number } }).lastAutoTable @@ -868,10 +1064,10 @@ export function buildDoctorReportPdfDocument( } if (data.mood) { - if (y > 240) { - doc.addPage(); - y = margin; - } + const moodRowCount = Object.keys(data.mood.distribution).length; + // Keep the heading + summary line with the start of the distribution + // table. + y = ensureSpace(y, 6 + 6 + estimateTableHeight(Math.min(moodRowCount, 1))); doc.setFontSize(14); doc.setFont("helvetica", "bold"); @@ -925,7 +1121,12 @@ export function buildDoctorReportPdfDocument( textColor: [30, 30, 30], fontStyle: "bold", }, - margin: { left: margin, right: margin }, + margin: { + left: margin, + right: margin, + top: margin, + bottom: tableBottomMargin, + }, }); y = (doc as jsPDF & { lastAutoTable: { finalY: number } }).lastAutoTable @@ -938,10 +1139,8 @@ export function buildDoctorReportPdfDocument( // for a machine-generated diagnosis. const aiText = typeof aiSummary === "string" ? aiSummary.trim() : ""; if (aiText.length > 0) { - if (y > 220) { - doc.addPage(); - y = margin; - } + // Keep the heading + the first disclaimer line together. + y = ensureSpace(y, 5 + 4 + 4.5); doc.setFontSize(12); doc.setFont("helvetica", "bold"); doc.setTextColor(120, 80, 0); @@ -955,6 +1154,10 @@ export function buildDoctorReportPdfDocument( pageWidth - 2 * margin, ); for (const line of disclaimer) { + if (y + 4 > contentMaxY) { + doc.addPage(); + y = margin; + } doc.text(line, margin, y); y += 4; } @@ -964,7 +1167,7 @@ export function buildDoctorReportPdfDocument( doc.setTextColor(60, 60, 60); const wrapped = doc.splitTextToSize(aiText, pageWidth - 2 * margin); for (const line of wrapped) { - if (y > 270) { + if (y + 4.5 > contentMaxY) { doc.addPage(); y = margin; }