@docfonts/fallbacks 0.2.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -56,6 +56,7 @@ Decision kinds:
56
56
 
57
57
  - `fallback` - render the returned `substituteFamily`.
58
58
  - `asset_missing` - docfonts has a fallback, but your app does not load that family.
59
+ - `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.
59
60
  - `no_recommended_fallback` - docfonts knows the font but recommends no renderable open family.
60
61
  - `customer_supplied` - the real font should come from the customer or environment.
61
62
  - `preserve_only` - keep the original family name. Do not substitute.
@@ -75,7 +76,7 @@ const map = createFallbackMap({
75
76
  map[normalizeFamilyName("Times New Roman")]; // { substituteFamily: "Liberation Serif", ... }
76
77
  ```
77
78
 
78
- Keys are normalized. Use `normalizeFamilyName` for lookups. Rows whose substitute family is not available are omitted.
79
+ 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.
79
80
 
80
81
  ## What the fields mean
81
82
 
@@ -83,11 +84,27 @@ Keys are normalized. Use `normalizeFamilyName` for lookups. Rows whose substitut
83
84
  - `policyAction` - what a renderer should do, not a quality claim. Use `verdict` for fidelity.
84
85
  - `verdict` - the measured fidelity. Examples: `metric_safe`, `near_metric`, `cell_width_only`, `visual_only`.
85
86
  - `lineBreakSafe` - true when advances preserve line breaks: `metric_safe`, `near_metric`, or monospace `cell_width_only`.
87
+ - `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.
86
88
  - `evidenceId` - the stable id for the reviewed evidence row; look the full row up in `SUBSTITUTION_EVIDENCE`.
89
+ - `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.
87
90
 
88
91
  `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.
89
92
 
90
- 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".
93
+ ## Face-aware routing (Regular-only substitutes)
94
+
95
+ 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:
96
+
97
+ ```ts
98
+ import { getRenderableFallbackForFace } from "@docfonts/fallbacks";
99
+ const opts = { canRenderFamily: (family) => bundledFamilies.has(family) };
100
+
101
+ getRenderableFallbackForFace("Baskerville Old Face", "regular", opts)?.substituteFamily; // "Bacasime Antique"
102
+ getRenderableFallbackForFace("Baskerville Old Face", "bold", opts); // null (Regular-only)
103
+ ```
104
+
105
+ `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`).
106
+
107
+ The full structured rows are exported as `SUBSTITUTION_EVIDENCE` for richer reporting (faces, per-face verdicts, glyph exceptions).
91
108
 
92
109
  ## Provenance
93
110
 
@@ -1,4 +1,4 @@
1
- import type { FallbackDecision, FontFallback } from "./types.js";
1
+ import type { FaceSlot, FallbackDecision, FontFallback } from "./types.js";
2
2
  /** Reports whether the consumer can actually render (i.e. bundles the asset for) a physical family. */
3
3
  export type CanRenderFamily = (family: string) => boolean;
4
4
  /** Options for {@link getRenderableFallback} and {@link createFallbackMap}: a render map must be asset-safe. */
@@ -24,8 +24,23 @@ export declare function getFallbackDecision(family: string, options?: FallbackDe
24
24
  */
25
25
  export declare function getRenderableFallback(family: string, options: RenderableFallbackOptions): FontFallback | null;
26
26
  /**
27
- * The renderer's substitute map: every fallback the consumer can actually render, keyed by the
28
- * normalized (lowercased) logical family - normalize lookups with {@link normalizeFamilyName}. Only
29
- * `kind: "fallback"` rows are included, so the map is safe to wire straight into a resolver.
27
+ * Face-aware outcome for a requested family + RIBBI face. Like {@link getFallbackDecision} but adds
28
+ * `face_missing` when the substitute exists yet does not provide that face (a Regular-only row asked
29
+ * for bold/italic). A covered face's fallback carries that face's own verdict. Case- and quote-insensitive.
30
+ */
31
+ export declare function getFallbackDecisionForFace(family: string, face: FaceSlot, options?: FallbackDecisionOptions): FallbackDecision;
32
+ /**
33
+ * The open family to render for a requested font AND a specific face, or null when that face has no
34
+ * renderable substitute (face not covered, no row, a non-substitution policy, or not bundled). This is
35
+ * the face-SAFE lookup: a Regular-only substitute returns null for bold/italic instead of being routed
36
+ * to a face it does not have. Use {@link getFallbackDecisionForFace} to report the reason.
37
+ */
38
+ export declare function getRenderableFallbackForFace(family: string, face: FaceSlot, options: RenderableFallbackOptions): FontFallback | null;
39
+ /**
40
+ * A family-level substitute map: every fallback the consumer can render, keyed by the normalized
41
+ * (lowercased) logical family - normalize lookups with {@link normalizeFamilyName}. Only renderable
42
+ * rows are included. Each entry carries `faces`; a face-scoped (e.g. Regular-only) row is only safe in
43
+ * a FACE-AWARE resolver - one that checks `faces` or uses {@link getRenderableFallbackForFace} - since
44
+ * applying a Regular-only entry to bold/italic would route a face the substitute does not provide.
30
45
  */
31
46
  export declare function createFallbackMap(options: RenderableFallbackOptions): Record<string, FontFallback>;
package/dist/fallbacks.js CHANGED
@@ -1,9 +1,10 @@
1
1
  /**
2
- * Fallback lookups over the reviewed evidence. Three intents:
3
- * - getRenderableFallback - "I need a family to render now" (asset-gated, returns a family or null).
4
- * - getFallbackDecision - "I need diagnostics / UI / reporting" (the full honest outcome).
5
- * - createFallbackMap - "I need a resolver map" (asset-gated, render-only rows).
6
- * Face routing stays consumer-owned: these answer which family, not which face.
2
+ * Fallback lookups over the reviewed evidence:
3
+ * - getRenderableFallback / getFallbackDecision - family-level ("which family", + the full outcome).
4
+ * - getRenderableFallbackForFace / getFallbackDecisionForFace - face-SAFE: a Regular-only substitute
5
+ * returns null / `face_missing` for bold/italic instead of being wrongly routed to a face it lacks.
6
+ * - createFallbackMap - a family-level resolver map (asset-gated). Each entry carries `faces`, so a
7
+ * consumer can route per-face; for Regular-only rows it MUST, or use the face-aware lookups.
7
8
  */
8
9
  import { SUBSTITUTION_EVIDENCE } from "./data.js";
9
10
  /**
@@ -29,14 +30,31 @@ const BY_LOGICAL = new Map(SUBSTITUTION_EVIDENCE.map((row) => [
29
30
  normalizeFamilyName(row.logicalFamily),
30
31
  row,
31
32
  ]));
32
- /** Build the FontFallback for a row known to carry a renderable physical family. */
33
- function buildFallback(row, physicalFamily) {
33
+ /**
34
+ * Build the FontFallback for a row known to carry a renderable physical family. `verdict` is passed in
35
+ * so a face-aware caller can supply the per-face verdict (faceVerdicts[face]) instead of the worst-face
36
+ * top-level one - e.g. Cambria regular is metric_safe even though the family rolls up to visual_only.
37
+ * `faceSlot` (a face lookup) scopes the glyph exceptions to that face; omitting it (a family lookup)
38
+ * carries all of the row's. Empty exception sets are dropped so the field is present only when it bites.
39
+ */
40
+ function buildFallback(row, physicalFamily, verdict, faceSlot) {
41
+ // Always hand back a FRESH array - filter() already copies; the family path must copy too, or a
42
+ // consumer mutating it would corrupt the shared evidence row for later lookups.
43
+ const glyphExceptions = faceSlot
44
+ ? row.glyphExceptions?.filter((g) => g.slot === faceSlot)
45
+ : row.glyphExceptions
46
+ ? [...row.glyphExceptions]
47
+ : undefined;
34
48
  return {
35
49
  substituteFamily: physicalFamily,
36
50
  policyAction: row.policyAction,
37
- verdict: row.verdict,
38
- lineBreakSafe: LINE_BREAK_SAFE_VERDICTS.has(row.verdict),
51
+ verdict,
52
+ lineBreakSafe: LINE_BREAK_SAFE_VERDICTS.has(verdict),
53
+ faces: row.faces,
39
54
  evidenceId: row.evidenceId,
55
+ ...(glyphExceptions && glyphExceptions.length > 0
56
+ ? { glyphExceptions }
57
+ : {}),
40
58
  };
41
59
  }
42
60
  /** Decide a single row against the consumer's asset availability. Pure. */
@@ -59,7 +77,41 @@ function decideRow(row, canRenderFamily) {
59
77
  verdict,
60
78
  evidenceId,
61
79
  };
62
- return { kind: "fallback", fallback: buildFallback(row, physicalFamily) };
80
+ return {
81
+ kind: "fallback",
82
+ fallback: buildFallback(row, physicalFamily, verdict),
83
+ };
84
+ }
85
+ /** True when a row actually scopes faces (any RIBBI face marked covered). An all-false `faces` means
86
+ * the row is NOT face-scoped - e.g. a category fallback whose physical font does have faces - so it
87
+ * must not be gated per-face, only a measured per-face substitute (Baskerville Regular-only) is. */
88
+ function isFaceScoped(row) {
89
+ const f = row.faces;
90
+ return f.regular || f.bold || f.italic || f.boldItalic;
91
+ }
92
+ /**
93
+ * Face-aware variant of {@link decideRow}: same family-level outcome, but when the family HAS a
94
+ * renderable substitute AND the row is face-scoped, gate on whether it provides the requested `face`.
95
+ * A face a face-scoped substitute does not cover yields `face_missing` (route it through absence
96
+ * handling); a covered face yields a fallback carrying that face's own verdict. A NON-face-scoped row
97
+ * (category fallback, all-false `faces`) renders for any face - it never becomes face_missing.
98
+ */
99
+ function decideRowForFace(row, face, canRenderFamily) {
100
+ const base = decideRow(row, canRenderFamily);
101
+ // Non-fallback outcomes (asset_missing / no_recommended_fallback / policy) do not depend on the face.
102
+ if (base.kind !== "fallback")
103
+ return base;
104
+ if (isFaceScoped(row) && !row.faces[face])
105
+ return {
106
+ kind: "face_missing",
107
+ substituteFamily: base.fallback.substituteFamily,
108
+ evidenceId: row.evidenceId,
109
+ };
110
+ const faceVerdict = row.faceVerdicts?.[face] ?? row.verdict;
111
+ return {
112
+ kind: "fallback",
113
+ fallback: buildFallback(row, base.fallback.substituteFamily, faceVerdict, face),
114
+ };
63
115
  }
64
116
  /**
65
117
  * The full, honest outcome for a requested family: a discriminated union (see {@link FallbackDecision}).
@@ -80,9 +132,32 @@ export function getRenderableFallback(family, options) {
80
132
  return decision.kind === "fallback" ? decision.fallback : null;
81
133
  }
82
134
  /**
83
- * The renderer's substitute map: every fallback the consumer can actually render, keyed by the
84
- * normalized (lowercased) logical family - normalize lookups with {@link normalizeFamilyName}. Only
85
- * `kind: "fallback"` rows are included, so the map is safe to wire straight into a resolver.
135
+ * Face-aware outcome for a requested family + RIBBI face. Like {@link getFallbackDecision} but adds
136
+ * `face_missing` when the substitute exists yet does not provide that face (a Regular-only row asked
137
+ * for bold/italic). A covered face's fallback carries that face's own verdict. Case- and quote-insensitive.
138
+ */
139
+ export function getFallbackDecisionForFace(family, face, options = {}) {
140
+ const row = BY_LOGICAL.get(normalizeFamilyName(family));
141
+ return row
142
+ ? decideRowForFace(row, face, options.canRenderFamily)
143
+ : { kind: "unknown" };
144
+ }
145
+ /**
146
+ * The open family to render for a requested font AND a specific face, or null when that face has no
147
+ * renderable substitute (face not covered, no row, a non-substitution policy, or not bundled). This is
148
+ * the face-SAFE lookup: a Regular-only substitute returns null for bold/italic instead of being routed
149
+ * to a face it does not have. Use {@link getFallbackDecisionForFace} to report the reason.
150
+ */
151
+ export function getRenderableFallbackForFace(family, face, options) {
152
+ const decision = getFallbackDecisionForFace(family, face, options);
153
+ return decision.kind === "fallback" ? decision.fallback : null;
154
+ }
155
+ /**
156
+ * A family-level substitute map: every fallback the consumer can render, keyed by the normalized
157
+ * (lowercased) logical family - normalize lookups with {@link normalizeFamilyName}. Only renderable
158
+ * rows are included. Each entry carries `faces`; a face-scoped (e.g. Regular-only) row is only safe in
159
+ * a FACE-AWARE resolver - one that checks `faces` or uses {@link getRenderableFallbackForFace} - since
160
+ * applying a Regular-only entry to bold/italic would route a face the substitute does not provide.
86
161
  */
87
162
  export function createFallbackMap(options) {
88
163
  const out = {};
package/dist/index.d.ts CHANGED
@@ -3,5 +3,5 @@
3
3
  * dependency.
4
4
  */
5
5
  export { SUBSTITUTION_EVIDENCE } from "./data.js";
6
- export { type CanRenderFamily, createFallbackMap, type FallbackDecisionOptions, getFallbackDecision, getRenderableFallback, normalizeFamilyName, type RenderableFallbackOptions, } from "./fallbacks.js";
6
+ export { type CanRenderFamily, createFallbackMap, type FallbackDecisionOptions, getFallbackDecision, getFallbackDecisionForFace, getRenderableFallback, getRenderableFallbackForFace, normalizeFamilyName, type RenderableFallbackOptions, } from "./fallbacks.js";
7
7
  export type { AdvanceDelta, FaceCoverage, FaceSlot, FallbackDecision, FontFallback, GlyphException, PolicyAction, SubstituteGates, SubstitutionEvidence, Verdict, } from "./types.js";
package/dist/index.js CHANGED
@@ -3,4 +3,4 @@
3
3
  * dependency.
4
4
  */
5
5
  export { SUBSTITUTION_EVIDENCE } from "./data.js";
6
- export { createFallbackMap, getFallbackDecision, getRenderableFallback, normalizeFamilyName, } from "./fallbacks.js";
6
+ export { createFallbackMap, getFallbackDecision, getFallbackDecisionForFace, getRenderableFallback, getRenderableFallbackForFace, normalizeFamilyName, } from "./fallbacks.js";
package/dist/types.d.ts CHANGED
@@ -88,16 +88,34 @@ export interface FontFallback {
88
88
  * the row's `faceVerdicts`) for the precise tier.
89
89
  */
90
90
  lineBreakSafe: boolean;
91
+ /**
92
+ * Reviewed face coverage: which RIBBI faces this substitute is PROVEN to supply. A renderer MUST
93
+ * respect a face-scoped row: it can be Regular-only (e.g. Baskerville -> Bacasime, Cooper Black ->
94
+ * Caprasimo), and routing bold/italic to a face it lacks is wrong. NOTE: an all-false `faces` means
95
+ * the row is NOT face-scoped (e.g. a category fallback, whose physical font does have faces), NOT
96
+ * that the font has no faces - such rows render for any face. The face-aware helpers
97
+ * ({@link getRenderableFallbackForFace}) encode this rule for you.
98
+ */
99
+ faces: FaceCoverage;
91
100
  /** stable reviewed-evidence id; look the full row up in {@link SUBSTITUTION_EVIDENCE}. */
92
101
  evidenceId: string;
102
+ /**
103
+ * Named glyph-level divergences that qualify this fallback (e.g. one codepoint reflows). Scoped to
104
+ * the lookup: a family lookup ({@link getRenderableFallback}) carries ALL of the row's exceptions; a
105
+ * face lookup ({@link getRenderableFallbackForFace}) carries only the requested face's. Omitted when
106
+ * none apply - so a renderer can surface a precise "this face reflows on U+0060" without re-deriving.
107
+ * A fresh array each call (readonly): mutating it never affects another lookup.
108
+ */
109
+ glyphExceptions?: readonly GlyphException[];
93
110
  }
94
111
  /**
95
112
  * The full, honest outcome of a fallback lookup. A discriminated union so a consumer can tell apart
96
113
  * cases that a bare `FontFallback | null` collapses: docfonts has never heard of the font (`unknown`)
97
114
  * vs knows it but recommends no renderable family (`no_recommended_fallback`), the substitute exists
98
115
  * but the consumer does not bundle it (`asset_missing`), and the deliberate non-substitution policies
99
- * (`preserve_only`, `customer_supplied`). `evidenceId` on the terminal kinds points back into
100
- * {@link SUBSTITUTION_EVIDENCE} for the full row (verdict, faces, ...).
116
+ * (`preserve_only`, `customer_supplied`). The face-aware lookups add `face_missing`: a substitute is
117
+ * recommended for the family but does NOT provide the requested face. `evidenceId` on the terminal
118
+ * kinds points back into {@link SUBSTITUTION_EVIDENCE} for the full row (verdict, faces, ...).
101
119
  */
102
120
  export type FallbackDecision = {
103
121
  kind: "fallback";
@@ -107,6 +125,12 @@ export type FallbackDecision = {
107
125
  substituteFamily: string;
108
126
  verdict: Verdict;
109
127
  evidenceId: string;
128
+ } | {
129
+ /** the family has a renderable substitute, but it does not provide the requested face - route
130
+ * this face through face-aware absence handling, do NOT substitute it. */
131
+ kind: "face_missing";
132
+ substituteFamily: string;
133
+ evidenceId: string;
110
134
  } | {
111
135
  kind: "no_recommended_fallback";
112
136
  evidenceId: string;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@docfonts/fallbacks",
3
- "version": "0.2.0",
3
+ "version": "0.4.0",
4
4
  "description": "Measured open-font fallbacks for proprietary document fonts.",
5
5
  "license": "MIT",
6
6
  "type": "module",