diff --git a/packages/fallbacks/README.md b/packages/fallbacks/README.md index 7ecb06e..b3e2a3a 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 @@ -80,3 +80,5 @@ Some fallbacks are face-scoped. Use `getRenderableFallbackForFace`, or respect t ## Provenance Measurements are produced against licensed originals. This package distributes no proprietary binaries, raw proprietary metrics, or font files. + +`candidateLicense` is a candidate license id or expression: SPDX when exact, stable docfonts label otherwise. diff --git a/packages/fallbacks/fallbacks.test.ts b/packages/fallbacks/fallbacks.test.ts index e3896e7..bf60236 100644 --- a/packages/fallbacks/fallbacks.test.ts +++ b/packages/fallbacks/fallbacks.test.ts @@ -54,15 +54,15 @@ describe("getFallbackDecision", () => { }); 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". + // The whole point of the decision API: docfonts MEASURED Aptos and Verdana; it never heard of "Foo". expect(getFallbackDecision("Aptos")).toEqual({ kind: "customer_supplied", evidenceId: "aptos", generic: "sans-serif", }); - expect(getFallbackDecision("Tahoma")).toEqual({ + expect(getFallbackDecision("Verdana")).toEqual({ kind: "no_recommended_fallback", - evidenceId: "tahoma", + evidenceId: "verdana", generic: "sans-serif", }); expect(getFallbackDecision("Cambria Math")).toEqual({ @@ -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( @@ -300,6 +314,87 @@ describe("glyphExceptions projection", () => { }); }); +describe("selected visual fallback rows", () => { + const renderAll = { canRenderFamily: () => true }; + + test("newly reviewed visual rows resolve to their selected families without claiming line-break safety", () => { + const expected = [ + ["Arial Black", "Archivo Black", "substitute"], + ["Arial Rounded MT Bold", "Ubuntu", "category_fallback"], + ["Bookman Old Style", "TeX Gyre Bonum", "substitute"], + ["Century", "C059", "substitute"], + ["Comic Sans MS", "Comic Relief", "category_fallback"], + ["Garamond", "Cardo", "category_fallback"], + ["Gill Sans MT Condensed", "PT Sans Narrow", "category_fallback"], + ["Tahoma", "Noto Sans", "category_fallback"], + ["Trebuchet MS", "PT Sans", "category_fallback"], + ] as const; + + for (const [logical, physical, policyAction] of expected) { + expect(getRenderableFallback(logical, renderAll), logical).toMatchObject({ + substituteFamily: physical, + policyAction, + verdict: "visual_only", + lineBreakSafe: false, + }); + } + }); + + test("rows with synthetic selected faces expose render instructions without marking those faces real", () => { + expect( + getRenderableFallbackForFace("Arial Black", "italic", renderAll), + ).toMatchObject({ + substituteFamily: "Archivo Black", + faceSource: { kind: "synthetic", from: "regular" }, + faces: { regular: true, bold: false, italic: false, boldItalic: false }, + }); + expect( + getFallbackDecisionForFace("Arial Black", "bold", renderAll).kind, + ).toBe("face_missing"); + + expect( + getRenderableFallbackForFace("Comic Sans MS", "boldItalic", renderAll), + ).toMatchObject({ + substituteFamily: "Comic Relief", + faceSource: { kind: "synthetic", from: "bold" }, + faces: { regular: true, bold: true, italic: false, boldItalic: false }, + }); + + expect( + getRenderableFallbackForFace( + "Gill Sans MT Condensed", + "italic", + renderAll, + ), + ).toMatchObject({ + substituteFamily: "PT Sans Narrow", + faceSource: { kind: "synthetic", from: "regular" }, + faces: { regular: true, bold: true, italic: false, boldItalic: false }, + }); + }); +}); + +describe("candidate license metadata", () => { + test("uses public license identifiers or stable docfonts labels", () => { + const LICENSE_IDS = new Set([ + "AGPL-3.0-only WITH PS-or-PDF-font-exception-20170817", + "Apache-2.0", + "GPLv2-with-font-exception", + "GUST-Font-License-1.0", + "OFL-1.1", + "Ubuntu-font-1.0", + ]); + + for (const row of SUBSTITUTION_EVIDENCE) { + if (!row.candidateLicense) continue; + expect( + LICENSE_IDS.has(row.candidateLicense), + `${row.evidenceId} (${row.candidateLicense})`, + ).toBe(true); + } + }); +}); + describe("generic CSS family metadata", () => { const renderAll = { canRenderFamily: () => true }; @@ -348,42 +443,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 +493,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 +501,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..a45e4a4 100644 --- a/packages/fallbacks/records.json +++ b/packages/fallbacks/records.json @@ -292,6 +292,143 @@ "exportRule": "preserve_original_name", "candidateLicense": null }, + { + "evidenceId": "arial-black", + "generic": "sans-serif", + "logicalFamily": "Arial Black", + "physicalFamily": "Archivo Black", + "verdict": "visual_only", + "faces": { + "regular": true, + "bold": false, + "italic": false, + "boldItalic": false + }, + "faceSources": { + "italic": { + "kind": "synthetic", + "from": "regular" + } + }, + "gates": { + "static": "pass", + "metric": "fail", + "layout": "not_run", + "ship": "fail" + }, + "policyAction": "substitute", + "measurementRefs": ["arial-black__archivo-black#visual_review#2026-06-09"], + "exportRule": "preserve_original_name", + "candidateLicense": "OFL-1.1", + "faceVerdicts": { + "italic": "visual_only" + } + }, + { + "evidenceId": "arial-rounded-mt-bold", + "generic": "sans-serif", + "logicalFamily": "Arial Rounded MT Bold", + "physicalFamily": "Ubuntu", + "verdict": "visual_only", + "faces": { + "regular": true, + "bold": true, + "italic": true, + "boldItalic": true + }, + "gates": { + "static": "pass", + "metric": "fail", + "layout": "not_run", + "ship": "fail" + }, + "policyAction": "category_fallback", + "measurementRefs": [ + "arial-rounded-mt-bold__ubuntu#visual_review#2026-06-09" + ], + "exportRule": "preserve_original_name", + "candidateLicense": "Ubuntu-font-1.0" + }, + { + "evidenceId": "bookman-old-style", + "generic": "serif", + "logicalFamily": "Bookman Old Style", + "physicalFamily": "TeX Gyre Bonum", + "verdict": "visual_only", + "faces": { + "regular": true, + "bold": true, + "italic": true, + "boldItalic": true + }, + "gates": { + "static": "pass", + "metric": "fail", + "layout": "not_run", + "ship": "fail" + }, + "policyAction": "substitute", + "measurementRefs": [ + "bookman-old-style__tex-gyre-bonum#visual_review#2026-06-09" + ], + "exportRule": "preserve_original_name", + "candidateLicense": "GUST-Font-License-1.0" + }, + { + "evidenceId": "century", + "generic": "serif", + "logicalFamily": "Century", + "physicalFamily": "C059", + "verdict": "visual_only", + "faces": { + "regular": true, + "bold": true, + "italic": true, + "boldItalic": true + }, + "gates": { + "static": "pass", + "metric": "fail", + "layout": "not_run", + "ship": "fail" + }, + "policyAction": "substitute", + "measurementRefs": ["century__c059#visual_review#2026-06-09"], + "exportRule": "preserve_original_name", + "candidateLicense": "AGPL-3.0-only WITH PS-or-PDF-font-exception-20170817" + }, + { + "evidenceId": "garamond", + "generic": "serif", + "logicalFamily": "Garamond", + "physicalFamily": "Cardo", + "verdict": "visual_only", + "faces": { + "regular": true, + "bold": true, + "italic": true, + "boldItalic": false + }, + "faceSources": { + "boldItalic": { + "kind": "synthetic", + "from": "bold" + } + }, + "gates": { + "static": "pass", + "metric": "fail", + "layout": "not_run", + "ship": "fail" + }, + "policyAction": "category_fallback", + "measurementRefs": ["garamond__cardo#visual_review#2026-06-09"], + "exportRule": "preserve_original_name", + "candidateLicense": "OFL-1.1", + "faceVerdicts": { + "boldItalic": "visual_only" + } + }, { "evidenceId": "consolas", "generic": "monospace", @@ -349,77 +486,84 @@ "evidenceId": "tahoma", "generic": "sans-serif", "logicalFamily": "Tahoma", - "physicalFamily": null, + "physicalFamily": "Noto Sans", "verdict": "visual_only", "faces": { - "regular": false, - "bold": false, - "italic": false, - "boldItalic": false + "regular": true, + "bold": true, + "italic": true, + "boldItalic": true }, "gates": { - "static": "not_run", + "static": "pass", "metric": "fail", "layout": "not_run", - "ship": "not_run" + "ship": "fail" }, "policyAction": "category_fallback", - "measurementRefs": ["tahoma#top_candidates#2026-06-03"], + "measurementRefs": ["tahoma__noto-sans#visual_review#2026-06-09"], "exportRule": "preserve_original_name", - "candidateLicense": null + "candidateLicense": "OFL-1.1" }, { "evidenceId": "trebuchet-ms", "generic": "sans-serif", "logicalFamily": "Trebuchet MS", - "physicalFamily": null, + "physicalFamily": "PT Sans", "verdict": "visual_only", "faces": { - "regular": false, - "bold": false, - "italic": false, - "boldItalic": false + "regular": true, + "bold": true, + "italic": true, + "boldItalic": true }, "gates": { - "static": "not_run", + "static": "pass", "metric": "fail", "layout": "not_run", - "ship": "not_run" + "ship": "fail" }, "policyAction": "category_fallback", - "measurementRefs": ["trebuchet-ms#top_candidates#2026-06-03"], + "measurementRefs": ["trebuchet-ms__pt-sans#visual_review#2026-06-09"], "exportRule": "preserve_original_name", - "candidateLicense": null + "candidateLicense": "OFL-1.1" }, { "evidenceId": "comic-sans-ms", "generic": "sans-serif", "logicalFamily": "Comic Sans MS", - "physicalFamily": "Comic Neue", + "physicalFamily": "Comic Relief", "verdict": "visual_only", "faces": { - "regular": false, - "bold": false, + "regular": true, + "bold": true, "italic": false, "boldItalic": false }, + "faceSources": { + "italic": { + "kind": "synthetic", + "from": "regular" + }, + "boldItalic": { + "kind": "synthetic", + "from": "bold" + } + }, "gates": { - "static": "not_run", + "static": "pass", "metric": "fail", "layout": "not_run", - "ship": "not_run" + "ship": "fail" }, "policyAction": "category_fallback", - "measurementRefs": [ - "comic-sans-ms__comic-neue#analytic_advance#2026-06-03" - ], + "measurementRefs": ["comic-sans-ms__comic-relief#visual_review#2026-06-09"], "exportRule": "preserve_original_name", - "advance": { - "basis": "latin_full", - "meanDelta": 0.1005, - "maxDelta": 0.1419 - }, - "candidateLicense": "OFL-1.1" + "candidateLicense": "OFL-1.1", + "faceVerdicts": { + "italic": "visual_only", + "boldItalic": "visual_only" + } }, { "evidenceId": "candara", @@ -518,6 +662,47 @@ }, "candidateLicense": "OFL-1.1" }, + { + "evidenceId": "gill-sans-mt-condensed", + "generic": "sans-serif", + "logicalFamily": "Gill Sans MT Condensed", + "physicalFamily": "PT Sans Narrow", + "verdict": "visual_only", + "faces": { + "regular": true, + "bold": true, + "italic": false, + "boldItalic": false + }, + "faceSources": { + "italic": { + "kind": "synthetic", + "from": "regular" + }, + "boldItalic": { + "kind": "synthetic", + "from": "bold" + } + }, + "gates": { + "static": "pass", + "metric": "fail", + "layout": "not_run", + "ship": "fail" + }, + "policyAction": "category_fallback", + "measurementRefs": [ + "gill-sans-mt-condensed__pt-sans-narrow#visual_review#2026-06-09" + ], + "exportRule": "preserve_original_name", + "candidateLicense": "OFL-1.1", + "faceVerdicts": { + "regular": "visual_only", + "bold": "visual_only", + "italic": "visual_only", + "boldItalic": "visual_only" + } + }, { "evidenceId": "aptos-display", "generic": "sans-serif", @@ -666,13 +851,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 +880,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 +891,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..4e68622 100644 --- a/packages/fallbacks/src/data.ts +++ b/packages/fallbacks/src/data.ts @@ -299,6 +299,149 @@ export const SUBSTITUTION_EVIDENCE: readonly SubstitutionEvidence[] = [ "exportRule": "preserve_original_name", "candidateLicense": null }, + { + "evidenceId": "arial-black", + "generic": "sans-serif", + "logicalFamily": "Arial Black", + "physicalFamily": "Archivo Black", + "verdict": "visual_only", + "faces": { + "regular": true, + "bold": false, + "italic": false, + "boldItalic": false + }, + "faceSources": { + "italic": { + "kind": "synthetic", + "from": "regular" + } + }, + "gates": { + "static": "pass", + "metric": "fail", + "layout": "not_run", + "ship": "fail" + }, + "policyAction": "substitute", + "measurementRefs": [ + "arial-black__archivo-black#visual_review#2026-06-09" + ], + "exportRule": "preserve_original_name", + "candidateLicense": "OFL-1.1", + "faceVerdicts": { + "italic": "visual_only" + } + }, + { + "evidenceId": "arial-rounded-mt-bold", + "generic": "sans-serif", + "logicalFamily": "Arial Rounded MT Bold", + "physicalFamily": "Ubuntu", + "verdict": "visual_only", + "faces": { + "regular": true, + "bold": true, + "italic": true, + "boldItalic": true + }, + "gates": { + "static": "pass", + "metric": "fail", + "layout": "not_run", + "ship": "fail" + }, + "policyAction": "category_fallback", + "measurementRefs": [ + "arial-rounded-mt-bold__ubuntu#visual_review#2026-06-09" + ], + "exportRule": "preserve_original_name", + "candidateLicense": "Ubuntu-font-1.0" + }, + { + "evidenceId": "bookman-old-style", + "generic": "serif", + "logicalFamily": "Bookman Old Style", + "physicalFamily": "TeX Gyre Bonum", + "verdict": "visual_only", + "faces": { + "regular": true, + "bold": true, + "italic": true, + "boldItalic": true + }, + "gates": { + "static": "pass", + "metric": "fail", + "layout": "not_run", + "ship": "fail" + }, + "policyAction": "substitute", + "measurementRefs": [ + "bookman-old-style__tex-gyre-bonum#visual_review#2026-06-09" + ], + "exportRule": "preserve_original_name", + "candidateLicense": "GUST-Font-License-1.0" + }, + { + "evidenceId": "century", + "generic": "serif", + "logicalFamily": "Century", + "physicalFamily": "C059", + "verdict": "visual_only", + "faces": { + "regular": true, + "bold": true, + "italic": true, + "boldItalic": true + }, + "gates": { + "static": "pass", + "metric": "fail", + "layout": "not_run", + "ship": "fail" + }, + "policyAction": "substitute", + "measurementRefs": [ + "century__c059#visual_review#2026-06-09" + ], + "exportRule": "preserve_original_name", + "candidateLicense": "AGPL-3.0-only WITH PS-or-PDF-font-exception-20170817" + }, + { + "evidenceId": "garamond", + "generic": "serif", + "logicalFamily": "Garamond", + "physicalFamily": "Cardo", + "verdict": "visual_only", + "faces": { + "regular": true, + "bold": true, + "italic": true, + "boldItalic": false + }, + "faceSources": { + "boldItalic": { + "kind": "synthetic", + "from": "bold" + } + }, + "gates": { + "static": "pass", + "metric": "fail", + "layout": "not_run", + "ship": "fail" + }, + "policyAction": "category_fallback", + "measurementRefs": [ + "garamond__cardo#visual_review#2026-06-09" + ], + "exportRule": "preserve_original_name", + "candidateLicense": "OFL-1.1", + "faceVerdicts": { + "boldItalic": "visual_only" + } + }, { "evidenceId": "consolas", "generic": "monospace", @@ -358,81 +501,90 @@ export const SUBSTITUTION_EVIDENCE: readonly SubstitutionEvidence[] = [ "evidenceId": "tahoma", "generic": "sans-serif", "logicalFamily": "Tahoma", - "physicalFamily": null, + "physicalFamily": "Noto Sans", "verdict": "visual_only", "faces": { - "regular": false, - "bold": false, - "italic": false, - "boldItalic": false + "regular": true, + "bold": true, + "italic": true, + "boldItalic": true }, "gates": { - "static": "not_run", + "static": "pass", "metric": "fail", "layout": "not_run", - "ship": "not_run" + "ship": "fail" }, "policyAction": "category_fallback", "measurementRefs": [ - "tahoma#top_candidates#2026-06-03" + "tahoma__noto-sans#visual_review#2026-06-09" ], "exportRule": "preserve_original_name", - "candidateLicense": null + "candidateLicense": "OFL-1.1" }, { "evidenceId": "trebuchet-ms", "generic": "sans-serif", "logicalFamily": "Trebuchet MS", - "physicalFamily": null, + "physicalFamily": "PT Sans", "verdict": "visual_only", "faces": { - "regular": false, - "bold": false, - "italic": false, - "boldItalic": false + "regular": true, + "bold": true, + "italic": true, + "boldItalic": true }, "gates": { - "static": "not_run", + "static": "pass", "metric": "fail", "layout": "not_run", - "ship": "not_run" + "ship": "fail" }, "policyAction": "category_fallback", "measurementRefs": [ - "trebuchet-ms#top_candidates#2026-06-03" + "trebuchet-ms__pt-sans#visual_review#2026-06-09" ], "exportRule": "preserve_original_name", - "candidateLicense": null + "candidateLicense": "OFL-1.1" }, { "evidenceId": "comic-sans-ms", "generic": "sans-serif", "logicalFamily": "Comic Sans MS", - "physicalFamily": "Comic Neue", + "physicalFamily": "Comic Relief", "verdict": "visual_only", "faces": { - "regular": false, - "bold": false, + "regular": true, + "bold": true, "italic": false, "boldItalic": false }, + "faceSources": { + "italic": { + "kind": "synthetic", + "from": "regular" + }, + "boldItalic": { + "kind": "synthetic", + "from": "bold" + } + }, "gates": { - "static": "not_run", + "static": "pass", "metric": "fail", "layout": "not_run", - "ship": "not_run" + "ship": "fail" }, "policyAction": "category_fallback", "measurementRefs": [ - "comic-sans-ms__comic-neue#analytic_advance#2026-06-03" + "comic-sans-ms__comic-relief#visual_review#2026-06-09" ], "exportRule": "preserve_original_name", - "advance": { - "basis": "latin_full", - "meanDelta": 0.1005, - "maxDelta": 0.1419 - }, - "candidateLicense": "OFL-1.1" + "candidateLicense": "OFL-1.1", + "faceVerdicts": { + "italic": "visual_only", + "boldItalic": "visual_only" + } }, { "evidenceId": "candara", @@ -539,6 +691,47 @@ export const SUBSTITUTION_EVIDENCE: readonly SubstitutionEvidence[] = [ }, "candidateLicense": "OFL-1.1" }, + { + "evidenceId": "gill-sans-mt-condensed", + "generic": "sans-serif", + "logicalFamily": "Gill Sans MT Condensed", + "physicalFamily": "PT Sans Narrow", + "verdict": "visual_only", + "faces": { + "regular": true, + "bold": true, + "italic": false, + "boldItalic": false + }, + "faceSources": { + "italic": { + "kind": "synthetic", + "from": "regular" + }, + "boldItalic": { + "kind": "synthetic", + "from": "bold" + } + }, + "gates": { + "static": "pass", + "metric": "fail", + "layout": "not_run", + "ship": "fail" + }, + "policyAction": "category_fallback", + "measurementRefs": [ + "gill-sans-mt-condensed__pt-sans-narrow#visual_review#2026-06-09" + ], + "exportRule": "preserve_original_name", + "candidateLicense": "OFL-1.1", + "faceVerdicts": { + "regular": "visual_only", + "bold": "visual_only", + "italic": "visual_only", + "boldItalic": "visual_only" + } + }, { "evidenceId": "aptos-display", "generic": "sans-serif", @@ -689,13 +882,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 +911,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 +922,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..3e1c846 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; @@ -99,7 +108,7 @@ export interface SubstitutionEvidence { policyAction: PolicyAction; /** stable measurement ids behind the row. */ measurementRefs: string[]; - /** SPDX id of the substitute's license. */ + /** Candidate license id or expression. SPDX when exact; stable docfonts label otherwise. */ candidateLicense?: string | null; exportRule: "preserve_original_name"; } @@ -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. */