From f57ac9db1db04fb5fa6d2d9936e2122e38e076e1 Mon Sep 17 00:00:00 2001 From: Caio Pizzol Date: Fri, 5 Jun 2026 17:49:22 -0300 Subject: [PATCH] feat(fallbacks): add decision-oriented fallback API Replace the FontFallback|null surface with three intent-named helpers and a discriminated union that preserves docfonts' signature value - the honest no. - getFallbackDecision returns kind: fallback | asset_missing | no_recommended_fallback | customer_supplied | preserve_only | unknown, so a consumer can tell a measured 'no open font' apart from a font docfonts never heard of, and from a substitute it simply does not bundle. The non-fallback kinds carry evidenceId back into SUBSTITUTION_EVIDENCE. - getRenderableFallback (render) and createFallbackMap (resolver map) only ever return a family the consumer can render; createFallbackMap requires canRenderFamily. normalizeFamilyName is now public for map lookups. - Rename result fields: family -> substituteFamily, action -> policyAction (an action, not a quality claim), faithful -> lineBreakSafe (true for metric_safe, near_metric, and monospace cell_width_only - advances preserve line breaks). Drop the 0.1 names (clean cut, pre-1.0). - Fix Node ESM loadability: build with NodeNext and emit .js import specifiers, so the published package loads in plain Node, not only bundlers. Guarded by a node-esm test that imports the built artifact. ESM-only; CommonJS require() is unsupported. --- packages/fallbacks/README.md | 54 ++++---- packages/fallbacks/fallbacks.test.ts | 164 ++++++++++++++----------- packages/fallbacks/node-esm.test.ts | 52 ++++++++ packages/fallbacks/src/data.ts | 2 +- packages/fallbacks/src/fallbacks.ts | 138 +++++++++++++-------- packages/fallbacks/src/index.ts | 19 +-- packages/fallbacks/src/types.ts | 54 +++++--- packages/fallbacks/tsconfig.build.json | 2 + 8 files changed, 315 insertions(+), 170 deletions(-) create mode 100644 packages/fallbacks/node-esm.test.ts diff --git a/packages/fallbacks/README.md b/packages/fallbacks/README.md index 7354c0b..059fe62 100644 --- a/packages/fallbacks/README.md +++ b/packages/fallbacks/README.md @@ -2,9 +2,9 @@ Document font substitution, measured. -Measured open-font fallbacks for proprietary document fonts (Office / Word / DOCX), as a tiny runtime data package. It carries the structured substitution evidence plus one asset-aware lookup, so a renderer can map a requested proprietary font to an open one without hand-copying tables, and without routing to a font it does not bundle. +Measured open-font fallbacks for proprietary document fonts (Office / Word / DOCX), as a tiny runtime data package. Map a requested proprietary font to an open one without hand-copying tables, and without routing to a font you do not bundle. -It ships no fonts and no proprietary binaries: only the measured evidence (which open family stands in, the verdict, the advance delta, the license id, and a stable evidence id). +It ships no fonts and no proprietary binaries: only the measured evidence - which open family stands in, the verdict, a stable evidence id - including the honest "no open font stands in." ## Install @@ -12,46 +12,50 @@ It ships no fonts and no proprietary binaries: only the measured evidence (which npm install @docfonts/fallbacks ``` -## Usage +ESM-only (`"type": "module"`); import it, or let your bundler. CommonJS `require()` is not supported. -`getFallback` answers "what does docfonts recommend for this family?". Pass `hasFamily` to keep it to fonts you actually bundle: +## Three things you might want ```ts -import { getFallback } from "@docfonts/fallbacks"; +// 1. I need a family to render right now (only ever a font you bundle, or null): +import { getRenderableFallback } from "@docfonts/fallbacks"; +getRenderableFallback("Helvetica", { canRenderFamily: (f) => bundled.has(f) }); +// { substituteFamily: "Liberation Sans", policyAction: "substitute", verdict: "metric_safe", lineBreakSafe: true, evidenceId: "helvetica" } +``` -getFallback("Helvetica", { hasFamily: (f) => bundled.has(f) }); -// { family: "Liberation Sans", action: "substitute", verdict: "metric_safe", faithful: true, evidenceId: "helvetica" } +```ts +// 2. I need to explain the decision (UI, diagnostics, reporting) - a discriminated union: +import { getFallbackDecision } from "@docfonts/fallbacks"; +getFallbackDecision("Aptos"); // { kind: "customer_supplied", evidenceId: "aptos" } +getFallbackDecision("Tahoma"); // { kind: "no_recommended_fallback", evidenceId: "tahoma" } +getFallbackDecision("Made Up Font"); // { kind: "unknown" } +getFallbackDecision("Georgia", { canRenderFamily: (f) => bundled.has(f) }); +// not bundled -> { kind: "asset_missing", substituteFamily: "Gelasio", verdict: "near_metric", evidenceId: "georgia" } ``` -`deriveFallbackMap` builds the substitute map you wire into a resolver. `hasFamily` is required here: a render map never includes a substitute whose font you cannot load. +This is the point of the package: a measured "no open font stands in" (Aptos) is a real answer, and it is **not** the same as a font docfonts never heard of (`unknown`). The `kind`s are `fallback`, `asset_missing`, `no_recommended_fallback`, `customer_supplied`, `preserve_only`, `unknown`. ```ts -import { deriveFallbackMap } from "@docfonts/fallbacks"; - -const map = deriveFallbackMap({ hasFamily: (f) => bundled.has(f) }); -// { helvetica: { family: "Liberation Sans", ... }, calibri: { ... }, ... } -// Rows whose family you do not bundle are left out, not routed to a missing asset. +// 3. I need a resolver map. `canRenderFamily` is required - the map only holds fonts you can render: +import { createFallbackMap, normalizeFamilyName } from "@docfonts/fallbacks"; +const map = createFallbackMap({ canRenderFamily: (f) => bundled.has(f) }); +map[normalizeFamilyName("Times New Roman")]; // { substituteFamily: "Liberation Serif", ... } ``` -Both resolve to `null` (or omit the row) when docfonts has no row, the recommendation is "no open font stands in", or you do not ship the physical family. +Keys are normalized (lowercased, quote-stripped); look up with `normalizeFamilyName`. Rows whose family you do not bundle are left out, never routed to a missing asset. ## What the fields mean -- `family` - the open family to render in place of the requested one. -- `action` - `substitute` (a measured metric match) or `category_fallback` (right letterforms, lower fidelity). +- `substituteFamily` - the open family to render in place of the requested one. +- `policyAction` - what a renderer should do, not a quality claim: `substitute` renders the named open family, `category_fallback` renders a lower-fidelity same-category family. A `substitute` can still be top-level `visual_only` (e.g. Cambria); use `verdict` for fidelity. - `verdict` - the measured fidelity, from a fixed taxonomy (`metric_safe`, `near_metric`, `cell_width_only`, `visual_only`, ...). The headline rolls up to the worst face. -- `faithful` - a coarse "good enough for line-break fidelity" flag (`metric_safe` or `near_metric`). Not a claim of an exact clone; read `verdict` for the tier. -- `evidenceId` - the stable id for the reviewed evidence row. +- `lineBreakSafe` - true when advances preserve line breaks: `metric_safe`, `near_metric`, or monospace `cell_width_only`. Not a claim of an exact clone (`cell_width_only` keeps advances but not glyph shapes); read `verdict` for the tier. +- `evidenceId` - the stable id for the reviewed evidence row; look the full row up in `SUBSTITUTION_EVIDENCE`. -The full structured rows are exported as `SUBSTITUTION_EVIDENCE` (faces, per-face verdicts, glyph exceptions) for richer reporting. Face-level routing stays yours: `getFallback` answers "which family", not "which face". +The full structured rows are exported as `SUBSTITUTION_EVIDENCE` (faces, per-face verdicts, glyph exceptions) for richer reporting. Face-level routing stays yours: these answer "which family", not "which face". ## Provenance -The data comes from reviewed docfonts evidence. Measurements are produced against licensed originals, -but this package distributes no proprietary binaries or raw proprietary metrics. +The data comes from reviewed docfonts evidence. Measurements are produced against licensed originals, but this package distributes no proprietary binaries or raw proprietary metrics. Built by the team behind SuperDoc. Standalone and neutral. - -## License - -MIT diff --git a/packages/fallbacks/fallbacks.test.ts b/packages/fallbacks/fallbacks.test.ts index d227a41..1ac28ca 100644 --- a/packages/fallbacks/fallbacks.test.ts +++ b/packages/fallbacks/fallbacks.test.ts @@ -1,15 +1,19 @@ /** - * Behavior contract for the public fallback helpers: honest action labels, asset-aware routing, and - * null for unknown or non-renderable rows. + * Behavior contract for the 0.2 fallback API. The load-bearing guarantees: getFallbackDecision tells + * the honest cases apart (a known font with no open substitute is NOT the same as an unknown font, and + * a substitute you do not bundle is its own case); getRenderableFallback / createFallbackMap only ever + * hand back a family the consumer can actually render. */ import { describe, expect, test } from "bun:test"; import { - deriveFallbackMap, - getFallback, + createFallbackMap, + getFallbackDecision, + getRenderableFallback, + normalizeFamilyName, SUBSTITUTION_EVIDENCE, } from "./src/index"; -// Sample consumer bundle for asset-gating tests. +// A consumer that ships exactly the five families the reference renderer bundles. const BUNDLED = new Set([ "Carlito", "Caladea", @@ -17,76 +21,95 @@ const BUNDLED = new Set([ "Liberation Serif", "Liberation Mono", ]); -const hasFamily = (f: string) => BUNDLED.has(f); +const canRenderFamily = (f: string) => BUNDLED.has(f); -describe("getFallback", () => { - test("a metric substitute resolves to its open family, marked faithful", () => { - expect(getFallback("Helvetica", { hasFamily })).toEqual({ - family: "Liberation Sans", - action: "substitute", - verdict: "metric_safe", - faithful: true, - evidenceId: "helvetica", +describe("getFallbackDecision", () => { + test("a renderable substitute -> kind 'fallback' with the resolved family", () => { + expect(getFallbackDecision("Helvetica", { canRenderFamily })).toEqual({ + kind: "fallback", + fallback: { + substituteFamily: "Liberation Sans", + policyAction: "substitute", + verdict: "metric_safe", + lineBreakSafe: true, + evidenceId: "helvetica", + }, }); }); - test("a category fallback is reported as such and is NOT faithful", () => { - expect(getFallback("Calibri Light", { hasFamily })).toEqual({ - family: "Carlito", - action: "category_fallback", - verdict: "visual_only", - faithful: false, - evidenceId: "calibri-light", + test("a substitute the consumer does NOT bundle -> kind 'asset_missing' (not null, not unknown)", () => { + // Georgia -> Gelasio is a real substitute; a consumer that doesn't ship Gelasio sees which font to add. + expect(getFallbackDecision("Georgia", { canRenderFamily })).toEqual({ + kind: "asset_missing", + substituteFamily: "Gelasio", + verdict: "near_metric", + evidenceId: "georgia", }); }); - test("case- and quote-insensitive lookup", () => { - expect(getFallback(" 'CALIBRI' ", { hasFamily })?.family).toBe("Carlito"); + test("a measured 'no open font' is distinct from an unknown font", () => { + // The whole point of the decision API: docfonts MEASURED Aptos and Tahoma; it never heard of "Foo". + expect(getFallbackDecision("Aptos")).toEqual({ + kind: "customer_supplied", + evidenceId: "aptos", + }); + expect(getFallbackDecision("Tahoma")).toEqual({ + kind: "no_recommended_fallback", + evidenceId: "tahoma", + }); + expect(getFallbackDecision("Cambria Math")).toEqual({ + kind: "preserve_only", + evidenceId: "cambria-math", + }); + expect(getFallbackDecision("Foo Unknown Font")).toEqual({ + kind: "unknown", + }); }); - test("a substitute whose family the consumer does NOT bundle resolves to null (inert row)", () => { - expect(getFallback("Georgia")?.family).toBe("Gelasio"); - expect(getFallback("Georgia", { hasFamily })).toBeNull(); - }); - - test("Baskerville (Regular-only) resolves the family; face routing is the consumer's job", () => { - expect(getFallback("Baskerville Old Face")?.family).toBe( - "Bacasime Antique", - ); - const row = SUBSTITUTION_EVIDENCE.find( - (r) => r.evidenceId === "baskerville-old-face", - ); - expect(row?.faces).toEqual({ - regular: true, - bold: false, - italic: false, - boldItalic: false, - }); + test("without canRenderFamily, a named substitute resolves to 'fallback' (un-gated)", () => { + expect(getFallbackDecision("Georgia").kind).toBe("fallback"); }); +}); - test("unknown family, and a row that recommends no substitute, resolve to null", () => { - expect(getFallback("Comic Papyrus Nonexistent", { hasFamily })).toBeNull(); - // Aptos is no_substitute (physicalFamily null) and Cambria Math is preserve_only (null): both null. - expect(getFallback("Aptos", { hasFamily })).toBeNull(); - expect(getFallback("Cambria Math", { hasFamily })).toBeNull(); +describe("getRenderableFallback", () => { + test("returns the family to render, or null when nothing is renderable", () => { + expect( + getRenderableFallback("Helvetica", { canRenderFamily })?.substituteFamily, + ).toBe("Liberation Sans"); + // asset_missing, no_recommended_fallback, customer_supplied, preserve_only, unknown -> null here. + expect(getRenderableFallback("Georgia", { canRenderFamily })).toBeNull(); + expect(getRenderableFallback("Aptos", { canRenderFamily })).toBeNull(); + expect( + getRenderableFallback("Foo Unknown", { canRenderFamily }), + ).toBeNull(); }); - test("faithful is exactly the two metric-grade bands (metric_safe | near_metric)", () => { + test("lineBreakSafe covers the advance-preserving verdicts (metric_safe | near_metric | cell_width_only)", () => { + const SAFE = new Set(["metric_safe", "near_metric", "cell_width_only"]); for (const row of SUBSTITUTION_EVIDENCE) { - if (row.physicalFamily === null) continue; - const fb = getFallback(row.logicalFamily); - const expected = - row.verdict === "metric_safe" || row.verdict === "near_metric"; - expect(fb?.faithful, `${row.evidenceId} (${row.verdict})`).toBe(expected); + const fb = getRenderableFallback(row.logicalFamily, { + canRenderFamily: () => true, + }); + if (!fb) continue; + expect(fb.lineBreakSafe, `${row.evidenceId} (${row.verdict})`).toBe( + SAFE.has(row.verdict), + ); } }); + + test("a monospace cell_width_only substitute is lineBreakSafe (advances match)", () => { + // Consolas -> Inconsolata SemiExpanded: glyph shapes differ, but cell width (every advance) matches. + const fb = getRenderableFallback("Consolas", { + canRenderFamily: () => true, + }); + expect(fb?.verdict).toBe("cell_width_only"); + expect(fb?.lineBreakSafe).toBe(true); + }); }); -describe("deriveFallbackMap", () => { - test("keys are the active, bundled logical families (lowercased); inert rows excluded", () => { - const map = deriveFallbackMap({ hasFamily }); - // The 5-family bundle activates exactly the seven rows whose physical family it ships (six - // substitutes + the Calibri Light category fallback, which also points at the bundled Carlito). +describe("createFallbackMap", () => { + test("only includes families the consumer can render, keyed by normalized logical family", () => { + const map = createFallbackMap({ canRenderFamily }); expect(Object.keys(map).sort()).toEqual([ "arial", "calibri", @@ -96,26 +119,25 @@ describe("deriveFallbackMap", () => { "helvetica", "times new roman", ]); - expect(map.helvetica.family).toBe("Liberation Sans"); + expect(map.helvetica.substituteFamily).toBe("Liberation Sans"); // Georgia/Arial Narrow/Baskerville point at un-bundled families, so they are absent. expect(map.georgia).toBeUndefined(); }); - test("deriveFallbackMap will not compile without hasFamily (asset-safety is enforced by the type)", () => { - // @ts-expect-error - hasFamily is required; an ungated render map is a compile error by design. - const unsafe = () => deriveFallbackMap(); + test("canRenderFamily is required (asset-safety enforced by the type)", () => { + // @ts-expect-error - a render map must be asset-safe; omitting canRenderFamily is a compile error. + const unsafe = () => createFallbackMap(); expect(typeof unsafe).toBe("function"); }); +}); - test("hasFamily is required, and a bundle-everything consumer activates all renderable rows", () => { - const map = deriveFallbackMap({ hasFamily: () => true }); - const renderable = SUBSTITUTION_EVIDENCE.filter( - (r) => - r.physicalFamily !== null && - (r.policyAction === "substitute" || - r.policyAction === "category_fallback"), - ).length; - expect(Object.keys(map).length).toBe(renderable); - expect(map.georgia?.family).toBe("Gelasio"); +describe("normalizeFamilyName (public)", () => { + test("trims, strips quotes, lowercases - matches the map keys", () => { + expect(normalizeFamilyName("Times New Roman")).toBe("times new roman"); + expect(normalizeFamilyName(" 'CALIBRI' ")).toBe("calibri"); + const map = createFallbackMap({ canRenderFamily }); + expect(map[normalizeFamilyName("Times New Roman")]?.substituteFamily).toBe( + "Liberation Serif", + ); }); }); diff --git a/packages/fallbacks/node-esm.test.ts b/packages/fallbacks/node-esm.test.ts new file mode 100644 index 0000000..7bef98f --- /dev/null +++ b/packages/fallbacks/node-esm.test.ts @@ -0,0 +1,52 @@ +/** + * Regression guard for the v0.1 bug where the published ESM had extensionless relative imports + * (`from "./data"`), which works in bundlers but throws ERR_MODULE_NOT_FOUND in plain Node ESM. The + * build emits NodeNext `.js` specifiers; these tests prove the SHIPPED artifact loads in real Node. + * + * The package is ESM-only (package.json `"type": "module"`, no `require` export). CommonJS `require()` + * is intentionally unsupported - consumers import it, or their bundler does. + */ +import { describe, expect, test } from "bun:test"; +import { execFileSync, execSync } from "node:child_process"; +import { readFileSync } from "node:fs"; +import { join } from "node:path"; +import { pathToFileURL } from "node:url"; + +const PKG_DIR = import.meta.dir; +const DIST = join(PKG_DIR, "dist"); + +describe("Node ESM loadability", () => { + // Build once for this file. + execSync("bun run build", { cwd: PKG_DIR, stdio: "pipe" }); + + test("emitted relative imports carry explicit .js extensions (NodeNext)", () => { + for (const file of ["index.js", "fallbacks.js", "data.js"]) { + const code = readFileSync(join(DIST, file), "utf8"); + // any `from "./x"` or `from "../x"` must end in .js - no extensionless relative specifiers. + const bad = [...code.matchAll(/from\s+"(\.[^"]*)"/g)].filter( + (m) => !m[1].endsWith(".js"), + ); + expect( + bad.map((m) => m[1]), + `extensionless imports in ${file}`, + ).toEqual([]); + } + }); + + test("the built artifact imports + runs under plain Node ESM", () => { + const url = pathToFileURL(join(DIST, "index.js")).href; + const script = ` + const m = await import(${JSON.stringify(url)}); + const d = m.getFallbackDecision("Helvetica", { canRenderFamily: () => true }); + process.stdout.write(JSON.stringify({ kind: d.kind, fam: d.fallback?.substituteFamily, has: typeof m.getFallbackDecision })); + `; + const out = execFileSync("node", ["--input-type=module", "-e", script], { + encoding: "utf8", + }); + expect(JSON.parse(out)).toEqual({ + kind: "fallback", + fam: "Liberation Sans", + has: "function", + }); + }); +}); diff --git a/packages/fallbacks/src/data.ts b/packages/fallbacks/src/data.ts index 348b78c..dca6541 100644 --- a/packages/fallbacks/src/data.ts +++ b/packages/fallbacks/src/data.ts @@ -1,5 +1,5 @@ // Generated from reviewed docfonts evidence. Do not edit by hand. -import type { SubstitutionEvidence } from "./types"; +import type { SubstitutionEvidence } from "./types.js"; export const SUBSTITUTION_EVIDENCE: readonly SubstitutionEvidence[] = [ { diff --git a/packages/fallbacks/src/fallbacks.ts b/packages/fallbacks/src/fallbacks.ts index 3b35e2f..c4a1bbc 100644 --- a/packages/fallbacks/src/fallbacks.ts +++ b/packages/fallbacks/src/fallbacks.ts @@ -1,47 +1,44 @@ /** - * Asset-aware fallback helpers. `getFallback` can inspect the raw recommendation; `deriveFallbackMap` - * requires `hasFamily` because a renderer map must not include fonts the consumer cannot load. Face - * routing stays consumer-owned; this package answers which family, not which face. + * Fallback lookups over the reviewed evidence. Three intents: + * - getRenderableFallback - "I need a family to render now" (asset-gated, returns a family or null). + * - getFallbackDecision - "I need diagnostics / UI / reporting" (the full honest outcome). + * - createFallbackMap - "I need a resolver map" (asset-gated, render-only rows). + * Face routing stays consumer-owned: these answer which family, not which face. */ -import { SUBSTITUTION_EVIDENCE } from "./data"; +import { SUBSTITUTION_EVIDENCE } from "./data.js"; import type { + FallbackDecision, FontFallback, - PolicyAction, SubstitutionEvidence, Verdict, -} from "./types"; +} from "./types.js"; /** Reports whether the consumer can actually render (i.e. bundles the asset for) a physical family. */ -export type HasFamily = (family: string) => boolean; +export type CanRenderFamily = (family: string) => boolean; -/** Options for {@link getFallback}. `hasFamily` is OPTIONAL here - omit it to get the raw recommendation. */ -export interface FallbackOptions { - /** - * When given, a row whose `physicalFamily` is not available resolves to null - the row stays inert - * until the consumer bundles it. When omitted, every row with a physical family is considered present. - */ - hasFamily?: HasFamily; +/** Options for {@link getRenderableFallback} and {@link createFallbackMap}: a render map must be asset-safe. */ +export interface RenderableFallbackOptions { + canRenderFamily: CanRenderFamily; } -/** Options for {@link deriveFallbackMap}. `hasFamily` is REQUIRED - a render map must be asset-safe. */ -export interface FallbackMapOptions { - hasFamily: HasFamily; +/** Options for {@link getFallbackDecision}. `canRenderFamily` is optional - omit it for the raw decision. */ +export interface FallbackDecisionOptions { + canRenderFamily?: CanRenderFamily; } -/** The two metric-grade bands. A substitution in either is line-break faithful; everything else is not. */ -const FAITHFUL_VERDICTS: ReadonlySet = new Set([ +/** + * Verdicts whose advances preserve line breaks: the proportional metric-grade bands, plus + * cell_width_only (a monospace whose cell width - and therefore every advance - matches). Glyph shapes + * may still differ (read `verdict`); line breaks do not move. + */ +const LINE_BREAK_SAFE_VERDICTS: ReadonlySet = new Set([ "metric_safe", "near_metric", -]); - -/** Actions that mean "render this physical family". */ -const RENDERABLE_ACTIONS: ReadonlySet = new Set([ - "substitute", - "category_fallback", + "cell_width_only", ]); /** Normalize a family name to a lookup key: trim, strip surrounding quotes, lowercase (CSS-name safe). */ -function normalizeFamily(name: string): string { +export function normalizeFamilyName(name: string): string { return name .trim() .replace(/^['"]+|['"]+$/g, "") @@ -51,51 +48,90 @@ function normalizeFamily(name: string): string { /** Evidence rows indexed by normalized logical family, built once. */ const BY_LOGICAL: ReadonlyMap = new Map( - SUBSTITUTION_EVIDENCE.map((row) => [normalizeFamily(row.logicalFamily), row]), + SUBSTITUTION_EVIDENCE.map((row) => [ + normalizeFamilyName(row.logicalFamily), + row, + ]), ); -/** Project a row to a FontFallback, or null when it offers nothing the consumer can render. */ -function toFallback( +/** Build the FontFallback for a row known to carry a renderable physical family. */ +function buildFallback( row: SubstitutionEvidence, - hasFamily: HasFamily | undefined, -): FontFallback | null { - if (row.physicalFamily === null) return null; - if (!RENDERABLE_ACTIONS.has(row.policyAction)) return null; - if (hasFamily && !hasFamily(row.physicalFamily)) return null; + physicalFamily: string, +): FontFallback { return { - family: row.physicalFamily, - action: row.policyAction, + substituteFamily: physicalFamily, + policyAction: row.policyAction, verdict: row.verdict, - faithful: FAITHFUL_VERDICTS.has(row.verdict), + lineBreakSafe: LINE_BREAK_SAFE_VERDICTS.has(row.verdict), evidenceId: row.evidenceId, }; } +/** Decide a single row against the consumer's asset availability. Pure. */ +function decideRow( + row: SubstitutionEvidence, + canRenderFamily: CanRenderFamily | undefined, +): FallbackDecision { + const { policyAction, physicalFamily, verdict, evidenceId } = row; + // Deliberate non-substitution policies first: nothing renders in the original's place. + if (policyAction === "preserve_only") + return { kind: "preserve_only", evidenceId }; + if (policyAction === "customer_supplied") + return { kind: "customer_supplied", evidenceId }; + // substitute / category_fallback with no named open family: docfonts knows the font but recommends + // no renderable family - distinct from the `no_substitute` verdict (read the row for that nuance). + if (physicalFamily === null) + return { kind: "no_recommended_fallback", evidenceId }; + // Named substitute the consumer does not bundle: surfaced so a UI can say which font to add. + if (canRenderFamily && !canRenderFamily(physicalFamily)) + return { + kind: "asset_missing", + substituteFamily: physicalFamily, + verdict, + evidenceId, + }; + return { kind: "fallback", fallback: buildFallback(row, physicalFamily) }; +} + +/** + * The full, honest outcome for a requested family: a discriminated union (see {@link FallbackDecision}). + * Distinguishes an unknown font from a measured "no open substitute", a substitute you do not bundle, + * and the deliberate non-substitution policies. Case- and quote-insensitive. + */ +export function getFallbackDecision( + family: string, + options: FallbackDecisionOptions = {}, +): FallbackDecision { + const row = BY_LOGICAL.get(normalizeFamilyName(family)); + return row ? decideRow(row, options.canRenderFamily) : { kind: "unknown" }; +} + /** - * The open fallback for a requested font family, or null when docfonts has no row, the row recommends - * no substitute, or the consumer does not ship the physical family. Case- and quote-insensitive. + * The open family to render for a requested font, or null when there is nothing the consumer can render + * (no row, no open substitute, a deliberate non-substitution policy, or a substitute it does not + * bundle). For the reasons behind a null - to report them in a UI - use {@link getFallbackDecision}. */ -export function getFallback( - logicalFamily: string, - options: FallbackOptions = {}, +export function getRenderableFallback( + family: string, + options: RenderableFallbackOptions, ): FontFallback | null { - const row = BY_LOGICAL.get(normalizeFamily(logicalFamily)); - return row ? toFallback(row, options.hasFamily) : null; + const decision = getFallbackDecision(family, options); + return decision.kind === "fallback" ? decision.fallback : null; } /** * The renderer's substitute map: every fallback the consumer can actually render, keyed by the - * normalized (lowercased) logical family. `hasFamily` is REQUIRED - rows whose physical family the - * consumer does not bundle are excluded, so the map is safe to wire straight into a resolver. The keys - * are exactly the families it should remap. For the un-gated single recommendation, use {@link getFallback}. + * normalized (lowercased) logical family - normalize lookups with {@link normalizeFamilyName}. Only + * `kind: "fallback"` rows are included, so the map is safe to wire straight into a resolver. */ -export function deriveFallbackMap( - options: FallbackMapOptions, +export function createFallbackMap( + options: RenderableFallbackOptions, ): Record { const out: Record = {}; for (const [key, row] of BY_LOGICAL) { - const fallback = toFallback(row, options.hasFamily); - if (fallback) out[key] = fallback; + const decision = decideRow(row, options.canRenderFamily); + if (decision.kind === "fallback") out[key] = decision.fallback; } return out; } diff --git a/packages/fallbacks/src/index.ts b/packages/fallbacks/src/index.ts index 0e45fa4..75422f5 100644 --- a/packages/fallbacks/src/index.ts +++ b/packages/fallbacks/src/index.ts @@ -2,22 +2,25 @@ * Runtime fallback evidence and asset-aware lookup helpers. No font parser, research data, or runtime * dependency. */ -export { SUBSTITUTION_EVIDENCE } from "./data"; +export { SUBSTITUTION_EVIDENCE } from "./data.js"; export { - deriveFallbackMap, - type FallbackMapOptions, - type FallbackOptions, - getFallback, - type HasFamily, -} from "./fallbacks"; + type CanRenderFamily, + createFallbackMap, + type FallbackDecisionOptions, + getFallbackDecision, + getRenderableFallback, + normalizeFamilyName, + type RenderableFallbackOptions, +} from "./fallbacks.js"; export type { AdvanceDelta, FaceCoverage, FaceSlot, + FallbackDecision, FontFallback, GlyphException, PolicyAction, SubstituteGates, SubstitutionEvidence, Verdict, -} from "./types"; +} from "./types.js"; diff --git a/packages/fallbacks/src/types.ts b/packages/fallbacks/src/types.ts index a379f79..0faff80 100644 --- a/packages/fallbacks/src/types.ts +++ b/packages/fallbacks/src/types.ts @@ -58,7 +58,8 @@ export interface GlyphException { } /** - * One logical font's structured fallback evidence. + * One logical font's structured fallback evidence. The raw rows are exported as + * {@link SUBSTITUTION_EVIDENCE} for reporting; the helpers project the renderer-relevant fields. */ export interface SubstitutionEvidence { /** docfonts evidence id, e.g. "cambria". */ @@ -86,24 +87,49 @@ export interface SubstitutionEvidence { } /** - * The ergonomic result of {@link getFallback}: the single decision a renderer needs to act on - which - * physical family to render, how it was chosen, and whether it is metric-faithful. The full structured - * row stays available via {@link SUBSTITUTION_EVIDENCE} for richer reporting. + * A resolved fallback: which open family to render, how it was chosen, and how faithful it is. The full + * structured row stays available via {@link SUBSTITUTION_EVIDENCE} for richer reporting. */ export interface FontFallback { - /** the physical family to render in place of the requested font. */ - family: string; - /** how it was chosen: a metric substitute vs a same-category visual fallback. */ - action: PolicyAction; + /** the open family to render in place of the requested font. */ + substituteFamily: string; + /** + * What a renderer should DO, independent of fidelity: `substitute` = render the named open family; + * `category_fallback` = render a lower-fidelity same-category family. NOT a quality claim - a + * `substitute` can still be top-level `visual_only` (e.g. Cambria). Read `verdict` for fidelity. + */ + policyAction: PolicyAction; /** the worst-face fidelity verdict behind the choice. */ verdict: Verdict; /** - * Coarse "good enough for line-break fidelity" flag: true for the metric-grade bands (verdict - * metric_safe or near_metric), false otherwise. NOT a claim of a perfect/exact clone - near_metric - * drifts a few glyphs, and a row can roll up to a worse top-level verdict because of one face (see - * Cambria). Read `verdict` (and the row's `faceVerdicts`) for the precise tier. + * Coarse "advances preserve line breaks" flag: true for metric_safe, near_metric, or monospace + * cell_width_only (cell width, and so every advance, matches). NOT a claim of a perfect/exact clone - + * near_metric drifts a few glyphs, cell_width_only keeps the advances but not the glyph shapes, and a + * row can roll up to a worse top-level verdict because of one face (see Cambria). Read `verdict` (and + * the row's `faceVerdicts`) for the precise tier. */ - faithful: boolean; - /** stable reviewed-evidence id. */ + lineBreakSafe: boolean; + /** stable reviewed-evidence id; look the full row up in {@link SUBSTITUTION_EVIDENCE}. */ evidenceId: string; } + +/** + * The full, honest outcome of a fallback lookup. A discriminated union so a consumer can tell apart + * cases that a bare `FontFallback | null` collapses: docfonts has never heard of the font (`unknown`) + * vs knows it but recommends no renderable family (`no_recommended_fallback`), the substitute exists + * but the consumer does not bundle it (`asset_missing`), and the deliberate non-substitution policies + * (`preserve_only`, `customer_supplied`). `evidenceId` on the terminal kinds points back into + * {@link SUBSTITUTION_EVIDENCE} for the full row (verdict, faces, ...). + */ +export type FallbackDecision = + | { kind: "fallback"; fallback: FontFallback } + | { + kind: "asset_missing"; + substituteFamily: string; + verdict: Verdict; + evidenceId: string; + } + | { kind: "no_recommended_fallback"; evidenceId: string } + | { kind: "customer_supplied"; evidenceId: string } + | { kind: "preserve_only"; evidenceId: string } + | { kind: "unknown" }; diff --git a/packages/fallbacks/tsconfig.build.json b/packages/fallbacks/tsconfig.build.json index 8f5b190..42c1c0b 100644 --- a/packages/fallbacks/tsconfig.build.json +++ b/packages/fallbacks/tsconfig.build.json @@ -1,6 +1,8 @@ { "extends": "../../tsconfig.json", "compilerOptions": { + "module": "NodeNext", + "moduleResolution": "NodeNext", "noEmit": false, "declaration": true, "declarationMap": false,