fix(report): keep modules together, label the chart time axis, fix glyph stretch

Four layout defects in the clinical PDF renderer:

- Stretched lines: the built-in Helvetica is WinAnsi-encoded, so glyphs
  outside it (the trend arrows the summary injects, the superscript-two in
  kg/m2) resolved to the .notdef box at a fallback advance width and stretched
  the surrounding words. Route every drawn string (direct text, wrapped
  paragraphs, and autoTable cells) through a central WinAnsi sanitiser that
  maps the offenders onto safe equivalents. Embedding a Unicode TTF so the
  arrows render as true glyphs is a documented follow-up.

- The sparkline dropped each reading's timestamp and plotted on an index
  axis, so a trend gave no sense of its time span. Plot points by their real
  timestamp and print the first/last dates under the baseline.

- No consistent bottom safe area: scattered y > 240 magic numbers and tables
  with no bottom margin let content run into the footer. Introduce a single
  contentMaxY derived from the page height, footer band, and bottom margin;
  every break check and every autoTable bottom margin refers to it.

- Modules tore mid-block. Add an ensureSpace guard called before each module
  heading and a per-line guard in every free-text loop; give the short
  titration and side-effect tables rowPageBreak: avoid.
This commit is contained in:
Marc-André Bombeck committed 2026-05-31 22:47:44 +02:00
1 parent c6e782cdb8
commit aed83b7747
2 files changed
+353 -59

No files matched your search

@@ -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.
+262 -59
View File
@@ -18,6 +18,93 @@ import type { DoctorReportData } from "./doctor-report-data";
type T = (key: string, params?: Record<string, string | number>) => 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<string, string> = {
// 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["text"]>
): jsPDF {
const next = [...args] as unknown[];
next[0] = clean(next[0]);
return originalText(...(next as Parameters<jsPDF["text"]>));
} as jsPDF["text"];
const originalSplit = doc.splitTextToSize.bind(doc);
doc.splitTextToSize = function patchedSplit(
this: jsPDF,
...args: Parameters<jsPDF["splitTextToSize"]>
): ReturnType<jsPDF["splitTextToSize"]> {
const next = [...args] as unknown[];
next[0] = clean(next[0]);
return originalSplit(
...(next as Parameters<jsPDF["splitTextToSize"]>),
);
} 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;
}