From 837538f3c0877f2392b560cc5a678b45e1e4283e Mon Sep 17 00:00:00 2001 From: Caio Pizzol Date: Fri, 5 Jun 2026 21:09:02 -0300 Subject: [PATCH] feat(fallbacks): face-aware lookups for Regular-only substitutes A Regular-only row (Baskerville -> Bacasime, already published; Cooper Black -> Caprasimo, coming) could be blindly routed for bold/italic by the family-level API. Add face safety: - getRenderableFallbackForFace(family, face, opts) returns the substitute only for a covered face, null otherwise - bold/italic of a Regular-only row no longer route to a face the substitute lacks. - getFallbackDecisionForFace adds a face_missing decision kind (substitute exists, this face does not). A covered face carries its OWN verdict (Cambria regular is metric_safe though the family rolls up to visual_only). - Every FontFallback now carries faces, so the family-level createFallbackMap is self-describing; its doc notes a face-scoped entry is only safe in a face-aware resolver. Face-scope rule: an all-false faces means the row is NOT face-scoped (a category fallback whose physical font does have faces), so it renders for every face and never becomes face_missing. Only a measured per-face substitute (Baskerville) is gated. No data change. --- packages/fallbacks/README.md | 20 +++++- packages/fallbacks/fallbacks.test.ts | 94 ++++++++++++++++++++++++ packages/fallbacks/src/fallbacks.ts | 104 +++++++++++++++++++++++---- packages/fallbacks/src/index.ts | 2 + packages/fallbacks/src/types.ts | 21 +++++- 5 files changed, 225 insertions(+), 16 deletions(-) diff --git a/packages/fallbacks/README.md b/packages/fallbacks/README.md index 6c42992..02bc35c 100644 --- a/packages/fallbacks/README.md +++ b/packages/fallbacks/README.md @@ -56,6 +56,7 @@ Decision kinds: - `fallback` - render the returned `substituteFamily`. - `asset_missing` - docfonts has a fallback, but your app does not load that family. +- `face_missing` - (face-aware lookups only) the family has a substitute, but not for the requested face. Route that face through your absence handling; do not substitute it. - `no_recommended_fallback` - docfonts knows the font but recommends no renderable open family. - `customer_supplied` - the real font should come from the customer or environment. - `preserve_only` - keep the original family name. Do not substitute. @@ -75,7 +76,7 @@ const map = createFallbackMap({ map[normalizeFamilyName("Times New Roman")]; // { substituteFamily: "Liberation Serif", ... } ``` -Keys are normalized. Use `normalizeFamilyName` for lookups. Rows whose substitute family is not available are omitted. +Keys are normalized. Use `normalizeFamilyName` for lookups. Rows whose substitute family is not available are omitted. Each entry carries `faces`: a Regular-only entry is only safe in a **face-aware** resolver (one that checks `faces` or uses `getRenderableFallbackForFace`), since applying it to bold/italic would route a face the substitute does not provide. ## What the fields mean @@ -83,11 +84,26 @@ Keys are normalized. Use `normalizeFamilyName` for lookups. Rows whose substitut - `policyAction` - what a renderer should do, not a quality claim. Use `verdict` for fidelity. - `verdict` - the measured fidelity. Examples: `metric_safe`, `near_metric`, `cell_width_only`, `visual_only`. - `lineBreakSafe` - true when advances preserve line breaks: `metric_safe`, `near_metric`, or monospace `cell_width_only`. +- `faces` - reviewed face coverage for this evidence row. If any face is `true`, respect it as face-scoped coverage (a row can be Regular-only). If all faces are `false`, the row is **not** face-scoped (e.g. a category fallback whose physical font does have faces) and the face-aware helpers treat it as renderable for any face. - `evidenceId` - the stable id for the reviewed evidence row; look the full row up in `SUBSTITUTION_EVIDENCE`. `cell_width_only` keeps monospace advances stable, but glyph shapes can still differ. A `substitute` can still have a lower-fidelity `verdict` when one face or glyph is qualified. The verdict is the fidelity signal. -The full structured rows are exported as `SUBSTITUTION_EVIDENCE` for richer reporting, including faces, per-face verdicts, and glyph exceptions. Face-level routing stays yours: these helpers answer "which family", not "which face". +## Face-aware routing (Regular-only substitutes) + +Some substitutes provide only some faces - e.g. Baskerville Old Face -> Bacasime Antique is Regular-only. The family-level helpers above answer "which family", and every result carries `faces`, so a resolver must route per-face. The face-aware helpers do it for you: + +```ts +import { getRenderableFallbackForFace } from "@docfonts/fallbacks"; +const opts = { canRenderFamily: (family) => bundledFamilies.has(family) }; + +getRenderableFallbackForFace("Baskerville Old Face", "regular", opts)?.substituteFamily; // "Bacasime Antique" +getRenderableFallbackForFace("Baskerville Old Face", "bold", opts); // null (Regular-only) +``` + +`getFallbackDecisionForFace(family, face, options)` reports the reason - `face_missing` when the substitute exists but lacks that face. A covered face carries its OWN verdict, not the family's worst-face rollup (e.g. `Cambria` regular is `metric_safe` even though the family rolls up to `visual_only`). + +The full structured rows are exported as `SUBSTITUTION_EVIDENCE` for richer reporting (faces, per-face verdicts, glyph exceptions). ## Provenance diff --git a/packages/fallbacks/fallbacks.test.ts b/packages/fallbacks/fallbacks.test.ts index 1ac28ca..e152714 100644 --- a/packages/fallbacks/fallbacks.test.ts +++ b/packages/fallbacks/fallbacks.test.ts @@ -8,7 +8,9 @@ import { describe, expect, test } from "bun:test"; import { createFallbackMap, getFallbackDecision, + getFallbackDecisionForFace, getRenderableFallback, + getRenderableFallbackForFace, normalizeFamilyName, SUBSTITUTION_EVIDENCE, } from "./src/index"; @@ -32,6 +34,7 @@ describe("getFallbackDecision", () => { policyAction: "substitute", verdict: "metric_safe", lineBreakSafe: true, + faces: { regular: true, bold: true, italic: true, boldItalic: true }, evidenceId: "helvetica", }, }); @@ -141,3 +144,94 @@ describe("normalizeFamilyName (public)", () => { ); }); }); + +describe("face-aware lookups (Regular-only safety)", () => { + const renderAll = { canRenderFamily: () => true }; + + test("every FontFallback now carries the substitute's face coverage", () => { + // The family-level result is self-describing, so a map consumer can route per-face. + expect( + getRenderableFallback("Baskerville Old Face", renderAll)?.faces, + ).toEqual({ + regular: true, + bold: false, + italic: false, + boldItalic: false, + }); + }); + + test("a Regular-only substitute is returned for Regular and NULL for bold/italic", () => { + // Baskerville -> Bacasime is Regular-only. The face-safe lookup must not route bold to it. + expect( + getRenderableFallbackForFace("Baskerville Old Face", "regular", renderAll) + ?.substituteFamily, + ).toBe("Bacasime Antique"); + expect( + getRenderableFallbackForFace("Baskerville Old Face", "bold", renderAll), + ).toBeNull(); + expect( + getRenderableFallbackForFace("Baskerville Old Face", "italic", renderAll), + ).toBeNull(); + }); + + test("an uncovered face is `face_missing`, not `unknown` or null collapse", () => { + expect( + getFallbackDecisionForFace( + "Baskerville Old Face", + "boldItalic", + renderAll, + ), + ).toEqual({ + kind: "face_missing", + substituteFamily: "Bacasime Antique", + evidenceId: "baskerville-old-face", + }); + }); + + test("a covered face carries its OWN verdict, not the worst-face rollup", () => { + // Cambria rolls up to visual_only (Bold Italic), but the regular face is metric_safe. + expect(getRenderableFallback("Cambria", renderAll)?.verdict).toBe( + "visual_only", + ); + expect( + getRenderableFallbackForFace("Cambria", "regular", renderAll)?.verdict, + ).toBe("metric_safe"); + expect( + getRenderableFallbackForFace("Cambria", "boldItalic", renderAll)?.verdict, + ).toBe("visual_only"); + }); + + test("face-aware lookups stay asset-aware and honest for non-fallback families", () => { + // Not bundled -> asset_missing (face is moot). Unknown / policy rows pass through unchanged. + expect( + getFallbackDecisionForFace("Baskerville Old Face", "regular", { + canRenderFamily: () => false, + }).kind, + ).toBe("asset_missing"); + expect(getFallbackDecisionForFace("Foo Unknown", "regular").kind).toBe( + "unknown", + ); + expect(getFallbackDecisionForFace("Aptos", "regular").kind).toBe( + "customer_supplied", + ); + }); + + test("a category fallback (all-false faces) is NOT face-scoped: it renders for EVERY face", () => { + // Regression: Calibri Light -> Carlito is a category_fallback with all-false faces. That means + // "not face-scoped", not "no faces" - it must render for every face, never face_missing. + const carlito = { canRenderFamily: (f: string) => f === "Carlito" }; + for (const face of ["regular", "bold", "italic", "boldItalic"] as const) { + expect( + getRenderableFallbackForFace("Calibri Light", face, carlito) + ?.substituteFamily, + `Calibri Light ${face}`, + ).toBe("Carlito"); + } + // ...while a genuinely face-scoped Regular-only row still gates bold to face_missing. + expect( + getFallbackDecisionForFace("Baskerville Old Face", "bold", { + canRenderFamily: () => true, + }).kind, + ).toBe("face_missing"); + }); +}); diff --git a/packages/fallbacks/src/fallbacks.ts b/packages/fallbacks/src/fallbacks.ts index c4a1bbc..2324412 100644 --- a/packages/fallbacks/src/fallbacks.ts +++ b/packages/fallbacks/src/fallbacks.ts @@ -1,12 +1,14 @@ /** - * 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. + * Fallback lookups over the reviewed evidence: + * - getRenderableFallback / getFallbackDecision - family-level ("which family", + the full outcome). + * - getRenderableFallbackForFace / getFallbackDecisionForFace - face-SAFE: a Regular-only substitute + * returns null / `face_missing` for bold/italic instead of being wrongly routed to a face it lacks. + * - createFallbackMap - a family-level resolver map (asset-gated). Each entry carries `faces`, so a + * consumer can route per-face; for Regular-only rows it MUST, or use the face-aware lookups. */ import { SUBSTITUTION_EVIDENCE } from "./data.js"; import type { + FaceSlot, FallbackDecision, FontFallback, SubstitutionEvidence, @@ -54,16 +56,22 @@ const BY_LOGICAL: ReadonlyMap = new Map( ]), ); -/** Build the FontFallback for a row known to carry a renderable physical family. */ +/** + * Build the FontFallback for a row known to carry a renderable physical family. `verdict` is passed in + * so a face-aware caller can supply the per-face verdict (faceVerdicts[face]) instead of the worst-face + * top-level one - e.g. Cambria regular is metric_safe even though the family rolls up to visual_only. + */ function buildFallback( row: SubstitutionEvidence, physicalFamily: string, + verdict: Verdict, ): FontFallback { return { substituteFamily: physicalFamily, policyAction: row.policyAction, - verdict: row.verdict, - lineBreakSafe: LINE_BREAK_SAFE_VERDICTS.has(row.verdict), + verdict, + lineBreakSafe: LINE_BREAK_SAFE_VERDICTS.has(verdict), + faces: row.faces, evidenceId: row.evidenceId, }; } @@ -91,7 +99,46 @@ function decideRow( verdict, evidenceId, }; - return { kind: "fallback", fallback: buildFallback(row, physicalFamily) }; + return { + kind: "fallback", + fallback: buildFallback(row, physicalFamily, verdict), + }; +} + +/** True when a row actually scopes faces (any RIBBI face marked covered). An all-false `faces` means + * the row is NOT face-scoped - e.g. a category fallback whose physical font does have faces - so it + * must not be gated per-face, only a measured per-face substitute (Baskerville Regular-only) is. */ +function isFaceScoped(row: SubstitutionEvidence): boolean { + const f = row.faces; + return f.regular || f.bold || f.italic || f.boldItalic; +} + +/** + * Face-aware variant of {@link decideRow}: same family-level outcome, but when the family HAS a + * renderable substitute AND the row is face-scoped, gate on whether it provides the requested `face`. + * A face a face-scoped substitute does not cover yields `face_missing` (route it through absence + * handling); a covered face yields a fallback carrying that face's own verdict. A NON-face-scoped row + * (category fallback, all-false `faces`) renders for any face - it never becomes face_missing. + */ +function decideRowForFace( + row: SubstitutionEvidence, + face: FaceSlot, + canRenderFamily: CanRenderFamily | undefined, +): FallbackDecision { + const base = decideRow(row, canRenderFamily); + // Non-fallback outcomes (asset_missing / no_recommended_fallback / policy) do not depend on the face. + if (base.kind !== "fallback") return base; + if (isFaceScoped(row) && !row.faces[face]) + return { + kind: "face_missing", + substituteFamily: base.fallback.substituteFamily, + evidenceId: row.evidenceId, + }; + const faceVerdict = row.faceVerdicts?.[face] ?? row.verdict; + return { + kind: "fallback", + fallback: buildFallback(row, base.fallback.substituteFamily, faceVerdict), + }; } /** @@ -121,9 +168,42 @@ export function getRenderableFallback( } /** - * The renderer's substitute map: every fallback the consumer can actually render, keyed by the - * 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. + * Face-aware outcome for a requested family + RIBBI face. Like {@link getFallbackDecision} but adds + * `face_missing` when the substitute exists yet does not provide that face (a Regular-only row asked + * for bold/italic). A covered face's fallback carries that face's own verdict. Case- and quote-insensitive. + */ +export function getFallbackDecisionForFace( + family: string, + face: FaceSlot, + options: FallbackDecisionOptions = {}, +): FallbackDecision { + const row = BY_LOGICAL.get(normalizeFamilyName(family)); + return row + ? decideRowForFace(row, face, options.canRenderFamily) + : { kind: "unknown" }; +} + +/** + * The open family to render for a requested font AND a specific face, or null when that face has no + * renderable substitute (face not covered, no row, a non-substitution policy, or not bundled). This is + * the face-SAFE lookup: a Regular-only substitute returns null for bold/italic instead of being routed + * to a face it does not have. Use {@link getFallbackDecisionForFace} to report the reason. + */ +export function getRenderableFallbackForFace( + family: string, + face: FaceSlot, + options: RenderableFallbackOptions, +): FontFallback | null { + const decision = getFallbackDecisionForFace(family, face, options); + return decision.kind === "fallback" ? decision.fallback : null; +} + +/** + * A family-level substitute map: every fallback the consumer can render, keyed by the normalized + * (lowercased) logical family - normalize lookups with {@link normalizeFamilyName}. Only renderable + * rows are included. Each entry carries `faces`; a face-scoped (e.g. Regular-only) row is only safe in + * a FACE-AWARE resolver - one that checks `faces` or uses {@link getRenderableFallbackForFace} - since + * applying a Regular-only entry to bold/italic would route a face the substitute does not provide. */ export function createFallbackMap( options: RenderableFallbackOptions, diff --git a/packages/fallbacks/src/index.ts b/packages/fallbacks/src/index.ts index 75422f5..47e6545 100644 --- a/packages/fallbacks/src/index.ts +++ b/packages/fallbacks/src/index.ts @@ -8,7 +8,9 @@ export { createFallbackMap, type FallbackDecisionOptions, getFallbackDecision, + getFallbackDecisionForFace, getRenderableFallback, + getRenderableFallbackForFace, normalizeFamilyName, type RenderableFallbackOptions, } from "./fallbacks.js"; diff --git a/packages/fallbacks/src/types.ts b/packages/fallbacks/src/types.ts index 7d25d54..a239aaf 100644 --- a/packages/fallbacks/src/types.ts +++ b/packages/fallbacks/src/types.ts @@ -109,6 +109,15 @@ export interface FontFallback { * the row's `faceVerdicts`) for the precise tier. */ lineBreakSafe: boolean; + /** + * Reviewed face coverage: which RIBBI faces this substitute is PROVEN to supply. A renderer MUST + * respect a face-scoped row: it can be Regular-only (e.g. Baskerville -> Bacasime, Cooper Black -> + * Caprasimo), and routing bold/italic to a face it lacks is wrong. NOTE: an all-false `faces` means + * the row is NOT face-scoped (e.g. a category fallback, whose physical font does have faces), NOT + * that the font has no faces - such rows render for any face. The face-aware helpers + * ({@link getRenderableFallbackForFace}) encode this rule for you. + */ + faces: FaceCoverage; /** stable reviewed-evidence id; look the full row up in {@link SUBSTITUTION_EVIDENCE}. */ evidenceId: string; } @@ -118,8 +127,9 @@ export interface FontFallback { * 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, ...). + * (`preserve_only`, `customer_supplied`). The face-aware lookups add `face_missing`: a substitute is + * recommended for the family but does NOT provide the requested face. `evidenceId` on the terminal + * kinds points back into {@link SUBSTITUTION_EVIDENCE} for the full row (verdict, faces, ...). */ export type FallbackDecision = | { kind: "fallback"; fallback: FontFallback } @@ -129,6 +139,13 @@ export type FallbackDecision = verdict: Verdict; evidenceId: string; } + | { + /** the family has a renderable substitute, but it does not provide the requested face - route + * this face through face-aware absence handling, do NOT substitute it. */ + kind: "face_missing"; + substituteFamily: string; + evidenceId: string; + } | { kind: "no_recommended_fallback"; evidenceId: string } | { kind: "customer_supplied"; evidenceId: string } | { kind: "preserve_only"; evidenceId: string }