mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-10 13:22:14 +00:00
Merge branch 'fix/pdf-unicode-font' into release/v1.46.6
This commit is contained in:
commit
70cc753c49
20 files changed
+590
-175
No files matched your search
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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 <download-dir> <output-dir>
|
||||
|
||||
where <download-dir> 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]))
|
||||
@@ -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<string> {
|
||||
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");
|
||||
});
|
||||
});
|
||||
@@ -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, 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;
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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)) {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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<FontStyle, string> = {
|
||||
normal: "ReportSans-Regular.ttf",
|
||||
bold: "ReportSans-Bold.ttf",
|
||||
italic: "ReportSans-Italic.ttf",
|
||||
};
|
||||
|
||||
const HANGUL_FILES: Record<FontStyle, string> = {
|
||||
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<string, string>();
|
||||
|
||||
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 },
|
||||
});
|
||||
}
|
||||
@@ -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.
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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(
|
||||
|
||||
Reference in new issue
Block a user