diff --git a/packages/fallbacks/README.md b/packages/fallbacks/README.md index 02bc35c..a55d56e 100644 --- a/packages/fallbacks/README.md +++ b/packages/fallbacks/README.md @@ -86,6 +86,7 @@ Keys are normalized. Use `normalizeFamilyName` for lookups. Rows whose substitut - `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`. +- `glyphExceptions` - named glyph-level divergences that qualify this fallback (e.g. one codepoint reflows), or omitted when none. A family lookup carries all of the row's; a face lookup (`getRenderableFallbackForFace`) carries only that face's, so Cambria Regular shows none while Bold Italic shows its grave-accent exception. `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. diff --git a/packages/fallbacks/fallbacks.test.ts b/packages/fallbacks/fallbacks.test.ts index e152714..c815a5e 100644 --- a/packages/fallbacks/fallbacks.test.ts +++ b/packages/fallbacks/fallbacks.test.ts @@ -7,6 +7,7 @@ import { describe, expect, test } from "bun:test"; import { createFallbackMap, + type GlyphException, getFallbackDecision, getFallbackDecisionForFace, getRenderableFallback, @@ -235,3 +236,56 @@ describe("face-aware lookups (Regular-only safety)", () => { ).toBe("face_missing"); }); }); + +describe("glyphExceptions projection", () => { + const renderAll = { canRenderFamily: () => true }; + + test("a family lookup carries ALL the row's glyph exceptions", () => { + // Cambria -> Caladea's only exception is the Bold Italic grave accent (U+0060). + const fb = getRenderableFallback("Cambria", renderAll); + expect(fb?.verdict).toBe("visual_only"); + expect(fb?.glyphExceptions).toMatchObject([ + { slot: "boldItalic", codepoint: 0x60 }, + ]); + }); + + test("a face lookup carries ONLY that face's exceptions (and per-face verdict)", () => { + const regular = getRenderableFallbackForFace( + "Cambria", + "regular", + renderAll, + ); + expect(regular?.verdict).toBe("metric_safe"); + expect(regular?.glyphExceptions).toBeUndefined(); // no Bold-Italic exception leaks onto Regular + + const boldItalic = getRenderableFallbackForFace( + "Cambria", + "boldItalic", + renderAll, + ); + expect(boldItalic?.verdict).toBe("visual_only"); + expect(boldItalic?.glyphExceptions).toMatchObject([ + { slot: "boldItalic", codepoint: 0x60 }, + ]); + }); + + test("a fallback with no exceptions omits the field entirely", () => { + expect( + getRenderableFallback("Calibri", renderAll)?.glyphExceptions, + ).toBeUndefined(); + expect( + getRenderableFallbackForFace("Calibri", "regular", renderAll) + ?.glyphExceptions, + ).toBeUndefined(); + }); + + test("returns a FRESH array each call - mutating it does not corrupt a later lookup", () => { + const first = getRenderableFallback("Cambria", renderAll) + ?.glyphExceptions as GlyphException[]; + expect(first.length).toBe(1); + first.push(first[0]); // a careless consumer mutates the returned array + expect( + getRenderableFallback("Cambria", renderAll)?.glyphExceptions, + ).toHaveLength(1); + }); +}); diff --git a/packages/fallbacks/src/fallbacks.ts b/packages/fallbacks/src/fallbacks.ts index 2324412..c9a1729 100644 --- a/packages/fallbacks/src/fallbacks.ts +++ b/packages/fallbacks/src/fallbacks.ts @@ -60,12 +60,22 @@ const BY_LOGICAL: ReadonlyMap = new Map( * 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. + * `faceSlot` (a face lookup) scopes the glyph exceptions to that face; omitting it (a family lookup) + * carries all of the row's. Empty exception sets are dropped so the field is present only when it bites. */ function buildFallback( row: SubstitutionEvidence, physicalFamily: string, verdict: Verdict, + faceSlot?: FaceSlot, ): FontFallback { + // Always hand back a FRESH array - filter() already copies; the family path must copy too, or a + // consumer mutating it would corrupt the shared evidence row for later lookups. + const glyphExceptions = faceSlot + ? row.glyphExceptions?.filter((g) => g.slot === faceSlot) + : row.glyphExceptions + ? [...row.glyphExceptions] + : undefined; return { substituteFamily: physicalFamily, policyAction: row.policyAction, @@ -73,6 +83,9 @@ function buildFallback( lineBreakSafe: LINE_BREAK_SAFE_VERDICTS.has(verdict), faces: row.faces, evidenceId: row.evidenceId, + ...(glyphExceptions && glyphExceptions.length > 0 + ? { glyphExceptions } + : {}), }; } @@ -137,7 +150,12 @@ function decideRowForFace( const faceVerdict = row.faceVerdicts?.[face] ?? row.verdict; return { kind: "fallback", - fallback: buildFallback(row, base.fallback.substituteFamily, faceVerdict), + fallback: buildFallback( + row, + base.fallback.substituteFamily, + faceVerdict, + face, + ), }; } diff --git a/packages/fallbacks/src/types.ts b/packages/fallbacks/src/types.ts index a239aaf..268a9f0 100644 --- a/packages/fallbacks/src/types.ts +++ b/packages/fallbacks/src/types.ts @@ -120,6 +120,14 @@ export interface FontFallback { faces: FaceCoverage; /** stable reviewed-evidence id; look the full row up in {@link SUBSTITUTION_EVIDENCE}. */ evidenceId: string; + /** + * Named glyph-level divergences that qualify this fallback (e.g. one codepoint reflows). Scoped to + * the lookup: a family lookup ({@link getRenderableFallback}) carries ALL of the row's exceptions; a + * face lookup ({@link getRenderableFallbackForFace}) carries only the requested face's. Omitted when + * none apply - so a renderer can surface a precise "this face reflows on U+0060" without re-deriving. + * A fresh array each call (readonly): mutating it never affects another lookup. + */ + glyphExceptions?: readonly GlyphException[]; } /**