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
54 changes: 29 additions & 25 deletions packages/fallbacks/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,56 +2,60 @@

Document font substitution, measured.

Measured open-font fallbacks for proprietary document fonts (Office / Word / DOCX), as a tiny runtime data package. It carries the structured substitution evidence plus one asset-aware lookup, so a renderer can map a requested proprietary font to an open one without hand-copying tables, and without routing to a font it does not bundle.
Measured open-font fallbacks for proprietary document fonts (Office / Word / DOCX), as a tiny runtime data package. Map a requested proprietary font to an open one without hand-copying tables, and without routing to a font you do not bundle.

It ships no fonts and no proprietary binaries: only the measured evidence (which open family stands in, the verdict, the advance delta, the license id, and a stable evidence id).
It ships no fonts and no proprietary binaries: only the measured evidence - which open family stands in, the verdict, a stable evidence id - including the honest "no open font stands in."

## Install

```sh
npm install @docfonts/fallbacks
```

## Usage
ESM-only (`"type": "module"`); import it, or let your bundler. CommonJS `require()` is not supported.

`getFallback` answers "what does docfonts recommend for this family?". Pass `hasFamily` to keep it to fonts you actually bundle:
## Three things you might want

```ts
import { getFallback } from "@docfonts/fallbacks";
// 1. I need a family to render right now (only ever a font you bundle, or null):
import { getRenderableFallback } from "@docfonts/fallbacks";
getRenderableFallback("Helvetica", { canRenderFamily: (f) => bundled.has(f) });
// { substituteFamily: "Liberation Sans", policyAction: "substitute", verdict: "metric_safe", lineBreakSafe: true, evidenceId: "helvetica" }
```

getFallback("Helvetica", { hasFamily: (f) => bundled.has(f) });
// { family: "Liberation Sans", action: "substitute", verdict: "metric_safe", faithful: true, evidenceId: "helvetica" }
```ts
// 2. I need to explain the decision (UI, diagnostics, reporting) - a discriminated union:
import { getFallbackDecision } from "@docfonts/fallbacks";
getFallbackDecision("Aptos"); // { kind: "customer_supplied", evidenceId: "aptos" }
getFallbackDecision("Tahoma"); // { kind: "no_recommended_fallback", evidenceId: "tahoma" }
getFallbackDecision("Made Up Font"); // { kind: "unknown" }
getFallbackDecision("Georgia", { canRenderFamily: (f) => bundled.has(f) });
// not bundled -> { kind: "asset_missing", substituteFamily: "Gelasio", verdict: "near_metric", evidenceId: "georgia" }
```

`deriveFallbackMap` builds the substitute map you wire into a resolver. `hasFamily` is required here: a render map never includes a substitute whose font you cannot load.
This is the point of the package: a measured "no open font stands in" (Aptos) is a real answer, and it is **not** the same as a font docfonts never heard of (`unknown`). The `kind`s are `fallback`, `asset_missing`, `no_recommended_fallback`, `customer_supplied`, `preserve_only`, `unknown`.

```ts
import { deriveFallbackMap } from "@docfonts/fallbacks";

const map = deriveFallbackMap({ hasFamily: (f) => bundled.has(f) });
// { helvetica: { family: "Liberation Sans", ... }, calibri: { ... }, ... }
// Rows whose family you do not bundle are left out, not routed to a missing asset.
// 3. I need a resolver map. `canRenderFamily` is required - the map only holds fonts you can render:
import { createFallbackMap, normalizeFamilyName } from "@docfonts/fallbacks";
const map = createFallbackMap({ canRenderFamily: (f) => bundled.has(f) });
map[normalizeFamilyName("Times New Roman")]; // { substituteFamily: "Liberation Serif", ... }
```

Both resolve to `null` (or omit the row) when docfonts has no row, the recommendation is "no open font stands in", or you do not ship the physical family.
Keys are normalized (lowercased, quote-stripped); look up with `normalizeFamilyName`. Rows whose family you do not bundle are left out, never routed to a missing asset.

## What the fields mean

- `family` - the open family to render in place of the requested one.
- `action` - `substitute` (a measured metric match) or `category_fallback` (right letterforms, lower fidelity).
- `substituteFamily` - the open family to render in place of the requested one.
- `policyAction` - what a renderer should do, not a quality claim: `substitute` renders the named open family, `category_fallback` renders a lower-fidelity same-category family. A `substitute` can still be top-level `visual_only` (e.g. Cambria); use `verdict` for fidelity.
- `verdict` - the measured fidelity, from a fixed taxonomy (`metric_safe`, `near_metric`, `cell_width_only`, `visual_only`, ...). The headline rolls up to the worst face.
- `faithful` - a coarse "good enough for line-break fidelity" flag (`metric_safe` or `near_metric`). Not a claim of an exact clone; read `verdict` for the tier.
- `evidenceId` - the stable id for the reviewed evidence row.
- `lineBreakSafe` - true when advances preserve line breaks: `metric_safe`, `near_metric`, or monospace `cell_width_only`. Not a claim of an exact clone (`cell_width_only` keeps advances but not glyph shapes); read `verdict` for the tier.
- `evidenceId` - the stable id for the reviewed evidence row; look the full row up in `SUBSTITUTION_EVIDENCE`.

The full structured rows are exported as `SUBSTITUTION_EVIDENCE` (faces, per-face verdicts, glyph exceptions) for richer reporting. Face-level routing stays yours: `getFallback` answers "which family", not "which face".
The full structured rows are exported as `SUBSTITUTION_EVIDENCE` (faces, per-face verdicts, glyph exceptions) for richer reporting. Face-level routing stays yours: these answer "which family", not "which face".

## Provenance

The data comes from reviewed docfonts evidence. Measurements are produced against licensed originals,
but this package distributes no proprietary binaries or raw proprietary metrics.
The data comes from reviewed docfonts evidence. Measurements are produced against licensed originals, but this package distributes no proprietary binaries or raw proprietary metrics.

Built by the team behind SuperDoc. Standalone and neutral.

## License

MIT
164 changes: 93 additions & 71 deletions packages/fallbacks/fallbacks.test.ts
Original file line number Diff line number Diff line change
@@ -1,92 +1,115 @@
/**
* Behavior contract for the public fallback helpers: honest action labels, asset-aware routing, and
* null for unknown or non-renderable rows.
* Behavior contract for the 0.2 fallback API. The load-bearing guarantees: getFallbackDecision tells
* the honest cases apart (a known font with no open substitute is NOT the same as an unknown font, and
* a substitute you do not bundle is its own case); getRenderableFallback / createFallbackMap only ever
* hand back a family the consumer can actually render.
*/
import { describe, expect, test } from "bun:test";
import {
deriveFallbackMap,
getFallback,
createFallbackMap,
getFallbackDecision,
getRenderableFallback,
normalizeFamilyName,
SUBSTITUTION_EVIDENCE,
} from "./src/index";

// Sample consumer bundle for asset-gating tests.
// A consumer that ships exactly the five families the reference renderer bundles.
const BUNDLED = new Set([
"Carlito",
"Caladea",
"Liberation Sans",
"Liberation Serif",
"Liberation Mono",
]);
const hasFamily = (f: string) => BUNDLED.has(f);
const canRenderFamily = (f: string) => BUNDLED.has(f);

describe("getFallback", () => {
test("a metric substitute resolves to its open family, marked faithful", () => {
expect(getFallback("Helvetica", { hasFamily })).toEqual({
family: "Liberation Sans",
action: "substitute",
verdict: "metric_safe",
faithful: true,
evidenceId: "helvetica",
describe("getFallbackDecision", () => {
test("a renderable substitute -> kind 'fallback' with the resolved family", () => {
expect(getFallbackDecision("Helvetica", { canRenderFamily })).toEqual({
kind: "fallback",
fallback: {
substituteFamily: "Liberation Sans",
policyAction: "substitute",
verdict: "metric_safe",
lineBreakSafe: true,
evidenceId: "helvetica",
},
});
});

test("a category fallback is reported as such and is NOT faithful", () => {
expect(getFallback("Calibri Light", { hasFamily })).toEqual({
family: "Carlito",
action: "category_fallback",
verdict: "visual_only",
faithful: false,
evidenceId: "calibri-light",
test("a substitute the consumer does NOT bundle -> kind 'asset_missing' (not null, not unknown)", () => {
// Georgia -> Gelasio is a real substitute; a consumer that doesn't ship Gelasio sees which font to add.
expect(getFallbackDecision("Georgia", { canRenderFamily })).toEqual({
kind: "asset_missing",
substituteFamily: "Gelasio",
verdict: "near_metric",
evidenceId: "georgia",
});
});

test("case- and quote-insensitive lookup", () => {
expect(getFallback(" 'CALIBRI' ", { hasFamily })?.family).toBe("Carlito");
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".
expect(getFallbackDecision("Aptos")).toEqual({
kind: "customer_supplied",
evidenceId: "aptos",
});
expect(getFallbackDecision("Tahoma")).toEqual({
kind: "no_recommended_fallback",
evidenceId: "tahoma",
});
expect(getFallbackDecision("Cambria Math")).toEqual({
kind: "preserve_only",
evidenceId: "cambria-math",
});
expect(getFallbackDecision("Foo Unknown Font")).toEqual({
kind: "unknown",
});
});

test("a substitute whose family the consumer does NOT bundle resolves to null (inert row)", () => {
expect(getFallback("Georgia")?.family).toBe("Gelasio");
expect(getFallback("Georgia", { hasFamily })).toBeNull();
});

test("Baskerville (Regular-only) resolves the family; face routing is the consumer's job", () => {
expect(getFallback("Baskerville Old Face")?.family).toBe(
"Bacasime Antique",
);
const row = SUBSTITUTION_EVIDENCE.find(
(r) => r.evidenceId === "baskerville-old-face",
);
expect(row?.faces).toEqual({
regular: true,
bold: false,
italic: false,
boldItalic: false,
});
test("without canRenderFamily, a named substitute resolves to 'fallback' (un-gated)", () => {
expect(getFallbackDecision("Georgia").kind).toBe("fallback");
});
});

test("unknown family, and a row that recommends no substitute, resolve to null", () => {
expect(getFallback("Comic Papyrus Nonexistent", { hasFamily })).toBeNull();
// Aptos is no_substitute (physicalFamily null) and Cambria Math is preserve_only (null): both null.
expect(getFallback("Aptos", { hasFamily })).toBeNull();
expect(getFallback("Cambria Math", { hasFamily })).toBeNull();
describe("getRenderableFallback", () => {
test("returns the family to render, or null when nothing is renderable", () => {
expect(
getRenderableFallback("Helvetica", { canRenderFamily })?.substituteFamily,
).toBe("Liberation Sans");
// asset_missing, no_recommended_fallback, customer_supplied, preserve_only, unknown -> null here.
expect(getRenderableFallback("Georgia", { canRenderFamily })).toBeNull();
expect(getRenderableFallback("Aptos", { canRenderFamily })).toBeNull();
expect(
getRenderableFallback("Foo Unknown", { canRenderFamily }),
).toBeNull();
});

test("faithful is exactly the two metric-grade bands (metric_safe | near_metric)", () => {
test("lineBreakSafe covers the advance-preserving verdicts (metric_safe | near_metric | cell_width_only)", () => {
const SAFE = new Set(["metric_safe", "near_metric", "cell_width_only"]);
for (const row of SUBSTITUTION_EVIDENCE) {
if (row.physicalFamily === null) continue;
const fb = getFallback(row.logicalFamily);
const expected =
row.verdict === "metric_safe" || row.verdict === "near_metric";
expect(fb?.faithful, `${row.evidenceId} (${row.verdict})`).toBe(expected);
const fb = getRenderableFallback(row.logicalFamily, {
canRenderFamily: () => true,
});
if (!fb) continue;
expect(fb.lineBreakSafe, `${row.evidenceId} (${row.verdict})`).toBe(
SAFE.has(row.verdict),
);
}
});

test("a monospace cell_width_only substitute is lineBreakSafe (advances match)", () => {
// Consolas -> Inconsolata SemiExpanded: glyph shapes differ, but cell width (every advance) matches.
const fb = getRenderableFallback("Consolas", {
canRenderFamily: () => true,
});
expect(fb?.verdict).toBe("cell_width_only");
expect(fb?.lineBreakSafe).toBe(true);
});
});

describe("deriveFallbackMap", () => {
test("keys are the active, bundled logical families (lowercased); inert rows excluded", () => {
const map = deriveFallbackMap({ hasFamily });
// The 5-family bundle activates exactly the seven rows whose physical family it ships (six
// substitutes + the Calibri Light category fallback, which also points at the bundled Carlito).
describe("createFallbackMap", () => {
test("only includes families the consumer can render, keyed by normalized logical family", () => {
const map = createFallbackMap({ canRenderFamily });
expect(Object.keys(map).sort()).toEqual([
"arial",
"calibri",
Expand All @@ -96,26 +119,25 @@ describe("deriveFallbackMap", () => {
"helvetica",
"times new roman",
]);
expect(map.helvetica.family).toBe("Liberation Sans");
expect(map.helvetica.substituteFamily).toBe("Liberation Sans");
// Georgia/Arial Narrow/Baskerville point at un-bundled families, so they are absent.
expect(map.georgia).toBeUndefined();
});

test("deriveFallbackMap will not compile without hasFamily (asset-safety is enforced by the type)", () => {
// @ts-expect-error - hasFamily is required; an ungated render map is a compile error by design.
const unsafe = () => deriveFallbackMap();
test("canRenderFamily is required (asset-safety enforced by the type)", () => {
// @ts-expect-error - a render map must be asset-safe; omitting canRenderFamily is a compile error.
const unsafe = () => createFallbackMap();
expect(typeof unsafe).toBe("function");
});
});

test("hasFamily is required, and a bundle-everything consumer activates all renderable rows", () => {
const map = deriveFallbackMap({ hasFamily: () => true });
const renderable = SUBSTITUTION_EVIDENCE.filter(
(r) =>
r.physicalFamily !== null &&
(r.policyAction === "substitute" ||
r.policyAction === "category_fallback"),
).length;
expect(Object.keys(map).length).toBe(renderable);
expect(map.georgia?.family).toBe("Gelasio");
describe("normalizeFamilyName (public)", () => {
test("trims, strips quotes, lowercases - matches the map keys", () => {
expect(normalizeFamilyName("Times New Roman")).toBe("times new roman");
expect(normalizeFamilyName(" 'CALIBRI' ")).toBe("calibri");
const map = createFallbackMap({ canRenderFamily });
expect(map[normalizeFamilyName("Times New Roman")]?.substituteFamily).toBe(
"Liberation Serif",
);
});
});
52 changes: 52 additions & 0 deletions packages/fallbacks/node-esm.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
/**
* Regression guard for the v0.1 bug where the published ESM had extensionless relative imports
* (`from "./data"`), which works in bundlers but throws ERR_MODULE_NOT_FOUND in plain Node ESM. The
* build emits NodeNext `.js` specifiers; these tests prove the SHIPPED artifact loads in real Node.
*
* The package is ESM-only (package.json `"type": "module"`, no `require` export). CommonJS `require()`
* is intentionally unsupported - consumers import it, or their bundler does.
*/
import { describe, expect, test } from "bun:test";
import { execFileSync, execSync } from "node:child_process";
import { readFileSync } from "node:fs";
import { join } from "node:path";
import { pathToFileURL } from "node:url";

const PKG_DIR = import.meta.dir;
const DIST = join(PKG_DIR, "dist");

describe("Node ESM loadability", () => {
// Build once for this file.
execSync("bun run build", { cwd: PKG_DIR, stdio: "pipe" });

test("emitted relative imports carry explicit .js extensions (NodeNext)", () => {
for (const file of ["index.js", "fallbacks.js", "data.js"]) {
const code = readFileSync(join(DIST, file), "utf8");
// any `from "./x"` or `from "../x"` must end in .js - no extensionless relative specifiers.
const bad = [...code.matchAll(/from\s+"(\.[^"]*)"/g)].filter(
(m) => !m[1].endsWith(".js"),
);
expect(
bad.map((m) => m[1]),
`extensionless imports in ${file}`,
).toEqual([]);
}
});

test("the built artifact imports + runs under plain Node ESM", () => {
const url = pathToFileURL(join(DIST, "index.js")).href;
const script = `
const m = await import(${JSON.stringify(url)});
const d = m.getFallbackDecision("Helvetica", { canRenderFamily: () => true });
process.stdout.write(JSON.stringify({ kind: d.kind, fam: d.fallback?.substituteFamily, has: typeof m.getFallbackDecision }));
`;
const out = execFileSync("node", ["--input-type=module", "-e", script], {
encoding: "utf8",
});
expect(JSON.parse(out)).toEqual({
kind: "fallback",
fam: "Liberation Sans",
has: "function",
});
});
});
2 changes: 1 addition & 1 deletion packages/fallbacks/src/data.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
// Generated from reviewed docfonts evidence. Do not edit by hand.
import type { SubstitutionEvidence } from "./types";
import type { SubstitutionEvidence } from "./types.js";

export const SUBSTITUTION_EVIDENCE: readonly SubstitutionEvidence[] = [
{
Expand Down
Loading
Loading