mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 21:10:59 +00:00
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:
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.
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user