diff --git a/next.config.ts b/next.config.ts index 013e81c7a..a4813cdab 100644 --- a/next.config.ts +++ b/next.config.ts @@ -309,6 +309,9 @@ const nextConfig: NextConfig = { "./src/lib/documents/pdf-child.mjs", "./src/lib/documents/pdf-engine.mjs", "./src/lib/documents/image-engine.mjs", + // The doctor-report PDF embeds its fonts, read from disk by a path the + // tracer cannot follow (`src/lib/doctor-report-pdf/fonts.ts`). + "./src/lib/doctor-report-pdf/fonts/**", // Document AI local text extraction and rasterization both run the // app's one pdfjs-dist (`src/lib/documents/pdfjs.ts`) server-side; the // text read is the provider-free fallback that keeps content search diff --git a/scripts/README.md b/scripts/README.md index 5692b78e4..18da03099 100644 --- a/scripts/README.md +++ b/scripts/README.md @@ -31,6 +31,7 @@ typecheck. - **`bench/`** — `seed-large-account.ts` (a synthetic ten-year account of about 1.26 million measurement rows) and `run.ts` (latency and SQL statements per request against it); see `docs/contributing/benchmarks.md`. Results land in `bench/results/`. - **`test-notifications.ts`** — exercise the notification dispatcher + reminder check. - **`fetch-geolite2.sh`** — download the MaxMind GeoLite2 MMDBs (needs a licence key) before `docker build`. +- **`build-pdf-fonts.py`** — rebuild the fonts the doctor-report PDF embeds (`src/lib/doctor-report-pdf/fonts/`) from the upstream Noto sources; needs `fonttools`. The source URLs are in its header. - **`install-hooks.sh`** — install the local git hooks. - **`generate-sw-version.mjs`**, **`print-bundle-report.mjs`** — service-worker version stamp and bundle-size report. - **`i18n/`** — locale tooling; **`__tests__/`** — tests for the scripts above. diff --git a/scripts/build-pdf-fonts.py b/scripts/build-pdf-fonts.py new file mode 100644 index 000000000..e6d2d5b2f --- /dev/null +++ b/scripts/build-pdf-fonts.py @@ -0,0 +1,191 @@ +#!/usr/bin/env python3 +"""Build the fonts the doctor-report PDF embeds. + +jsPDF draws one font per text run and has no glyph fallback, so every script +the report can carry has to live in one font file. No single upstream Noto +font covers what the report needs: Noto Sans has Latin, Greek and Cyrillic but +no arrows or comparison signs, Noto Sans KR has Hangul but no Latin +Extended-A (Polish). This script merges them: + + ReportSans-{Regular,Bold,Italic}.ttf + Noto Sans (Latin, Latin Extended-A/B, Greek, Cyrillic, punctuation, + super-/subscripts, currency) plus the arrows and mathematical operators + of Noto Sans Math. + ReportSansKR-{Regular,Bold}.ttf + The matching ReportSans weight plus Hangul (syllables, jamo, + compatibility jamo) and CJK punctuation from Noto Sans KR, instanced + at weight 400 / 700. Loaded only for a report that contains Hangul. + +All sources are SIL Open Font License 1.1; the licence texts sit beside the +output. The merged fonts carry their own family name, as the OFL asks of a +modified version. Layout tables (GSUB/GPOS/GDEF/MATH) are dropped: jsPDF +does no shaping, and precomposed Hangul syllables need none. + +Requires fonttools (`pip install fonttools`). Usage: + + python3 scripts/build-pdf-fonts.py + +where holds the unhinted upstream TTFs: + NotoSans-Regular.ttf, NotoSans-Bold.ttf, NotoSans-Italic.ttf + https://github.com/notofonts/notofonts.github.io/tree/main/fonts/NotoSans/unhinted/ttf + NotoSansMath-Regular.ttf + https://github.com/notofonts/notofonts.github.io/tree/main/fonts/NotoSansMath/unhinted/ttf + NotoSansKR[wght].ttf + https://github.com/google/fonts/tree/main/ofl/notosanskr +""" + +import copy +import sys +from pathlib import Path + +from fontTools import subset +from fontTools.ttLib import TTFont +from fontTools.varLib import instancer + + +def ranges(*spans): + out = set() + for lo, hi in spans: + out.update(range(lo, hi + 1)) + return out + + +BASE_CODEPOINTS = ranges( + (0x0020, 0x007E), # Basic Latin + (0x00A0, 0x024F), # Latin-1, Latin Extended-A/B + (0x0250, 0x02FF), # IPA, spacing modifiers + (0x0300, 0x036F), # combining diacritics + (0x0370, 0x03FF), # Greek + (0x0400, 0x052F), # Cyrillic + supplement + (0x1E00, 0x1EFF), # Latin Extended Additional + (0x2000, 0x206F), # general punctuation + (0x2070, 0x209F), # super-/subscripts + (0x20A0, 0x20CF), # currency + (0x2100, 0x214F), # letterlike (°C, №, ™) + (0x2150, 0x218F), # number forms +) +SYMBOL_CODEPOINTS = ranges( + (0x2190, 0x21FF), # arrows + (0x2200, 0x22FF), # mathematical operators (≈ ≤ ≥ ±) +) +HANGUL_CODEPOINTS = ranges( + (0x1100, 0x11FF), # Hangul jamo + (0x3000, 0x303F), # CJK symbols and punctuation + (0x3130, 0x318F), # compatibility jamo + (0xA960, 0xA97F), # jamo extended-A + (0xAC00, 0xD7A3), # syllables + (0xD7B0, 0xD7FF), # jamo extended-B +) +DROP_TABLES = ["GSUB", "GPOS", "GDEF", "MATH", "BASE", "JSTF", "DSIG", "STAT"] + + +def subset_font(font: TTFont, codepoints: set[int]) -> TTFont: + options = subset.Options() + options.layout_features = [] + options.drop_tables += DROP_TABLES + options.hinting = False + options.notdef_outline = True + options.name_IDs = ["*"] + options.glyph_names = True + subsetter = subset.Subsetter(options) + subsetter.populate(unicodes=codepoints & set(font.getBestCmap())) + subsetter.subset(font) + return font + + +def copy_glyph(base: TTFont, donor: TTFont, name: str, prefix: str) -> str: + """Copy one donor glyph (and its components) into base; return its name.""" + new_name = f"{prefix}{name}" + glyf = base["glyf"] + if new_name in glyf.glyphs: + return new_name + glyph = copy.deepcopy(donor["glyf"][name]) + if glyph.isComposite(): + for component in glyph.components: + component.glyphName = copy_glyph( + base, donor, component.glyphName, prefix + ) + glyf.glyphs[new_name] = glyph + glyf.glyphOrder.append(new_name) + base["hmtx"].metrics[new_name] = donor["hmtx"].metrics[name] + order = base.getGlyphOrder() + if new_name not in order: + order.append(new_name) + base.setGlyphOrder(order) + return new_name + + +def merge(base: TTFont, donor: TTFont, prefix: str) -> None: + if base["head"].unitsPerEm != donor["head"].unitsPerEm: + raise SystemExit("unitsPerEm differs between base and donor") + base_cmap = base.getBestCmap() + additions = {} + for cp, name in donor.getBestCmap().items(): + if cp in base_cmap: + continue + additions[cp] = copy_glyph(base, donor, name, prefix) + for table in base["cmap"].tables: + if table.isUnicode(): + if table.format == 4 and any(cp > 0xFFFF for cp in additions): + continue + table.cmap.update(additions) + base["maxp"].numGlyphs = len(base.getGlyphOrder()) + base["hhea"].numberOfHMetrics = len(base.getGlyphOrder()) + base["hhea"].advanceWidthMax = max( + base["hhea"].advanceWidthMax, + max(width for width, _ in donor["hmtx"].metrics.values()), + ) + + +def finish(font: TTFont, family: str, style: str, path: Path) -> None: + """Rename, drop glyph names, save. + + jsPDF copies the whole `post` table into every PDF it writes, subset or + not, so glyph names would cost each report tens of kilobytes (hundreds + with Hangul). Format 3 carries none. + """ + font["post"].formatType = 3.0 + rename(font, family, style) + font.save(path) + + +def rename(font: TTFont, family: str, style: str) -> None: + name = font["name"] + postscript = f"{family}-{style}" + for record in list(name.names): + if record.nameID in (1, 16): + record.string = family + elif record.nameID in (2, 17): + record.string = style + elif record.nameID == 3: + record.string = f"{postscript};HealthLog" + elif record.nameID == 4: + record.string = f"{family} {style}" + elif record.nameID == 6: + record.string = postscript + + +def build(src: Path, out: Path) -> None: + out.mkdir(parents=True, exist_ok=True) + math = subset_font(TTFont(src / "NotoSansMath-Regular.ttf"), SYMBOL_CODEPOINTS) + kr_source = src / "NotoSansKR[wght].ttf" + + for style in ("Regular", "Bold", "Italic"): + base = subset_font(TTFont(src / f"NotoSans-{style}.ttf"), BASE_CODEPOINTS) + merge(base, math, "math.") + finish(base, "ReportSans", style, out / f"ReportSans-{style}.ttf") + + if style == "Italic": + continue + weight = 400 if style == "Regular" else 700 + kr = subset_font(TTFont(kr_source), HANGUL_CODEPOINTS) + kr = instancer.instantiateVariableFont(kr, {"wght": weight}) + with_hangul = TTFont(out / f"ReportSans-{style}.ttf") + merge(with_hangul, kr, "kr.") + finish(with_hangul, "ReportSansKR", style, out / f"ReportSansKR-{style}.ttf") + + +if __name__ == "__main__": + if len(sys.argv) != 3: + raise SystemExit(__doc__) + build(Path(sys.argv[1]), Path(sys.argv[2])) diff --git a/src/lib/__tests__/doctor-report-pdf-core.test.ts b/src/lib/__tests__/doctor-report-pdf-core.test.ts index 0adcc830c..081dd0a57 100644 --- a/src/lib/__tests__/doctor-report-pdf-core.test.ts +++ b/src/lib/__tests__/doctor-report-pdf-core.test.ts @@ -2,7 +2,6 @@ import { describe, it, expect } from "vitest"; import { buildDoctorReportPdfDocument, renderDoctorReportPdfBytes, - sanitiseForPdf, DOCTOR_REPORT_VITAL_GROUPS, } from "../doctor-report-pdf-core"; import { measurementTypeEnum } from "../validations/measurement"; @@ -313,26 +312,10 @@ 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 () => { +// ── embedded font ── the report draws in an embedded Unicode font, so the +// GLP-1 summary's arrow separator prints as the arrow and not as ASCII. +describe("doctor-report arrows", () => { + it("renders the GLP-1 weight summary with its arrow separator", async () => { const data = makeData({ glp1: { weightStartKg: 92, @@ -350,10 +333,8 @@ describe("sanitiseForPdf", () => { 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("->"); + expect(text).toMatch(/92[.,]0 kg → 86[.,]0 kg/); + expect(text).not.toContain("->"); }); }); @@ -747,8 +728,7 @@ describe("doctor-report recorded precision", () => { }), ); expect(text).toContain("201 mg/dL"); - // The PDF spells "≤" as "<=" (WinAnsi). - expect(text).toContain("<= 193"); + expect(text).toContain("≤ 193"); }); it("keeps the digits of a measurement read below one", async () => { @@ -1199,3 +1179,77 @@ describe("doctor report — the user's date order", () => { expect(text).toContain("05/03/2026"); }); }); + +// ── script coverage ── the report is read in every shipped locale and carries +// free text the user typed in any script. Each string below has to come back +// out of the PDF's text layer exactly as it went in: a glyph the font lacks +// draws as a box and extracts as nothing, or as the wrong character. +describe("doctor-report script coverage", () => { + const POLISH = "Łódź, źdźbło, ężą"; + const KOREAN = "혈압 측정"; + const RUSSIAN = "Давление крови"; + + function withMedicationNames(names: string[]): DoctorReportData { + const compliance: DoctorReportData["compliance"] = {}; + names.forEach((name, i) => { + compliance[`med-${i}`] = { + name, + total: 30, + taken: 28, + skipped: 1, + missed: 1, + }; + }); + return makeData({ compliance }); + } + + async function render( + locale: "en" | "pl" | "ko", + data: DoctorReportData, + ): Promise { + const { t } = getServerTranslator(locale); + return extractText( + renderDoctorReportPdfBytes(data, { + t, + locale, + now: FIXED_NOW, + timeFormat: "AUTO", + dateFormat: "AUTO", + }), + ); + } + + it("round-trips Polish, Russian and Korean free text in an English report", async () => { + const text = await render( + "en", + withMedicationNames([POLISH, RUSSIAN, KOREAN]), + ); + expect(text).toContain(POLISH); + expect(text).toContain(RUSSIAN); + expect(text).toContain(KOREAN); + }); + + it("prints the Polish report's own wording with its diacritics", async () => { + const { t } = getServerTranslator("pl"); + const text = await render("pl", withMedicationNames([POLISH])); + expect(text).toContain(t("doctorReport.title")); + expect(text).toContain(POLISH); + }); + + it("prints the Korean report's own wording", async () => { + const { t } = getServerTranslator("ko"); + const text = await render("ko", withMedicationNames([KOREAN, RUSSIAN])); + expect(text).toContain(t("doctorReport.title")); + expect(text).toContain(KOREAN); + expect(text).toContain(RUSSIAN); + }); + + it("draws arrows and superscripts as themselves", async () => { + const text = await render( + "en", + withMedicationNames(["Dose ↑ 10 mg → 20 mg ↓", "kg/m² ≤ 25 × 2"]), + ); + expect(text).toContain("Dose ↑ 10 mg → 20 mg ↓"); + expect(text).toContain("kg/m² ≤ 25 × 2"); + }); +}); diff --git a/src/lib/doctor-report-pdf-core.ts b/src/lib/doctor-report-pdf-core.ts index bdf82ce9d..ad1d366a7 100644 --- a/src/lib/doctor-report-pdf-core.ts +++ b/src/lib/doctor-report-pdf-core.ts @@ -1,16 +1,16 @@ /** - * Isomorphic PDF renderer for the doctor report. + * Server-side PDF renderer for the doctor report. * - * Runs identically in the browser (settings page download) and in Node - * (the clinician share link's `/c/[token]/report.pdf` route). The comment here + * Callers are the clinician share link's `/c/[token]/report.pdf` route and + * the health-record export. The comment here * named `/api/doctor-report/pdf` until 2026-08-23; that route was removed and * the sweep behind the retired-route registry found the reference still * standing. All locale-sensitive * strings and number/date formatting are driven by the injected - * `{ t, locale }` so DE and EN output match the user's UI language. + * `{ t, locale }` so the output matches the user's UI language. * - * jsPDF is fully isomorphic: `doc.output("arraybuffer")` returns a valid - * `%PDF-` byte stream in both environments. + * The text is drawn in embedded Unicode fonts read from disk + * (`doctor-report-pdf/fonts.ts`), which ties the renderer to Node. */ import { jsPDF } from "jspdf"; import type { MeasurementType } from "@/generated/prisma/client"; @@ -43,98 +43,14 @@ import { pdfCursorState, type DoctorReportPdfRenderContext, } from "./doctor-report-pdf/render-context"; +import { + registerReportFonts, + reportNeedsHangul, +} from "./doctor-report-pdf/fonts"; import { DEFAULT_TIMEZONE } from "@/lib/tz/format"; 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; @@ -209,9 +125,8 @@ export const DOCTOR_REPORT_VITAL_GROUPS = REPORT_GROUPS.map((group) => ({ /** * Render the doctor report into a `jsPDF` instance. * - * Used internally by both the client wrapper (which calls `.save()` on the - * returned doc) and the server renderer (which calls `.output("arraybuffer")` - * via `renderDoctorReportPdfBytes`). + * `renderDoctorReportPdfBytes` serialises the returned doc; tests inspect it + * directly. */ export function buildDoctorReportPdfDocument( data: DoctorReportData, @@ -238,8 +153,13 @@ export function buildDoctorReportPdfDocument( const unitFor = (type: string): string => reportStatUnit(type, glucoseUnit, t); - const doc = new jsPDF({ orientation: "portrait", unit: "mm", format: "a4" }); - patchPdfTextSanitiser(doc); + const doc = new jsPDF({ + orientation: "portrait", + unit: "mm", + format: "a4", + compress: true, + }); + registerReportFonts(doc, reportNeedsHangul(locale, data)); doc.setProperties({ title: t("doctorReport.title"), subject: t("doctorReport.subtitle"), @@ -304,7 +224,6 @@ export function buildDoctorReportPdfDocument( /** * Render the doctor report and return the PDF as a `Uint8Array`. - * Isomorphic — works in browser and Node. */ export function renderDoctorReportPdfBytes( data: DoctorReportData, diff --git a/src/lib/doctor-report-pdf/__tests__/fonts.test.ts b/src/lib/doctor-report-pdf/__tests__/fonts.test.ts new file mode 100644 index 000000000..5868f5e00 --- /dev/null +++ b/src/lib/doctor-report-pdf/__tests__/fonts.test.ts @@ -0,0 +1,47 @@ +import { jsPDF } from "jspdf"; + +import { readSource } from "@/__tests__/helpers/source-files"; +import { describe, expect, it } from "vitest"; + +import { + PDF_FONT_FAMILY, + registerReportFonts, + reportNeedsHangul, +} from "../fonts"; + +const FONT_DIR = "src/lib/doctor-report-pdf/fonts"; + +describe("doctor-report fonts", () => { + it("ships the OFL licence beside the font files", () => { + const licence = readSource( + `${FONT_DIR}/OFL.txt`, + "SIL Open Font License, Version 1.1", + ); + expect(licence).toContain("The Noto Project Authors"); + expect(licence).toContain("Adobe"); + }); + + it("names the font directory in the standalone trace includes", () => { + // The files are read by a path built at runtime; without the include the + // production image ships the renderer and none of its fonts. + const config = readSource("next.config.ts", "outputFileTracingIncludes"); + expect(config).toContain(`"./${FONT_DIR}/**"`); + }); + + it("registers every style in both variants", () => { + for (const withHangul of [false, true]) { + const doc = new jsPDF(); + registerReportFonts(doc, withHangul); + const styles = doc.getFontList()[PDF_FONT_FAMILY]; + expect(styles?.sort()).toEqual(["bold", "italic", "normal"]); + expect(doc.getFont().fontName).toBe(PDF_FONT_FAMILY); + } + }); + + it("loads the Hangul font for a Korean report or Hangul in the data", () => { + expect(reportNeedsHangul("ko", { name: "Ramipril" })).toBe(true); + expect(reportNeedsHangul("en", { name: "혈압 측정" })).toBe(true); + expect(reportNeedsHangul("pl", { name: "Łódź" })).toBe(false); + expect(reportNeedsHangul("en", { name: "Давление крови" })).toBe(false); + }); +}); diff --git a/src/lib/doctor-report-pdf/activity-effect-sections.ts b/src/lib/doctor-report-pdf/activity-effect-sections.ts index e122c7e8f..a36fbb26e 100644 --- a/src/lib/doctor-report-pdf/activity-effect-sections.ts +++ b/src/lib/doctor-report-pdf/activity-effect-sections.ts @@ -9,6 +9,7 @@ */ import type { jsPDF } from "jspdf"; import autoTable from "jspdf-autotable"; +import { PDF_FONT_FAMILY } from "./fonts"; import { dateOnlyAtNoonUtc } from "@/lib/tz/date-only"; import type { MeasurementType } from "@/generated/prisma/client"; import { formatEstimate } from "@/lib/medications/efficacy/segmented-format"; @@ -54,7 +55,7 @@ function heading( const { doc, margin, ensureSpace } = context; const top = ensureSpace(y, 6 + keepWith); doc.setFontSize(12); - doc.setFont("helvetica", "bold"); + doc.setFont(PDF_FONT_FAMILY, "bold"); doc.setTextColor(30, 30, 30); doc.text(text, margin, top); return top + 5; @@ -67,7 +68,7 @@ function note( ): number { const { doc, margin, pageWidth, ensureSpace } = context; doc.setFontSize(8); - doc.setFont("helvetica", "normal"); + doc.setFont(PDF_FONT_FAMILY, "normal"); doc.setTextColor(90, 90, 90); let cursor = y; for (const line of doc.splitTextToSize(text, pageWidth - 2 * margin)) { diff --git a/src/lib/doctor-report-pdf/clinical-records-notes-section.ts b/src/lib/doctor-report-pdf/clinical-records-notes-section.ts index 294399161..59b2c265e 100644 --- a/src/lib/doctor-report-pdf/clinical-records-notes-section.ts +++ b/src/lib/doctor-report-pdf/clinical-records-notes-section.ts @@ -1,3 +1,4 @@ +import { PDF_FONT_FAMILY } from "./fonts"; import { lifeReportRows } from "@/lib/profile/life-report-rows"; import type { jsPDF } from "jspdf"; import autoTable from "jspdf-autotable"; @@ -43,7 +44,7 @@ export function buildClinicalRecordsNotesSection( if (data.labResults && data.labResults.length > 0) { y = ensureSpace(y, 6 + 18); doc.setFontSize(14); - doc.setFont("helvetica", "bold"); + doc.setFont(PDF_FONT_FAMILY, "bold"); doc.setTextColor(30, 30, 30); doc.text(t("doctorReport.labsTitle"), margin, y); y += 6; @@ -161,7 +162,7 @@ export function buildClinicalRecordsNotesSection( if (diverging.length > 0) { y = ensureSpace(y, 4 + diverging.length * 4); doc.setFontSize(8); - doc.setFont("helvetica", "normal"); + doc.setFont(PDF_FONT_FAMILY, "normal"); doc.setTextColor(110, 110, 110); doc.text(t("doctorReport.labsSourceRangeNote"), margin, y); y += 4; @@ -189,7 +190,7 @@ export function buildClinicalRecordsNotesSection( if (data.illnessEpisodes && data.illnessEpisodes.length > 0) { y = ensureSpace(y, 6 + 18); doc.setFontSize(14); - doc.setFont("helvetica", "bold"); + doc.setFont(PDF_FONT_FAMILY, "bold"); doc.setTextColor(30, 30, 30); doc.text(t("doctorReport.illnessTitle"), margin, y); y += 6; @@ -264,7 +265,7 @@ export function buildClinicalRecordsNotesSection( if (data.visits && data.visits.length > 0) { y = ensureSpace(y, 6 + 18); doc.setFontSize(14); - doc.setFont("helvetica", "bold"); + doc.setFont(PDF_FONT_FAMILY, "bold"); doc.setTextColor(30, 30, 30); doc.text(t("doctorReport.visitsTitle"), margin, y); y += 6; @@ -332,7 +333,7 @@ export function buildClinicalRecordsNotesSection( if (data.surgicalHistory && data.surgicalHistory.length > 0) { y = ensureSpace(y, 6 + 18); doc.setFontSize(14); - doc.setFont("helvetica", "bold"); + doc.setFont(PDF_FONT_FAMILY, "bold"); doc.setTextColor(30, 30, 30); doc.text(t("doctorReport.surgicalHistoryTitle"), margin, y); y += 6; @@ -400,7 +401,7 @@ export function buildClinicalRecordsNotesSection( if (data.immunizations && data.immunizations.length > 0) { y = ensureSpace(y, 6 + 18); doc.setFontSize(14); - doc.setFont("helvetica", "bold"); + doc.setFont(PDF_FONT_FAMILY, "bold"); doc.setTextColor(30, 30, 30); doc.text(t("doctorReport.immunizationsTitle"), margin, y); y += 6; @@ -474,7 +475,7 @@ export function buildClinicalRecordsNotesSection( if (data.allergies && data.allergies.length > 0) { y = ensureSpace(y, 6 + 18); doc.setFontSize(14); - doc.setFont("helvetica", "bold"); + doc.setFont(PDF_FONT_FAMILY, "bold"); doc.setTextColor(30, 30, 30); doc.text(t("doctorReport.allergiesTitle"), margin, y); y += 6; @@ -538,7 +539,7 @@ export function buildClinicalRecordsNotesSection( if (data.familyHistory && data.familyHistory.length > 0) { y = ensureSpace(y, 6 + 18); doc.setFontSize(14); - doc.setFont("helvetica", "bold"); + doc.setFont(PDF_FONT_FAMILY, "bold"); doc.setTextColor(30, 30, 30); doc.text(t("doctorReport.familyHistoryTitle"), margin, y); y += 6; @@ -588,7 +589,7 @@ export function buildClinicalRecordsNotesSection( if (data.anamnesis) { y = ensureSpace(y, 6 + 22); doc.setFontSize(14); - doc.setFont("helvetica", "bold"); + doc.setFont(PDF_FONT_FAMILY, "bold"); doc.setTextColor(30, 30, 30); doc.text(t("doctorReport.anamnesisTitle"), margin, y); y += 6; diff --git a/src/lib/doctor-report-pdf/emergency-first-page-section.ts b/src/lib/doctor-report-pdf/emergency-first-page-section.ts index ecb56efa4..f7893da02 100644 --- a/src/lib/doctor-report-pdf/emergency-first-page-section.ts +++ b/src/lib/doctor-report-pdf/emergency-first-page-section.ts @@ -1,3 +1,4 @@ +import { PDF_FONT_FAMILY } from "./fonts"; import { pdfCursorState, type DoctorReportPdfCursorState, @@ -41,13 +42,13 @@ export function buildEmergencyFirstPageSection( doc.rect(margin, y, contentWidth, 12, "F"); doc.setTextColor(255, 255, 255); doc.setFontSize(15); - doc.setFont("helvetica", "bold"); + doc.setFont(PDF_FONT_FAMILY, "bold"); doc.text(t("doctorReport.emergency.title"), margin + 3, y + 8); y += 12; doc.setTextColor(120, 120, 120); doc.setFontSize(8); - doc.setFont("helvetica", "normal"); + doc.setFont(PDF_FONT_FAMILY, "normal"); y += 5; doc.text(t("doctorReport.emergency.subtitle"), margin, y); y += 6; @@ -57,12 +58,12 @@ export function buildEmergencyFirstPageSection( const block = (label: string, body: string, emphasise = false) => { ensureRoom(5 + 4.8); doc.setFontSize(10); - doc.setFont("helvetica", "bold"); + doc.setFont(PDF_FONT_FAMILY, "bold"); doc.setTextColor(40, 40, 40); doc.text(label, margin, y); y += 5; doc.setFontSize(emphasise ? 11 : 9.5); - doc.setFont("helvetica", emphasise ? "bold" : "normal"); + doc.setFont(PDF_FONT_FAMILY, emphasise ? "bold" : "normal"); doc.setTextColor( emphasise ? 150 : 60, emphasise ? 20 : 60, diff --git a/src/lib/doctor-report-pdf/fonts.ts b/src/lib/doctor-report-pdf/fonts.ts new file mode 100644 index 000000000..6fe427d4f --- /dev/null +++ b/src/lib/doctor-report-pdf/fonts.ts @@ -0,0 +1,98 @@ +import { readFileSync } from "node:fs"; +import { join } from "node:path"; +import { jsPDF } from "jspdf"; +import { applyPlugin, type UserOptions } from "jspdf-autotable"; + +import type { Locale } from "@/lib/i18n/config"; + +/** + * The font family every string in the doctor report is drawn in. + * + * jsPDF's built-in Helvetica is WinAnsi-encoded, so anything outside Latin-1 + * (Polish, Cyrillic, Greek, Hangul, arrows) drew as a `.notdef` box. The + * report embeds Unicode TrueType fonts instead, built from Noto Sans, Noto + * Sans Math and Noto Sans KR by `scripts/build-pdf-fonts.py` (SIL Open Font + * License 1.1, `fonts/OFL.txt`). jsPDF has no per-glyph fallback, so each + * file carries every script it is meant for: + * + * - `ReportSans-*` (about 190 KB each): Latin incl. Extended-A/B, Greek, + * Cyrillic, punctuation, super-/subscripts, currency, arrows and + * mathematical operators. Regular, bold and italic. + * - `ReportSansKR-*` (about 2.7 MB each): the same plus Hangul. Read only + * for a report that contains Hangul, so the other locales never load it. + * Hangul has no italic; the italic style maps to the upright regular. + * + * jsPDF subsets each font to the glyphs a document uses, so the size of the + * produced PDF does not follow the size of these files. + * + * The files are read from disk at render time. The standalone build cannot + * see a path assembled at runtime, so `next.config.ts` names the directory in + * `outputFileTracingIncludes`. + */ +export const PDF_FONT_FAMILY = "ReportSans"; + +type FontStyle = "normal" | "bold" | "italic"; + +const FONT_DIR = join(process.cwd(), "src/lib/doctor-report-pdf/fonts"); + +const LATIN_FILES: Record = { + normal: "ReportSans-Regular.ttf", + bold: "ReportSans-Bold.ttf", + italic: "ReportSans-Italic.ttf", +}; + +const HANGUL_FILES: Record = { + normal: "ReportSansKR-Regular.ttf", + bold: "ReportSansKR-Bold.ttf", + italic: "ReportSansKR-Regular.ttf", +}; + +/** Jamo, compatibility jamo, jamo extended-A/B and the syllable block. */ +const HANGUL_RE = /[\u1100-\u11FF\u3130-\u318F\uA960-\uA97F\uAC00-\uD7FF]/; + +// The plugin form of jspdf-autotable is the one that can set per-document +// defaults, so every table in the report inherits the font without each call +// naming it. +applyPlugin(jsPDF); + +type AutoTableDocument = jsPDF & { + autoTableSetDefaults(defaults: UserOptions): jsPDF; +}; + +/** Base64 per file, read once per process. */ +const fontCache = new Map(); + +function fontBase64(file: string): string { + let encoded = fontCache.get(file); + if (encoded === undefined) { + encoded = readFileSync(join(FONT_DIR, file)).toString("base64"); + fontCache.set(file, encoded); + } + return encoded; +} + +/** + * Whether the report needs the Hangul font: a Korean report, or any other + * whose data carries Hangul (a medication name, a note, a lab name). + */ +export function reportNeedsHangul(locale: Locale, data: unknown): boolean { + return locale === "ko" || HANGUL_RE.test(JSON.stringify(data) ?? ""); +} + +/** + * Register the report fonts on one jsPDF instance, make them current, and + * make them the default of every autoTable drawn on it. Callers name + * `PDF_FONT_FAMILY` in every `setFont`. + */ +export function registerReportFonts(doc: jsPDF, withHangul: boolean): void { + const files = withHangul ? HANGUL_FILES : LATIN_FILES; + for (const style of Object.keys(files) as FontStyle[]) { + const file = files[style]; + doc.addFileToVFS(file, fontBase64(file)); + doc.addFont(file, PDF_FONT_FAMILY, style); + } + doc.setFont(PDF_FONT_FAMILY, "normal"); + (doc as AutoTableDocument).autoTableSetDefaults({ + styles: { font: PDF_FONT_FAMILY }, + }); +} diff --git a/src/lib/doctor-report-pdf/fonts/OFL.txt b/src/lib/doctor-report-pdf/fonts/OFL.txt new file mode 100644 index 000000000..9bbcbbf53 --- /dev/null +++ b/src/lib/doctor-report-pdf/fonts/OFL.txt @@ -0,0 +1,95 @@ +Copyright 2022 The Noto Project Authors (https://github.com/notofonts/latin-greek-cyrillic) +Copyright 2022 The Noto Project Authors (https://github.com/notofonts/math) +Copyright 2014-2021 Adobe (http://www.adobe.com/), with Reserved Font Name 'Source' + +This Font Software is licensed under the SIL Open Font License, Version 1.1. +This license is copied below, and is also available with a FAQ at: +https://scripts.sil.org/OFL + + +----------------------------------------------------------- +SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 +----------------------------------------------------------- + +PREAMBLE +The goals of the Open Font License (OFL) are to stimulate worldwide +development of collaborative font projects, to support the font creation +efforts of academic and linguistic communities, and to provide a free and +open framework in which fonts may be shared and improved in partnership +with others. + +The OFL allows the licensed fonts to be used, studied, modified and +redistributed freely as long as they are not sold by themselves. The +fonts, including any derivative works, can be bundled, embedded, +redistributed and/or sold with any software provided that any reserved +names are not used by derivative works. The fonts and derivatives, +however, cannot be released under any other type of license. The +requirement for fonts to remain under this license does not apply +to any document created using the fonts or their derivatives. + +DEFINITIONS +"Font Software" refers to the set of files released by the Copyright +Holder(s) under this license and clearly marked as such. This may +include source files, build scripts and documentation. + +"Reserved Font Name" refers to any names specified as such after the +copyright statement(s). + +"Original Version" refers to the collection of Font Software components as +distributed by the Copyright Holder(s). + +"Modified Version" refers to any derivative made by adding to, deleting, +or substituting -- in part or in whole -- any of the components of the +Original Version, by changing formats or by porting the Font Software to a +new environment. + +"Author" refers to any designer, engineer, programmer, technical +writer or other person who contributed to the Font Software. + +PERMISSION & CONDITIONS +Permission is hereby granted, free of charge, to any person obtaining +a copy of the Font Software, to use, study, copy, merge, embed, modify, +redistribute, and sell modified and unmodified copies of the Font +Software, subject to the following conditions: + +1) Neither the Font Software nor any of its individual components, +in Original or Modified Versions, may be sold by itself. + +2) Original or Modified Versions of the Font Software may be bundled, +redistributed and/or sold with any software, provided that each copy +contains the above copyright notice and this license. These can be +included either as stand-alone text files, human-readable headers or +in the appropriate machine-readable metadata fields within text or +binary files as long as those fields can be easily viewed by the user. + +3) No Modified Version of the Font Software may use the Reserved Font +Name(s) unless explicit written permission is granted by the corresponding +Copyright Holder. This restriction only applies to the primary font name as +presented to the users. + +4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font +Software shall not be used to promote, endorse or advertise any +Modified Version, except to acknowledge the contribution(s) of the +Copyright Holder(s) and the Author(s) or with their explicit written +permission. + +5) The Font Software, modified or unmodified, in part or in whole, +must be distributed entirely under this license, and must not be +distributed under any other license. The requirement for fonts to +remain under this license does not apply to any document created +using the Font Software. + +TERMINATION +This license becomes null and void if any of the above conditions are +not met. + +DISCLAIMER +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT +OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE +COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL +DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM +OTHER DEALINGS IN THE FONT SOFTWARE. diff --git a/src/lib/doctor-report-pdf/fonts/ReportSans-Bold.ttf b/src/lib/doctor-report-pdf/fonts/ReportSans-Bold.ttf new file mode 100644 index 000000000..fd7041ade Binary files /dev/null and b/src/lib/doctor-report-pdf/fonts/ReportSans-Bold.ttf differ diff --git a/src/lib/doctor-report-pdf/fonts/ReportSans-Italic.ttf b/src/lib/doctor-report-pdf/fonts/ReportSans-Italic.ttf new file mode 100644 index 000000000..e6066aa1d Binary files /dev/null and b/src/lib/doctor-report-pdf/fonts/ReportSans-Italic.ttf differ diff --git a/src/lib/doctor-report-pdf/fonts/ReportSans-Regular.ttf b/src/lib/doctor-report-pdf/fonts/ReportSans-Regular.ttf new file mode 100644 index 000000000..d399a8ed9 Binary files /dev/null and b/src/lib/doctor-report-pdf/fonts/ReportSans-Regular.ttf differ diff --git a/src/lib/doctor-report-pdf/fonts/ReportSansKR-Bold.ttf b/src/lib/doctor-report-pdf/fonts/ReportSansKR-Bold.ttf new file mode 100644 index 000000000..1debef876 Binary files /dev/null and b/src/lib/doctor-report-pdf/fonts/ReportSansKR-Bold.ttf differ diff --git a/src/lib/doctor-report-pdf/fonts/ReportSansKR-Regular.ttf b/src/lib/doctor-report-pdf/fonts/ReportSansKR-Regular.ttf new file mode 100644 index 000000000..38fbf61db Binary files /dev/null and b/src/lib/doctor-report-pdf/fonts/ReportSansKR-Regular.ttf differ diff --git a/src/lib/doctor-report-pdf/footer-section.ts b/src/lib/doctor-report-pdf/footer-section.ts index e28d8f597..d63102ee4 100644 --- a/src/lib/doctor-report-pdf/footer-section.ts +++ b/src/lib/doctor-report-pdf/footer-section.ts @@ -1,3 +1,4 @@ +import { PDF_FONT_FAMILY } from "./fonts"; import type { jsPDF } from "jspdf"; import { pdfCursorState, @@ -14,7 +15,7 @@ export function buildReportFooter( const addFooter = (pageDoc: jsPDF) => { const pageHeight = pageDoc.internal.pageSize.getHeight(); pageDoc.setFontSize(7); - pageDoc.setFont("helvetica", "italic"); + pageDoc.setFont(PDF_FONT_FAMILY, "italic"); pageDoc.setTextColor(140, 140, 140); pageDoc.text(t("doctorReport.footerDisclaimer1"), margin, pageHeight - 14); pageDoc.text(t("doctorReport.footerDisclaimer2"), margin, pageHeight - 10); diff --git a/src/lib/doctor-report-pdf/header-profile-section.ts b/src/lib/doctor-report-pdf/header-profile-section.ts index ac9683a83..27975a81b 100644 --- a/src/lib/doctor-report-pdf/header-profile-section.ts +++ b/src/lib/doctor-report-pdf/header-profile-section.ts @@ -1,3 +1,4 @@ +import { PDF_FONT_FAMILY } from "./fonts"; import { buildClinicalSummaryLines } from "./clinical-summary"; import { pdfCursorState, @@ -25,13 +26,13 @@ export function buildHeaderProfileSection( let y = state.y; doc.setFontSize(22); - doc.setFont("helvetica", "bold"); + doc.setFont(PDF_FONT_FAMILY, "bold"); doc.setTextColor(30, 30, 30); doc.text(t("doctorReport.title"), margin, y); y += 8; doc.setFontSize(10); - doc.setFont("helvetica", "normal"); + doc.setFont(PDF_FONT_FAMILY, "normal"); doc.setTextColor(100, 100, 100); doc.text(t("doctorReport.subtitle"), margin, y); y += 6; @@ -42,7 +43,7 @@ export function buildHeaderProfileSection( // one — keeps the cover compact for self-archive use. if (data.practiceName) { doc.setFontSize(11); - doc.setFont("helvetica", "bold"); + doc.setFont(PDF_FONT_FAMILY, "bold"); doc.setTextColor(30, 30, 30); doc.text( `${t("doctorReport.practiceLabel")}: ${data.practiceName}`, @@ -50,7 +51,7 @@ export function buildHeaderProfileSection( y, ); y += 6; - doc.setFont("helvetica", "normal"); + doc.setFont(PDF_FONT_FAMILY, "normal"); } doc.setDrawColor(200, 200, 200); @@ -131,12 +132,12 @@ export function buildHeaderProfileSection( // line 4.5 mm + trailing gap). y = ensureSpace(y, 5 + 4.5 + 4); doc.setFontSize(12); - doc.setFont("helvetica", "bold"); + doc.setFont(PDF_FONT_FAMILY, "bold"); doc.setTextColor(30, 30, 30); doc.text(t("doctorReport.summaryTitle"), margin, y); y += 5; doc.setFontSize(9); - doc.setFont("helvetica", "normal"); + doc.setFont(PDF_FONT_FAMILY, "normal"); doc.setTextColor(60, 60, 60); for (const line of summaryLines) { const wrapped = doc.splitTextToSize(line, pageWidth - 2 * margin); diff --git a/src/lib/doctor-report-pdf/measurements-charts-section.ts b/src/lib/doctor-report-pdf/measurements-charts-section.ts index 9f559f1a5..700fbabd6 100644 --- a/src/lib/doctor-report-pdf/measurements-charts-section.ts +++ b/src/lib/doctor-report-pdf/measurements-charts-section.ts @@ -1,3 +1,4 @@ +import { PDF_FONT_FAMILY } from "./fonts"; import type { jsPDF } from "jspdf"; import { GLUCOSE_CONTEXT_BUCKETS } from "@/lib/glucose"; import autoTable from "jspdf-autotable"; @@ -82,7 +83,7 @@ function drawSparkline( const axisLabelHeight = 4.5; doc.setFontSize(9); - doc.setFont("helvetica", "bold"); + doc.setFont(PDF_FONT_FAMILY, "bold"); doc.setTextColor(30, 30, 30); doc.text(label, x, y + labelHeight - 1.5); @@ -141,7 +142,7 @@ function drawSparkline( // Min/max labels in the right gutter. doc.setFontSize(7); - doc.setFont("helvetica", "normal"); + doc.setFont(PDF_FONT_FAMILY, "normal"); doc.setTextColor(120, 120, 120); const unitSuffix = unit ? ` ${unit}` : ""; doc.text(`${num(max, 1)}${unitSuffix}`, x + chartWidth + 2, chartTop + 2); @@ -243,7 +244,7 @@ function drawScreenerTranslationNotes( if (notes.length === 0) return startY; let y = startY; doc.setFontSize(8); - doc.setFont("helvetica", "italic"); + doc.setFont(PDF_FONT_FAMILY, "italic"); doc.setTextColor(110, 110, 110); for (const note of notes) { for (const line of doc.splitTextToSize( @@ -282,7 +283,7 @@ export function buildMeasurementsChartsSection( // Vitals heading — keep it with the start of its table. y = ensureSpace(y, 6 + 18); doc.setFontSize(14); - doc.setFont("helvetica", "bold"); + doc.setFont(PDF_FONT_FAMILY, "bold"); doc.setTextColor(30, 30, 30); doc.text(t("doctorReport.vitalsTitle"), margin, y); y += 6; @@ -429,7 +430,7 @@ export function buildMeasurementsChartsSection( // Keep the heading with the start of its table. y = ensureSpace(y, 6 + 14); doc.setFontSize(12); - doc.setFont("helvetica", "bold"); + doc.setFont(PDF_FONT_FAMILY, "bold"); doc.setTextColor(30, 30, 30); doc.text(t("doctorReport.glucoseClinical.title"), margin, y); y += 6; @@ -472,7 +473,7 @@ export function buildMeasurementsChartsSection( // clinician reads the panel as a direction-of-travel, not a CGM AGP. if (clinical.isSpotEstimate) { doc.setFontSize(8); - doc.setFont("helvetica", "italic"); + doc.setFont(PDF_FONT_FAMILY, "italic"); doc.setTextColor(110, 110, 110); const caveat = doc.splitTextToSize( t("doctorReport.glucoseClinical.spotCaveat"), @@ -504,7 +505,7 @@ export function buildMeasurementsChartsSection( // Keep the heading with at least the first chart. y = ensureSpace(y, 6 + SPARKLINE_HEIGHT + 4); doc.setFontSize(12); - doc.setFont("helvetica", "bold"); + doc.setFont(PDF_FONT_FAMILY, "bold"); doc.setTextColor(30, 30, 30); doc.text(t("doctorReport.chartsTitle"), margin, y); y += 6; @@ -561,12 +562,12 @@ export function buildMeasurementsChartsSection( if (sysStat && diaStat) { y = ensureSpace(y, 5 + 8); doc.setFontSize(12); - doc.setFont("helvetica", "bold"); + doc.setFont(PDF_FONT_FAMILY, "bold"); doc.text(t("doctorReport.bpClassificationTitle"), margin, y); y += 5; doc.setFontSize(9); - doc.setFont("helvetica", "normal"); + doc.setFont(PDF_FONT_FAMILY, "normal"); const bpClass = t(getBpClassificationKey(sysStat.avg, diaStat.avg)); doc.text( `${t("doctorReport.avgBp")}: ${num(sysStat.avg, 0)}/${num(diaStat.avg, 0)} mmHg — ${t("doctorReport.classification")}: ${bpClass}`, @@ -579,12 +580,12 @@ export function buildMeasurementsChartsSection( if (data.bmi) { y = ensureSpace(y, 5 + 8); doc.setFontSize(12); - doc.setFont("helvetica", "bold"); + doc.setFont(PDF_FONT_FAMILY, "bold"); doc.text(t("doctorReport.bmiTitle"), margin, y); y += 5; doc.setFontSize(9); - doc.setFont("helvetica", "normal"); + doc.setFont(PDF_FONT_FAMILY, "normal"); doc.text( t("doctorReport.bmiRow", { bmi: num(data.bmi, 1), @@ -603,11 +604,11 @@ export function buildMeasurementsChartsSection( if (loggedGlucose.length > 0) { y = ensureSpace(y, 5 + 5 + 3); doc.setFontSize(12); - doc.setFont("helvetica", "bold"); + doc.setFont(PDF_FONT_FAMILY, "bold"); doc.text(t("doctorReport.glucoseClassificationTitle"), margin, y); y += 5; doc.setFontSize(9); - doc.setFont("helvetica", "normal"); + doc.setFont(PDF_FONT_FAMILY, "normal"); for (const ctx of loggedGlucose) { const s = data.glucoseStats[ctx]; if (!s) continue; diff --git a/src/lib/doctor-report-pdf/medication-mood-wellness-section.ts b/src/lib/doctor-report-pdf/medication-mood-wellness-section.ts index 55a42ae4a..9976a2703 100644 --- a/src/lib/doctor-report-pdf/medication-mood-wellness-section.ts +++ b/src/lib/doctor-report-pdf/medication-mood-wellness-section.ts @@ -1,3 +1,4 @@ +import { PDF_FONT_FAMILY } from "./fonts"; import type { jsPDF } from "jspdf"; import { dateOnlyAtNoonUtc } from "@/lib/tz/date-only"; import autoTable from "jspdf-autotable"; @@ -66,7 +67,7 @@ export function buildMedicationMoodWellnessSection( ); doc.setFontSize(14); - doc.setFont("helvetica", "bold"); + doc.setFont(PDF_FONT_FAMILY, "bold"); doc.text(t("doctorReport.complianceTitle"), margin, y); y += 6; @@ -154,12 +155,12 @@ export function buildMedicationMoodWellnessSection( y = ensureSpace(y, 6 + 6 + 5); doc.setFontSize(14); - doc.setFont("helvetica", "bold"); + doc.setFont(PDF_FONT_FAMILY, "bold"); doc.text(t("doctorReport.glp1Title"), margin, y); y += 6; doc.setFontSize(9); - doc.setFont("helvetica", "normal"); + doc.setFont(PDF_FONT_FAMILY, "normal"); if ( data.glp1.weightDeltaKg !== null && data.glp1.weightStartKg !== null && @@ -181,11 +182,11 @@ export function buildMedicationMoodWellnessSection( // Keep the med name with at least its first detail line. y = ensureSpace(y, 5 + 5); doc.setFontSize(11); - doc.setFont("helvetica", "bold"); + doc.setFont(PDF_FONT_FAMILY, "bold"); doc.text(med.name, margin, y); y += 5; doc.setFontSize(9); - doc.setFont("helvetica", "normal"); + doc.setFont(PDF_FONT_FAMILY, "normal"); if (med.currentDose) { doc.text( t("doctorReport.glp1CurrentDose", { @@ -272,7 +273,7 @@ export function buildMedicationMoodWellnessSection( // block. y = ensureSpace(y, 5 + estimateTableHeight(seRows.length)); doc.setFontSize(11); - doc.setFont("helvetica", "bold"); + doc.setFont(PDF_FONT_FAMILY, "bold"); doc.text(t("doctorReport.glp1SideEffectsTitle"), margin, y); y += 5; autoTable(doc, { @@ -316,12 +317,12 @@ export function buildMedicationMoodWellnessSection( y = ensureSpace(y, 6 + 6 + estimateTableHeight(Math.min(moodRowCount, 1))); doc.setFontSize(14); - doc.setFont("helvetica", "bold"); + doc.setFont(PDF_FONT_FAMILY, "bold"); doc.text(t("doctorReport.moodTitle"), margin, y); y += 6; doc.setFontSize(9); - doc.setFont("helvetica", "normal"); + doc.setFont(PDF_FONT_FAMILY, "normal"); doc.text( t("doctorReport.moodSummary", { avg: num(data.mood.avg, 1), @@ -399,12 +400,12 @@ export function buildMedicationMoodWellnessSection( y = ensureSpace(y, 6 + 6 + 6 + estimateTableHeight(1)); doc.setFontSize(14); - doc.setFont("helvetica", "bold"); + doc.setFont(PDF_FONT_FAMILY, "bold"); doc.text(t("doctorReport.wellnessTitle"), margin, y); y += 6; doc.setFontSize(8); - doc.setFont("helvetica", "italic"); + doc.setFont(PDF_FONT_FAMILY, "italic"); const wellnessDisclaimer = doc.splitTextToSize( t("doctorReport.wellnessDisclaimer"), pageWidth - margin * 2, @@ -414,7 +415,7 @@ export function buildMedicationMoodWellnessSection( y += 4; } y += 2; - doc.setFont("helvetica", "normal"); + doc.setFont(PDF_FONT_FAMILY, "normal"); autoTable(doc, { startY: y, @@ -463,13 +464,13 @@ export function buildMedicationMoodWellnessSection( y = ensureSpace(y, 6 + 6 + estimateTableHeight(1)); doc.setFontSize(14); - doc.setFont("helvetica", "bold"); + doc.setFont(PDF_FONT_FAMILY, "bold"); doc.text(t("doctorReport.cycleTitle"), margin, y); y += 6; // Summary line: LMP, avg length ± variability, avg period, phase. doc.setFontSize(9); - doc.setFont("helvetica", "normal"); + doc.setFont(PDF_FONT_FAMILY, "normal"); const summaryBits: string[] = []; if (cyc.lastPeriodStart) { summaryBits.push(