From 02aa7d5c10c61110a94acc3f73393bfed8ee6a02 Mon Sep 17 00:00:00 2001 From: Caio Pizzol Date: Sat, 13 Jun 2026 12:27:20 -0300 Subject: [PATCH 1/6] 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. --- .github/workflows/ci-superdoc.yml | 8 + apps/docs/advanced/headless-toolbar.mdx | 4 +- .../editor/custom-ui/toolbar-and-commands.mdx | 2 +- apps/docs/getting-started/fonts.mdx | 31 +++- packages/fonts/README.md | 19 +++ packages/fonts/package.json | 6 +- .../fonts/scripts/check-bundled-families.ts | 39 +++++ .../fonts/scripts/generate-asset-urls.mjs | 26 +-- .../scripts/generate-bundled-families.ts | 55 +++++++ packages/fonts/src/bundled-families.ts | 40 +++++ packages/fonts/src/index.test.ts | 72 +++++++++ packages/fonts/src/index.ts | 138 ++++++++++++++++ packages/fonts/vitest.config.mjs | 11 ++ .../v1/components/toolbar/constants.js | 67 +++++--- .../v1/components/toolbar/constants.test.js | 114 ++++++++----- .../v1/components/toolbar/super-toolbar.js | 7 +- .../fonts/DocumentFontController.test.ts | 4 +- .../fonts/DocumentFontController.ts | 15 +- .../src/editors/v1/core/types/EditorConfig.ts | 10 +- .../src/headless-toolbar/constants.test.ts | 33 ++-- .../src/headless-toolbar/constants.ts | 10 +- .../src/ui/create-super-doc-ui.test.ts | 33 +++- .../src/ui/create-super-doc-ui.ts | 12 +- .../super-editor/src/ui/react/hooks.test.tsx | 10 +- packages/super-editor/src/ui/types.ts | 7 + packages/superdoc/src/cdn-entry.js | 10 +- scripts/generate-all.mjs | 9 +- shared/font-system/src/activation.test.ts | 96 +++++++++++ shared/font-system/src/activation.ts | 138 ++++++++++++++++ shared/font-system/src/bundled.ts | 22 +++ .../src/document-font-options.test.ts | 36 ++++- .../font-system/src/document-font-options.ts | 16 +- shared/font-system/src/font-offerings.test.ts | 89 +++++++++-- shared/font-system/src/font-offerings.ts | 150 ++++++++++++++++-- shared/font-system/src/index.ts | 21 ++- shared/font-system/src/resolver.test.ts | 109 +++++++++++++ shared/font-system/src/resolver.ts | 118 ++++++++++---- tests/behavior/harness/main.ts | 4 + vitest.config.mjs | 1 + 39 files changed, 1410 insertions(+), 182 deletions(-) create mode 100644 packages/fonts/scripts/check-bundled-families.ts create mode 100644 packages/fonts/scripts/generate-bundled-families.ts create mode 100644 packages/fonts/src/bundled-families.ts create mode 100644 packages/fonts/src/index.test.ts create mode 100644 packages/fonts/vitest.config.mjs create mode 100644 shared/font-system/src/activation.test.ts create mode 100644 shared/font-system/src/activation.ts diff --git a/.github/workflows/ci-superdoc.yml b/.github/workflows/ci-superdoc.yml index 3860946284..c0d3850a99 100644 --- a/.github/workflows/ci-superdoc.yml +++ b/.github/workflows/ci-superdoc.yml @@ -39,6 +39,7 @@ jobs: - 'packages/document-api/**' - 'packages/collaboration-yjs/**' - 'packages/docx-evidence-contracts/**' + - 'packages/fonts/**' - 'shared/**' - 'tests/**' - 'scripts/**' @@ -131,6 +132,13 @@ jobs: # Local equivalent: `pnpm check:public:superdoc` (with build). run: pnpm check:public:superdoc --skip-build + - name: Font curation list drift check + # Fails if the committed packages/fonts/src/bundled-families.ts no longer matches the + # font-system curation set, so a font-offerings change that is not regenerated cannot merge + # stale (guards the Verdana-bug class). Runs here because the job's path filter covers both + # the font offerings (shared/**) and the fonts package (packages/fonts/**). + run: pnpm --filter @superdoc/fonts run check:families + unit-tests: needs: build runs-on: ubuntu-latest diff --git a/apps/docs/advanced/headless-toolbar.mdx b/apps/docs/advanced/headless-toolbar.mdx index 67c744488b..acaf84adb4 100644 --- a/apps/docs/advanced/headless-toolbar.mdx +++ b/apps/docs/advanced/headless-toolbar.mdx @@ -329,7 +329,7 @@ Snapshot values match the format you pass to `execute()`. What you read is what 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`. -`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. +`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. 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. @@ -361,7 +361,7 @@ const { | Constant | Contents | |----------|----------| -| `DEFAULT_FONT_FAMILY_OPTIONS` | Built-in picker list. Includes strict defaults plus explicitly advertised bundled fallback choices. | +| `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`. | | `DEFAULT_FONT_SIZE_OPTIONS` | 8pt through 96pt (14 sizes) | | `DEFAULT_TEXT_ALIGN_OPTIONS` | left, center, right, justify | | `DEFAULT_LINE_HEIGHT_OPTIONS` | 1.00, 1.15, 1.50, 2.00, 2.50, 3.00 | diff --git a/apps/docs/editor/custom-ui/toolbar-and-commands.mdx b/apps/docs/editor/custom-ui/toolbar-and-commands.mdx index 0fe7bf225e..5fb97ed1ad 100644 --- a/apps/docs/editor/custom-ui/toolbar-and-commands.mdx +++ b/apps/docs/editor/custom-ui/toolbar-and-commands.mdx @@ -101,7 +101,7 @@ function FontSizePicker() { ## Font family picker -Use `useSuperDocFontOptions()` for a custom font dropdown. It returns the built-in defaults plus fonts used by the active document, sorted alphabetically. +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`. `label` is what you show. `value` is what you pass to the `font-family` command. `previewFamily` is only for rendering the option row. diff --git a/apps/docs/getting-started/fonts.mdx b/apps/docs/getting-started/fonts.mdx index 523e9a65cf..fced8e1b3e 100644 --- a/apps/docs/getting-started/fonts.mdx +++ b/apps/docs/getting-started/fonts.mdx @@ -10,6 +10,8 @@ SuperDoc keeps the font name from the Word document. When SuperDoc ships an appr 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. +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. + 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. ### Recommended: the `@superdoc/fonts` package @@ -31,6 +33,30 @@ new SuperDoc({ }); ``` +### Choose which bundled fonts + +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`). + +Drop a few: + +```js +import { createSuperDocFonts } from '@superdoc/fonts'; + +new SuperDoc({ + selector: '#editor', + document: 'contract.docx', + fonts: createSuperDocFonts({ exclude: ['Cooper Black', 'Brush Script MT'] }), +}); +``` + +Or allow only a set: + +```js +fonts: createSuperDocFonts({ include: ['Calibri', 'Cambria', 'Arial'] }), +``` + +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. + ### Alternative: host the files yourself 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 ### Skipping the pack -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. +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. ## Load your own fonts @@ -69,7 +95,7 @@ SuperDoc's built-in fallbacks cover only the fonts it ships and verifies. For ev ## Toolbar font list -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. +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. Each custom entry is a `{ label, key }` pair where `key` is the CSS `font-family` value: @@ -108,6 +134,7 @@ editor.doc.format.apply({ - **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. - **Custom toolbar list hides document fonts.** Passing `modules.toolbar.fonts` replaces the built-in list. Include every option you want users to pick. +- **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. - **Office font licensing.** Calibri, Cambria, and Aptos are licensed Microsoft fonts. Self-hosting the real files requires a license. ## Where to next diff --git a/packages/fonts/README.md b/packages/fonts/README.md index 7d781c3d46..7e6a15b057 100644 --- a/packages/fonts/README.md +++ b/packages/fonts/README.md @@ -40,6 +40,25 @@ That is the whole setup. No copying files into `public/`, no `assetBaseUrl`. The written as `new URL('../assets/', import.meta.url)`, which Vite, Webpack 5, Next, Nuxt, esbuild, and Parcel all detect, emit, and rewrite to the final hashed path. +### Choosing which fonts + +`superdocFonts` enables every reviewed family. To narrow the set, use `createSuperDocFonts` and name +the families by their Word name (`Calibri`, not the substitute `Carlito`): + +```js +import { createSuperDocFonts } from '@superdoc/fonts'; + +// Everything except a couple: +new SuperDoc({ selector: '#editor', document, fonts: createSuperDocFonts({ exclude: ['Cooper Black'] }) }); + +// Or only an explicit set: +new SuperDoc({ selector: '#editor', document, fonts: createSuperDocFonts({ include: ['Calibri', 'Cambria'] }) }); +``` + +`include` is an allow-list; `exclude` keeps everything but the named families. Curation drives the +toolbar list and which families SuperDoc substitutes. Your own licensed fonts stay separate +(`fonts.families`). + ### Hosting the assets another way If you serve the fonts from a CDN or a signed path instead, you do not need this package's diff --git a/packages/fonts/package.json b/packages/fonts/package.json index 0b4f853aed..9d733fdcf3 100644 --- a/packages/fonts/package.json +++ b/packages/fonts/package.json @@ -27,10 +27,12 @@ "sideEffects": false, "scripts": { "sync": "node scripts/sync-assets.mjs", - "generate": "node scripts/generate-asset-urls.mjs", + "generate": "node scripts/generate-asset-urls.mjs && pnpm exec tsx scripts/generate-bundled-families.ts", + "check:families": "pnpm exec tsx scripts/check-bundled-families.ts", "build": "node scripts/sync-assets.mjs && node scripts/generate-asset-urls.mjs && tsc --project tsconfig.json", "prepare": "node scripts/sync-assets.mjs && node scripts/generate-asset-urls.mjs && tsc --project tsconfig.json", - "typecheck": "tsc --noEmit" + "typecheck": "tsc --noEmit", + "test": "vitest" }, "devDependencies": { "@types/node": "catalog:", diff --git a/packages/fonts/scripts/check-bundled-families.ts b/packages/fonts/scripts/check-bundled-families.ts new file mode 100644 index 0000000000..f61f11b97a --- /dev/null +++ b/packages/fonts/scripts/check-bundled-families.ts @@ -0,0 +1,39 @@ +// Non-mutating drift check (CI-safe): fails if the committed src/bundled-families.ts no longer +// matches @superdoc/font-system's runtime curation set (getBundledFamilyNames). Guards the +// Verdana-bug class - a font-offerings change that regenerates the list in a CI working tree but +// merges without the committed update, so the published list silently drifts. +// +// Skips when the font-system source is absent (a standalone install cannot recompute the set); in the +// monorepo it runs and a real import error fails loudly. Run via +// `pnpm --filter @superdoc/fonts check:families`. +import { existsSync } from 'node:fs'; +import { dirname, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { BUNDLED_FAMILY_NAMES } from '../src/bundled-families'; + +const here = dirname(fileURLToPath(import.meta.url)); +const fontSystemSource = resolve(here, '../../../shared/font-system/src/font-offerings.ts'); + +if (!existsSync(fontSystemSource)) { + console.log('[@superdoc/fonts] font-system source not present (standalone install); skipping curation-drift check'); + process.exit(0); +} + +// 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 +const { getBundledFamilyNames } = await import('../../../shared/font-system/src/font-offerings'); +const expected = [...getBundledFamilyNames()].sort(); +const committed = [...BUNDLED_FAMILY_NAMES].sort(); + +if (JSON.stringify(expected) !== JSON.stringify(committed)) { + const missing = expected.filter((name) => !committed.includes(name)); + const extra = committed.filter((name) => !expected.includes(name)); + console.error( + '[@superdoc/fonts] src/bundled-families.ts is STALE: it no longer matches the font-system curation set.', + ); + if (missing.length) console.error(` missing (in offerings, not committed): ${missing.join(', ')}`); + if (extra.length) console.error(` extra (committed, not in offerings): ${extra.join(', ')}`); + console.error(' Fix: run `pnpm --filter @superdoc/fonts generate` and commit src/bundled-families.ts'); + process.exit(1); +} + +console.log(`[@superdoc/fonts] curation list in sync with font-system (${committed.length} families)`); diff --git a/packages/fonts/scripts/generate-asset-urls.mjs b/packages/fonts/scripts/generate-asset-urls.mjs index 262754a7dc..f48301c2ba 100644 --- a/packages/fonts/scripts/generate-asset-urls.mjs +++ b/packages/fonts/scripts/generate-asset-urls.mjs @@ -17,6 +17,17 @@ const local = resolve(here, '../assets'); const sourceDir = existsSync(canonical) ? canonical : local; const outFile = resolve(here, '../src/asset-urls.ts'); +/** Format with the repo Prettier when available so committed output is stable; tolerate its absence. */ +async function formatTs(content, filepath) { + try { + const prettier = await import('prettier'); + const config = (await prettier.resolveConfig(filepath)) ?? {}; + return await prettier.format(content, { ...config, filepath }); + } catch { + return content; + } +} + const files = readdirSync(sourceDir) .filter((f) => f.endsWith('.woff2')) .sort(); @@ -44,16 +55,11 @@ ${entries} // generator produces byte-identical output and the format hook never rewrites it. Prettier is // optional - in a consumer git-install it may be absent, and the unformatted output is still // valid TypeScript - so a missing prettier is not an error. -let output = content; -try { - const prettier = await import('prettier'); - const config = (await prettier.resolveConfig(outFile)) ?? {}; - output = await prettier.format(content, { ...config, filepath: outFile }); -} catch { - // Prettier not resolvable here; write the unformatted (still valid) output. -} - -writeFileSync(outFile, output); +writeFileSync(outFile, await formatTs(content, outFile)); console.log( `[@superdoc/fonts] wrote ${files.length} asset URLs -> src/asset-urls.ts (source: ${sourceDir.includes('shared') ? 'canonical' : 'local'})`, ); + +// The curatable family-name list (src/bundled-families.ts) is generated SEPARATELY by +// scripts/generate-bundled-families.ts: it must mirror the runtime resolver/offerings curation set +// (which includes category fallbacks like Verdana), not the asset manifest's metric-clone `replaces`. diff --git a/packages/fonts/scripts/generate-bundled-families.ts b/packages/fonts/scripts/generate-bundled-families.ts new file mode 100644 index 0000000000..66a1e04830 --- /dev/null +++ b/packages/fonts/scripts/generate-bundled-families.ts @@ -0,0 +1,55 @@ +// Generates src/bundled-families.ts: the Word family names a document can curate via +// createSuperDocFonts({ include / exclude }), mirrored from @superdoc/font-system's runtime curation +// set (every logical family the resolver substitutes to a bundled face, including category fallbacks +// like Verdana) - NOT the asset manifest's narrower metric-clone `replaces`. +// +// Skips when the font-system source is absent (a rare standalone install), leaving the committed file; +// in the monorepo a real import error fails loudly. Committed so npm consumers get the list without +// re-generating. Re-run via `pnpm --filter @superdoc/fonts generate` when the curation set changes. +import { existsSync, writeFileSync } from 'node:fs'; +import { dirname, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const here = dirname(fileURLToPath(import.meta.url)); +const outFile = resolve(here, '../src/bundled-families.ts'); +const fontSystemSource = resolve(here, '../../../shared/font-system/src/font-offerings.ts'); + +if (!existsSync(fontSystemSource)) { + console.log( + '[@superdoc/fonts] font-system source not present (standalone install); kept committed src/bundled-families.ts', + ); + process.exit(0); +} + +// 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 +const { getBundledFamilyNames } = await import('../../../shared/font-system/src/font-offerings'); +const names = getBundledFamilyNames(); +if (names.length === 0) { + console.error('[@superdoc/fonts] font-system returned no curatable families'); + process.exit(1); +} + +const content = `/** + * GENERATED by scripts/generate-bundled-families.ts - do not edit by hand. + * + * The Word family names a document can curate via createSuperDocFonts({ include / exclude }), mirrored + * from @superdoc/font-system's runtime curation set (every logical family the resolver substitutes to a + * bundled face, including category fallbacks like Verdana). A name outside this set is a typo or an + * unbundled font, so createSuperDocFonts rejects it instead of silently dropping the intended fonts. + */ +export const BUNDLED_FAMILY_NAMES: readonly string[] = Object.freeze([ +${names.map((name) => ` ${JSON.stringify(name)},`).join('\n')} +]); +`; + +let output = content; +try { + const prettier = await import('prettier'); + const config = (await prettier.resolveConfig(outFile)) ?? {}; + output = await prettier.format(content, { ...config, filepath: outFile }); +} catch { + // Prettier optional; the unformatted output is still valid TypeScript. +} + +writeFileSync(outFile, output); +console.log(`[@superdoc/fonts] wrote ${names.length} curatable family names -> src/bundled-families.ts`); diff --git a/packages/fonts/src/bundled-families.ts b/packages/fonts/src/bundled-families.ts new file mode 100644 index 0000000000..141c1c8fb8 --- /dev/null +++ b/packages/fonts/src/bundled-families.ts @@ -0,0 +1,40 @@ +/** + * GENERATED by scripts/generate-bundled-families.ts - do not edit by hand. + * + * The Word family names a document can curate via createSuperDocFonts({ include / exclude }), mirrored + * from @superdoc/font-system's runtime curation set (every logical family the resolver substitutes to a + * bundled face, including category fallbacks like Verdana). A name outside this set is a typo or an + * unbundled font, so createSuperDocFonts rejects it instead of silently dropping the intended fonts. + */ +export const BUNDLED_FAMILY_NAMES: readonly string[] = Object.freeze([ + 'Arial', + 'Arial Black', + 'Arial MT', + 'Arial Narrow', + 'Baskerville Old Face', + 'Bookman Old Style', + 'Brush Script MT', + 'Calibri', + 'Calibri Light', + 'Cambria', + 'Century', + 'Century Gothic', + 'Century Schoolbook', + 'Comic Sans MS', + 'Consolas', + 'Cooper Black', + 'Courier', + 'Courier New', + 'Garamond', + 'Georgia', + 'Gill Sans MT Condensed', + 'Helvetica', + 'ITC Bookman', + 'Lucida Console', + 'Segoe UI', + 'Tahoma', + 'Times', + 'Times New Roman', + 'Trebuchet MS', + 'Verdana', +]); diff --git a/packages/fonts/src/index.test.ts b/packages/fonts/src/index.test.ts new file mode 100644 index 0000000000..63df8e6255 --- /dev/null +++ b/packages/fonts/src/index.test.ts @@ -0,0 +1,72 @@ +import { describe, expect, it } from 'vitest'; +import { BUNDLED_FONT_ASSET_URLS, createSuperDocFonts, resolveBundledFontAssetUrl, superdocFonts } from './index'; + +describe('superdocFonts / resolveBundledFontAssetUrl', () => { + it('superdocFonts is a ready-made resolveAssetUrl config', () => { + expect(typeof superdocFonts.resolveAssetUrl).toBe('function'); + }); + + it('resolves a known bundled face to its emitted URL', () => { + const file = Object.keys(BUNDLED_FONT_ASSET_URLS)[0]; + expect(file).toBeTruthy(); + expect(superdocFonts.resolveAssetUrl({ file })).toBe(BUNDLED_FONT_ASSET_URLS[file]); + }); + + it('throws on an unknown file so a version mismatch surfaces immediately', () => { + expect(() => resolveBundledFontAssetUrl({ file: 'NotAFont.woff2' })).toThrow(/no bundled asset/); + }); +}); + +describe('createSuperDocFonts', () => { + it('with no options is the full pack: resolver only, no curation', () => { + const config = createSuperDocFonts(); + expect(typeof config.resolveAssetUrl).toBe('function'); + expect(config.bundled).toBeUndefined(); + }); + + it('include sets an allow-list', () => { + expect(createSuperDocFonts({ include: ['Calibri', 'Cambria'] }).bundled).toEqual({ + include: ['Calibri', 'Cambria'], + }); + }); + + it('exclude sets a block-list', () => { + expect(createSuperDocFonts({ exclude: ['Cooper Black'] }).bundled).toEqual({ exclude: ['Cooper Black'] }); + }); + + it('trims entries and drops empty lists', () => { + expect(createSuperDocFonts({ include: [' Calibri '] }).bundled).toEqual({ include: ['Calibri'] }); + expect(createSuperDocFonts({ include: [] }).bundled).toBeUndefined(); + expect(createSuperDocFonts({ exclude: [] }).bundled).toBeUndefined(); + }); + + it('throws when include and exclude are both given (mutually exclusive intents)', () => { + expect(() => createSuperDocFonts({ include: ['Calibri'], exclude: ['Cambria'] })).toThrow(/not both/); + }); + + it('throws on a non-array list or non-string entries', () => { + expect(() => createSuperDocFonts({ include: 'Calibri' as unknown as string[] })).toThrow(/must be an array/); + expect(() => createSuperDocFonts({ exclude: ['Calibri', 42 as unknown as string] })).toThrow( + /non-empty font name strings/, + ); + }); + + it('throws on a font name SuperDoc does not bundle, suggesting the closest match for a typo', () => { + expect(() => createSuperDocFonts({ include: ['Calbri'] })).toThrow(/does not bundle/); + expect(() => createSuperDocFonts({ include: ['Calbri'] })).toThrow(/did you mean "Calibri"\?/); + // A real Word font with no bundled clone is also rejected (curating it would be a no-op). + expect(() => createSuperDocFonts({ exclude: ['Aptos'] })).toThrow(/does not bundle/); + }); + + it('accepts every family the toolbar advertises, including category fallbacks like Verdana', () => { + // Regression: the curatable list must mirror the resolver/offerings, not the manifest's narrower + // metric-clone `replaces` (which omits Verdana). A font shown in the toolbar must be curatable. + expect(() => createSuperDocFonts({ exclude: ['Verdana'] })).not.toThrow(); + expect(() => createSuperDocFonts({ include: ['Verdana', 'Calibri'] })).not.toThrow(); + expect(createSuperDocFonts({ exclude: ['Verdana'] }).bundled).toEqual({ exclude: ['Verdana'] }); + }); + + it('accepts case- and quote-variant spellings of bundled families (matched normalized)', () => { + expect(() => createSuperDocFonts({ include: [' calibri ', '"Cambria"'] })).not.toThrow(); + }); +}); diff --git a/packages/fonts/src/index.ts b/packages/fonts/src/index.ts index 6d5af872d2..05e126396a 100644 --- a/packages/fonts/src/index.ts +++ b/packages/fonts/src/index.ts @@ -10,6 +10,7 @@ * @beta This package is in preview; its surface may change before 1.0. */ import { BUNDLED_FONT_ASSET_URLS } from './asset-urls'; +import { BUNDLED_FAMILY_NAMES } from './bundled-families'; /** * The full filename -> bundler-emitted URL map for every bundled face. Most consumers use @@ -70,3 +71,140 @@ export function resolveBundledFontAssetUrl(context: BundledFontAssetContext): st export const superdocFonts: { resolveAssetUrl: (context: BundledFontAssetContext) => string } = { resolveAssetUrl: resolveBundledFontAssetUrl, }; + +/** + * Choose which bundled families SuperDoc advertises and renders, by LOGICAL Word family name + * (`"Calibri"`, never the physical `"Carlito"`). + * + * @beta + */ +export interface SuperDocFontsOptions { + /** Allow-list: ONLY these logical families are active, e.g. `['Calibri', 'Cambria']`. */ + include?: string[]; + /** Block-list: every bundled family EXCEPT these, e.g. `['Cooper Black', 'Brush Script MT']`. */ + exclude?: string[]; +} + +/** + * The `fonts` config {@link createSuperDocFonts} returns: the bundled-asset resolver plus the + * curation. Structurally assignable to SuperDoc's `fonts` option. + * + * @beta + */ +export interface SuperDocFontsConfig { + resolveAssetUrl: (context: BundledFontAssetContext) => string; + bundled?: { include?: string[]; exclude?: string[] }; +} + +/** + * Build a curated `fonts` config: the bundled pack, narrowed to the families you choose. + * + * import { createSuperDocFonts } from '@superdoc/fonts'; + * new SuperDoc({ + * selector: '#editor', + * document, + * fonts: createSuperDocFonts({ exclude: ['Cooper Black', 'Brush Script MT'] }), + * }); + * + * Think in Word logical names. `include` is an allow-list (only those families resolve and appear in + * the toolbar); `exclude` keeps everything but those. Pass neither for the full pack - that is exactly + * {@link superdocFonts}. Curation governs the BUNDLED pack only; your own licensed fonts stay separate + * (`fonts.families` / `fonts.map`). + * + * @beta + */ +export function createSuperDocFonts(options: SuperDocFontsOptions = {}): SuperDocFontsConfig { + const include = normalizeNameList(options.include, 'include'); + const exclude = normalizeNameList(options.exclude, 'exclude'); + // include and exclude are mutually exclusive intents; accepting both silently would hide which one + // wins. Reject at the API boundary (this runs in the consumer's setup code, so it fails fast and + // clearly) rather than picking one arbitrarily. + if (include && exclude) { + throw new Error( + '[@superdoc/fonts] createSuperDocFonts: pass `include` OR `exclude`, not both. ' + + '`include` is an allow-list (only those families resolve and appear in the toolbar); ' + + '`exclude` keeps everything but the named families.', + ); + } + // Reject names SuperDoc does not bundle. This runs in the consumer's setup code, so it fails fast + // and clearly - especially for `include`, where a typo would otherwise silently hide the fonts the + // consumer meant to keep. (Raw `fonts.bundled` set directly stays lenient and only warns.) + if (include) assertKnownFamilies(include, 'include'); + if (exclude) assertKnownFamilies(exclude, 'exclude'); + const config: SuperDocFontsConfig = { resolveAssetUrl: resolveBundledFontAssetUrl }; + if (include) config.bundled = { include }; + else if (exclude) config.bundled = { exclude }; + return config; +} + +/** Validate and clean a curation list: an array of non-empty strings, or omitted. Throws on misuse. */ +function normalizeNameList(value: string[] | undefined, field: 'include' | 'exclude'): string[] | undefined { + if (value == null) return undefined; + if (!Array.isArray(value)) { + throw new Error(`[@superdoc/fonts] createSuperDocFonts: \`${field}\` must be an array of Word font names.`); + } + const names = value.map((name) => (typeof name === 'string' ? name.trim() : '')).filter(Boolean); + if (names.length !== value.length) { + throw new Error( + `[@superdoc/fonts] createSuperDocFonts: \`${field}\` must contain only non-empty font name strings ` + + `(e.g. ["Calibri", "Cambria"]).`, + ); + } + return names.length ? names : undefined; +} + +/** Normalize a family name for matching: trim, strip surrounding quotes, lowercase. */ +function normalizeKey(name: string): string { + return name + .trim() + .replace(/^["']|["']$/g, '') + .toLowerCase(); +} + +const KNOWN_FAMILY_KEYS: ReadonlySet = new Set(BUNDLED_FAMILY_NAMES.map(normalizeKey)); + +/** Bounded Levenshtein distance between two short strings, for a "did you mean" hint on a typo. */ +function editDistance(a: string, b: string): number { + const m = a.length; + const n = b.length; + if (Math.abs(m - n) > 2) return 3; + let prev = Array.from({ length: n + 1 }, (_, i) => i); + for (let i = 1; i <= m; i += 1) { + const curr = [i]; + for (let j = 1; j <= n; j += 1) { + const cost = a[i - 1] === b[j - 1] ? 0 : 1; + curr[j] = Math.min(curr[j - 1] + 1, prev[j] + 1, prev[j - 1] + cost); + } + prev = curr; + } + return prev[n]; +} + +/** The closest bundled family within edit distance 2, or null - so a typo gets a concrete suggestion. */ +function closestKnownFamily(name: string): string | null { + const key = normalizeKey(name); + let best: string | null = null; + let bestDist = 3; + for (const family of BUNDLED_FAMILY_NAMES) { + const dist = editDistance(key, normalizeKey(family)); + if (dist < bestDist) { + bestDist = dist; + best = family; + } + } + return best; +} + +/** Throw on any curation name SuperDoc does not bundle, with a suggestion and the full valid list. */ +function assertKnownFamilies(names: readonly string[], field: 'include' | 'exclude'): void { + const unknown = names.filter((name) => !KNOWN_FAMILY_KEYS.has(normalizeKey(name))); + if (unknown.length === 0) return; + const hints = unknown.map((name) => { + const suggestion = closestKnownFamily(name); + return suggestion ? `"${name}" (did you mean "${suggestion}"?)` : `"${name}"`; + }); + throw new Error( + `[@superdoc/fonts] createSuperDocFonts: \`${field}\` names a font SuperDoc does not bundle: ${hints.join(', ')}. ` + + `Curate by Word family name. Bundled families: ${BUNDLED_FAMILY_NAMES.join(', ')}.`, + ); +} diff --git a/packages/fonts/vitest.config.mjs b/packages/fonts/vitest.config.mjs new file mode 100644 index 0000000000..a95488f542 --- /dev/null +++ b/packages/fonts/vitest.config.mjs @@ -0,0 +1,11 @@ +import { defineConfig } from 'vitest/config'; +import baseConfig from '../../vitest.baseConfig'; + +export default defineConfig({ + ...baseConfig, + test: { + name: '@fonts', + environment: 'node', + globals: true, + }, +}); diff --git a/packages/super-editor/src/editors/v1/components/toolbar/constants.js b/packages/super-editor/src/editors/v1/components/toolbar/constants.js index 623a8b673e..fe60ee10b9 100644 --- a/packages/super-editor/src/editors/v1/components/toolbar/constants.js +++ b/packages/super-editor/src/editors/v1/components/toolbar/constants.js @@ -1,24 +1,39 @@ -import { fontOfferingRenderStack, fontOfferingStack, getBuiltInToolbarFontOfferings } from '@superdoc/font-system'; +import { + BASELINE_BUNDLED, + fontOfferingRenderStack, + fontOfferingStack, + getBuiltInToolbarFontOfferings, +} from '@superdoc/font-system'; /** - * Built-in toolbar font dropdown options, DERIVED from the shared font-offering registry - * (`@superdoc/font-system`) instead of a hand-maintained list. Bundled clean defaults and explicit - * qualified/category fallback choices are advertised; unbundled candidates are intentionally absent - * from the static defaults. + * Build the built-in toolbar font dropdown options for a bundled-font activation, DERIVED from the + * shared font-offering registry (`@superdoc/font-system`). Without a configured pack this is the + * conservative baseline; with one it is the curated rich set. * * Per `FontConfig`: `label` is the Word-facing logical name (stored on the selection + active-state * match), `key` is the logical CSS stack, and the row preview renders in the physical clone that * actually paints (e.g. Carlito), so the dropdown looks like the rendered result. + * + * @param {import('@superdoc/font-system').BundledActivation} [activation] + */ +export function toolbarFontOptionsFor(activation = BASELINE_BUNDLED) { + return getBuiltInToolbarFontOfferings(activation).map((offering) => ({ + label: offering.logicalFamily, + key: fontOfferingStack(offering), + fontWeight: 400, + props: { + style: { fontFamily: fontOfferingRenderStack(offering) }, + 'data-item': 'btn-fontFamily-option', + }, + })); +} + +/** + * Static baseline dropdown options (no pack configured). The fallback when a toolbar has no live + * activation to build from; the live path uses {@link composeToolbarFontOptions} with the document's + * activation so a configured pack shows the rich set. */ -export const TOOLBAR_FONTS = getBuiltInToolbarFontOfferings().map((offering) => ({ - label: offering.logicalFamily, - key: fontOfferingStack(offering), - fontWeight: 400, - props: { - style: { fontFamily: fontOfferingRenderStack(offering) }, - 'data-item': 'btn-fontFamily-option', - }, -})); +export const TOOLBAR_FONTS = toolbarFontOptionsFor(BASELINE_BUNDLED); function normalizeToolbarFamily(value) { return String(value ?? '') @@ -38,21 +53,29 @@ function compareToolbarFontOptions(a, b) { * with the bundled defaults. The toolbar only asks for the result; it does not know how a font previews. * * - A consumer-provided `configFonts` list is returned UNCHANGED (custom toolbars own their list). - * - With no document options, returns `undefined` so the caller keeps its fallback to {@link TOOLBAR_FONTS}. - * - Otherwise: bundled defaults and document fonts are deduped by normalized logical family, then sorted + * - The built-in base is gated on `activation`: the baseline without a configured pack, the curated + * rich set with one. + * - When the base is the baseline AND there are no document options, returns `undefined` so the caller + * keeps its fallback to the static {@link TOOLBAR_FONTS} (it is the same list). Any configured pack + * or extra document font returns the built list. + * - Bundled base and document fonts are deduped by normalized logical family, then sorted * alphabetically by the visible font name. `label`/`key` stay the pure logical family (active-state * matching + the stored value), and the preview renders in `previewFamily`. * * @param {ReadonlyArray} documentOptions * @param {Array} [configFonts] - the consumer's `fonts` config, if any + * @param {import('@superdoc/font-system').BundledActivation} [activation] - the document's bundled-font activation * @returns {Array|undefined} */ -export function composeToolbarFontOptions(documentOptions, configFonts) { +export function composeToolbarFontOptions(documentOptions, configFonts, activation = BASELINE_BUNDLED) { if (configFonts) return configFonts; - if (!documentOptions?.length) return undefined; - const seen = new Set(TOOLBAR_FONTS.map((option) => normalizeToolbarFamily(option.label))); - const merged = [...TOOLBAR_FONTS]; - for (const option of documentOptions) { + // Baseline base AND no document fonts is identical to the static TOOLBAR_FONTS const: let the + // caller keep that const instead of rebuilding an equal array. + if (!activation.packConfigured && !documentOptions?.length) return undefined; + const base = toolbarFontOptionsFor(activation); + const seen = new Set(base.map((option) => normalizeToolbarFamily(option.label))); + const merged = [...base]; + for (const option of documentOptions ?? []) { const dedupeKey = normalizeToolbarFamily(option.logicalFamily); if (seen.has(dedupeKey)) continue; seen.add(dedupeKey); @@ -66,7 +89,7 @@ export function composeToolbarFontOptions(documentOptions, configFonts) { }, }); } - return merged.length > TOOLBAR_FONTS.length ? merged.sort(compareToolbarFontOptions) : undefined; + return merged.sort(compareToolbarFontOptions); } export const TOOLBAR_FONT_SIZES = [ diff --git a/packages/super-editor/src/editors/v1/components/toolbar/constants.test.js b/packages/super-editor/src/editors/v1/components/toolbar/constants.test.js index e44af345a1..7eedc9f3aa 100644 --- a/packages/super-editor/src/editors/v1/components/toolbar/constants.test.js +++ b/packages/super-editor/src/editors/v1/components/toolbar/constants.test.js @@ -1,43 +1,68 @@ import { describe, expect, it } from 'vitest'; -import { composeToolbarFontOptions, TOOLBAR_FONTS } from './constants'; +import { FULLY_ACTIVE_BUNDLED } from '@superdoc/font-system'; +import { composeToolbarFontOptions, TOOLBAR_FONTS, toolbarFontOptionsFor } from './constants'; -describe('TOOLBAR_FONTS (built-in font dropdown, derived from the font-offering registry)', () => { - it('advertises bundled defaults and selected bundled fallback choices, in alphabetical order', () => { - expect(TOOLBAR_FONTS.map((f) => f.label)).toEqual([ - 'Arial', - 'Arial Black', - 'Arial Narrow', - 'Baskerville Old Face', - 'Bookman Old Style', - 'Brush Script MT', +const RICH_LABELS = [ + 'Arial', + 'Arial Black', + 'Arial Narrow', + 'Baskerville Old Face', + 'Bookman Old Style', + 'Brush Script MT', + 'Calibri', + 'Century', + 'Century Gothic', + 'Comic Sans MS', + 'Cooper Black', + 'Courier New', + 'Garamond', + 'Georgia', + 'Gill Sans MT Condensed', + 'Helvetica', + 'Lucida Console', + 'Segoe UI', + 'Tahoma', + 'Times New Roman', + 'Trebuchet MS', + 'Verdana', +]; + +describe('TOOLBAR_FONTS (static built-in font dropdown)', () => { + it('is the conservative no-pack baseline: one Word font per CSS generic', () => { + expect(TOOLBAR_FONTS.map((f) => f.label)).toEqual(['Arial', 'Courier New', 'Times New Roman']); + }); + + it('does not advertise the rich pack or unsupported fonts in the static default', () => { + const labels = new Set(TOOLBAR_FONTS.map((f) => f.label)); + // Georgia is a second serif: a pack-enabled rich option, not part of the one-per-generic baseline. + for (const name of [ 'Calibri', - 'Century', - 'Century Gothic', - 'Comic Sans MS', - 'Cooper Black', - 'Courier New', - 'Garamond', 'Georgia', - 'Gill Sans MT Condensed', - 'Helvetica', - 'Lucida Console', - 'Segoe UI', - 'Tahoma', - 'Times New Roman', - 'Trebuchet MS', + 'Cooper Black', 'Verdana', - ]); + 'Aptos', + 'Cambria', + 'Calibri Light', + 'Arial MT', + ]) { + expect(labels.has(name)).toBe(false); + } }); - it('does not leak non-advertised fonts into the default dropdown', () => { - const labels = new Set(TOOLBAR_FONTS.map((f) => f.label)); - for (const name of ['Aptos', 'Cambria', 'Calibri Light', 'Century Schoolbook', 'Arial MT', 'Courier', 'Times']) { - expect(labels.has(name)).toBe(false); + it('honors the FontConfig contract: label equals the first family in key', () => { + for (const f of TOOLBAR_FONTS) { + expect(f.key.split(',')[0].trim()).toBe(f.label); } }); +}); + +describe('toolbarFontOptionsFor (the configured rich set)', () => { + it('returns the full advertised set when the pack is active', () => { + expect(toolbarFontOptionsFor(FULLY_ACTIVE_BUNDLED).map((f) => f.label)).toEqual(RICH_LABELS); + }); it('builds a FontConfig: logical label + logical key + physical-clone preview', () => { - const calibri = TOOLBAR_FONTS.find((f) => f.label === 'Calibri'); + const calibri = toolbarFontOptionsFor(FULLY_ACTIVE_BUNDLED).find((f) => f.label === 'Calibri'); expect(calibri).toMatchObject({ label: 'Calibri', // applied to the selection + active-state match (Word-facing name) key: 'Calibri, sans-serif', // logical CSS stack (option identity) @@ -48,15 +73,9 @@ describe('TOOLBAR_FONTS (built-in font dropdown, derived from the font-offering }, }); }); - - it('honors the FontConfig contract: label equals the first family in key', () => { - for (const f of TOOLBAR_FONTS) { - expect(f.key.split(',')[0].trim()).toBe(f.label); - } - }); }); -describe('composeToolbarFontOptions (document fonts unioned with the bundled defaults)', () => { +describe('composeToolbarFontOptions (document fonts unioned with the built-in set)', () => { const doc = (logicalFamily, previewFamily) => ({ logicalFamily, previewFamily: previewFamily ?? logicalFamily, @@ -67,15 +86,34 @@ describe('composeToolbarFontOptions (document fonts unioned with the bundled def expect(composeToolbarFontOptions([doc('Aptos')], custom)).toBe(custom); }); - it('returns undefined with no document fonts, so the caller keeps the bundled defaults', () => { + it('returns undefined with baseline base AND no document fonts, so the caller keeps the static const', () => { expect(composeToolbarFontOptions([], undefined)).toBeUndefined(); expect(composeToolbarFontOptions(undefined, undefined)).toBeUndefined(); }); - it('combines defaults and document fonts alphabetically, deduping one already in the defaults', () => { + it('with no pack configured: baseline base unioned with document fonts, alphabetical', () => { + const options = composeToolbarFontOptions( + [doc('Calibri', 'Carlito'), doc('Bangla MN'), doc('Aptos'), doc('Apple Chancery')], + undefined, + ); + expect(options.map((o) => o.label)).toEqual([ + 'Apple Chancery', + 'Aptos', + 'Arial', + 'Bangla MN', + 'Calibri', + 'Courier New', + 'Georgia', + 'Times New Roman', + ]); + expect(options.filter((o) => o.label === 'Calibri')).toHaveLength(1); + }); + + it('with the pack configured: the full set unioned with document fonts, deduping shared names', () => { const options = composeToolbarFontOptions( [doc('Calibri', 'Carlito'), doc('Bangla MN'), doc('Aptos'), doc('Apple Chancery')], undefined, + FULLY_ACTIVE_BUNDLED, ); expect(options.map((o) => o.label)).toEqual([ 'Apple Chancery', diff --git a/packages/super-editor/src/editors/v1/components/toolbar/super-toolbar.js b/packages/super-editor/src/editors/v1/components/toolbar/super-toolbar.js index 7c03677c1c..7ce01386e8 100644 --- a/packages/super-editor/src/editors/v1/components/toolbar/super-toolbar.js +++ b/packages/super-editor/src/editors/v1/components/toolbar/super-toolbar.js @@ -5,6 +5,7 @@ import { createHeadlessToolbar } from '../../../../headless-toolbar/create-headl import { getActiveFormatting } from '@core/helpers/getActiveFormatting.js'; import { findParentNode } from '@helpers/index.js'; import { vClickOutside } from '@superdoc/common'; +import { deriveBundledActivation } from '@superdoc/font-system'; import Toolbar from './Toolbar.vue'; import { toolbarIcons } from './toolbarIcons.js'; import { toolbarTexts } from './toolbarTexts.js'; @@ -546,7 +547,11 @@ export class SuperToolbar extends EventEmitter { * @returns {Array|undefined} the toolbar font options, or undefined to fall back to the bundled defaults */ #resolveToolbarFonts(configFonts) { - return composeToolbarFontOptions(this.superdoc?.fonts?.getDocumentFontOptions?.() ?? [], configFonts); + return composeToolbarFontOptions( + this.superdoc?.fonts?.getDocumentFontOptions?.() ?? [], + configFonts, + deriveBundledActivation(this.superdoc?.config?.fonts), + ); } /** diff --git a/packages/super-editor/src/editors/v1/core/presentation-editor/fonts/DocumentFontController.test.ts b/packages/super-editor/src/editors/v1/core/presentation-editor/fonts/DocumentFontController.test.ts index 0287783117..a4c6b1d347 100644 --- a/packages/super-editor/src/editors/v1/core/presentation-editor/fonts/DocumentFontController.test.ts +++ b/packages/super-editor/src/editors/v1/core/presentation-editor/fonts/DocumentFontController.test.ts @@ -205,7 +205,9 @@ describe('DocumentFontController', () => { controller.map({ Georgia: 'Tinos', Verdana: 'Some Runtime Font' }); controller.reset(); - controller.applyInitialConfig({ map: { Georgia: 'Gelasio' } }); + // assetBaseUrl marks the pack configured, so bundled substitutes stay active and Verdana reverts + // to its bundled default (Noto Sans) - proving reset cleared the stale runtime Verdana mapping. + controller.applyInitialConfig({ assetBaseUrl: '/fonts/', map: { Georgia: 'Gelasio' } }); flushMicrotasks(); expect(resolver.resolvePrimaryPhysicalFamily('Georgia')).toBe('Gelasio'); diff --git a/packages/super-editor/src/editors/v1/core/presentation-editor/fonts/DocumentFontController.ts b/packages/super-editor/src/editors/v1/core/presentation-editor/fonts/DocumentFontController.ts index f4de192b55..9f07836d81 100644 --- a/packages/super-editor/src/editors/v1/core/presentation-editor/fonts/DocumentFontController.ts +++ b/packages/super-editor/src/editors/v1/core/presentation-editor/fonts/DocumentFontController.ts @@ -1,4 +1,9 @@ -import type { FontFaceRequest, FontResolver } from '@superdoc/font-system'; +import { + deriveBundledActivation, + warnUnknownBundledSelection, + type FontFaceRequest, + type FontResolver, +} from '@superdoc/font-system'; import type { FontFamilyConfig, FontsConfig } from '../../types/EditorConfig.js'; import type { FontReadinessGate } from './FontReadinessGate.js'; @@ -169,8 +174,14 @@ export class DocumentFontController { * registration cannot move the resolver signature that would otherwise bust them), so the first * measure cannot reuse a stale fallback width another editor instance left in the global cache. */ - applyInitialConfig(config: Pick | null | undefined): void { + applyInitialConfig(config: FontsConfig | null | undefined): void { this.#cancelPendingRuntimeReflow(); + // Surface curation typos / non-bundled names (and include+exclude both) once, at config time. + warnUnknownBundledSelection(config?.bundled); + // Derive activation even with NO config: an npm app that wired no pack defaults to the safe + // baseline (no substitution, no stray .woff2 fetches), while the CDN build - which marks the pack + // present - stays rich. Set before families/map so the first resolution already reflects it. + this.#resolver.setActivation(deriveBundledActivation(config)); if (!config) return; const registered = this.#registerFamilies(config.families); this.#applyMappings(config.map); diff --git a/packages/super-editor/src/editors/v1/core/types/EditorConfig.ts b/packages/super-editor/src/editors/v1/core/types/EditorConfig.ts index fcc5c46922..a454358803 100644 --- a/packages/super-editor/src/editors/v1/core/types/EditorConfig.ts +++ b/packages/super-editor/src/editors/v1/core/types/EditorConfig.ts @@ -13,7 +13,7 @@ import type { CommentLocationsPayload, ListDefinitionsPayload, } from './EditorEvents.js'; -import type { FontAssetUrlResolver } from '@superdoc/font-system'; +import type { BundledFontSelection, FontAssetUrlResolver } from '@superdoc/font-system'; /** * One physical font face to register from a URL source. @@ -57,6 +57,14 @@ export interface FontsConfig { * Synchronous (font resolution stays deterministic). Takes precedence over `assetBaseUrl`. */ resolveAssetUrl?: FontAssetUrlResolver; + /** + * Curate which bundled families this document advertises and substitutes to, by LOGICAL Word name + * (e.g. `"Calibri"`). Applies only when the pack is configured; it narrows the rich set, it does + * not enable it. Prefer setting this with `createSuperDocFonts({ include / exclude })` from + * `@superdoc/fonts` rather than by hand. Does not affect customer fonts ({@link families} / + * {@link map}), which are always honored. + */ + bundled?: BundledFontSelection; } import type { ProseMirrorJSON } from './EditorTypes.js'; diff --git a/packages/super-editor/src/headless-toolbar/constants.test.ts b/packages/super-editor/src/headless-toolbar/constants.test.ts index 21c6d104e8..d45bd51817 100644 --- a/packages/super-editor/src/headless-toolbar/constants.test.ts +++ b/packages/super-editor/src/headless-toolbar/constants.test.ts @@ -2,41 +2,26 @@ import { describe, expect, it } from 'vitest'; import { DEFAULT_FONT_FAMILY_OPTIONS } from './constants'; describe('DEFAULT_FONT_FAMILY_OPTIONS (headless default font options, derived from the font-offering registry)', () => { - it('advertises bundled defaults and selected bundled fallback choices (logical name + logical stack)', () => { + it('is the conservative no-pack baseline: one Word font per CSS generic', () => { expect(DEFAULT_FONT_FAMILY_OPTIONS).toEqual([ { label: 'Arial', value: 'Arial, sans-serif' }, - { label: 'Arial Black', value: 'Arial Black, sans-serif' }, - { label: 'Arial Narrow', value: 'Arial Narrow, sans-serif' }, - { label: 'Baskerville Old Face', value: 'Baskerville Old Face, serif' }, - { label: 'Bookman Old Style', value: 'Bookman Old Style, serif' }, - { label: 'Brush Script MT', value: 'Brush Script MT, serif' }, - { label: 'Calibri', value: 'Calibri, sans-serif' }, - { label: 'Century', value: 'Century, serif' }, - { label: 'Century Gothic', value: 'Century Gothic, sans-serif' }, - { label: 'Comic Sans MS', value: 'Comic Sans MS, sans-serif' }, - { label: 'Cooper Black', value: 'Cooper Black, serif' }, { label: 'Courier New', value: 'Courier New, monospace' }, - { label: 'Garamond', value: 'Garamond, serif' }, - { label: 'Georgia', value: 'Georgia, serif' }, - { label: 'Gill Sans MT Condensed', value: 'Gill Sans MT Condensed, sans-serif' }, - { label: 'Helvetica', value: 'Helvetica, sans-serif' }, - { label: 'Lucida Console', value: 'Lucida Console, monospace' }, - { label: 'Segoe UI', value: 'Segoe UI, sans-serif' }, - { label: 'Tahoma', value: 'Tahoma, sans-serif' }, { label: 'Times New Roman', value: 'Times New Roman, serif' }, - { label: 'Trebuchet MS', value: 'Trebuchet MS, sans-serif' }, - { label: 'Verdana', value: 'Verdana, sans-serif' }, ]); }); - it('drops non-advertised fonts from defaults', () => { + it('does not advertise the rich pack or unsupported fonts in the static default', () => { const labels = new Set(DEFAULT_FONT_FAMILY_OPTIONS.map((o) => o.label)); + // Rich-pack families appear only once the pack is configured (built per instance). Georgia is a + // second serif, so it is a pack-enabled option, not part of the one-per-generic baseline. + expect(labels.has('Calibri')).toBe(false); + expect(labels.has('Georgia')).toBe(false); + expect(labels.has('Cooper Black')).toBe(false); + expect(labels.has('Verdana')).toBe(false); + // Never-default fonts stay absent regardless. expect(labels.has('Aptos')).toBe(false); expect(labels.has('Cambria')).toBe(false); expect(labels.has('Calibri Light')).toBe(false); - expect(labels.has('Century Schoolbook')).toBe(false); expect(labels.has('Arial MT')).toBe(false); - expect(labels.has('Courier')).toBe(false); - expect(labels.has('Times')).toBe(false); }); }); diff --git a/packages/super-editor/src/headless-toolbar/constants.ts b/packages/super-editor/src/headless-toolbar/constants.ts index e0cb9cce01..df65e6e917 100644 --- a/packages/super-editor/src/headless-toolbar/constants.ts +++ b/packages/super-editor/src/headless-toolbar/constants.ts @@ -63,10 +63,12 @@ export const DEFAULT_FONT_SIZE_OPTIONS = [ /** * Default headless-toolbar font options, DERIVED from the shared font-offering registry - * (`@superdoc/font-system`) instead of a hand-maintained list. Bundled clean defaults and explicit - * qualified choices are advertised; customer-supplied and category fallback rows stay absent. - * `label` is the Word-facing logical name (stored/exported); `value` is the logical CSS stack applied - * to the run. + * (`@superdoc/font-system`). This static const is the conservative NO-PACK baseline - one Word font + * per CSS generic (Arial, Times New Roman, Courier New) - the floor for a headless toolbar with no + * runtime font config. + * A headless consumer that wires the bundled pack and wants the curated rich set builds its options + * per instance from `getDefaultFontFamilyOptions(deriveBundledActivation(fontsConfig))`. `label` is + * the Word-facing logical name (stored/exported); `value` is the logical CSS stack applied to the run. */ export const DEFAULT_FONT_FAMILY_OPTIONS = getDefaultFontFamilyOptions(); diff --git a/packages/super-editor/src/ui/create-super-doc-ui.test.ts b/packages/super-editor/src/ui/create-super-doc-ui.test.ts index f7ea11ea7d..de7b9658d3 100644 --- a/packages/super-editor/src/ui/create-super-doc-ui.test.ts +++ b/packages/super-editor/src/ui/create-super-doc-ui.test.ts @@ -14,6 +14,12 @@ function makeSuperdocStub( documentMode?: 'editing' | 'suggesting' | 'viewing'; selection?: { empty: boolean; text?: string }; documentFontOptions?: Array<{ logicalFamily: string; previewFamily: string }>; + /** The `fonts` config the toolbar derives bundled activation from. Omit for the no-pack baseline. */ + fontsConfig?: { + resolveAssetUrl?: unknown; + assetBaseUrl?: string; + bundled?: { include?: string[]; exclude?: string[] }; + }; } = {}, ) { const editorListeners = new Map void>>(); @@ -53,7 +59,7 @@ function makeSuperdocStub( superdocListenerCount(event: string): number; } = { activeEditor: editor, - config: { documentMode: initial.documentMode ?? 'editing' }, + config: { documentMode: initial.documentMode ?? 'editing', fonts: initial.fontsConfig }, fonts: { getDocumentFontOptions: vi.fn(() => documentFontOptions), }, @@ -136,8 +142,28 @@ describe('createSuperDocUI', () => { expect(slice.get()).toBe('editing'); }); - it('exposes final font-family options for custom UI', () => { + it('defaults to the baseline font set when no pack is configured', () => { + const superdoc = makeSuperdocStub({ + documentFontOptions: [ + { logicalFamily: 'Aptos', previewFamily: 'Aptos' }, + { logicalFamily: 'Calibri', previewFamily: 'Carlito' }, + ], + }); + const ui = createSuperDocUI({ superdoc }); + teardown.push(() => ui.destroy()); + // No pack: the one-per-generic baseline plus the document's own fonts (Calibri is a document font). + expect(ui.fonts.getOptions().map((o) => o.label)).toEqual([ + 'Aptos', + 'Arial', + 'Calibri', + 'Courier New', + 'Times New Roman', + ]); + }); + + it('exposes final font-family options for custom UI when the pack is configured', () => { const superdoc = makeSuperdocStub({ + fontsConfig: { assetBaseUrl: '/fonts/' }, documentFontOptions: [ { logicalFamily: 'Aptos', previewFamily: 'Aptos' }, { logicalFamily: 'Bangla MN', previewFamily: 'Bangla MN' }, @@ -201,7 +227,8 @@ describe('createSuperDocUI', () => { }); it('refreshes ui.fonts when fonts-changed fires', async () => { - const superdoc = makeSuperdocStub(); + // Configured pack so the snapshot is the full set (the refresh behavior is the point here). + const superdoc = makeSuperdocStub({ fontsConfig: { assetBaseUrl: '/fonts/' } }); const ui = createSuperDocUI({ superdoc }); teardown.push(() => ui.destroy()); diff --git a/packages/super-editor/src/ui/create-super-doc-ui.ts b/packages/super-editor/src/ui/create-super-doc-ui.ts index 067fab5e97..def6ffc320 100644 --- a/packages/super-editor/src/ui/create-super-doc-ui.ts +++ b/packages/super-editor/src/ui/create-super-doc-ui.ts @@ -22,7 +22,7 @@ import type { } from '@superdoc/document-api'; import { composeAuthorColorResolver } from '@superdoc/contracts'; import type { TrackChangeAuthorColorResolver } from '@superdoc/contracts'; -import { buildFontFamilyOptions } from '@superdoc/font-system'; +import { buildFontFamilyOptions, deriveBundledActivation } from '@superdoc/font-system'; import type { FontFamilyOption } from '@superdoc/font-system'; import { collectEntityHitsFromChain } from './entity-at.js'; import { shallowEqual } from './equality.js'; @@ -599,16 +599,20 @@ export function createSuperDocUI(options: SuperDocUIOptions): SuperDocUI { }; refreshContentControlsListCache(); - let fontOptionsCache: FontFamilyOption[] = buildFontFamilyOptions([]); + // The document's bundled-font activation, derived from the same `fonts` config the editor used + // (a pure function of config + the CDN pack flag, so it matches the editor's resolver). Gates the + // built-in picker rows: baseline without a pack, the curated rich set with one. + const bundledActivation = () => deriveBundledActivation(superdoc.config?.fonts); + let fontOptionsCache: FontFamilyOption[] = buildFontFamilyOptions([], bundledActivation()); const fontOptionsSignatureFor = (options: readonly FontFamilyOption[]) => JSON.stringify(options.map((option) => [option.label, option.value, option.previewFamily])); let fontOptionsSignature = fontOptionsSignatureFor(fontOptionsCache); const refreshFontOptionsCache = () => { let next: FontFamilyOption[]; try { - next = buildFontFamilyOptions(superdoc.fonts?.getDocumentFontOptions?.() ?? []); + next = buildFontFamilyOptions(superdoc.fonts?.getDocumentFontOptions?.() ?? [], bundledActivation()); } catch { - next = buildFontFamilyOptions([]); + next = buildFontFamilyOptions([], bundledActivation()); } const signature = fontOptionsSignatureFor(next); if (signature === fontOptionsSignature) return false; diff --git a/packages/super-editor/src/ui/react/hooks.test.tsx b/packages/super-editor/src/ui/react/hooks.test.tsx index faba8450f1..36e54ed1f3 100644 --- a/packages/super-editor/src/ui/react/hooks.test.tsx +++ b/packages/super-editor/src/ui/react/hooks.test.tsx @@ -16,6 +16,11 @@ function makeSuperdocStub( overrides: { selectionInfo?: unknown; documentFontOptions?: Array<{ logicalFamily: string; previewFamily: string }>; + fontsConfig?: { + resolveAssetUrl?: unknown; + assetBaseUrl?: string; + bundled?: { include?: string[]; exclude?: string[] }; + }; } = {}, ) { const editorListeners = new Map void>>(); @@ -51,7 +56,7 @@ function makeSuperdocStub( return { activeEditor: editor, - config: { documentMode: 'editing' as const }, + config: { documentMode: 'editing' as const, fonts: overrides.fontsConfig }, on: vi.fn((event: string, handler: (...args: unknown[]) => void) => { if (!superdocListeners.has(event)) superdocListeners.set(event, new Set()); superdocListeners.get(event)!.add(handler); @@ -157,7 +162,7 @@ describe('domain hooks', () => { expect(toolbar).toEqual({ context: null, commands: {} }); }); - it('useSuperDocFontOptions returns defaults plus active document fonts', () => { + it('useSuperDocFontOptions returns the configured set plus active document fonts', () => { let options: ReturnType | undefined; let setSuperDoc: ReturnType | undefined; @@ -178,6 +183,7 @@ describe('domain hooks', () => { act(() => { setSuperDoc!( makeSuperdocStub({ + fontsConfig: { assetBaseUrl: '/fonts/' }, documentFontOptions: [{ logicalFamily: 'Aptos', previewFamily: 'Aptos' }], }), ); diff --git a/packages/super-editor/src/ui/types.ts b/packages/super-editor/src/ui/types.ts index d1f85f9b36..95873faeb8 100644 --- a/packages/super-editor/src/ui/types.ts +++ b/packages/super-editor/src/ui/types.ts @@ -81,6 +81,13 @@ export interface SuperDocLike { authorColors?: import('@superdoc/contracts').AuthorColorsConfig; }; }; + /** + * The `fonts` config (the same object passed to `new SuperDoc({ fonts })`). The toolbar derives + * the document's bundled-font activation from it via `deriveBundledActivation`, so the picker + * advertises the conservative baseline by default and the curated rich set only once the pack is wired. + * Loosely typed (structural) so test stubs need not model the full `FontsConfig`. + */ + fonts?: import('@superdoc/font-system').FontAssetConfigLike; }; /** * Optional setter for documentMode. Consumed by `ui.document.setMode` diff --git a/packages/superdoc/src/cdn-entry.js b/packages/superdoc/src/cdn-entry.js index 053c4a8bd7..055b5ab943 100644 --- a/packages/superdoc/src/cdn-entry.js +++ b/packages/superdoc/src/cdn-entry.js @@ -5,11 +5,15 @@ // from Quill / Chart.js. import { SuperDoc } from './core/SuperDoc.js'; -import { setBundledFontAssetBase } from '@superdoc/font-system'; +import { markBundledPackPresent, setBundledFontAssetBase } from '@superdoc/font-system'; import * as namespace from './index.js'; -// Default the bundled-font asset base to `./fonts/` relative to THIS script - the CDN -// package layout ships `superdoc.min.js` next to `fonts/*.woff2`. `document.currentScript` +// The CDN package layout ships `fonts/*.woff2` next to `superdoc.min.js`, so the bundled pack is +// served by default - mark it present so the toolbar and resolver light up the rich pack without +// per-instance `fonts` config (npm consumers signal it via `fonts.resolveAssetUrl` / `assetBaseUrl`). +markBundledPackPresent(); + +// Default the bundled-font asset base to `./fonts/` relative to THIS script. `document.currentScript` // is valid while the script is executing (this top-level runs then), and null later in // callbacks, so we must capture it here, not inside font loading. Consumer config // (`fonts.assetBaseUrl` / `fonts.resolveAssetUrl`) takes precedence over this default. diff --git a/scripts/generate-all.mjs b/scripts/generate-all.mjs index a46e354e95..6a5a39ef54 100644 --- a/scripts/generate-all.mjs +++ b/scripts/generate-all.mjs @@ -3,11 +3,12 @@ /** * Full generation DAG — produces all derived artifacts from source-of-truth inputs. * - * Phases (sequential — each depends on the previous): + * Phases (run in order; 1-4 are sequential, 5 is independent): * 1. docapi:sync → packages/document-api/generated/** + apps/docs/document-api/reference/** * 2. cli:export-sdk-contract → apps/cli/generated/sdk-contract.json * 3. docs:sync-engine → SDK overview operations table in apps/docs/document-engine/sdks.mdx * 4. sdk codegen → packages/sdk/langs/{node,python}/…/generated/** + packages/sdk/tools/*.json + * 5. @superdoc/fonts generate → packages/fonts/src/{asset-urls,bundled-families}.ts (from font-system) * * Before generation, gitignored output directories are cleaned to prevent stale file accumulation. * apps/docs/document-api/reference/ is NOT cleaned here — it stays committed (Mintlify deploys from git) @@ -98,6 +99,12 @@ async function main() { console.log('\n--- Phase 4: sdk codegen ---'); await run('node', [path.join(REPO_ROOT, 'packages/sdk/codegen/src/generate-all.mjs')]); + // Phase 5: @superdoc/fonts derived sources: bundled asset URLs (from the asset dir) plus curatable + // family names (from font-system offerings). Kept out of the package's build/prepare lifecycle so a + // standalone install never needs tsx; regenerated here, in the workspace pipeline. + console.log('\n--- Phase 5: @superdoc/fonts generate ---'); + await run('pnpm', ['--filter', '@superdoc/fonts', 'generate']); + console.log('\ngenerate:all complete.'); } diff --git a/shared/font-system/src/activation.test.ts b/shared/font-system/src/activation.test.ts new file mode 100644 index 0000000000..00c60c41ad --- /dev/null +++ b/shared/font-system/src/activation.test.ts @@ -0,0 +1,96 @@ +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { BASELINE_BUNDLED, FULLY_ACTIVE_BUNDLED, createBundledActivation, deriveBundledActivation } from './activation'; +import { __resetBundledPackPresent, isBundledPackPresent, markBundledPackPresent } from './bundled'; + +describe('createBundledActivation', () => { + it('no pack configured: nothing active, with a distinct non-empty signature', () => { + const a = createBundledActivation({ packConfigured: false }); + expect(a).toBe(BASELINE_BUNDLED); + expect(a.packConfigured).toBe(false); + expect(a.isActive('Calibri')).toBe(false); + expect(a.isActive('Arial')).toBe(false); + // Non-empty so a no-pack document never shares a full-pack document's measure cache. + expect(a.signature).not.toBe(''); + }); + + it('pack configured with no curation: fully active, empty signature (preserves cache sharing)', () => { + const a = createBundledActivation({ packConfigured: true }); + expect(a).toBe(FULLY_ACTIVE_BUNDLED); + expect(a.packConfigured).toBe(true); + expect(a.isActive('Calibri')).toBe(true); + expect(a.signature).toBe(''); + }); + + it('include is an allow-list, matched case-insensitively after quote-strip', () => { + const a = createBundledActivation({ packConfigured: true, include: ['Calibri', 'Cambria'] }); + expect(a.isActive('Calibri')).toBe(true); + expect(a.isActive('"calibri"')).toBe(true); + expect(a.isActive('Cambria')).toBe(true); + expect(a.isActive('Arial')).toBe(false); + expect(a.signature).not.toBe(''); + }); + + it('exclude keeps every family but the listed ones', () => { + const a = createBundledActivation({ packConfigured: true, exclude: ['Cooper Black'] }); + expect(a.isActive('Cooper Black')).toBe(false); + expect(a.isActive('cooper black')).toBe(false); + expect(a.isActive('Calibri')).toBe(true); + }); + + it('include wins over exclude when both are given', () => { + const a = createBundledActivation({ packConfigured: true, include: ['Calibri'], exclude: ['Calibri'] }); + expect(a.isActive('Calibri')).toBe(true); + expect(a.isActive('Arial')).toBe(false); + }); + + it('include/exclude with no usable entries are ignored (no accidental empty toolbar)', () => { + expect(createBundledActivation({ packConfigured: true, include: [] })).toBe(FULLY_ACTIVE_BUNDLED); + expect(createBundledActivation({ packConfigured: true, exclude: ['', ' '] })).toBe(FULLY_ACTIVE_BUNDLED); + }); + + it('curation is moot with no pack: still baseline', () => { + expect(createBundledActivation({ packConfigured: false, include: ['Calibri'] })).toBe(BASELINE_BUNDLED); + }); + + it('signatures distinguish baseline / full / curated, and equal curation gives an equal signature', () => { + const baseline = createBundledActivation({ packConfigured: false }).signature; + const full = createBundledActivation({ packConfigured: true }).signature; + const inc = createBundledActivation({ packConfigured: true, include: ['Calibri'] }).signature; + const exc = createBundledActivation({ packConfigured: true, exclude: ['Calibri'] }).signature; + expect(new Set([baseline, full, inc, exc]).size).toBe(4); + // Order-independent and de-duplicated, so the same curation is the same signature. + expect( + createBundledActivation({ packConfigured: true, include: ['Cambria', 'Calibri', 'Calibri'] }).signature, + ).toBe(createBundledActivation({ packConfigured: true, include: ['Calibri', 'Cambria'] }).signature); + }); +}); + +describe('deriveBundledActivation', () => { + beforeEach(() => __resetBundledPackPresent()); + afterEach(() => __resetBundledPackPresent()); + + it('npm with no config and no CDN flag: baseline', () => { + expect(isBundledPackPresent()).toBe(false); + expect(deriveBundledActivation(undefined)).toBe(BASELINE_BUNDLED); + expect(deriveBundledActivation(null)).toBe(BASELINE_BUNDLED); + expect(deriveBundledActivation({})).toBe(BASELINE_BUNDLED); + }); + + it('resolveAssetUrl or assetBaseUrl marks the pack configured (rich)', () => { + expect(deriveBundledActivation({ resolveAssetUrl: () => 'x' }).packConfigured).toBe(true); + expect(deriveBundledActivation({ assetBaseUrl: '/fonts/' }).packConfigured).toBe(true); + }); + + it('passes bundled curation through', () => { + const a = deriveBundledActivation({ assetBaseUrl: '/fonts/', bundled: { exclude: ['Cooper Black'] } }); + expect(a.packConfigured).toBe(true); + expect(a.isActive('Cooper Black')).toBe(false); + expect(a.isActive('Calibri')).toBe(true); + }); + + it('CDN: markBundledPackPresent makes a no-config document rich', () => { + markBundledPackPresent(); + expect(deriveBundledActivation(undefined).packConfigured).toBe(true); + expect(deriveBundledActivation({}).isActive('Calibri')).toBe(true); + }); +}); diff --git a/shared/font-system/src/activation.ts b/shared/font-system/src/activation.ts new file mode 100644 index 0000000000..dfebc88825 --- /dev/null +++ b/shared/font-system/src/activation.ts @@ -0,0 +1,138 @@ +/** + * Per-document "bundled font activation": which bundled substitute families a document may + * ADVERTISE (toolbar) and RESOLVE to (substitute). The decision is config-driven, never a runtime + * probe, and it is DOCUMENT-SCOPED - it lives in the resolver / offering / load / warning layers, + * not in registration. The shared per-`FontFaceSet` registry still registers the full pack + * (registration is lazy and global; filtering it would break two editors sharing one page), so a + * document simply decides which of those faces it actually uses. + * + * Two states, driven by whether the consumer wired the pack: + * - `packConfigured: false` (no `@superdoc/fonts`, no `fonts.assetBaseUrl` / `fonts.resolveAssetUrl`, + * not the CDN build): NO bundled family is active. The document advertises the conservative + * baseline and renders logical names with system fonts; nothing fetches a substitute `.woff2` that + * is not being served, so there is no stray 404 and no spurious warning. + * - `packConfigured: true`: every bundled family is active, minus `exclude` (or, with `include`, + * only the listed families). + * + * `include` / `exclude` are by LOGICAL Word family name (e.g. `"Calibri"`, never the physical + * `"Carlito"`), matched case-insensitively after quote-stripping - the same normalization the + * resolver and offerings use as keys. The documented way to set them is `createSuperDocFonts`. + */ +import { isBundledPackPresent } from './bundled'; + +/** Selection of which bundled families are active, by logical Word family name. */ +export interface BundledFontSelection { + /** Active families. When set (non-empty), ONLY these logical families resolve/advertise. */ + include?: readonly string[]; + /** Inactive families. When set, every bundled family EXCEPT these is active. */ + exclude?: readonly string[]; +} + +/** Inputs that decide bundled activation for a document. */ +export interface BundledActivationInput extends BundledFontSelection { + /** Whether the consumer wired the bundled pack (config presence or the CDN build). */ + packConfigured: boolean; +} + +/** A resolved activation: the packConfigured flag, a per-logical-family predicate, and a signature. */ +export interface BundledActivation { + /** True when the bundled pack is wired for this document (config presence or the CDN build). */ + readonly packConfigured: boolean; + /** Whether bundled substitution / advertising is active for this LOGICAL family (any case). */ + isActive(logicalFamily: string): boolean; + /** + * Deterministic identity of this activation. `''` when FULLY active (packConfigured with no + * include/exclude) so default / full-pack documents keep sharing the measure cache and match the + * global resolver; any narrower activation (no pack, or curated) gets a distinct, non-empty + * signature the resolver folds into its own {@link FontResolver.signature} (the measure-cache key) + * - so a no-pack or curated document never reuses a full-pack document's cached measures. + */ + readonly signature: string; +} + +/** Normalize a family name for keying: trim, strip surrounding quotes, lowercase. */ +function normalizeKey(family: string): string { + return family + .trim() + .replace(/^["']|["']$/g, '') + .toLowerCase(); +} + +/** Normalized, de-duplicated, sorted list - or undefined when there is nothing usable. */ +function normalizeList(families: readonly string[] | undefined): string[] | undefined { + if (!families) return undefined; + const out = [...new Set(families.map(normalizeKey).filter(Boolean))].sort(); + return out.length > 0 ? out : undefined; +} + +/** + * Fully-active activation: every bundled family, signature `''`. The resolver's default, so the + * module-level resolver and any `createFontResolver()` with no activation keep the prior global + * substitution behavior (and share the measure cache). + */ +export const FULLY_ACTIVE_BUNDLED: BundledActivation = Object.freeze({ + packConfigured: true, + isActive: () => true, + signature: '', +}); + +/** + * No-pack activation: no bundled family is active. The conservative baseline default for the STATIC + * toolbar surfaces (the headless `DEFAULT_FONT_FAMILY_OPTIONS` / Vue `TOOLBAR_FONTS` consts, which + * have no runtime config to read). + */ +export const BASELINE_BUNDLED: BundledActivation = Object.freeze({ + packConfigured: false, + isActive: () => false, + signature: JSON.stringify({ p: false }), +}); + +/** + * Build a {@link BundledActivation} from config-presence plus optional include/exclude. + * + * `include` is the stronger intent and WINS if both are passed (they are not meant to be combined). + * An include/exclude list with no usable entries is ignored rather than emptying the toolbar - "no + * bundled fonts" is expressed by not configuring the pack, not by `include: []`. + */ +export function createBundledActivation(input: BundledActivationInput): BundledActivation { + if (!input.packConfigured) return BASELINE_BUNDLED; + const include = normalizeList(input.include); + const exclude = include ? undefined : normalizeList(input.exclude); + if (!include && !exclude) return FULLY_ACTIVE_BUNDLED; + if (include) { + const set = new Set(include); + return Object.freeze({ + packConfigured: true, + isActive: (family: string) => set.has(normalizeKey(family)), + signature: JSON.stringify({ p: true, i: include }), + }); + } + const set = new Set(exclude); + return Object.freeze({ + packConfigured: true, + isActive: (family: string) => !set.has(normalizeKey(family)), + signature: JSON.stringify({ p: true, x: exclude }), + }); +} + +/** Structural slice of a `fonts` config the activation needs - keeps this free of the editor type. */ +export interface FontAssetConfigLike { + resolveAssetUrl?: unknown; + assetBaseUrl?: unknown; + bundled?: BundledFontSelection; +} + +/** + * Derive a document's {@link BundledActivation} from its `fonts` config. The pack counts as + * configured when the consumer set `resolveAssetUrl` (e.g. `superdocFonts`) or `assetBaseUrl`, or + * when the CDN build marked it present ({@link isBundledPackPresent}). Selection comes from + * `fonts.bundled` (set via `createSuperDocFonts`). + */ +export function deriveBundledActivation(config: FontAssetConfigLike | null | undefined): BundledActivation { + const packConfigured = !!(config?.resolveAssetUrl || config?.assetBaseUrl) || isBundledPackPresent(); + return createBundledActivation({ + packConfigured, + include: config?.bundled?.include, + exclude: config?.bundled?.exclude, + }); +} diff --git a/shared/font-system/src/bundled.ts b/shared/font-system/src/bundled.ts index 7e7098c03d..6f4682faab 100644 --- a/shared/font-system/src/bundled.ts +++ b/shared/font-system/src/bundled.ts @@ -28,6 +28,28 @@ export function getBundledFontAssetBase(): string { return defaultAssetBase; } +// Whether the bundled pack is served WITHOUT per-instance config. The CDN `