Skip to content

Commit 02aa7d5

Browse files
committed
feat(fonts): config-gated bundled font availability and curation (SD-3441)
Without a configured pack SuperDoc advertises and renders only a safe baseline (one font per CSS generic), so an unconfigured app never fetches a bundled substitute it cannot serve. Configuring the pack (@superdoc/fonts, fonts.assetBaseUrl/resolveAssetUrl, or the CDN build) lights up the full reviewed set, gated by config presence rather than a runtime probe. createSuperDocFonts({ include / exclude }) curates the pack by Word family name, validated against the offerings so a typo fails fast at setup time. Activation is document-scoped in the resolver and offerings and folded into the resolver signature, so a no-pack or curated document never reuses a full-pack document's measures. A CI drift check keeps the generated curatable list in sync with the offerings.
1 parent bcd15a5 commit 02aa7d5

39 files changed

Lines changed: 1410 additions & 182 deletions

.github/workflows/ci-superdoc.yml

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@ jobs:
3939
- 'packages/document-api/**'
4040
- 'packages/collaboration-yjs/**'
4141
- 'packages/docx-evidence-contracts/**'
42+
- 'packages/fonts/**'
4243
- 'shared/**'
4344
- 'tests/**'
4445
- 'scripts/**'
@@ -131,6 +132,13 @@ jobs:
131132
# Local equivalent: `pnpm check:public:superdoc` (with build).
132133
run: pnpm check:public:superdoc --skip-build
133134

135+
- name: Font curation list drift check
136+
# Fails if the committed packages/fonts/src/bundled-families.ts no longer matches the
137+
# font-system curation set, so a font-offerings change that is not regenerated cannot merge
138+
# stale (guards the Verdana-bug class). Runs here because the job's path filter covers both
139+
# the font offerings (shared/**) and the fonts package (packages/fonts/**).
140+
run: pnpm --filter @superdoc/fonts run check:families
141+
134142
unit-tests:
135143
needs: build
136144
runs-on: ubuntu-latest

apps/docs/advanced/headless-toolbar.mdx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -329,7 +329,7 @@ Snapshot values match the format you pass to `execute()`. What you read is what
329329

330330
For a font dropdown that also includes fonts used by the active document, use `useSuperDocFontOptions()` from `superdoc/ui/react` or `ui.fonts` from `superdoc/ui`.
331331

332-
`DEFAULT_FONT_FAMILY_OPTIONS` mirrors the built-in picker list. It can include bundled font choices beyond the core defaults; use the font report when you need per-font rendering details.
332+
`DEFAULT_FONT_FAMILY_OPTIONS` is the conservative no-pack baseline - one Word font per CSS generic (Arial, Times New Roman, Courier New). For the full pack plus any curation and the active document's fonts, use `useSuperDocFontOptions()` from `superdoc/ui/react` or `ui.fonts` from `superdoc/ui`; they reflect the configured pack at runtime, which a static constant cannot.
333333

334334
For font family, font size, and other commands that apply to selected text, prefer a button menu or popover that prevents `mousedown` from moving focus out of the editor. Native selects can visually clear the editor selection while their menu is open.
335335

@@ -361,7 +361,7 @@ const {
361361

362362
| Constant | Contents |
363363
|----------|----------|
364-
| `DEFAULT_FONT_FAMILY_OPTIONS` | Built-in picker list. Includes strict defaults plus explicitly advertised bundled fallback choices. |
364+
| `DEFAULT_FONT_FAMILY_OPTIONS` | Conservative no-pack baseline: one font per CSS generic (Arial, Times New Roman, Courier New). Configure the pack for the full list, or use `useSuperDocFontOptions()` / `ui.fonts`. |
365365
| `DEFAULT_FONT_SIZE_OPTIONS` | 8pt through 96pt (14 sizes) |
366366
| `DEFAULT_TEXT_ALIGN_OPTIONS` | left, center, right, justify |
367367
| `DEFAULT_LINE_HEIGHT_OPTIONS` | 1.00, 1.15, 1.50, 2.00, 2.50, 3.00 |

apps/docs/editor/custom-ui/toolbar-and-commands.mdx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -101,7 +101,7 @@ function FontSizePicker() {
101101

102102
## Font family picker
103103

104-
Use `useSuperDocFontOptions()` for a custom font dropdown. It returns the built-in defaults plus fonts used by the active document, sorted alphabetically.
104+
Use `useSuperDocFontOptions()` for a custom font dropdown. It returns the fonts SuperDoc can render plus fonts used by the active document, sorted alphabetically. Without a configured font pack that is the conservative baseline; configure the pack (`@superdoc/fonts`, or `fonts.assetBaseUrl`) and it returns the full set, minus anything curated with `createSuperDocFonts`.
105105

106106
`label` is what you show. `value` is what you pass to the `font-family` command. `previewFamily` is only for rendering the option row.
107107

apps/docs/getting-started/fonts.mdx

Lines changed: 29 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,8 @@ SuperDoc keeps the font name from the Word document. When SuperDoc ships an appr
1010

1111
A Word document asks for fonts like Calibri, Cambria, and Times New Roman. Most are proprietary, or not installed on every machine. SuperDoc renders them with reviewed open substitutes that match the metrics: Carlito for Calibri, Liberation Serif for Times New Roman, and more. The original name is kept for export.
1212

13+
Without the pack, the toolbar lists one widely available font per CSS generic: Arial for sans-serif, Times New Roman for serif, Courier New for monospace. Each is applied with that generic as a fallback, so it renders acceptably even where the exact font is absent - a readable floor, not an exact-typography guarantee. Wire the pack and the toolbar lists the full reviewed set, and SuperDoc renders the substitutes everywhere. Either way, the Word font name is kept for export.
14+
1315
These substitutes are real `.woff2` files. The browser fetches them from a URL. Installing SuperDoc from npm puts them in `node_modules`, which does not serve them to the browser. So you tell your app where they live. Pick one path.
1416

1517
### Recommended: the `@superdoc/fonts` package
@@ -31,6 +33,30 @@ new SuperDoc({
3133
});
3234
```
3335

36+
### Choose which bundled fonts
37+
38+
By default the pack enables every reviewed font. To narrow it, use `createSuperDocFonts` and name the families. Think in Word names (`Calibri`, not the substitute `Carlito`).
39+
40+
Drop a few:
41+
42+
```js
43+
import { createSuperDocFonts } from '@superdoc/fonts';
44+
45+
new SuperDoc({
46+
selector: '#editor',
47+
document: 'contract.docx',
48+
fonts: createSuperDocFonts({ exclude: ['Cooper Black', 'Brush Script MT'] }),
49+
});
50+
```
51+
52+
Or allow only a set:
53+
54+
```js
55+
fonts: createSuperDocFonts({ include: ['Calibri', 'Cambria', 'Arial'] }),
56+
```
57+
58+
Curation changes the toolbar list and which families SuperDoc substitutes. It does not touch your own licensed fonts (see [Load your own fonts](#load-your-own-fonts)). A curated-out family a document still uses keeps its Word name for export and renders with a system font.
59+
3460
### Alternative: host the files yourself
3561

3662
Serve the `.woff2` from your own path or a CDN, then point SuperDoc at them. The files ship in the package at `node_modules/superdoc/dist/fonts/`.
@@ -51,7 +77,7 @@ The CDN build loads the fonts from a path relative to the script. Loading from a
5177

5278
### Skipping the pack
5379

54-
The pack is optional. Skip it if you load your own fonts for every family, or only need fonts the user's OS already has. When the bundled `.woff2` cannot be fetched, SuperDoc logs a one-time warning and falls back to the original font name. On a machine without that font, the text renders in a system fallback, so it can look unchanged.
80+
The pack is optional. Skip it if you load your own fonts for every family, or only need fonts the user's OS already has. With no pack configured the toolbar shows the baseline and documents render with system fonts, quietly. If you do wire the pack but the `.woff2` cannot be fetched, SuperDoc logs a one-time warning that names the fix; until then text falls back to the original font name, so it can look unchanged on a machine that lacks it.
5581

5682
## Load your own fonts
5783

@@ -69,7 +95,7 @@ SuperDoc's built-in fallbacks cover only the fonts it ships and verifies. For ev
6995

7096
## Toolbar font list
7197

72-
The built-in toolbar lists SuperDoc's defaults plus fonts used by the active document, sorted alphabetically. If you pass `modules.toolbar.fonts`, that custom list replaces the default list.
98+
The built-in toolbar lists the fonts SuperDoc can render, plus the fonts the active document uses, sorted alphabetically. With no pack configured that is the baseline of one font per CSS generic (Arial, Times New Roman, Courier New); with the pack it is the full reviewed set, minus anything you curated out with `createSuperDocFonts`. If you pass `modules.toolbar.fonts`, that custom list replaces it entirely.
7399

74100
Each custom entry is a `{ label, key }` pair where `key` is the CSS `font-family` value:
75101

@@ -108,6 +134,7 @@ editor.doc.format.apply({
108134

109135
- **Font name preserved, browser falls back.** SuperDoc keeps the DOCX font name. If no bundled fallback or loaded real font exists, the browser chooses its own fallback.
110136
- **Custom toolbar list hides document fonts.** Passing `modules.toolbar.fonts` replaces the built-in list. Include every option you want users to pick.
137+
- **Not every bundled family ships every weight and style.** A few substitutes are a single face. For a bold or italic run the substitute lacks, SuperDoc renders the faces it has and leaves the missing ones to the browser's fallback rather than synthesizing a face, so spacing stays predictable.
111138
- **Office font licensing.** Calibri, Cambria, and Aptos are licensed Microsoft fonts. Self-hosting the real files requires a license.
112139

113140
## Where to next

packages/fonts/README.md

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,25 @@ That is the whole setup. No copying files into `public/`, no `assetBaseUrl`. The
4040
written as `new URL('../assets/<file>', import.meta.url)`, which Vite, Webpack 5, Next, Nuxt,
4141
esbuild, and Parcel all detect, emit, and rewrite to the final hashed path.
4242

43+
### Choosing which fonts
44+
45+
`superdocFonts` enables every reviewed family. To narrow the set, use `createSuperDocFonts` and name
46+
the families by their Word name (`Calibri`, not the substitute `Carlito`):
47+
48+
```js
49+
import { createSuperDocFonts } from '@superdoc/fonts';
50+
51+
// Everything except a couple:
52+
new SuperDoc({ selector: '#editor', document, fonts: createSuperDocFonts({ exclude: ['Cooper Black'] }) });
53+
54+
// Or only an explicit set:
55+
new SuperDoc({ selector: '#editor', document, fonts: createSuperDocFonts({ include: ['Calibri', 'Cambria'] }) });
56+
```
57+
58+
`include` is an allow-list; `exclude` keeps everything but the named families. Curation drives the
59+
toolbar list and which families SuperDoc substitutes. Your own licensed fonts stay separate
60+
(`fonts.families`).
61+
4362
### Hosting the assets another way
4463

4564
If you serve the fonts from a CDN or a signed path instead, you do not need this package's

packages/fonts/package.json

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -27,10 +27,12 @@
2727
"sideEffects": false,
2828
"scripts": {
2929
"sync": "node scripts/sync-assets.mjs",
30-
"generate": "node scripts/generate-asset-urls.mjs",
30+
"generate": "node scripts/generate-asset-urls.mjs && pnpm exec tsx scripts/generate-bundled-families.ts",
31+
"check:families": "pnpm exec tsx scripts/check-bundled-families.ts",
3132
"build": "node scripts/sync-assets.mjs && node scripts/generate-asset-urls.mjs && tsc --project tsconfig.json",
3233
"prepare": "node scripts/sync-assets.mjs && node scripts/generate-asset-urls.mjs && tsc --project tsconfig.json",
33-
"typecheck": "tsc --noEmit"
34+
"typecheck": "tsc --noEmit",
35+
"test": "vitest"
3436
},
3537
"devDependencies": {
3638
"@types/node": "catalog:",
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
// Non-mutating drift check (CI-safe): fails if the committed src/bundled-families.ts no longer
2+
// matches @superdoc/font-system's runtime curation set (getBundledFamilyNames). Guards the
3+
// Verdana-bug class - a font-offerings change that regenerates the list in a CI working tree but
4+
// merges without the committed update, so the published list silently drifts.
5+
//
6+
// Skips when the font-system source is absent (a standalone install cannot recompute the set); in the
7+
// monorepo it runs and a real import error fails loudly. Run via
8+
// `pnpm --filter @superdoc/fonts check:families`.
9+
import { existsSync } from 'node:fs';
10+
import { dirname, resolve } from 'node:path';
11+
import { fileURLToPath } from 'node:url';
12+
import { BUNDLED_FAMILY_NAMES } from '../src/bundled-families';
13+
14+
const here = dirname(fileURLToPath(import.meta.url));
15+
const fontSystemSource = resolve(here, '../../../shared/font-system/src/font-offerings.ts');
16+
17+
if (!existsSync(fontSystemSource)) {
18+
console.log('[@superdoc/fonts] font-system source not present (standalone install); skipping curation-drift check');
19+
process.exit(0);
20+
}
21+
22+
// eslint-disable-next-line import-x/no-relative-packages -- build-only script (not shipped); @superdoc/fonts stays a dependency-free runtime package and font-system exposes no /src export, so reading its source relatively here is intentional
23+
const { getBundledFamilyNames } = await import('../../../shared/font-system/src/font-offerings');
24+
const expected = [...getBundledFamilyNames()].sort();
25+
const committed = [...BUNDLED_FAMILY_NAMES].sort();
26+
27+
if (JSON.stringify(expected) !== JSON.stringify(committed)) {
28+
const missing = expected.filter((name) => !committed.includes(name));
29+
const extra = committed.filter((name) => !expected.includes(name));
30+
console.error(
31+
'[@superdoc/fonts] src/bundled-families.ts is STALE: it no longer matches the font-system curation set.',
32+
);
33+
if (missing.length) console.error(` missing (in offerings, not committed): ${missing.join(', ')}`);
34+
if (extra.length) console.error(` extra (committed, not in offerings): ${extra.join(', ')}`);
35+
console.error(' Fix: run `pnpm --filter @superdoc/fonts generate` and commit src/bundled-families.ts');
36+
process.exit(1);
37+
}
38+
39+
console.log(`[@superdoc/fonts] curation list in sync with font-system (${committed.length} families)`);

packages/fonts/scripts/generate-asset-urls.mjs

Lines changed: 16 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,17 @@ const local = resolve(here, '../assets');
1717
const sourceDir = existsSync(canonical) ? canonical : local;
1818
const outFile = resolve(here, '../src/asset-urls.ts');
1919

20+
/** Format with the repo Prettier when available so committed output is stable; tolerate its absence. */
21+
async function formatTs(content, filepath) {
22+
try {
23+
const prettier = await import('prettier');
24+
const config = (await prettier.resolveConfig(filepath)) ?? {};
25+
return await prettier.format(content, { ...config, filepath });
26+
} catch {
27+
return content;
28+
}
29+
}
30+
2031
const files = readdirSync(sourceDir)
2132
.filter((f) => f.endsWith('.woff2'))
2233
.sort();
@@ -44,16 +55,11 @@ ${entries}
4455
// generator produces byte-identical output and the format hook never rewrites it. Prettier is
4556
// optional - in a consumer git-install it may be absent, and the unformatted output is still
4657
// valid TypeScript - so a missing prettier is not an error.
47-
let output = content;
48-
try {
49-
const prettier = await import('prettier');
50-
const config = (await prettier.resolveConfig(outFile)) ?? {};
51-
output = await prettier.format(content, { ...config, filepath: outFile });
52-
} catch {
53-
// Prettier not resolvable here; write the unformatted (still valid) output.
54-
}
55-
56-
writeFileSync(outFile, output);
58+
writeFileSync(outFile, await formatTs(content, outFile));
5759
console.log(
5860
`[@superdoc/fonts] wrote ${files.length} asset URLs -> src/asset-urls.ts (source: ${sourceDir.includes('shared') ? 'canonical' : 'local'})`,
5961
);
62+
63+
// The curatable family-name list (src/bundled-families.ts) is generated SEPARATELY by
64+
// scripts/generate-bundled-families.ts: it must mirror the runtime resolver/offerings curation set
65+
// (which includes category fallbacks like Verdana), not the asset manifest's metric-clone `replaces`.
Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
// Generates src/bundled-families.ts: the Word family names a document can curate via
2+
// createSuperDocFonts({ include / exclude }), mirrored from @superdoc/font-system's runtime curation
3+
// set (every logical family the resolver substitutes to a bundled face, including category fallbacks
4+
// like Verdana) - NOT the asset manifest's narrower metric-clone `replaces`.
5+
//
6+
// Skips when the font-system source is absent (a rare standalone install), leaving the committed file;
7+
// in the monorepo a real import error fails loudly. Committed so npm consumers get the list without
8+
// re-generating. Re-run via `pnpm --filter @superdoc/fonts generate` when the curation set changes.
9+
import { existsSync, writeFileSync } from 'node:fs';
10+
import { dirname, resolve } from 'node:path';
11+
import { fileURLToPath } from 'node:url';
12+
13+
const here = dirname(fileURLToPath(import.meta.url));
14+
const outFile = resolve(here, '../src/bundled-families.ts');
15+
const fontSystemSource = resolve(here, '../../../shared/font-system/src/font-offerings.ts');
16+
17+
if (!existsSync(fontSystemSource)) {
18+
console.log(
19+
'[@superdoc/fonts] font-system source not present (standalone install); kept committed src/bundled-families.ts',
20+
);
21+
process.exit(0);
22+
}
23+
24+
// eslint-disable-next-line import-x/no-relative-packages -- build-only script (not shipped); @superdoc/fonts stays a dependency-free runtime package and font-system exposes no /src export, so reading its source relatively here is intentional
25+
const { getBundledFamilyNames } = await import('../../../shared/font-system/src/font-offerings');
26+
const names = getBundledFamilyNames();
27+
if (names.length === 0) {
28+
console.error('[@superdoc/fonts] font-system returned no curatable families');
29+
process.exit(1);
30+
}
31+
32+
const content = `/**
33+
* GENERATED by scripts/generate-bundled-families.ts - do not edit by hand.
34+
*
35+
* The Word family names a document can curate via createSuperDocFonts({ include / exclude }), mirrored
36+
* from @superdoc/font-system's runtime curation set (every logical family the resolver substitutes to a
37+
* bundled face, including category fallbacks like Verdana). A name outside this set is a typo or an
38+
* unbundled font, so createSuperDocFonts rejects it instead of silently dropping the intended fonts.
39+
*/
40+
export const BUNDLED_FAMILY_NAMES: readonly string[] = Object.freeze([
41+
${names.map((name) => ` ${JSON.stringify(name)},`).join('\n')}
42+
]);
43+
`;
44+
45+
let output = content;
46+
try {
47+
const prettier = await import('prettier');
48+
const config = (await prettier.resolveConfig(outFile)) ?? {};
49+
output = await prettier.format(content, { ...config, filepath: outFile });
50+
} catch {
51+
// Prettier optional; the unformatted output is still valid TypeScript.
52+
}
53+
54+
writeFileSync(outFile, output);
55+
console.log(`[@superdoc/fonts] wrote ${names.length} curatable family names -> src/bundled-families.ts`);
Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
/**
2+
* GENERATED by scripts/generate-bundled-families.ts - do not edit by hand.
3+
*
4+
* The Word family names a document can curate via createSuperDocFonts({ include / exclude }), mirrored
5+
* from @superdoc/font-system's runtime curation set (every logical family the resolver substitutes to a
6+
* bundled face, including category fallbacks like Verdana). A name outside this set is a typo or an
7+
* unbundled font, so createSuperDocFonts rejects it instead of silently dropping the intended fonts.
8+
*/
9+
export const BUNDLED_FAMILY_NAMES: readonly string[] = Object.freeze([
10+
'Arial',
11+
'Arial Black',
12+
'Arial MT',
13+
'Arial Narrow',
14+
'Baskerville Old Face',
15+
'Bookman Old Style',
16+
'Brush Script MT',
17+
'Calibri',
18+
'Calibri Light',
19+
'Cambria',
20+
'Century',
21+
'Century Gothic',
22+
'Century Schoolbook',
23+
'Comic Sans MS',
24+
'Consolas',
25+
'Cooper Black',
26+
'Courier',
27+
'Courier New',
28+
'Garamond',
29+
'Georgia',
30+
'Gill Sans MT Condensed',
31+
'Helvetica',
32+
'ITC Bookman',
33+
'Lucida Console',
34+
'Segoe UI',
35+
'Tahoma',
36+
'Times',
37+
'Times New Roman',
38+
'Trebuchet MS',
39+
'Verdana',
40+
]);

0 commit comments

Comments
 (0)