+ shownUnit ? text : text.replace(/ +([.,])/g, "$1").replace(/ {2,}/g, " ");
+ const slopeLine = tidy(
+ t(`labs.trend.slope.${direction}`, {
+ amount: per(trend.slopePerYear),
+ unit: shownUnit,
+ n: trend.n,
+ since: fmt.dateShortSmartCalendar(trend.firstAt),
+ }),
+ );
const projectionLine =
trend.projection === "exits" && trend.exitDate
? t(`labs.trend.projection.exits.${trend.exitBound ?? "high"}`, {
@@ -85,11 +93,13 @@ export function LabTrendCard({
) : null}
- {t("labs.trend.interval", {
- lo: fmt.number(trend.slopeCi95[0], 2),
- hi: fmt.number(trend.slopeCi95[1], 2),
- unit,
- })}
+ {tidy(
+ t("labs.trend.interval", {
+ lo: fmt.number(trend.slopeCi95[0], 2),
+ hi: fmt.number(trend.slopeCi95[1], 2),
+ unit: shownUnit,
+ }),
+ )}
diff --git a/src/components/labs/labs-settings.tsx b/src/components/labs/labs-settings.tsx
index 12ca02e37..709f550b5 100644
--- a/src/components/labs/labs-settings.tsx
+++ b/src/components/labs/labs-settings.tsx
@@ -46,6 +46,7 @@ import {
import { BiomarkerManager } from "./biomarker-manager";
import type { BiomarkerDto, BiomarkerListResponse } from "./types";
import { useLabsLocalOcr, useUpdateLabsLocalOcr } from "./use-ocr-extract";
+import { labUnitLabel } from "@/lib/labs/dimensionless";
export function LabsSettings() {
const { t } = useTranslations();
@@ -61,7 +62,7 @@ export function LabsSettings() {
const reorderItems: ReorderItem[] = ordered.map((m) => ({
id: m.id,
name: m.name,
- secondary: m.panel ? `${m.unit} · ${m.panel}` : m.unit,
+ secondary: [labUnitLabel(m.unit, t), m.panel].filter(Boolean).join(" · "),
}));
const manual = prefs.sortDir === "manual";
diff --git a/src/components/labs/marker-match-field.tsx b/src/components/labs/marker-match-field.tsx
index 399bdac2a..cd1521bd0 100644
--- a/src/components/labs/marker-match-field.tsx
+++ b/src/components/labs/marker-match-field.tsx
@@ -21,6 +21,7 @@ import { Badge } from "@/components/ui/badge";
import { Label } from "@/components/ui/label";
import { NativeSelect } from "@/components/ui/native-select";
import { useTranslations } from "@/lib/i18n/context";
+import { labUnitLabel } from "@/lib/labs/dimensionless";
export interface MarkerSuggestionDto {
biomarkerId: string;
@@ -110,7 +111,7 @@ export function MarkerMatchField({
{t("labs.match.suggestionOption", {
name: suggestion.name,
- unit: suggestion.unit,
+ unit: labUnitLabel(suggestion.unit, t),
})}
))}
diff --git a/src/components/labs/source-range-note.tsx b/src/components/labs/source-range-note.tsx
index d5e3dde2c..b5a7f195e 100644
--- a/src/components/labs/source-range-note.tsx
+++ b/src/components/labs/source-range-note.tsx
@@ -5,6 +5,7 @@ import { useTranslations } from "@/lib/i18n/context";
import type { LabResultDto } from "./types";
import { useLabNumber } from "./use-lab-format";
+import { withLabUnit } from "@/lib/labs/dimensionless";
/**
* The reference window a reading's own report printed, shown wherever that
@@ -54,11 +55,14 @@ export function SourceRangeNote({
// then stated from the converted bounds, in the marker's unit.
const inForce =
reading.sourceReferenceText ??
- `${formatReferenceRange(
- reading.referenceLow,
- reading.referenceHigh,
- labNumber,
- )} ${reading.unit}`.trim();
+ withLabUnit(
+ formatReferenceRange(
+ reading.referenceLow,
+ reading.referenceHigh,
+ labNumber,
+ ),
+ reading.unit,
+ );
const catalog = formatReferenceRange(
reading.catalogReferenceLow,
@@ -77,7 +81,7 @@ export function SourceRangeNote({
: null}
{reading.referenceDivergesFromCatalog && catalog
? ` · ${t("labs.sourceRange.catalogDiffers", {
- range: `${catalog} ${reading.unit}`.trim(),
+ range: withLabUnit(catalog, reading.unit),
})}`
: null}
diff --git a/src/components/timeline/lab-lines.tsx b/src/components/timeline/lab-lines.tsx
index 619f627bc..833a4a7bc 100644
--- a/src/components/timeline/lab-lines.tsx
+++ b/src/components/timeline/lab-lines.tsx
@@ -38,6 +38,7 @@ import {
formatDayMonthYear,
} from "./timeline-dates";
import { seriesNameLines, type TimelineLayout } from "./timeline-geometry";
+import { withLabUnit } from "@/lib/labs/dimensionless";
/** "18 ng/mL", every digit the reading holds, in the reader's locale. */
export type LabValueText = (
@@ -50,7 +51,7 @@ export function useLabValueText(): LabValueText {
const labNumber = useLabNumber();
return useCallback(
(value, sourceValue, unit) =>
- `${labNumber(value, sourceValue)} ${unit}`.trim(),
+ withLabUnit(labNumber(value, sourceValue), unit),
[labNumber],
);
}
@@ -84,7 +85,7 @@ export function useLabReference(): (
(v) => labNumber(v),
);
return range
- ? t("timeline.labs.reference", { range: `${range} ${unit}`.trim() })
+ ? t("timeline.labs.reference", { range: withLabUnit(range, unit) })
: null;
},
[t, labNumber],
diff --git a/src/lib/ai/coach/labs-snapshot.ts b/src/lib/ai/coach/labs-snapshot.ts
index 85e102617..5407b90aa 100644
--- a/src/lib/ai/coach/labs-snapshot.ts
+++ b/src/lib/ai/coach/labs-snapshot.ts
@@ -67,6 +67,7 @@ import { resolveLabFields } from "@/lib/labs/serialise";
import { sanitizeForPrompt } from "@/lib/insights/sanitize";
import { prisma } from "@/lib/db";
import { isModuleEnabled } from "@/lib/modules/gate";
+import { labUnitText } from "@/lib/labs/dimensionless";
/** Cap on distinct biomarkers in the block (newest reading first). */
const MAX_BIOMARKERS = 24;
@@ -120,7 +121,7 @@ export interface CoachLabReading {
value: number | null;
/** Qualitative result text ("negativ" / …); null for a numeric row. */
valueText: string | null;
- /** Resolved unit; empty for a qualitative marker. */
+ /** Resolved unit; empty for a qualitative marker and for a ratio. */
unit: string;
referenceLow: number | null;
referenceHigh: number | null;
@@ -359,7 +360,9 @@ export async function buildLabsSnapshotBlock(
: null,
value: row.value,
valueText: row.valueText ? sanitizeValueText(row.valueText) : null,
- unit: sanitizeForPrompt(resolved.unit, MAX_UNIT_CHARS),
+ // A value without a unit is stored as "1"; the model is shown none,
+ // so it never writes "3.6 1".
+ unit: sanitizeForPrompt(labUnitText(resolved.unit), MAX_UNIT_CHARS),
referenceLow: resolved.referenceLow,
referenceHigh: resolved.referenceHigh,
// A qualitative row has nothing to compare against the bounds →
diff --git a/src/lib/day/records.ts b/src/lib/day/records.ts
index 31be1a0bb..ec2073c8e 100644
--- a/src/lib/day/records.ts
+++ b/src/lib/day/records.ts
@@ -47,6 +47,7 @@ import {
readWorkoutsAroundRange,
WORKOUT_PICK_SELECT,
} from "@/lib/workouts/canonical-read";
+import { labUnitText } from "@/lib/labs/dimensionless";
/** Everything a section reader needs about the day. */
export interface DayFrame {
@@ -717,10 +718,9 @@ async function labsPart(frame: DayFrame): Promise
{
title: row.analyte,
meta:
row.value !== null
- ? // Every recorded digit, without the float noise a converted
- // reading carries (the labs page prints the same figure).
+ ? // Every recorded digit, minus a converted reading's float noise.
// eslint-disable-next-line healthlog/no-unlocalised-decimal -- the day contract's meta is wire text; the lab figure keeps the notation it was stored in
- `${formatRecordedNumber(row.value, (v, digits) => v.toFixed(digits), row.sourceValue)} ${row.unit}`.trim()
+ `${formatRecordedNumber(row.value, (v, digits) => v.toFixed(digits), row.sourceValue)} ${labUnitText(row.unit)}`.trim()
: (row.valueText ?? null),
note: openText(row.noteEncrypted, "lab note"),
href: row.biomarkerId ? `/labs/${row.biomarkerId}` : "/labs",
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 02538766d..294399161 100644
--- a/src/lib/doctor-report-pdf/clinical-records-notes-section.ts
+++ b/src/lib/doctor-report-pdf/clinical-records-notes-section.ts
@@ -16,6 +16,7 @@ import {
type DoctorReportPdfCursorState,
type DoctorReportPdfRenderContext,
} from "./render-context";
+import { withLabUnit } from "@/lib/labs/dimensionless";
export function buildClinicalRecordsNotesSection(
context: DoctorReportPdfRenderContext,
@@ -109,7 +110,7 @@ export function buildClinicalRecordsNotesSection(
lr.panel ? `${lr.analyte} (${lr.panel})` : lr.analyte,
isQualitative
? (lr.valueText ?? "")
- : `${labNum(lr.value as number, lr.sourceValue)} ${lr.unit}`.trim(),
+ : withLabUnit(labNum(lr.value as number, lr.sourceValue), lr.unit),
isQualitative ? "—" : referenceCell(lr),
isQualitative
? ""
@@ -166,10 +167,10 @@ export function buildClinicalRecordsNotesSection(
y += 4;
for (const lr of diverging) {
doc.text(
- `* ${lr.analyte}: ${t("doctorReport.labsCatalogRangeLabel")} ${rangeText(
- lr.catalogReferenceLow,
- lr.catalogReferenceHigh,
- )} ${lr.unit}`.trim(),
+ `* ${lr.analyte}: ${t("doctorReport.labsCatalogRangeLabel")} ${withLabUnit(
+ rangeText(lr.catalogReferenceLow, lr.catalogReferenceHigh),
+ lr.unit,
+ )}`.trim(),
margin,
y,
);
diff --git a/src/lib/insights/biomarker-status.ts b/src/lib/insights/biomarker-status.ts
index 75c4c7f8b..0306ccf37 100644
--- a/src/lib/insights/biomarker-status.ts
+++ b/src/lib/insights/biomarker-status.ts
@@ -53,6 +53,7 @@ import {
import { annotate } from "@/lib/logging/context";
import { userDayKey } from "@/lib/tz/resolver";
import { sanitizeForPrompt } from "@/lib/insights/sanitize";
+import { labUnitText } from "@/lib/labs/dimensionless";
export type BiomarkerStatusResult = {
hasProvider: boolean;
@@ -321,7 +322,7 @@ function biomarkerSnapshot(args: {
// position below, so both are scrubbed exactly like the coach
// labs-snapshot scrubs the same fields.
const safeMarkerName = sanitizeForPrompt(marker.name, 120);
- const safeMarkerUnit = sanitizeForPrompt(marker.unit, 40);
+ const safeMarkerUnit = sanitizeForPrompt(labUnitText(marker.unit), 40);
const snapshot = {
locale,
diff --git a/src/lib/jobs/reaction-line-labs.ts b/src/lib/jobs/reaction-line-labs.ts
index 8d71e8a37..d164e0c5a 100644
--- a/src/lib/jobs/reaction-line-labs.ts
+++ b/src/lib/jobs/reaction-line-labs.ts
@@ -8,6 +8,7 @@
import { fenceUserText } from "@/lib/ai/coach/data-fence";
import { prisma } from "@/lib/db";
import { isModuleEnabled } from "@/lib/modules/gate";
+import { labUnitText } from "@/lib/labs/dimensionless";
/** Up to eight results of the panel, fenced; null when there is nothing to say. */
export async function labsPanelEvidence(
@@ -33,7 +34,8 @@ export async function labsPanelEvidence(
lab.value === null
? fenceUserText(lab.valueText ?? "not reported")
: String(lab.value);
- const unit = lab.unit ? ` ${fenceUserText(lab.unit)}` : "";
+ const shown = labUnitText(lab.unit);
+ const unit = shown ? ` ${fenceUserText(shown)}` : "";
return `- Newly arrived lab result: ${fenceUserText(lab.analyte)} ${value}${unit}.`;
})
.join("\n");
diff --git a/src/lib/mcp/doctor-visit-summary.ts b/src/lib/mcp/doctor-visit-summary.ts
index 6b00444d6..41c3aaaf1 100644
--- a/src/lib/mcp/doctor-visit-summary.ts
+++ b/src/lib/mcp/doctor-visit-summary.ts
@@ -19,6 +19,7 @@
import type { DoctorReportData } from "@/lib/doctor-report-data";
import { getMetricStatusMeta } from "@/lib/insights/metric-status-registry";
import { fenceUserText } from "@/lib/ai/coach/data-fence";
+import { labUnitText } from "@/lib/labs/dimensionless";
/**
* Fallback units for the headline specialised metric types the generic
@@ -91,7 +92,7 @@ export function summariseForVisit(
panel: l.panel,
value: l.value,
valueText: l.valueText,
- unit: l.unit,
+ unit: labUnitText(l.unit),
referenceLow: l.referenceLow,
referenceHigh: l.referenceHigh,
takenAt: l.takenAt,
diff --git a/src/lib/mcp/prompts.ts b/src/lib/mcp/prompts.ts
index 9507775c2..cdba743fd 100644
--- a/src/lib/mcp/prompts.ts
+++ b/src/lib/mcp/prompts.ts
@@ -34,6 +34,7 @@ import { detectDerivedBriefingSignals } from "@/lib/insights/derived-briefing";
import { buildBriefingIllnessCycleContext } from "@/lib/insights/illness-cycle-briefing";
import { resolveUserTimezone } from "@/lib/tz/resolver";
import type { McpAuthContext } from "./auth";
+import { labUnitText } from "@/lib/labs/dimensionless";
const MS_PER_DAY = 24 * 60 * 60 * 1000;
@@ -254,7 +255,7 @@ async function collectLabTrajectories(
const history = readings.slice(0, 12).map((r) => ({
value: r.value,
valueText: r.valueText,
- unit: r.unit,
+ unit: labUnitText(r.unit),
referenceLow: r.referenceLow,
referenceHigh: r.referenceHigh,
takenAt: r.takenAt.toISOString(),
@@ -263,7 +264,7 @@ async function collectLabTrajectories(
return {
analyte: latest.analyte,
panel: latest.panel,
- unit: latest.unit,
+ unit: labUnitText(latest.unit),
readingsOnFile: readings.length,
latest: history[0],
history,
diff --git a/src/lib/mcp/tools/get-labs.ts b/src/lib/mcp/tools/get-labs.ts
index f9c3dec33..7c233a298 100644
--- a/src/lib/mcp/tools/get-labs.ts
+++ b/src/lib/mcp/tools/get-labs.ts
@@ -2,12 +2,17 @@ import { z } from "zod/v4";
import { fenceUserText } from "@/lib/ai/coach/data-fence";
import { resolveMarkerForRead } from "@/lib/labs/biomarker-resolve";
-import { getLabHistory, LAB_HISTORY_MAX_LIMIT } from "@/lib/mcp/rich-reads";
+import {
+ getLabHistory,
+ LAB_HISTORY_MAX_LIMIT,
+ type LabHistoryResult,
+} from "@/lib/mcp/rich-reads";
import { decodeOffsetCursor } from "@/lib/mcp/pagination";
import type { McpAuthContext } from "../auth";
import { coachReadOutput } from "./output-shapes";
import { runCoachTool, whenModuleOn } from "./shared";
import { READ_ONLY_ANNOTATIONS, type McpToolDefinition } from "./types";
+import { labUnitText } from "@/lib/labs/dimensionless";
/** Output schema for `get_labs` — latest-per-biomarker OR a paginated history. */
const getLabsOutput: z.ZodRawShape = {
@@ -59,6 +64,19 @@ function fenceLabText(value: unknown): unknown {
);
}
+/**
+ * A value without a unit (a ratio, INR) is stored as the UCUM unity "1". The
+ * model is shown no unit for it, as the Coach's lab block does, so it never
+ * writes "3.6 1".
+ */
+function withShownUnits(result: LabHistoryResult): LabHistoryResult {
+ if (!result.readings) return result;
+ return {
+ ...result,
+ readings: result.readings.map((r) => ({ ...r, unit: labUnitText(r.unit) })),
+ };
+}
+
/** `get_labs` behind its module gate: latest-per-biomarker or a history page. */
function readLabs(
ctx: McpAuthContext,
@@ -78,7 +96,7 @@ function readLabs(
analyte,
offset: decodeOffsetCursor(args.cursor),
limit: LAB_HISTORY_MAX_LIMIT,
- });
+ }).then(withShownUnits);
}
// Default: latest reading per biomarker via the Coach labs read. A name that
// resolves to one of the user's markers (an alias or a catalog abbreviation
diff --git a/src/lib/mcp/tools/render/citation-text.ts b/src/lib/mcp/tools/render/citation-text.ts
index 9326d5f52..89059d6f1 100644
--- a/src/lib/mcp/tools/render/citation-text.ts
+++ b/src/lib/mcp/tools/render/citation-text.ts
@@ -6,6 +6,7 @@
import { fenceUserText } from "@/lib/ai/coach/data-fence";
import type { NutrientsDailyResult } from "@/lib/mcp/nutrients-read";
import type { NutrientUpperLimit } from "@/lib/nutrients/catalog";
+import { labUnitText } from "@/lib/labs/dimensionless";
/** Finite-number guard for the citation summarisers. */
function num(v: unknown): number | null {
@@ -66,7 +67,9 @@ export function plainLabText(rid: string, result: unknown): string {
: typeof reading.valueText === "string"
? reading.valueText
: "—";
- const unit = typeof reading.unit === "string" ? reading.unit : "";
+ // A value without a unit (a ratio, INR) is stored as "1"; it reads as none.
+ const unit =
+ typeof reading.unit === "string" ? labUnitText(reading.unit) : "";
const status =
typeof reading.rangeStatus === "string" ? reading.rangeStatus : "";
const taken =
From e605fe0ae8896f357470142fa905b6b085656ffc Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?=
Date: Mon, 2 Nov 2026 20:28:35 +0100
Subject: [PATCH 11/20] docs(api): describe the dimensionless lab unit on every
lab unit field
The unit on lab results, biomarkers, the scanned-report commit and the
document fact edit now states that "1" means the value has no unit, that
it matches only itself, and that an empty unit stays refused. The lab CSV
import description names the 1 in the unit column.
Refs #1313
---
docs/api/openapi.yaml | 59 ++++++++++++++++++++++--
src/lib/openapi/routes/biomarkers.ts | 4 +-
src/lib/openapi/routes/import.ts | 2 +-
src/lib/openapi/routes/labs.ts | 4 +-
src/lib/validations/biomarkers.ts | 6 ++-
src/lib/validations/inbound-documents.ts | 4 +-
src/lib/validations/labs-ocr.ts | 9 +++-
src/lib/validations/labs.ts | 5 +-
8 files changed, 79 insertions(+), 14 deletions(-)
diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml
index 202e7ec9d..4785a7a6a 100644
--- a/docs/api/openapi.yaml
+++ b/docs/api/openapi.yaml
@@ -15875,10 +15875,11 @@ paths:
checks an hour, the import itself the shared 5-an-hour `import:` bucket. Owner only; refused with
`module.disabled` when the module is switched off. An unknown query parameter is refused with 422, so a misspelt
`dryRun` never writes. Each row's name resolves to the account's marker the way a scanned report's rows do
- (name, alias, catalog name); a new analyte mints one marker from its first row. A numeric result needs a unit;
- one in another unit is converted when the conversion is known and keeps the printed value beside it, otherwise
- the row is refused with `unit_mismatch`. Any other text in the value column is a qualitative result. A date
- without a time is stored as that calendar date. At most 20 000 data rows."
+ (name, alias, catalog name); a new analyte mints one marker from its first row. A numeric result needs a unit:
+ an empty unit cell is refused with `missing_unit`, and `1` (the UCUM unity) states a value without a unit, such
+ as a ratio or INR. A result in another unit is converted when the conversion is known and keeps the printed
+ value beside it, otherwise the row is refused with `unit_mismatch`. Any other text in the value column is a
+ qualitative result. A date without a time is stored as that calendar date. At most 20 000 data rows."
parameters:
- in: query
name: dryRun
@@ -34146,6 +34147,11 @@ components:
minLength: 1
maxLength: 120
unit:
+ description: 'The unit the value is in, free-form as the report prints it (e.g. `mg/dL`). `"1"`, the UCUM unity, means
+ the value has no unit: a ratio or an index such as INR or cholesterol/HDL. It is an explicit choice and a
+ unit of its own: it matches only `"1"`, so a reading without a unit is refused under a marker kept in a real
+ unit and the other way round. Clients show nothing beside such a value. An empty unit on a numeric value is
+ never read as `"1"` and stays refused.'
type: string
minLength: 1
maxLength: 40
@@ -34215,6 +34221,11 @@ components:
minLength: 1
maxLength: 120
unit:
+ description: 'The unit the value is in, free-form as the report prints it (e.g. `mg/dL`). `"1"`, the UCUM unity, means
+ the value has no unit: a ratio or an index such as INR or cholesterol/HDL. It is an explicit choice and a
+ unit of its own: it matches only `"1"`, so a reading without a unit is refused under a marker kept in a real
+ unit and the other way round. Clients show nothing beside such a value. An empty unit on a numeric value is
+ never read as `"1"` and stays refused.'
type: string
minLength: 1
maxLength: 40
@@ -34309,6 +34320,11 @@ components:
minLength: 1
maxLength: 120
unit:
+ description: 'The unit the value is in, free-form as the report prints it (e.g. `mg/dL`). `"1"`, the UCUM unity, means
+ the value has no unit: a ratio or an index such as INR or cholesterol/HDL. It is an explicit choice
+ and a unit of its own: it matches only `"1"`, so a reading without a unit is refused under a marker
+ kept in a real unit and the other way round. Clients show nothing beside such a value. An empty unit
+ on a numeric value is never read as `"1"` and stays refused.'
type: string
minLength: 1
maxLength: 40
@@ -34363,6 +34379,11 @@ components:
type: string
minLength: 1
maxLength: 40
+ description: 'The unit the value is in, free-form as the report prints it (e.g. `mg/dL`). `"1"`, the UCUM unity, means
+ the value has no unit: a ratio or an index such as INR or cholesterol/HDL. It is an explicit choice and a
+ unit of its own: it matches only `"1"`, so a reading without a unit is refused under a marker kept in a real
+ unit and the other way round. Clients show nothing beside such a value. An empty unit on a numeric value is
+ never read as `"1"` and stays refused.'
lowerBound:
type: number
upperBound:
@@ -34392,6 +34413,11 @@ components:
minLength: 1
maxLength: 120
unit:
+ description: 'The unit the value is in, free-form as the report prints it (e.g. `mg/dL`). `"1"`, the UCUM unity, means
+ the value has no unit: a ratio or an index such as INR or cholesterol/HDL. It is an explicit choice and a
+ unit of its own: it matches only `"1"`, so a reading without a unit is refused under a marker kept in a real
+ unit and the other way round. Clients show nothing beside such a value. An empty unit on a numeric value is
+ never read as `"1"` and stays refused.'
type: string
minLength: 1
maxLength: 40
@@ -35759,6 +35785,11 @@ components:
maxLength: 300
- type: "null"
unit:
+ description: 'The unit the value is in, free-form as the report prints it (e.g. `mg/dL`). `"1"`, the UCUM unity, means
+ the value has no unit: a ratio or an index such as INR or cholesterol/HDL. It is an explicit choice and
+ a unit of its own: it matches only `"1"`, so a reading without a unit is refused under a marker kept in
+ a real unit and the other way round. Clients show nothing beside such a value. An empty unit on a
+ numeric value is never read as `"1"` and stays refused.'
anyOf:
- type: string
maxLength: 80
@@ -75275,6 +75306,11 @@ components:
- "null"
unit:
type: string
+ description: 'The unit the value is in, free-form as the report prints it (e.g. `mg/dL`). `"1"`, the UCUM unity, means
+ the value has no unit: a ratio or an index such as INR or cholesterol/HDL. It is an explicit choice and a
+ unit of its own: it matches only `"1"`, so a reading without a unit is refused under a marker kept in a real
+ unit and the other way round. Clients show nothing beside such a value. An empty unit on a numeric value is
+ never read as `"1"` and stays refused.'
converted:
type: boolean
sourceValue:
@@ -75524,6 +75560,11 @@ components:
- "null"
unit:
type: string
+ description: 'The unit the value is in, free-form as the report prints it (e.g. `mg/dL`). `"1"`, the UCUM unity, means
+ the value has no unit: a ratio or an index such as INR or cholesterol/HDL. It is an explicit choice and a
+ unit of its own: it matches only `"1"`, so a reading without a unit is refused under a marker kept in a real
+ unit and the other way round. Clients show nothing beside such a value. An empty unit on a numeric value is
+ never read as `"1"` and stays refused.'
converted:
type: boolean
sourceValue:
@@ -76503,6 +76544,11 @@ components:
type: string
unit:
type: string
+ description: 'The unit the value is in, free-form as the report prints it (e.g. `mg/dL`). `"1"`, the UCUM unity, means
+ the value has no unit: a ratio or an index such as INR or cholesterol/HDL. It is an explicit choice and a
+ unit of its own: it matches only `"1"`, so a reading without a unit is refused under a marker kept in a real
+ unit and the other way round. Clients show nothing beside such a value. An empty unit on a numeric value is
+ never read as `"1"` and stays refused.'
lowerBound:
type:
- number
@@ -76602,6 +76648,11 @@ components:
type: string
unit:
type: string
+ description: 'The unit the value is in, free-form as the report prints it (e.g. `mg/dL`). `"1"`, the UCUM unity, means
+ the value has no unit: a ratio or an index such as INR or cholesterol/HDL. It is an explicit choice and a
+ unit of its own: it matches only `"1"`, so a reading without a unit is refused under a marker kept in a real
+ unit and the other way round. Clients show nothing beside such a value. An empty unit on a numeric value is
+ never read as `"1"` and stays refused.'
lowerBound:
type:
- number
diff --git a/src/lib/openapi/routes/biomarkers.ts b/src/lib/openapi/routes/biomarkers.ts
index fc850c598..893724720 100644
--- a/src/lib/openapi/routes/biomarkers.ts
+++ b/src/lib/openapi/routes/biomarkers.ts
@@ -14,6 +14,8 @@
import type { ZodOpenApiObject } from "zod-openapi";
import { z } from "zod/v4";
+import { LAB_UNIT_DESCRIPTION } from "@/lib/labs/dimensionless";
+
import {
createBiomarkerSchema,
updateBiomarkerSchema,
@@ -54,7 +56,7 @@ const biomarkerRow = z
.object({
id: z.string(),
name: z.string(),
- unit: z.string(),
+ unit: z.string().describe(LAB_UNIT_DESCRIPTION),
lowerBound: z.number().nullable(),
upperBound: z.number().nullable(),
panel: z.string().nullable(),
diff --git a/src/lib/openapi/routes/import.ts b/src/lib/openapi/routes/import.ts
index c22baff64..053cb10a8 100644
--- a/src/lib/openapi/routes/import.ts
+++ b/src/lib/openapi/routes/import.ts
@@ -553,7 +553,7 @@ export const importPaths: NonNullable = {
post: {
tags: ["Import"],
summary: "Import lab results from CSV",
- description: `${RECORD_IMPORT_COMMON} Each row's name resolves to the account's marker the way a scanned report's rows do (name, alias, catalog name); a new analyte mints one marker from its first row. A numeric result needs a unit; one in another unit is converted when the conversion is known and keeps the printed value beside it, otherwise the row is refused with \`unit_mismatch\`. Any other text in the value column is a qualitative result. A date without a time is stored as that calendar date. At most 20 000 data rows.`,
+ description: `${RECORD_IMPORT_COMMON} Each row's name resolves to the account's marker the way a scanned report's rows do (name, alias, catalog name); a new analyte mints one marker from its first row. A numeric result needs a unit: an empty unit cell is refused with \`missing_unit\`, and \`1\` (the UCUM unity) states a value without a unit, such as a ratio or INR. A result in another unit is converted when the conversion is known and keeps the printed value beside it, otherwise the row is refused with \`unit_mismatch\`. Any other text in the value column is a qualitative result. A date without a time is stored as that calendar date. At most 20 000 data rows.`,
requestParams: { query: labsCsvQuerySchema },
requestBody: {
required: true,
diff --git a/src/lib/openapi/routes/labs.ts b/src/lib/openapi/routes/labs.ts
index 8e7527928..279011802 100644
--- a/src/lib/openapi/routes/labs.ts
+++ b/src/lib/openapi/routes/labs.ts
@@ -11,6 +11,8 @@ import { withRelated } from "./relation-links";
import type { ZodOpenApiObject } from "zod-openapi";
import { z } from "zod/v4";
+import { LAB_UNIT_DESCRIPTION } from "@/lib/labs/dimensionless";
+
import {
createLabResultSchema,
labOverviewQuerySchema,
@@ -71,7 +73,7 @@ const labResultRow = z
// exactly one of the two is non-null per row.
value: z.number().nullable(),
valueText: z.string().nullable(),
- unit: z.string(),
+ unit: z.string().describe(LAB_UNIT_DESCRIPTION),
converted: z.boolean(),
sourceValue: z.number().nullable(),
sourceUnit: z.string().nullable(),
diff --git a/src/lib/validations/biomarkers.ts b/src/lib/validations/biomarkers.ts
index fa42d8de9..2841b3f9e 100644
--- a/src/lib/validations/biomarkers.ts
+++ b/src/lib/validations/biomarkers.ts
@@ -15,6 +15,8 @@
*/
import { z } from "zod/v4";
+import { LAB_UNIT_DESCRIPTION } from "@/lib/labs/dimensionless";
+
const requiredText = (max: number) => z.string().trim().min(1).max(max);
const lowerBound = z.number().finite().optional();
@@ -40,7 +42,7 @@ const optionalPanel = z
export const createBiomarkerSchema = z
.object({
name: requiredText(120),
- unit: requiredText(40),
+ unit: requiredText(40).describe(LAB_UNIT_DESCRIPTION),
lowerBound,
upperBound,
context: optionalContext,
@@ -67,7 +69,7 @@ export type CreateBiomarkerInput = z.infer;
export const updateBiomarkerSchema = z
.object({
name: requiredText(120).optional(),
- unit: requiredText(40).optional(),
+ unit: requiredText(40).optional().describe(LAB_UNIT_DESCRIPTION),
lowerBound: lowerBound.or(z.null()),
upperBound: upperBound.or(z.null()),
context: optionalContext.or(z.null()),
diff --git a/src/lib/validations/inbound-documents.ts b/src/lib/validations/inbound-documents.ts
index a66239531..d6c336c26 100644
--- a/src/lib/validations/inbound-documents.ts
+++ b/src/lib/validations/inbound-documents.ts
@@ -25,6 +25,7 @@
*/
import { z } from "zod/v4";
import { locales } from "@/lib/i18n/config";
+import { LAB_UNIT_DESCRIPTION } from "@/lib/labs/dimensionless";
import {
DOCUMENT_BULK_MAX_IDS,
DOCUMENT_CONTENT_INDEX_SOURCES,
@@ -34,7 +35,6 @@ import {
type DocumentContentIndexSourceValue,
type InboundDocumentKindValue,
} from "@/lib/documents/inbound-constants";
-
import type { AiCapabilityState } from "@/lib/ai/capabilities/types";
import { OCR_MAX_ROWS } from "@/lib/validations/labs-ocr";
@@ -443,7 +443,7 @@ const observationEditSchema = z
codeSystem: optCode,
value: z.number().finite().nullable().optional(),
valueText: optText(300),
- unit: optText(80),
+ unit: optText(80).describe(LAB_UNIT_DESCRIPTION),
referenceLow: z.number().finite().nullable().optional(),
referenceHigh: z.number().finite().nullable().optional(),
referenceText: optText(120),
diff --git a/src/lib/validations/labs-ocr.ts b/src/lib/validations/labs-ocr.ts
index 9e65d9a1c..8d92cafbc 100644
--- a/src/lib/validations/labs-ocr.ts
+++ b/src/lib/validations/labs-ocr.ts
@@ -19,6 +19,7 @@
import type { AiCapabilityState } from "@/lib/ai/capabilities/types";
import { z } from "zod/v4";
+import { LAB_UNIT_DESCRIPTION } from "@/lib/labs/dimensionless";
import type { WrittenOutcome } from "@/lib/outcome/written-outcome";
import { validateEntryInstant } from "@/lib/validations/entry-instant";
@@ -257,7 +258,13 @@ export const ocrCommitRowSchema = z
.or(z.literal("").transform(() => undefined)),
value: z.number().finite().optional(),
valueText: z.string().trim().min(1).max(OCR_VALUE_TEXT_MAX).optional(),
- unit: z.string().trim().min(1).max(OCR_UNIT_MAX).optional(),
+ unit: z
+ .string()
+ .trim()
+ .min(1)
+ .max(OCR_UNIT_MAX)
+ .optional()
+ .describe(LAB_UNIT_DESCRIPTION),
referenceLow,
referenceHigh,
/**
diff --git a/src/lib/validations/labs.ts b/src/lib/validations/labs.ts
index d19a4bfe5..8879d2032 100644
--- a/src/lib/validations/labs.ts
+++ b/src/lib/validations/labs.ts
@@ -14,6 +14,7 @@
*/
import { z } from "zod/v4";
+import { LAB_UNIT_DESCRIPTION } from "@/lib/labs/dimensionless";
import { SOURCE_REFERENCE_TEXT_MAX } from "@/lib/labs/parse-reference-range";
import { validateEntryInstant } from "@/lib/validations/entry-instant";
import { isoDateTime } from "@/lib/validations/datetime";
@@ -123,7 +124,7 @@ export const createLabResultSchema = z
// (`value`) OR qualitative (`valueText`), never both, never neither.
value: z.number().finite().optional(),
valueText,
- unit: requiredText(40).optional(),
+ unit: requiredText(40).optional().describe(LAB_UNIT_DESCRIPTION),
referenceLow,
referenceHigh,
sourceReferenceLow,
@@ -208,7 +209,7 @@ export const updateLabResultSchema = z
// row's type (numeric ↔ qualitative) is intentionally not supported here —
// delete and re-add — so an omitted key leaves the row's existing type.
valueText: requiredText(120).optional(),
- unit: requiredText(40).optional(),
+ unit: requiredText(40).optional().describe(LAB_UNIT_DESCRIPTION),
referenceLow: referenceLow.or(z.null()),
referenceHigh: referenceHigh.or(z.null()),
sourceReferenceLow: sourceReferenceLow.or(z.null()),
From a822aa5a0efd2300591bbf178697a293312e7276 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?=
Date: Mon, 2 Nov 2026 20:31:33 +0100
Subject: [PATCH 12/20] fix(documents): send a read that states no unit itself
to review
A unit of "1" is the explicit no-unit choice a person makes. When a
document read returns it on its own, the value is staged for review
instead of passing straight through, so a unit the read dropped never
lands as a dimensionless value.
---
src/lib/documents/__tests__/extract.test.ts | 28 +++++++++++++++++++++
src/lib/documents/extract.ts | 8 +++++-
2 files changed, 35 insertions(+), 1 deletion(-)
diff --git a/src/lib/documents/__tests__/extract.test.ts b/src/lib/documents/__tests__/extract.test.ts
index a5900338c..a637df428 100644
--- a/src/lib/documents/__tests__/extract.test.ts
+++ b/src/lib/documents/__tests__/extract.test.ts
@@ -165,6 +165,34 @@ describe("runInboundExtraction — stated-status-only mapping", () => {
expect(med.statusStated).toBe("ongoing");
});
+ it('sends a confident value the read itself gave no unit ("1") to review', async () => {
+ const provider = fakeProvider(
+ JSON.stringify({
+ facts: [
+ {
+ type: "OBSERVATION",
+ label: "CHOL/HDLC RATIO",
+ value: 4,
+ unit: "1",
+ confidence: 0.95,
+ sourceText: "CHOL/HDLC RATIO 4.0",
+ },
+ {
+ type: "OBSERVATION",
+ label: "HDL",
+ value: 52,
+ unit: "mg/dL",
+ confidence: 0.95,
+ sourceText: "HDL 52 mg/dL",
+ },
+ ],
+ }),
+ );
+
+ const result = await runInboundExtraction(oneRead(provider));
+ expect(result.facts.map((f) => f.needsReview)).toEqual([true, false]);
+ });
+
it("stores the document's real span, not the model's echo of it", async () => {
// The OCR text says "Hämoglobin 14.2 g/dL"; the model echoes a tidied
// rendering of it. The stored provenance must be the document's characters.
diff --git a/src/lib/documents/extract.ts b/src/lib/documents/extract.ts
index a1660adf5..a21f8eb44 100644
--- a/src/lib/documents/extract.ts
+++ b/src/lib/documents/extract.ts
@@ -57,6 +57,7 @@ import {
type OcrMeter,
type OcrReadSurface,
} from "@/lib/documents/ocr-read";
+import { isDimensionlessUnit } from "@/lib/labs/dimensionless";
import { annotate } from "@/lib/logging/context";
import {
INBOUND_CONFIDENCE_FLOOR,
@@ -383,7 +384,12 @@ export async function runInboundExtraction(
facts.push({
factType: raw.type,
confidence,
- needsReview: confidence < INBOUND_CONFIDENCE_FLOOR,
+ // A unit of "1" is the user's explicit no-unit choice. A read that
+ // states it itself goes to review rather than straight to the record,
+ // so a dropped unit never passes as a dimensionless value.
+ needsReview:
+ confidence < INBOUND_CONFIDENCE_FLOOR ||
+ (raw.type === "OBSERVATION" && isDimensionlessUnit(raw.unit)),
data,
provenance: {
sourceText: anchor.sourceText,
From 80a62afbbf58f2d2a0ff65275373cf944e27c226 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?=
Date: Mon, 2 Nov 2026 20:32:11 +0100
Subject: [PATCH 13/20] build(pdf): add Unicode fonts for the doctor report
The report's built-in Helvetica is WinAnsi-only. These fonts cover what
the report can carry: ReportSans (Noto Sans plus the arrows and
mathematical operators of Noto Sans Math; Latin incl. Extended-A/B,
Greek, Cyrillic) in regular, bold and italic, and ReportSansKR, the same
plus Hangul from Noto Sans KR, in regular and bold.
scripts/build-pdf-fonts.py rebuilds them from the upstream sources. All
are SIL OFL 1.1; the licence sits beside the files. The directory is
named in outputFileTracingIncludes because the renderer reads the files
by a path the tracer cannot follow.
---
next.config.ts | 3 +
scripts/README.md | 1 +
scripts/build-pdf-fonts.py | 191 ++++++++++++++++++
src/lib/doctor-report-pdf/fonts/OFL.txt | 95 +++++++++
.../fonts/ReportSans-Bold.ttf | Bin 0 -> 170236 bytes
.../fonts/ReportSans-Italic.ttf | Bin 0 -> 177800 bytes
.../fonts/ReportSans-Regular.ttf | Bin 0 -> 170332 bytes
.../fonts/ReportSansKR-Bold.ttf | Bin 0 -> 2557880 bytes
.../fonts/ReportSansKR-Regular.ttf | Bin 0 -> 2558196 bytes
9 files changed, 290 insertions(+)
create mode 100644 scripts/build-pdf-fonts.py
create mode 100644 src/lib/doctor-report-pdf/fonts/OFL.txt
create mode 100644 src/lib/doctor-report-pdf/fonts/ReportSans-Bold.ttf
create mode 100644 src/lib/doctor-report-pdf/fonts/ReportSans-Italic.ttf
create mode 100644 src/lib/doctor-report-pdf/fonts/ReportSans-Regular.ttf
create mode 100644 src/lib/doctor-report-pdf/fonts/ReportSansKR-Bold.ttf
create mode 100644 src/lib/doctor-report-pdf/fonts/ReportSansKR-Regular.ttf
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/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 0000000000000000000000000000000000000000..fd7041adedfcd81e77f44f75a020cdc7473df445
GIT binary patch
literal 170236
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