Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions packages/fallbacks/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
54 changes: 54 additions & 0 deletions packages/fallbacks/fallbacks.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
import { describe, expect, test } from "bun:test";
import {
createFallbackMap,
type GlyphException,
getFallbackDecision,
getFallbackDecisionForFace,
getRenderableFallback,
Expand Down Expand Up @@ -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);
});
});
20 changes: 19 additions & 1 deletion packages/fallbacks/src/fallbacks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -60,19 +60,32 @@ const BY_LOGICAL: ReadonlyMap<string, SubstitutionEvidence> = 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,
verdict,
lineBreakSafe: LINE_BREAK_SAFE_VERDICTS.has(verdict),
faces: row.faces,
evidenceId: row.evidenceId,
...(glyphExceptions && glyphExceptions.length > 0
? { glyphExceptions }
: {}),
};
}

Expand Down Expand Up @@ -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,
),
};
}

Expand Down
8 changes: 8 additions & 0 deletions packages/fallbacks/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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[];
}

/**
Expand Down
Loading