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
2 changes: 1 addition & 1 deletion packages/fallbacks/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
56 changes: 39 additions & 17 deletions packages/fallbacks/fallbacks.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -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" },
});
}
});
Expand All @@ -395,13 +412,18 @@ 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",
});
expect(
getRenderableFallbackForFace("Cooper Black", "regular", onlyCaprasimo)
?.substituteFamily,
).toBe("Caprasimo");
expect(
getFallbackDecisionForFace("Cooper Black", "bold", {
canRenderFamily: () => false,
}).kind,
).toBe("asset_missing");
});
});
24 changes: 21 additions & 3 deletions packages/fallbacks/records.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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": {
Expand All @@ -691,7 +706,10 @@
},
"candidateLicense": "OFL-1.1",
"faceVerdicts": {
"regular": "metric_safe"
"regular": "metric_safe",
"bold": "visual_only",
"italic": "visual_only",
"boldItalic": "visual_only"
}
}
]
24 changes: 21 additions & 3 deletions packages/fallbacks/src/data.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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": {
Expand All @@ -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"
}
}
];
18 changes: 17 additions & 1 deletion packages/fallbacks/src/fallbacks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import { SUBSTITUTION_EVIDENCE } from "./data.js";
import type {
FaceSlot,
FallbackDecision,
FallbackFaceSource,
FontFallback,
SubstitutionEvidence,
Verdict,
Expand Down Expand Up @@ -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.
Expand All @@ -84,6 +86,7 @@ function buildFallback(
faces: row.faces,
evidenceId: row.evidenceId,
generic: row.generic,
...(faceSource ? { faceSource: { ...faceSource } } : {}),
...(glyphExceptions && glyphExceptions.length > 0
? { glyphExceptions }
: {}),
Expand Down Expand Up @@ -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`.
Expand All @@ -143,21 +155,25 @@ 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,
evidenceId: row.evidenceId,
generic: row.generic,
};
const faceVerdict = row.faceVerdicts?.[face] ?? row.verdict;
const projectedFaceSource =
faceSource?.kind === "synthetic" ? faceSource : undefined;
return {
kind: "fallback",
fallback: buildFallback(
row,
base.fallback.substituteFamily,
faceVerdict,
face,
projectedFaceSource,
),
};
}
Expand Down
2 changes: 2 additions & 0 deletions packages/fallbacks/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,8 @@ export type {
FaceCoverage,
FaceSlot,
FallbackDecision,
FallbackFaceSource,
FallbackFaceSources,
FontFallback,
GlyphException,
PolicyAction,
Expand Down
23 changes: 19 additions & 4 deletions packages/fallbacks/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<Record<FaceSlot, FallbackFaceSource>>;

/** The four derived gate statuses behind a verdict; the proof is the referenced measurements. */
export interface SubstituteGates {
static: GateStatus;
Expand Down Expand Up @@ -90,6 +97,8 @@ export interface SubstitutionEvidence {
verdict: Verdict;
/** per-face verdicts, AUTHORITATIVE when present (a QUALIFIED substitute); top-level = worst face. */
faceVerdicts?: Partial<Record<FaceSlot, Verdict>>;
/** 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;
Expand Down Expand Up @@ -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. */
Expand Down
Loading