From 84bec7ffbbdabf046749874c0ecab689e4384e8f Mon Sep 17 00:00:00 2001 From: Caio Pizzol Date: Tue, 9 Jun 2026 13:38:36 -0300 Subject: [PATCH] feat(fallbacks): expose synthetic face sources --- packages/fallbacks/README.md | 2 +- packages/fallbacks/fallbacks.test.ts | 56 +++++++++++++++++++--------- packages/fallbacks/records.json | 24 ++++++++++-- packages/fallbacks/src/data.ts | 24 ++++++++++-- packages/fallbacks/src/fallbacks.ts | 18 ++++++++- packages/fallbacks/src/index.ts | 2 + packages/fallbacks/src/types.ts | 23 ++++++++++-- 7 files changed, 120 insertions(+), 29 deletions(-) diff --git a/packages/fallbacks/README.md b/packages/fallbacks/README.md index 7ecb06e..11f55e2 100644 --- a/packages/fallbacks/README.md +++ b/packages/fallbacks/README.md @@ -64,7 +64,7 @@ const map = createFallbackMap({ map[normalizeFamilyName("Times New Roman")]; ``` -Some fallbacks are face-scoped. Use `getRenderableFallbackForFace`, or respect the returned `faces` field before applying a fallback to bold or italic text. +Some fallbacks are face-scoped. `faces` reports real face coverage. Use `getRenderableFallbackForFace` for styled text; a result may include `faceSource`, where `synthetic` means render from the indicated face and let your renderer synthesize the requested style. ## Fidelity fields diff --git a/packages/fallbacks/fallbacks.test.ts b/packages/fallbacks/fallbacks.test.ts index e3896e7..0d3f1a3 100644 --- a/packages/fallbacks/fallbacks.test.ts +++ b/packages/fallbacks/fallbacks.test.ts @@ -158,6 +158,20 @@ describe("normalizeFamilyName (public)", () => { describe("face-aware lookups (Regular-only safety)", () => { const renderAll = { canRenderFamily: () => true }; + test("synthetic face sources stay separate from real face coverage", () => { + for (const row of SUBSTITUTION_EVIDENCE) { + if (!row.faceSources) continue; + for (const face of ["regular", "bold", "italic", "boldItalic"] as const) { + const source = row.faceSources[face]; + if (source?.kind !== "synthetic") continue; + expect(row.faces[face], `${row.evidenceId} ${face}`).toBe(false); + expect(row.faceVerdicts?.[face], `${row.evidenceId} ${face}`).toBe( + "visual_only", + ); + } + } + }); + 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( @@ -348,42 +362,45 @@ describe("advance measurement basis", () => { }); }); -describe("Cooper Black -> Caprasimo (Regular-only, metric_safe)", () => { +describe("Cooper Black -> Caprasimo (real Regular plus synthetic faces)", () => { const renderAll = { canRenderFamily: () => true }; const onlyCaprasimo = { canRenderFamily: (f: string) => f === "Caprasimo" }; - test("the family resolves to Caprasimo as an exact, line-break-safe Regular substitute", () => { + test("the family resolves to Caprasimo with Regular-only real face coverage", () => { // Unlike Baskerville -> Bacasime (visual_only, NBSP reflows), Cooper measures 0% across the Latin - // core, so the row is metric_safe with no glyph exceptions. + // core for Regular. Styled faces are synthetic and intentionally roll the family to visual_only. expect(getRenderableFallback("Cooper Black", renderAll)).toEqual({ substituteFamily: "Caprasimo", policyAction: "substitute", - verdict: "metric_safe", - lineBreakSafe: true, + verdict: "visual_only", + lineBreakSafe: false, faces: { regular: true, bold: false, italic: false, boldItalic: false }, evidenceId: "cooper-black", generic: "serif", }); }); - test("Regular maps; bold/italic/boldItalic are face_missing (never faux-styled onto Caprasimo)", () => { + test("Regular maps as metric_safe; bold/italic/boldItalic map as synthetic visual_only faces", () => { + expect( + getRenderableFallbackForFace("Cooper Black", "regular", renderAll), + ).toMatchObject({ + substituteFamily: "Caprasimo", + verdict: "metric_safe", + lineBreakSafe: true, + }); expect( getRenderableFallbackForFace("Cooper Black", "regular", renderAll) - ?.substituteFamily, - ).toBe("Caprasimo"); + ?.faceSource, + ).toBeUndefined(); for (const face of ["bold", "italic", "boldItalic"] as const) { expect( getRenderableFallbackForFace("Cooper Black", face, renderAll), `Cooper Black ${face}`, - ).toBeNull(); - expect( - getFallbackDecisionForFace("Cooper Black", face, renderAll), - `Cooper Black ${face} decision`, - ).toEqual({ - kind: "face_missing", + ).toMatchObject({ substituteFamily: "Caprasimo", - evidenceId: "cooper-black", - generic: "serif", + verdict: "visual_only", + lineBreakSafe: false, + faceSource: { kind: "synthetic", from: "regular" }, }); } }); @@ -395,7 +412,7 @@ describe("Cooper Black -> Caprasimo (Regular-only, metric_safe)", () => { ).toEqual({ kind: "asset_missing", substituteFamily: "Caprasimo", - verdict: "metric_safe", + verdict: "visual_only", evidenceId: "cooper-black", generic: "serif", }); @@ -403,5 +420,10 @@ describe("Cooper Black -> Caprasimo (Regular-only, metric_safe)", () => { getRenderableFallbackForFace("Cooper Black", "regular", onlyCaprasimo) ?.substituteFamily, ).toBe("Caprasimo"); + expect( + getFallbackDecisionForFace("Cooper Black", "bold", { + canRenderFamily: () => false, + }).kind, + ).toBe("asset_missing"); }); }); diff --git a/packages/fallbacks/records.json b/packages/fallbacks/records.json index 839d504..f3d361a 100644 --- a/packages/fallbacks/records.json +++ b/packages/fallbacks/records.json @@ -666,13 +666,27 @@ "generic": "serif", "logicalFamily": "Cooper Black", "physicalFamily": "Caprasimo", - "verdict": "metric_safe", + "verdict": "visual_only", "faces": { "regular": true, "bold": false, "italic": false, "boldItalic": false }, + "faceSources": { + "bold": { + "kind": "synthetic", + "from": "regular" + }, + "italic": { + "kind": "synthetic", + "from": "regular" + }, + "boldItalic": { + "kind": "synthetic", + "from": "regular" + } + }, "gates": { "static": "pass", "metric": "pass", @@ -681,7 +695,8 @@ }, "policyAction": "substitute", "measurementRefs": [ - "cooper-black_regular__caprasimo#regular#w400#786ab84e#analytic_advance#2026-06-05" + "cooper-black_regular__caprasimo#regular#w400#786ab84e#analytic_advance#2026-06-05", + "cooper-black__caprasimo#synthetic_faces#visual_review#2026-06-09" ], "exportRule": "preserve_original_name", "advance": { @@ -691,7 +706,10 @@ }, "candidateLicense": "OFL-1.1", "faceVerdicts": { - "regular": "metric_safe" + "regular": "metric_safe", + "bold": "visual_only", + "italic": "visual_only", + "boldItalic": "visual_only" } } ] diff --git a/packages/fallbacks/src/data.ts b/packages/fallbacks/src/data.ts index 91fc927..1a059f4 100644 --- a/packages/fallbacks/src/data.ts +++ b/packages/fallbacks/src/data.ts @@ -689,13 +689,27 @@ export const SUBSTITUTION_EVIDENCE: readonly SubstitutionEvidence[] = [ "generic": "serif", "logicalFamily": "Cooper Black", "physicalFamily": "Caprasimo", - "verdict": "metric_safe", + "verdict": "visual_only", "faces": { "regular": true, "bold": false, "italic": false, "boldItalic": false }, + "faceSources": { + "bold": { + "kind": "synthetic", + "from": "regular" + }, + "italic": { + "kind": "synthetic", + "from": "regular" + }, + "boldItalic": { + "kind": "synthetic", + "from": "regular" + } + }, "gates": { "static": "pass", "metric": "pass", @@ -704,7 +718,8 @@ export const SUBSTITUTION_EVIDENCE: readonly SubstitutionEvidence[] = [ }, "policyAction": "substitute", "measurementRefs": [ - "cooper-black_regular__caprasimo#regular#w400#786ab84e#analytic_advance#2026-06-05" + "cooper-black_regular__caprasimo#regular#w400#786ab84e#analytic_advance#2026-06-05", + "cooper-black__caprasimo#synthetic_faces#visual_review#2026-06-09" ], "exportRule": "preserve_original_name", "advance": { @@ -714,7 +729,10 @@ export const SUBSTITUTION_EVIDENCE: readonly SubstitutionEvidence[] = [ }, "candidateLicense": "OFL-1.1", "faceVerdicts": { - "regular": "metric_safe" + "regular": "metric_safe", + "bold": "visual_only", + "italic": "visual_only", + "boldItalic": "visual_only" } } ]; diff --git a/packages/fallbacks/src/fallbacks.ts b/packages/fallbacks/src/fallbacks.ts index 5ea2ab4..034b51f 100644 --- a/packages/fallbacks/src/fallbacks.ts +++ b/packages/fallbacks/src/fallbacks.ts @@ -10,6 +10,7 @@ import { SUBSTITUTION_EVIDENCE } from "./data.js"; import type { FaceSlot, FallbackDecision, + FallbackFaceSource, FontFallback, SubstitutionEvidence, Verdict, @@ -68,6 +69,7 @@ function buildFallback( physicalFamily: string, verdict: Verdict, faceSlot?: FaceSlot, + faceSource?: FallbackFaceSource, ): 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. @@ -84,6 +86,7 @@ function buildFallback( faces: row.faces, evidenceId: row.evidenceId, generic: row.generic, + ...(faceSource ? { faceSource: { ...faceSource } } : {}), ...(glyphExceptions && glyphExceptions.length > 0 ? { glyphExceptions } : {}), @@ -128,6 +131,15 @@ function isFaceScoped(row: SubstitutionEvidence): boolean { return f.regular || f.bold || f.italic || f.boldItalic; } +function faceSourceFor( + row: SubstitutionEvidence, + face: FaceSlot, +): FallbackFaceSource | undefined { + const explicit = row.faceSources?.[face]; + if (explicit) return explicit; + return isFaceScoped(row) && row.faces[face] ? { kind: "real" } : undefined; +} + /** * 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`. @@ -143,7 +155,8 @@ function decideRowForFace( 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]) + const faceSource = faceSourceFor(row, face); + if (isFaceScoped(row) && !faceSource) return { kind: "face_missing", substituteFamily: base.fallback.substituteFamily, @@ -151,6 +164,8 @@ function decideRowForFace( generic: row.generic, }; const faceVerdict = row.faceVerdicts?.[face] ?? row.verdict; + const projectedFaceSource = + faceSource?.kind === "synthetic" ? faceSource : undefined; return { kind: "fallback", fallback: buildFallback( @@ -158,6 +173,7 @@ function decideRowForFace( base.fallback.substituteFamily, faceVerdict, face, + projectedFaceSource, ), }; } diff --git a/packages/fallbacks/src/index.ts b/packages/fallbacks/src/index.ts index b72d0b0..a7b430a 100644 --- a/packages/fallbacks/src/index.ts +++ b/packages/fallbacks/src/index.ts @@ -21,6 +21,8 @@ export type { FaceCoverage, FaceSlot, FallbackDecision, + FallbackFaceSource, + FallbackFaceSources, FontFallback, GlyphException, PolicyAction, diff --git a/packages/fallbacks/src/types.ts b/packages/fallbacks/src/types.ts index 8233e1a..4b45b21 100644 --- a/packages/fallbacks/src/types.ts +++ b/packages/fallbacks/src/types.ts @@ -57,6 +57,13 @@ export interface FaceCoverage { boldItalic: boolean; } +/** How a reviewed fallback supplies one requested face. */ +export type FallbackFaceSource = + | { kind: "real" } + | { kind: "synthetic"; from: FaceSlot }; + +export type FallbackFaceSources = Partial>; + /** The four derived gate statuses behind a verdict; the proof is the referenced measurements. */ export interface SubstituteGates { static: GateStatus; @@ -90,6 +97,8 @@ export interface SubstitutionEvidence { verdict: Verdict; /** per-face verdicts, AUTHORITATIVE when present (a QUALIFIED substitute); top-level = worst face. */ faceVerdicts?: Partial>; + /** per-face render sources for reviewed synthetic faces. Real faces stay represented by `faces`. */ + faceSources?: FallbackFaceSources; /** named glyph-level divergences that qualify a face. */ glyphExceptions?: GlyphException[]; faces: FaceCoverage; @@ -129,13 +138,19 @@ export interface FontFallback { 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 + * respect a face-scoped row: it can be Regular-only (e.g. Baskerville -> Bacasime), 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; + /** + * Present on face-aware lookup results when docfonts knows the requested face should render from a + * synthetic source. Render from `from` and let the renderer synthesize the requested face. Omitted + * for real faces, family-level lookups, and non-face-scoped rows. + */ + faceSource?: FallbackFaceSource; /** stable reviewed-evidence id; look the full row up in {@link SUBSTITUTION_EVIDENCE}. */ evidenceId: string; /** the logical font's broad CSS category, for a last-resort generic `font-family` keyword. */