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
20 changes: 18 additions & 2 deletions packages/fallbacks/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,7 @@ Decision kinds:

- `fallback` - render the returned `substituteFamily`.
- `asset_missing` - docfonts has a fallback, but your app does not load that family.
- `face_missing` - (face-aware lookups only) the family has a substitute, but not for the requested face. Route that face through your absence handling; do not substitute it.
- `no_recommended_fallback` - docfonts knows the font but recommends no renderable open family.
- `customer_supplied` - the real font should come from the customer or environment.
- `preserve_only` - keep the original family name. Do not substitute.
Expand All @@ -75,19 +76,34 @@ const map = createFallbackMap({
map[normalizeFamilyName("Times New Roman")]; // { substituteFamily: "Liberation Serif", ... }
```

Keys are normalized. Use `normalizeFamilyName` for lookups. Rows whose substitute family is not available are omitted.
Keys are normalized. Use `normalizeFamilyName` for lookups. Rows whose substitute family is not available are omitted. Each entry carries `faces`: a Regular-only entry is only safe in a **face-aware** resolver (one that checks `faces` or uses `getRenderableFallbackForFace`), since applying it to bold/italic would route a face the substitute does not provide.

## What the fields mean

- `substituteFamily` - the open family to render in place of the requested one.
- `policyAction` - what a renderer should do, not a quality claim. Use `verdict` for fidelity.
- `verdict` - the measured fidelity. Examples: `metric_safe`, `near_metric`, `cell_width_only`, `visual_only`.
- `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`.

`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.

The full structured rows are exported as `SUBSTITUTION_EVIDENCE` for richer reporting, including faces, per-face verdicts, and glyph exceptions. Face-level routing stays yours: these helpers answer "which family", not "which face".
## Face-aware routing (Regular-only substitutes)

Some substitutes provide only some faces - e.g. Baskerville Old Face -> Bacasime Antique is Regular-only. The family-level helpers above answer "which family", and every result carries `faces`, so a resolver must route per-face. The face-aware helpers do it for you:

```ts
import { getRenderableFallbackForFace } from "@docfonts/fallbacks";
const opts = { canRenderFamily: (family) => bundledFamilies.has(family) };

getRenderableFallbackForFace("Baskerville Old Face", "regular", opts)?.substituteFamily; // "Bacasime Antique"
getRenderableFallbackForFace("Baskerville Old Face", "bold", opts); // null (Regular-only)
```

`getFallbackDecisionForFace(family, face, options)` reports the reason - `face_missing` when the substitute exists but lacks that face. A covered face carries its OWN verdict, not the family's worst-face rollup (e.g. `Cambria` regular is `metric_safe` even though the family rolls up to `visual_only`).

The full structured rows are exported as `SUBSTITUTION_EVIDENCE` for richer reporting (faces, per-face verdicts, glyph exceptions).

## Provenance

Expand Down
94 changes: 94 additions & 0 deletions packages/fallbacks/fallbacks.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,9 @@ import { describe, expect, test } from "bun:test";
import {
createFallbackMap,
getFallbackDecision,
getFallbackDecisionForFace,
getRenderableFallback,
getRenderableFallbackForFace,
normalizeFamilyName,
SUBSTITUTION_EVIDENCE,
} from "./src/index";
Expand All @@ -32,6 +34,7 @@ describe("getFallbackDecision", () => {
policyAction: "substitute",
verdict: "metric_safe",
lineBreakSafe: true,
faces: { regular: true, bold: true, italic: true, boldItalic: true },
evidenceId: "helvetica",
},
});
Expand Down Expand Up @@ -141,3 +144,94 @@ describe("normalizeFamilyName (public)", () => {
);
});
});

describe("face-aware lookups (Regular-only safety)", () => {
const renderAll = { canRenderFamily: () => true };

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(
getRenderableFallback("Baskerville Old Face", renderAll)?.faces,
).toEqual({
regular: true,
bold: false,
italic: false,
boldItalic: false,
});
});

test("a Regular-only substitute is returned for Regular and NULL for bold/italic", () => {
// Baskerville -> Bacasime is Regular-only. The face-safe lookup must not route bold to it.
expect(
getRenderableFallbackForFace("Baskerville Old Face", "regular", renderAll)
?.substituteFamily,
).toBe("Bacasime Antique");
expect(
getRenderableFallbackForFace("Baskerville Old Face", "bold", renderAll),
).toBeNull();
expect(
getRenderableFallbackForFace("Baskerville Old Face", "italic", renderAll),
).toBeNull();
});

test("an uncovered face is `face_missing`, not `unknown` or null collapse", () => {
expect(
getFallbackDecisionForFace(
"Baskerville Old Face",
"boldItalic",
renderAll,
),
).toEqual({
kind: "face_missing",
substituteFamily: "Bacasime Antique",
evidenceId: "baskerville-old-face",
});
});

test("a covered face carries its OWN verdict, not the worst-face rollup", () => {
// Cambria rolls up to visual_only (Bold Italic), but the regular face is metric_safe.
expect(getRenderableFallback("Cambria", renderAll)?.verdict).toBe(
"visual_only",
);
expect(
getRenderableFallbackForFace("Cambria", "regular", renderAll)?.verdict,
).toBe("metric_safe");
expect(
getRenderableFallbackForFace("Cambria", "boldItalic", renderAll)?.verdict,
).toBe("visual_only");
});

test("face-aware lookups stay asset-aware and honest for non-fallback families", () => {
// Not bundled -> asset_missing (face is moot). Unknown / policy rows pass through unchanged.
expect(
getFallbackDecisionForFace("Baskerville Old Face", "regular", {
canRenderFamily: () => false,
}).kind,
).toBe("asset_missing");
expect(getFallbackDecisionForFace("Foo Unknown", "regular").kind).toBe(
"unknown",
);
expect(getFallbackDecisionForFace("Aptos", "regular").kind).toBe(
"customer_supplied",
);
});

test("a category fallback (all-false faces) is NOT face-scoped: it renders for EVERY face", () => {
// Regression: Calibri Light -> Carlito is a category_fallback with all-false faces. That means
// "not face-scoped", not "no faces" - it must render for every face, never face_missing.
const carlito = { canRenderFamily: (f: string) => f === "Carlito" };
for (const face of ["regular", "bold", "italic", "boldItalic"] as const) {
expect(
getRenderableFallbackForFace("Calibri Light", face, carlito)
?.substituteFamily,
`Calibri Light ${face}`,
).toBe("Carlito");
}
// ...while a genuinely face-scoped Regular-only row still gates bold to face_missing.
expect(
getFallbackDecisionForFace("Baskerville Old Face", "bold", {
canRenderFamily: () => true,
}).kind,
).toBe("face_missing");
});
});
104 changes: 92 additions & 12 deletions packages/fallbacks/src/fallbacks.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,14 @@
/**
* Fallback lookups over the reviewed evidence. Three intents:
* - getRenderableFallback - "I need a family to render now" (asset-gated, returns a family or null).
* - getFallbackDecision - "I need diagnostics / UI / reporting" (the full honest outcome).
* - createFallbackMap - "I need a resolver map" (asset-gated, render-only rows).
* Face routing stays consumer-owned: these answer which family, not which face.
* Fallback lookups over the reviewed evidence:
* - getRenderableFallback / getFallbackDecision - family-level ("which family", + the full outcome).
* - getRenderableFallbackForFace / getFallbackDecisionForFace - face-SAFE: a Regular-only substitute
* returns null / `face_missing` for bold/italic instead of being wrongly routed to a face it lacks.
* - createFallbackMap - a family-level resolver map (asset-gated). Each entry carries `faces`, so a
* consumer can route per-face; for Regular-only rows it MUST, or use the face-aware lookups.
*/
import { SUBSTITUTION_EVIDENCE } from "./data.js";
import type {
FaceSlot,
FallbackDecision,
FontFallback,
SubstitutionEvidence,
Expand Down Expand Up @@ -54,16 +56,22 @@ const BY_LOGICAL: ReadonlyMap<string, SubstitutionEvidence> = new Map(
]),
);

/** Build the FontFallback for a row known to carry a renderable physical family. */
/**
* 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.
*/
function buildFallback(
row: SubstitutionEvidence,
physicalFamily: string,
verdict: Verdict,
): FontFallback {
return {
substituteFamily: physicalFamily,
policyAction: row.policyAction,
verdict: row.verdict,
lineBreakSafe: LINE_BREAK_SAFE_VERDICTS.has(row.verdict),
verdict,
lineBreakSafe: LINE_BREAK_SAFE_VERDICTS.has(verdict),
faces: row.faces,
evidenceId: row.evidenceId,
};
}
Expand Down Expand Up @@ -91,7 +99,46 @@ function decideRow(
verdict,
evidenceId,
};
return { kind: "fallback", fallback: buildFallback(row, physicalFamily) };
return {
kind: "fallback",
fallback: buildFallback(row, physicalFamily, verdict),
};
}

/** True when a row actually scopes faces (any RIBBI face marked covered). An all-false `faces` means
* the row is NOT face-scoped - e.g. a category fallback whose physical font does have faces - so it
* must not be gated per-face, only a measured per-face substitute (Baskerville Regular-only) is. */
function isFaceScoped(row: SubstitutionEvidence): boolean {
const f = row.faces;
return f.regular || f.bold || f.italic || f.boldItalic;
}

/**
* 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`.
* A face a face-scoped substitute does not cover yields `face_missing` (route it through absence
* handling); a covered face yields a fallback carrying that face's own verdict. A NON-face-scoped row
* (category fallback, all-false `faces`) renders for any face - it never becomes face_missing.
*/
function decideRowForFace(
row: SubstitutionEvidence,
face: FaceSlot,
canRenderFamily: CanRenderFamily | undefined,
): FallbackDecision {
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])
return {
kind: "face_missing",
substituteFamily: base.fallback.substituteFamily,
evidenceId: row.evidenceId,
};
const faceVerdict = row.faceVerdicts?.[face] ?? row.verdict;
return {
kind: "fallback",
fallback: buildFallback(row, base.fallback.substituteFamily, faceVerdict),
};
}

/**
Expand Down Expand Up @@ -121,9 +168,42 @@ export function getRenderableFallback(
}

/**
* The renderer's substitute map: every fallback the consumer can actually render, keyed by the
* normalized (lowercased) logical family - normalize lookups with {@link normalizeFamilyName}. Only
* `kind: "fallback"` rows are included, so the map is safe to wire straight into a resolver.
* Face-aware outcome for a requested family + RIBBI face. Like {@link getFallbackDecision} but adds
* `face_missing` when the substitute exists yet does not provide that face (a Regular-only row asked
* for bold/italic). A covered face's fallback carries that face's own verdict. Case- and quote-insensitive.
*/
export function getFallbackDecisionForFace(
family: string,
face: FaceSlot,
options: FallbackDecisionOptions = {},
): FallbackDecision {
const row = BY_LOGICAL.get(normalizeFamilyName(family));
return row
? decideRowForFace(row, face, options.canRenderFamily)
: { kind: "unknown" };
}

/**
* The open family to render for a requested font AND a specific face, or null when that face has no
* renderable substitute (face not covered, no row, a non-substitution policy, or not bundled). This is
* the face-SAFE lookup: a Regular-only substitute returns null for bold/italic instead of being routed
* to a face it does not have. Use {@link getFallbackDecisionForFace} to report the reason.
*/
export function getRenderableFallbackForFace(
family: string,
face: FaceSlot,
options: RenderableFallbackOptions,
): FontFallback | null {
const decision = getFallbackDecisionForFace(family, face, options);
return decision.kind === "fallback" ? decision.fallback : null;
}

/**
* A family-level substitute map: every fallback the consumer can render, keyed by the normalized
* (lowercased) logical family - normalize lookups with {@link normalizeFamilyName}. Only renderable
* rows are included. Each entry carries `faces`; a face-scoped (e.g. Regular-only) row is only safe in
* a FACE-AWARE resolver - one that checks `faces` or uses {@link getRenderableFallbackForFace} - since
* applying a Regular-only entry to bold/italic would route a face the substitute does not provide.
*/
export function createFallbackMap(
options: RenderableFallbackOptions,
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 @@ -8,7 +8,9 @@ export {
createFallbackMap,
type FallbackDecisionOptions,
getFallbackDecision,
getFallbackDecisionForFace,
getRenderableFallback,
getRenderableFallbackForFace,
normalizeFamilyName,
type RenderableFallbackOptions,
} from "./fallbacks.js";
Expand Down
21 changes: 19 additions & 2 deletions packages/fallbacks/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,15 @@ export interface FontFallback {
* the row's `faceVerdicts`) for the precise tier.
*/
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
* ({@link getRenderableFallbackForFace}) encode this rule for you.
*/
faces: FaceCoverage;
/** stable reviewed-evidence id; look the full row up in {@link SUBSTITUTION_EVIDENCE}. */
evidenceId: string;
}
Expand All @@ -118,8 +127,9 @@ export interface FontFallback {
* cases that a bare `FontFallback | null` collapses: docfonts has never heard of the font (`unknown`)
* vs knows it but recommends no renderable family (`no_recommended_fallback`), the substitute exists
* but the consumer does not bundle it (`asset_missing`), and the deliberate non-substitution policies
* (`preserve_only`, `customer_supplied`). `evidenceId` on the terminal kinds points back into
* {@link SUBSTITUTION_EVIDENCE} for the full row (verdict, faces, ...).
* (`preserve_only`, `customer_supplied`). The face-aware lookups add `face_missing`: a substitute is
* recommended for the family but does NOT provide the requested face. `evidenceId` on the terminal
* kinds points back into {@link SUBSTITUTION_EVIDENCE} for the full row (verdict, faces, ...).
*/
export type FallbackDecision =
| { kind: "fallback"; fallback: FontFallback }
Expand All @@ -129,6 +139,13 @@ export type FallbackDecision =
verdict: Verdict;
evidenceId: string;
}
| {
/** the family has a renderable substitute, but it does not provide the requested face - route
* this face through face-aware absence handling, do NOT substitute it. */
kind: "face_missing";
substituteFamily: string;
evidenceId: string;
}
| { kind: "no_recommended_fallback"; evidenceId: string }
| { kind: "customer_supplied"; evidenceId: string }
| { kind: "preserve_only"; evidenceId: string }
Expand Down
Loading