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
9 changes: 8 additions & 1 deletion .github/workflows/release.yml
Original file line number Diff line number Diff line change
@@ -1,13 +1,20 @@
name: release-fallbacks

on:
push:
branches:
- main
workflow_dispatch:
inputs:
dry_run:
description: "Dry run (compute version + notes, publish nothing)"
type: boolean
default: true

concurrency:
group: release-fallbacks
cancel-in-progress: false

permissions:
contents: write

Expand All @@ -34,4 +41,4 @@ jobs:
--package semantic-release@24
--package @semantic-release/github@12.0.8
--package semantic-release-ai-notes@0.3.0
semantic-release ${{ inputs.dry_run && '--dry-run' || '' }}
semantic-release ${{ github.event_name == 'workflow_dispatch' && inputs.dry_run && '--dry-run' || '' }}
24 changes: 14 additions & 10 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,21 +5,24 @@

> Document font substitution, measured.

This public repository currently publishes `@docfonts/fallbacks`: a small runtime package that maps
common proprietary document fonts to measured open-font fallbacks.
docfonts publishes `@docfonts/fallbacks`, a small runtime package for document renderers.
It maps common proprietary document fonts to reviewed open-font fallback decisions.

The package ships no font binaries and no proprietary data. It contains a reviewed evidence snapshot,
runtime lookup helpers, and tests that prove the npm tarball excludes research data, local paths, and
oracle-environment labels.
The package ships no font binaries and no proprietary data. It contains a public evidence snapshot,
asset-aware lookup helpers, and tests that prove the npm package only includes supported runtime files.

Built by the team behind [SuperDoc](https://github.com/superdoc-dev/superdoc). Standalone and neutral.

## Package

- `packages/fallbacks` - runtime fallback evidence and lookup helpers.
- `packages/fallbacks` - runtime fallback decisions and lookup helpers.

Everything else used to build, measure, or review the evidence stays outside the tracked public tree
until it is ready to be documented and supported as public source.
## API

- `getRenderableFallback` - returns the open family to render, or `null` when none is renderable.
- `getFallbackDecision` - explains the outcome for UI, diagnostics, and reporting.
- `createFallbackMap` - builds a resolver map from only the font families you can render.
- `normalizeFamilyName` - normalizes map lookup keys.

## Install

Expand All @@ -39,5 +42,6 @@ bun run build

## Release

`@docfonts/fallbacks` is released from the manual `release-fallbacks` workflow.
Versioning follows Conventional Commits through semantic-release.
`@docfonts/fallbacks` is released by the `release-fallbacks` workflow on every push to `main`.
semantic-release publishes a new version when the merged commits contain a releasable Conventional Commit.
The same workflow can still run manually as a dry run.
75 changes: 55 additions & 20 deletions packages/fallbacks/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,57 +2,92 @@

Document font substitution, measured.

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.
Measured open-font fallbacks for proprietary document fonts. Use it to decide whether a requested document font can render with an open family you actually ship.

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."
It ships no fonts and no proprietary binaries. It ships decisions: the recommended open family when one exists, the fidelity verdict, and the honest cases where no open family should be used.

## Install

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

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

## Three things you might want
## Render A Font

Use `getRenderableFallback` when you need one font family to render now. Pass `canRenderFamily` so docfonts only returns families your app can load.

```ts
// 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) });

const fallback = getRenderableFallback("Helvetica", {
canRenderFamily: (family) => bundledFamilies.has(family),
});

// { substituteFamily: "Liberation Sans", policyAction: "substitute", verdict: "metric_safe", lineBreakSafe: true, evidenceId: "helvetica" }
```

The result is `null` when there is nothing renderable from your available assets. Use `getFallbackDecision` when you need to know why.

## Explain A Decision

Use `getFallbackDecision` for UI, diagnostics, and reporting. It distinguishes known fonts with no recommended fallback from fonts docfonts has never seen.

```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" }

getFallbackDecision("Aptos");
// { kind: "customer_supplied", evidenceId: "aptos" }

getFallbackDecision("Tahoma");
// { kind: "no_recommended_fallback", evidenceId: "tahoma" }

getFallbackDecision("Made Up Font");
// { kind: "unknown" }

getFallbackDecision("Georgia", {
canRenderFamily: (family) => bundledFamilies.has(family),
});
// { kind: "asset_missing", substituteFamily: "Gelasio", verdict: "near_metric", evidenceId: "georgia" }
```

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`.
Decision kinds:

- `fallback` - render the returned `substituteFamily`.
- `asset_missing` - docfonts has a fallback, but your app does not load that family.
- `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.
- `unknown` - docfonts has no evidence for this family.

## Create A Resolver Map

Use `createFallbackMap` when wiring a resolver. `canRenderFamily` is required because a resolver map must never point at fonts you cannot load.

```ts
// 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) });

const map = createFallbackMap({
canRenderFamily: (family) => bundledFamilies.has(family),
});

map[normalizeFamilyName("Times New Roman")]; // { substituteFamily: "Liberation Serif", ... }
```

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.
Keys are normalized. Use `normalizeFamilyName` for lookups. Rows whose substitute family is not available are omitted.

## 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: `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.
- `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.
- `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`.
- `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: these answer "which family", not "which face".
`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".

## Provenance

Expand Down
6 changes: 3 additions & 3 deletions packages/fallbacks/pack.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
/**
* Publish-safety: prove the npm tarball ships only the built runtime artifact and no private evidence
* labels, local paths, tests, or source files.
* Publish-safety: prove the npm tarball ships only the built runtime artifact, not local paths,
* measurement environment details, tests, or source files.
*/
import { describe, expect, test } from "bun:test";
import { execSync } from "node:child_process";
Expand Down Expand Up @@ -59,7 +59,7 @@ describe("publish tarball hygiene", () => {
expect(pat.test(f), `forbidden file packed: ${f}`).toBe(false);
});

test("the shipped bytes carry no local path or oracle-environment leak", () => {
test("the shipped bytes carry no local paths or measurement environment details", () => {
const distDir = join(PKG_DIR, "dist");
expect(existsSync(distDir)).toBe(true);
const bytes = readdirSync(distDir)
Expand Down
4 changes: 2 additions & 2 deletions packages/fallbacks/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -87,8 +87,8 @@ export interface SubstitutionEvidence {
}

/**
* A resolved fallback: which open family to render, how it was chosen, and how faithful it is. The full
* structured row stays available via {@link SUBSTITUTION_EVIDENCE} for richer reporting.
* A resolved fallback: which open family to render, how it was chosen, and whether advances preserve
* line breaks. The full structured row stays available via {@link SUBSTITUTION_EVIDENCE} for reporting.
*/
export interface FontFallback {
/** the open family to render in place of the requested font. */
Expand Down
Loading