@openpresentation/opf-render 0.13.1 → 0.14.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,6 +1,33 @@
1
1
  # OPF Render
2
2
 
3
- **Aptos previews with Intos.** The Office pack includes Intos, Intos Display, Intos Narrow and Intos Serif (16 faces, about 12 MB, vendored under `fonts/intos/`, SIL OFL 1.1, pinned to upstream commit fef9315c14da9e4b23b4c3cac8e718998d4e4736 and per-file SHA-256, unmodified from the upstream files). They are the metric-compatible previews for Aptos, Aptos Display, Aptos Narrow and Aptos Serif, the default `aptos` font scheme: 0.000% mean and maximum width difference against Aptos 2.01 in all four styles, with equal vertical metrics. The letter shapes are Intos's own, not Aptos's. The exported PPTX still names Aptos; no Aptos file is bundled or embedded. A registry built without these faces (the base pack) has no Aptos substitute: visual policy falls back to Roboto or Carlito. Like the open families, Intos is `embed: "used"`: it is not in `registry.embeddedFonts` (the 33 eager npm faces), `prepareNodeFonts().options.embeddedFonts` supplies it, and an SVG embeds only the Intos faces its text draws. `registry.lazyFonts` lists the 51 vendored faces (35 open, 16 Intos) with package-relative file and sha256, and `loadBrowserFontRegistry(faces, {lazyFontsBaseUrl})` loads them on demand: `await registry.ensureLazyFonts(presentation)` fetches and verifies only the faces the document draws, by family, weight and style (a plain Aptos deck: Intos Display Bold and Intos Regular, 2 files, 1.5 MB; an italic run adds Intos Italic, a bold run Intos Bold; the code face only when a slide has code), then adds them to the document and the registry together so measurement and painting never disagree. Faces a host serves itself load the same way: `loadBrowserFontRegistry(startup, { extraLazyFonts: [{ family, weight, italic, url, sha256 }] })` adds them to the lazy list (`registry.lazyFonts`, `package: "host"`), so the registry can start with Roboto Regular only (`splitStartupFaces(faces)` splits a manifest into the startup face and the rest) and a document draws, fetches and verifies just the faces it needs, vendored and host faces in one `ensureLazyFonts` call. `sha256` is mandatory, loads are all or nothing, `dispose()` removes the faces, and, like the vendored ones, host faces are `embed: "used"`: they are not in `registry.embeddedFonts` (only the startup faces are), and a standalone SVG embeds one only when passed explicitly through `registry.selectEmbeddedFonts` and the slide's text uses its family. `registry.pendingLazyFonts(presentation)` is the synchronous check hosts gate rendering on, and after an edit it reports just the newly needed faces. The faces are those the renderer itself measures and draws (`presentationFaces(presentation, options, registry)`, one layout-and-paint run with a recording measurement), resolved by the registry's own face selection over the faces it holds plus the vendored ones, so the browser loads exactly what Node draws with every vendored face loaded. A document resolves with the `renderSvg` options the host renders with: pass them as `loadBrowserFontRegistry(entries, { renderOptions: { catalogs } })`, or per call (`pendingLazyFonts(presentation, { catalogs })`, `ensureLazyFonts(presentation, { signal, catalogs })`), so a layout or font scheme id that only the host's catalogs know resolves here too; a document that does not resolve throws (rejects) what `renderSvg` throws for it instead of reporting nothing. Node's `prepareNodeFonts` and `loadOfficeFontRegistry` keep loading the whole open pack and Intos eagerly, so nothing there is face level; with `scripts: 'auto'` they accept `renderOptions: { catalogs }` too. Hosts copy the vendored directories next to the page.
3
+ **0.14.0 (RR-55, with core 0.14.0, opf-pptx 0.14.0 and opf-editor 0.14.0).** One fonts handle, whole-deck and one-slide render functions, and core's slide context. This release renames and deletes outright (no aliases; the CHANGELOG has the old-to-new table):
4
+
5
+ - `renderSvg(deck, options)` returns `string[]`, one SVG per slide; `renderSlideSvg(deck, index, options)` returns the SVG of one slide.
6
+ - `loadFonts(options)` from `/fonts-node` or `/fonts-browser` (the environment is the subpath) returns the one fonts handle: `{ textMeasurement, embeddedFonts, fontFiles, useBundledFonts, loadSystemFonts, registry, manifest, substitutions, ensure, pending }` (a browser handle also has `dispose`). `createFontRegistry` is an internal building block, not an export.
7
+ - Every deck-level function takes `{ fonts }` (the handle, or any object with a `textMeasurement`) instead of separate `textMeasurement`, `embeddedFonts`, `fontFiles`, `useBundledFonts` and `loadSystemFonts` options: `renderSvg`, `renderSlideSvg`, `svgToPng`, `svgToPdf`, `/export-browser`, `/player`, `<opf-deck>` and `renderDeckHtml`.
8
+ - `OPFRenderError` `invalid-opf` carries `findings`: the error findings of core `validate(deck, { only: ['format'] })`.
9
+ - The layout engine is read from `@openpresentation/opf/composition`; `/fonts` re-exports core's `FONT_POLICY` (the table: `.families`, `.provisionalDecisions`), `fontPolicyFor`, `isSymbolEncodedFamily`, `symbolCodeOf`, `mapSymbolText` and `scriptFontRole` rather than keeping copies.
10
+
11
+ ```js
12
+ import { loadFonts } from '@openpresentation/opf-render/fonts-node';
13
+ import { renderSvg, renderSlideSvg, svgToPng, svgToPdf } from '@openpresentation/opf-render';
14
+ import { paginate } from '@openpresentation/opf/pagination';
15
+ import { toPptx } from '@openpresentation/opf-pptx';
16
+
17
+ const fonts = await loadFonts({ pack: 'office', substitutionPolicy: 'visual' });
18
+ const { presentation } = paginate(opf, { fonts });
19
+ const slides = renderSvg(presentation, { fonts }); // every slide
20
+ const first = renderSlideSvg(presentation, 0, { fonts }); // one slide
21
+ const png = await svgToPng(slides[0], { fonts });
22
+ const pdf = await svgToPdf(slides, { fonts });
23
+ const pptx = await toPptx(presentation, { fonts });
24
+ ```
25
+
26
+ A slide's canvas, layout, theme, colour scheme and font scheme come from core's `resolveSlideContext` (slide design, then deck design, then theme, then the default), so preview, pagination, validation and export resolve them one way. A slide with no `layout`, or one that names an id no catalog has, composes with no layout record. An unknown theme, colour scheme, layout or font scheme id never throws: the context falls back (`minimal`, `cool-horizon`, none, `aptos`) and the render reports `unresolved-theme`, `unresolved-color-scheme`, `unresolved-layout` or `unresolved-font-scheme` through `onDiagnostic`.
27
+
28
+ An option a function does not know is not read: a top-level `textMeasurement` or `embeddedFonts` is ignored, so pass `{ fonts }`.
29
+
30
+ **Aptos previews with Intos.** The Office pack includes Intos, Intos Display, Intos Narrow and Intos Serif (16 faces, about 12 MB, vendored under `fonts/intos/`, SIL OFL 1.1, pinned to upstream commit fef9315c14da9e4b23b4c3cac8e718998d4e4736 and per-file SHA-256, unmodified from the upstream files). They are the metric-compatible previews for Aptos, Aptos Display, Aptos Narrow and Aptos Serif, the default `aptos` font scheme: 0.000% mean and maximum width difference against Aptos 2.01 in all four styles, with equal vertical metrics. The letter shapes are Intos's own, not Aptos's. The exported PPTX still names Aptos; no Aptos file is bundled or embedded. A registry built without these faces (the base pack) has no Aptos substitute: visual policy falls back to Roboto or Carlito. Like the open families, Intos is `embed: "used"`: it is not in `registry.embeddedFonts` (the 33 eager npm faces), the `loadFonts()` handle's `embeddedFonts` supplies it, and an SVG embeds only the Intos faces its text draws. `registry.lazyFonts` lists the 51 vendored faces (35 open, 16 Intos) with package-relative file and sha256, and `loadFonts({ faces, lazyFontsBaseUrl })` from `/fonts-browser` loads them on demand: `await fonts.ensure(presentation)` (the registry's `ensureLazyFonts` and `ensureScripts` are the lower-level calls) fetches and verifies only the faces the document draws, by family, weight and style (a plain Aptos deck: Intos Display Bold and Intos Regular, 2 files, 1.5 MB; an italic run adds Intos Italic, a bold run Intos Bold; the code face only when a slide has code), then adds them to the document and the registry together so measurement and painting never disagree. Faces a host serves itself load the same way: `loadFonts({ faces: startup, extraLazyFonts: [{ family, weight, italic, url, sha256 }] })` adds them to the lazy list (`registry.lazyFonts`, `package: "host"`), so the registry can start with Roboto Regular only (`splitStartupFaces(faces)` splits a manifest into the startup face and the rest) and a document draws, fetches and verifies just the faces it needs, vendored and host faces in one `ensureLazyFonts` call. `sha256` is mandatory, loads are all or nothing, `dispose()` removes the faces, and, like the vendored ones, host faces are `embed: "used"`: they are not in `registry.embeddedFonts` (only the startup faces are), and a standalone SVG embeds one only when passed explicitly through `registry.selectEmbeddedFonts` and the slide's text uses its family. `fonts.pending(presentation)` (and the registry's `pendingLazyFonts`) is the synchronous check hosts gate rendering on, and after an edit it reports just the newly needed faces. The faces are those the renderer itself measures and draws (`presentationFaces(presentation, options, registry)`, one layout-and-paint run with a recording measurement), resolved by the registry's own face selection over the faces it holds plus the vendored ones, so the browser loads exactly what Node draws with every vendored face loaded. A document resolves with the `renderSvg` options the host renders with: pass them as `loadFonts({ faces, renderOptions: { catalogs } })`, or per call (`pendingLazyFonts(presentation, { catalogs })`, `ensureLazyFonts(presentation, { signal, catalogs })`), so a layout or font scheme id that only the host's catalogs know resolves here too; a document that does not resolve throws (rejects) what `renderSvg` throws for it instead of reporting nothing. Node's `loadFonts` keeps loading the whole open pack and Intos eagerly, so nothing there is face level; with `scripts: 'auto'` they accept `renderOptions: { catalogs }` too. Hosts copy the vendored directories next to the page.
4
31
 
5
32
  Version 0.12.0 requires `@openpresentation/opf` ^0.12.0 (composed font sizes on PowerPoint's 0.01 pt grid, hanging wrap whitespace, promoted regions in reading order, right-to-left decks composed mirrored) and draws what that core composes, plus the preview changes listed in the changelog; use it with opf-pptx 0.12.0 and opf-editor 0.11.0 so preview and export resolve one core. Version 0.11.9 requires `@openpresentation/opf` ^0.11.4 and draws the design fields (deck and organization logos on covers and section slides, header and footer `logo` parts, `design.listBullet: image` picture bullets at the size PowerPoint draws them, the accent font, `contentDirection` and `chartPrimary` through core composition), and the slide tag falls back to the text colour when the scheme primary is under 4.5:1 against the background; furniture images sit on their zone edge. Use it with opf-pptx 0.11.7 and opf-editor 0.10.6 so preview and export resolve one core (no API change; the preview of covers, list slides with picture bullets, chart slides with a primary chart and slides with furniture images changes). Version 0.11.8 draws the slide tag in the scheme primary colour, as opf-pptx 0.11.5 writes it (no API change; the preview of slides with a tag changes). Version 0.11.7 lets a browser registry load the host's own faces on demand (`extraLazyFonts`, `splitStartupFaces`: a registry can start with Roboto Regular only; additive, `registry.lazyFonts` still lists the 94 vendored faces plus the host's). Version 0.11.6 makes the category-axis labels of chart previews rotate and skip instead of wrapping mid-word (no API change; the preview of dense-axis chart slides changes) and requires `@openpresentation/opf` ^0.11.3 (the 70 legacy gallery layout ids and their geometry); use it with PPTX 0.11.4 and editor 0.10.4 so preview and export resolve the same core. Version 0.11.5 loads vendored preview fonts by face, not by family, and lets the font loaders resolve documents with the host's render options (`presentationFaces`, `lazyFacesNeeded`; `pendingLazyFonts`, `ensureLazyFonts`, `pendingScripts` and `ensureScripts` take `catalogs` and throw or reject with the `renderSvg` error for a document that does not resolve; `registry.lazyFonts` still lists 94 faces). Version 0.11.4 previews the seven chartex chart types natively (the world map as a non-geographic tile grid), keeps the Latin Noto Sans replacement for script schemes under `scripts: 'auto'` in Node, shapes Noto Sans Mongolian again and bundles Raleway and Playfair Display (`registry.lazyFonts` lists 94 faces; no API change). Version 0.11.3 previews the classic chart types natively, draws deprecated chart ids as their replacement and scales value axes like Office (no API change; the preview of chart slides changes). Version 0.11.2 makes `scripts: 'auto'` load the script faces a font scheme names and adds `details.loadedFaceHasGlyph` to `missing-glyph` (no export change). Version 0.11.1 adds the open replacement font families the policy routes to and complete Red Hat styles (no API change). Version 0.11.0 requires `@openpresentation/opf` ^0.11.2 (cover centering, Intos Aptos previews, lazy font loading, automatic script fonts and glyph fallback); use it with PPTX 0.11.0 and editor 0.10.0 so preview and export resolve the same core. Version 0.10.0 required `@openpresentation/opf` ^0.11.1 (font policy table, script fonts and right-to-left paragraphs, socials, slide-image treatments, formatted slide-number and date furniture with the host `date` option, per-item alignment). See the changelog for the intentional preview-output changes.
6
33
 
@@ -12,7 +39,7 @@ Unfinished prepared shaping work is preserved in the [September 15 roadmap](docs
12
39
 
13
40
  This checkout supports Node 22 or later (`engines.node` `>=22`; CI tests Node 22, 24 and 26). Versions 0.8.0 to 0.12.0 declared `24.x`, so npm on Node 22 or 26 silently installed an older release instead (RR-20); upgrade past 0.12.0. Use `.nvmrc` (Node 24) for local development. Earlier published versions retain their original engine declarations. Browser entrypoints remain browser-safe; native application compatibility is verified separately.
14
41
 
15
- Version 0.8.0 adds optional font-registry vector outlines and shared heading/scalar/rich line placement. Pass identical `textMeasurement` and `textRasterPadding` options to preview, pagination and export; padding defaults to one scaled reference pixel. SVG consumes accepted origins without remeasuring. See the [source contract and limits](https://github.com/OpenPresentation/opf/blob/f94125a1ff95bf0974a055fe1c081d348dad54f2/docs/plans/text-placement.md). Native raster certification remains separate.
42
+ Version 0.8.0 adds optional font-registry vector outlines and shared heading/scalar/rich line placement. Pass the same `fonts` handle and `textRasterPadding` option to preview, pagination and export; padding defaults to one scaled reference pixel. SVG consumes accepted origins without remeasuring. See the [source contract and limits](https://github.com/OpenPresentation/opf/blob/f94125a1ff95bf0974a055fe1c081d348dad54f2/docs/plans/text-placement.md). Native raster certification remains separate.
16
43
 
17
44
  Version 0.8.0 requires core 0.10.0 and renders accepted quote and code geometry without fitting it again. Code filename/language/body parts preserve source whitespace, literal tabs and metadata case and expose trace targets for editing. The [43 reviewed code raster changes](docs/evidence/shared-code/raster-review.json) retain the other 762 corpus hashes. Coordinated releases PPTX 0.8.0 and editor 0.7.0 add native source recovery and editing. Glyph containment and separation do not establish native pixel equivalence.
18
45
 
@@ -20,31 +47,31 @@ Deterministic local renderer for Open Presentation Format documents. The shared
20
47
 
21
48
  In version 0.8.0, `design.contentBox` uses core's shared padded geometry. The card renders at `item.frameBox`; its payload uses `item.box` and accepted internals. Card padding participates in composition scoring, strict overflow and pagination. This requires core 0.10.0.
22
49
 
23
- Version 0.8.0 paints plain and rich text, titles, subtitles and tags using composition's accepted fits and resolved styles. Painting does not measure those payloads again. Supply a font registry during composition: reusing an estimated fit cannot correct spacing when the actual drawing font has different advances.
50
+ Version 0.8.0 paints plain and rich text, titles, subtitles and tags using composition's accepted fits and resolved styles. A `TextRun[]` title, subtitle, tag or quote text (FA-10) is painted the same way, through the rich-line painter used for body text. Painting does not measure those payloads again. Supply a font registry during composition: reusing an estimated fit cannot correct spacing when the actual drawing font has different advances.
24
51
 
25
52
  Citations, footnotes and captions (core RR-34 fields `cite`, `footnote`, `references` and `caption`) draw from the same composed geometry: a marker is a superscript segment after its run (traced as `data-opf-segment="marker"` without source offsets), the slide's footnote area is a rule and `<n> <text>` lines in the muted colour above the footer band, and a caption is a band inside its block's region. Decks without those fields render as before.
26
53
 
27
- Version 0.8.0 includes `prepareNodeFonts` in `/fonts-node`. It verifies all selected font files and license notices against the versioned `BUNDLED_FONT_MANIFEST`, including exact npm versions and SHA-256 hashes. Carlito is not an npm dependency: the package ships the unmodified google/fonts Carlito files and their OFL notice in `fonts/carlito`, pinned to a google/fonts commit and verified by the same hashes. The returned options configure layout, SVG, editing, PPTX, and Node raster output with the same font inputs:
54
+ `loadFonts` in `/fonts-node` prepares the fonts. It verifies all selected font files and license notices against the versioned `BUNDLED_FONT_MANIFEST`, including exact npm versions and SHA-256 hashes. Carlito is not an npm dependency: the package ships the unmodified google/fonts Carlito files and their OFL notice in `fonts/carlito`, pinned to a google/fonts commit and verified by the same hashes. The returned handle configures layout, SVG, editing, PPTX, and Node raster output with the same font inputs (`textMeasurement`, `embeddedFonts`, `fontFiles`, `useBundledFonts: false`, `loadSystemFonts: false`, plus `registry`, `manifest`, `substitutions`, `ensure(presentation)` and `pending(presentation)`):
28
55
 
29
56
  ```js
30
- import { prepareNodeFonts } from '@openpresentation/opf-render/fonts-node';
31
- import { renderSvgDeck, svgToPng } from '@openpresentation/opf-render';
32
- import { paginatePresentation } from '@openpresentation/opf/pagination';
57
+ import { loadFonts } from '@openpresentation/opf-render/fonts-node';
58
+ import { renderSvg, svgToPng } from '@openpresentation/opf-render';
59
+ import { paginate } from '@openpresentation/opf/pagination';
33
60
  import { toPptx } from '@openpresentation/opf-pptx';
34
61
 
35
- const { registry, options } = await prepareNodeFonts({
62
+ const fonts = await loadFonts({
36
63
  pack: 'office', substitutionPolicy: 'visual',
37
64
  });
38
- const { presentation } = paginatePresentation(opf, options);
39
- const slides = renderSvgDeck(presentation, options);
40
- const png = await svgToPng(slides[0], options);
41
- const pptx = await toPptx(presentation, options);
42
- console.log(registry.substitutions);
65
+ const { presentation } = paginate(opf, { fonts });
66
+ const slides = renderSvg(presentation, { fonts });
67
+ const png = await svgToPng(slides[0], { fonts });
68
+ const pptx = await toPptx(presentation, { fonts });
69
+ console.log(fonts.substitutions);
43
70
  ```
44
71
 
45
- The default `pack: 'base'` contains nine Roboto/Roboto Mono faces and suits a document using `design.fontScheme: 'roboto'`. The Office pack includes 24 Office-substitute faces plus 70 vendored faces of the open families that font schemes select and the open replacements the font policy routes to (below) and the 16 vendored Intos faces that preview the Aptos family (below), and defaults to metric substitution policy. Its four Carlito faces are the byte-identical upstream release (OFL reserves the name "Carlito", so a subset or conversion could not keep it); visual substitution remains explicit. Neither helper changes the document's authored font scheme or installs system fonts. The authored (selected) font is the source of truth. Licensed fonts are never bundled, so these packs supply open look-alikes for previews and SVG: metric-compatible where one exists (Carlito for Calibri, Intos for Aptos), visual-only where none does yet (a documented fallback and known layout-fidelity gap). The PPTX exporter still writes the selected name; see the [OPF font policy](https://github.com/OpenPresentation/opf/blob/main/docs/font-fidelity.md#font-policy-ff-31). Missing resources, changed bytes/notices, and unexpected package versions fail with actionable font errors. Requested/resolved substitution records remain on `registry.substitutions`; source paths appear where the caller supplies them. Font coverage, variant naming, shaping, and native fidelity retain their documented limits.
72
+ `pack` is `base` (default), `office` or `none` (only the `faces` you supply). The `base` pack contains nine Roboto/Roboto Mono faces and suits a document using `design.fontScheme: 'roboto'`. The Office pack includes 24 Office-substitute faces plus 70 vendored faces of the open families that font schemes select and the open replacements the font policy routes to (below) and the 16 vendored Intos faces that preview the Aptos family (below), and defaults to metric substitution policy. Its four Carlito faces are the byte-identical upstream release (OFL reserves the name "Carlito", so a subset or conversion could not keep it); visual substitution remains explicit. Neither helper changes the document's authored font scheme or installs system fonts. The authored (selected) font is the source of truth. Licensed fonts are never bundled, so these packs supply open look-alikes for previews and SVG: metric-compatible where one exists (Carlito for Calibri, Intos for Aptos), visual-only where none does yet (a documented fallback and known layout-fidelity gap). The PPTX exporter still writes the selected name; see the [OPF font policy](https://github.com/OpenPresentation/opf/blob/main/docs/font-fidelity.md#font-policy-ff-31). Missing resources, changed bytes/notices, and unexpected package versions fail with actionable font errors. Requested/resolved substitution records remain on `registry.substitutions`; source paths appear where the caller supplies them. Font coverage, variant naming, shaping, and native fidelity retain their documented limits.
46
73
 
47
- Default PNG/PDF raster loading now uses the same complete nine-face base pack, fixing omitted semibold and italic faces. Custom raster callers may still set `useBundledFonts: false`. Font files must stay available and unchanged for subsequent raster calls. `node scripts/update-font-manifest.mjs` is an explicit maintenance operation requiring review of font bytes, style metadata, licenses and raster changes; builds and installs never regenerate the expected hashes.
74
+ Default PNG/PDF raster loading now uses the same complete nine-face base pack, fixing omitted semibold and italic faces. A handle says `useBundledFonts: false` because it already holds its files; a plain `fonts: { useBundledFonts: false }` draws with no bundled face. Font files must stay available and unchanged for subsequent raster calls. `node scripts/update-font-manifest.mjs` is an explicit maintenance operation requiring review of font bytes, style metadata, licenses and raster changes; builds and installs never regenerate the expected hashes.
48
75
 
49
76
  Version 0.8.0 resolves OpenType preferred-family groups, so a Roboto request at 500, 600 or 800 selects the installed Medium, SemiBold or ExtraBold face. Explicit caller family renames keep their own namespace; ambiguous grouped faces reject. Resolved styles also carry optional `fontFace` metadata with the physical legacy family and its bold/italic style-link flags. Coordinated PPTX export consumes those flags independently of numeric weight. This prevents requesting a second bold style from an already named ExtraBold/SemiBold family. Browser CSS and native selectors retain their respective family names; no font is synthesized or installed by this lookup. See [exact-weight evidence and limits](docs/evidence/font-variants/README.md).
50
77
 
@@ -56,33 +83,29 @@ Code strings that [XML 1.0 cannot represent](https://www.w3.org/TR/xml/#charsets
56
83
  - Repository: `OpenPresentation/opf-render`
57
84
  - License: MIT
58
85
  - Compatibility target: `@openpresentation/opf`
59
- - Public API: `renderSvg(opf, opts)`, `renderSvgDeck(opf, opts)`, `resolvePresentation(opf, opts)`, `svgToPng(svg, opts)`, and `svgToPdf(svgs, opts)`
86
+ - Public API: `renderSvg(opf, opts)` (every slide), `renderSlideSvg(opf, index, opts)` (one slide), `resolvePresentation(opf, opts)`, `svgToPng(svg, opts)`, `svgToPdf(svgs, opts)` and `loadFonts(opts)` (`/fonts-node`, `/fonts-browser`)
60
87
  - Player and embedding (RR-28): `<opf-deck>` and a slideshow with a speaker view, see [Player and `<opf-deck>`](#player-and-opf-deck-rr-28)
61
88
 
62
- `renderSvg` renders a single slide selected by `opts.slideIndex` (default `0`). `renderSvgDeck` returns one SVG string per slide. Both validate OPF at the boundary via `@openpresentation/opf`, resolve inline and bundled catalogs locally, and emit byte-stable SVG for the same input. A catalog `source` may be one string or an ordered array (first match wins, the bundled default is appended); the renderer never fetches, so non-bundled sources resolve only from `catalogSources`. Slide background colours (solid, gradient stops, pattern colours) accept the same colour references as text and table colours: a hex value, a `var:` variable, or a colour scheme slot or role such as `accent2` or `primary`.
89
+ `renderSvg` returns one SVG string per slide (`skipHidden: true` leaves out slides marked `hidden`, the player's sequence, so the result is then shorter than the deck); `renderSlideSvg` returns the SVG of one slide (a zero-based index; out of range throws `slide-index-out-of-range`). Both check the format at the boundary (core `validate(deck, { only: ['format'] })`; a failure throws `OPFRenderError` `invalid-opf` whose `findings` are the error findings), resolve inline and bundled catalogs locally, and emit byte-stable SVG for the same input. A catalog `source` may be one string or an ordered array (first match wins, the bundled default is appended); the renderer never fetches, so non-bundled sources resolve only from `catalogSources`. Slide background colours (solid, gradient stops, pattern colours) accept the same colour references as text and table colours: a hex value, a `var:` variable, or a colour scheme slot or role such as `accent2` or `primary`.
63
90
 
64
91
  The preview draws three accepted spec fields from tables shared with opf-pptx through core (`@openpresentation/opf`): `code.language` colours code (comments, strings, numbers, keywords, names and types, in theme-derived colours kept at 4.5:1 or more on the code panel; an unknown language stays plain and the text is never changed), `metric.trend` draws an arrow beside the trend word (green up, red down, neutral flat, with "Trend: up" as its text alternative) and colours the delta and trend text, and every one of the 54 DrawingML preset pattern backgrounds draws from its 8 x 8 bitmap (one pixel per 1/96 inch from the slide's top-left). With a core that lacks these exports the preview draws plain, without a new diagnostic. `scripts/derive-pattern-bitmaps.mjs` compares a PowerPoint export of the pattern slides with core's tiles (ECMA-376 names the presets without defining their pixels; core's tiles were measured with it from desktop PowerPoint, Office 365, Windows, 2026-10-01).
65
92
 
66
93
  ```js
67
- import { renderSvgDeck } from "@openpresentation/opf-render";
68
- import { loadOfficeFontRegistry } from "@openpresentation/opf-render/fonts-node";
69
-
70
- const fonts = await loadOfficeFontRegistry({ substitutionPolicy: "visual" });
71
- const svgs = renderSvgDeck(opf, {
72
- trace: true,
73
- textMeasurement: fonts.textMeasurement,
74
- embeddedFonts: fonts.embeddedFonts,
75
- });
94
+ import { renderSvg } from "@openpresentation/opf-render";
95
+ import { loadFonts } from "@openpresentation/opf-render/fonts-node";
96
+
97
+ const fonts = await loadFonts({ pack: "office", substitutionPolicy: "visual" });
98
+ const svgs = renderSvg(opf, { trace: true, fonts });
76
99
  console.log(fonts.substitutions);
77
100
  ```
78
101
 
79
102
  Set `trace: true` to stamp rendered SVG elements with `data-opf-path` values such as `slides.0.title`; omit it for smaller production SVG.
80
103
 
81
- The installed open font pack works offline. The Aptos family previews with the metric-compatible Intos even under the default metric policy; this example additionally permits visual-only look-alikes for families with no metric replacement yet (a documented fallback), and the substitution report identifies requested and resolved faces. A visual substitute is not a claim of metric or pixel equivalence, and it is a known layout-fidelity gap rather than the end state. Replacements apply to rendering only; the document keeps the selected font name. Omit `substitutionPolicy` to restrict the Office loader to its metric mappings, or supply your own licensed faces. Missing fonts/glyphs fail explicitly. Without `textMeasurement`, synchronous SVG APIs use estimated widths; named fonts alone do not make rich-run spacing reliable. Browser hosts should load the same bytes using the loader below.
104
+ The installed open font pack works offline. The Aptos family previews with the metric-compatible Intos even under the default metric policy; this example additionally permits visual-only look-alikes for families with no metric replacement yet (a documented fallback), and the substitution report identifies requested and resolved faces. A visual substitute is not a claim of metric or pixel equivalence, and it is a known layout-fidelity gap rather than the end state. Replacements apply to rendering only; the document keeps the selected font name. Omit `substitutionPolicy` to restrict the Office pack to its metric mappings, or supply your own licensed faces (`faces`). Missing fonts/glyphs fail explicitly. Without `fonts`, synchronous SVG APIs use estimated widths; named fonts alone do not make rich-run spacing reliable. Browser hosts should load the same bytes using the loader below.
82
105
 
83
106
  Unresolved images produce an `unresolved-asset` diagnostic through `onDiagnostic`, including the OPF path, reason and complete description. The fallback shows a bounded status label when it fits above the selected readability floor, otherwise an icon with the full accessible description. The label is `Image unavailable` over the description (alt, else title, else `Image`) in semibold at 20 px on a 1280 x 720 canvas, and opf-pptx exports the same placeholder as native shapes. It preserves authored opacity, including faint decorative watermarks; a missing image is still missing even when its fallback fits. Use `strictAssets: true` to reject unresolved images. Supply embedded raster data URIs or a synchronous host `imageResolver` to resolve them; the renderer does not fetch URLs or read local paths. Caller descriptions and other metadata override referenced asset metadata through alias chains. These diagnostics identify unresolved sources, not malformed image bytes or native PowerPoint compatibility.
84
107
 
85
- Header/footer images and watermarks fit their complete artwork within their allocated regions. `design.imageFill: "crop"` continues to crop content picture placeholders; background images retain their own fit policy.
108
+ Header/footer images and watermarks fit their complete artwork within their allocated regions. `design.imageFill: "crop"` continues to crop content images; background images retain their own fit policy.
86
109
 
87
110
  An `image/svg+xml` data URI (base64 or text) is drawn as an image wherever a raster is (content, header/footer, watermark, logo, slide image, background, picture bullet), with the raster's box and fit, when it is an `<svg>` in the SVG namespace with an intrinsic size (`width` and `height`, or a `viewBox`); anything else keeps the `Image unavailable` placeholder. opf-pptx exports the same picture as a native SVG picture over a PNG fallback. An SVG used as an image never runs script or loads anything outside its own document, in a browser or in PNG/PDF output; PNG and PDF output draw it with resvg, and its text uses the same bundled fonts.
88
111
 
@@ -99,14 +122,13 @@ Unresolved sources draw no treatments. See core `docs/image-treatments.md` for t
99
122
  PNG and PDF conversion APIs are async because they load the local raster/PDF engines on demand:
100
123
 
101
124
  ```js
102
- import { renderSvgDeck, svgToPdf, svgToPng } from "@openpresentation/opf-render";
103
- import { loadOfficeFontRegistry } from "@openpresentation/opf-render/fonts-node";
104
-
105
- const fonts = await loadOfficeFontRegistry({ substitutionPolicy: "visual" });
106
- const svgs = renderSvgDeck(opf, { textMeasurement: fonts.textMeasurement });
107
- const rasterOptions = { scale: 1, fontFiles: fonts.fontFiles, useBundledFonts: false, loadSystemFonts: false };
108
- const png = await svgToPng(svgs[0], rasterOptions);
109
- const pdf = await svgToPdf(svgs, rasterOptions);
125
+ import { renderSvg, svgToPdf, svgToPng } from "@openpresentation/opf-render";
126
+ import { loadFonts } from "@openpresentation/opf-render/fonts-node";
127
+
128
+ const fonts = await loadFonts({ pack: "office", substitutionPolicy: "visual" });
129
+ const svgs = renderSvg(opf, { fonts });
130
+ const png = await svgToPng(svgs[0], { fonts, scale: 1 });
131
+ const pdf = await svgToPdf(svgs, { fonts });
110
132
  ```
111
133
 
112
134
  `svgToPng` returns PNG bytes for one SVG. `svgToPdf` accepts one SVG or an array of SVGs and returns PDF bytes with one slide per page. The SVG page `width`/`height` or `viewBox` determines the PDF page size. **Units: one SVG pixel is one PDF point (1/72 inch), in both modes**, so a 1280 x 720 slide is a 1280 x 720 pt page (17.8 x 10 in, not the 13.33 x 7.5 in PowerPoint prints); a viewer or printer scales it to the paper; `scale` controls raster density only. `svgToPdf` has two modes: `"vector"` (the default, see below) and `"raster"` (each slide an image, the output of earlier releases): pass `{ mode: "raster" }` for the image-only compatibility output.
@@ -117,7 +139,7 @@ const pdf = await svgToPdf(svgs, rasterOptions);
117
139
 
118
140
  - Shapes, lines, polylines, paths and rounded rectangles are PDF paths; linear and radial gradients are PDF shadings and hatch/tile `<pattern>`s are tiling patterns; `clip-path`, stroke dashes, group `opacity` (a transparency-group form) and per-element opacity are native. Pictures are image XObjects (PNG with alpha as an SMask, unoriented JPEG passed through, WebP and oriented JPEG decoded as in raster mode; identical pictures are stored once).
119
141
  - Text is real text: TrueType subsets (the font program, a `ToUnicode` map and a `CIDToGIDMap`) of the same font files the PNG preview draws with, positioned glyph by glyph (kerning, ligatures, combining marks and mixed scripts as fontkit shapes them), with the SVG's `textLength` and `text-anchor`, underline and strike-through. The Unicode bidirectional algorithm orders right-to-left and mixed-direction lines. Text is selectable, searchable and copies as the authored text. The encoding is the one Chromium writes, so PDFium (Chrome, Edge), poppler and pdf.js read it back the way they read a Chrome-printed page: runs are drawn in visual order, right-to-left runs are marked `/ReversedChars`, and a glyph or cluster the glyph map cannot give (an Arabic ligature or mirrored bracket, a reordered Indic or Khmer syllable, a no-break space) carries its logical text as `/ActualText`, one span per glyph or cluster. A mark the font split off a letter (Arabic dots) is drawn as a filled outline, so no extractor sees an extra character. There is no hidden text layer and no outline-only text. Known reader limits, shared with Chrome's own PDFs: PDFium duplicates some Thai and Burmese marks, PDFium reorders the runs of a mixed-direction line, and pdf.js ignores `/ActualText` (it reports reordered Indic and Khmer clusters in drawing order).
120
- - Fonts: only the bundled pack (unless `useBundledFonts: false`), `fontFiles`, `fontDirs` and the SVG's own `@font-face` data are used; `loadSystemFonts: true` is rejected in vector mode because system fonts could be proprietary. A face whose OS/2 `fsType` forbids embedding is never embedded (a diagnostic says so); one that forbids subsetting is embedded whole. A requested family that has no face is drawn with the family the PNG preview draws (`defaultFontFamily`, `sansSerifFamily`, `monospaceFamily`, `serifFamily`) and reported.
142
+ - Fonts: only the bundled pack (unless the handle says `useBundledFonts: false`), the handle's `fontFiles`, `fontDirs` and the SVG's own `@font-face` data are used; a handle with `loadSystemFonts: true` is rejected in vector mode because system fonts could be proprietary. A face whose OS/2 `fsType` forbids embedding is never embedded (a diagnostic says so); one that forbids subsetting is embedded whole. A requested family that has no face is drawn with the family the PNG preview draws (`defaultFontFamily`, `sansSerifFamily`, `monospaceFamily`, `serifFamily`) and reported.
121
143
  - Document metadata: `metadata: { title, author, subject, keywords, language, creator, creationDate }`. The language defaults to the first SVG's `lang`. No date is written unless you supply one, there is no random data, and the file identifier is a hash of the content, so identical input gives identical bytes on every machine.
122
144
  - Accessibility basics (not a conformance claim): the catalog carries `/Lang`, `/MarkInfo`, `/DisplayDocTitle` (with a title) and XMP metadata; with `tagged` (default) the structure tree lists, per slide in source order, headings (the `.title` placeholder), paragraphs, figures with their `aria-label` as `/Alt` and link elements bound to their link annotations, and decoration (backgrounds, rules, bullets) is marked as an artifact. Not done: PDF/UA or PDF/A claims, list and table structure, per-span languages; text inside a translucent group is not in the structure tree.
123
145
  - Links: `<a href>` with an http, https, mailto or tel target becomes a link annotation on each run of its text (other schemes are refused with a diagnostic).
@@ -140,7 +162,7 @@ The default changed from raster to vector in the release that added this section
140
162
 
141
163
  `@openpresentation/opf-render/export-browser` exports `svgToPdf(svgs, options)` and `svgToPng(svg, options)` for a page. They take the SVG the preview draws and need no Node module, no network and no system font (a bundle of this entry contains no sharp, resvg, fs or crypto, and the vector converter's SHA-256 is plain JavaScript).
142
164
 
143
- - **Vector PDF** (default) is the same converter the Node export uses (selectable real text in embedded font subsets, paths, gradients, patterns, clips, opacity, links, tagged structure, byte-identical output for the same SVG). Fonts come from the SVG's own `@font-face` data (pass `embeddedFonts` to `renderSvg`, for example `registry.selectEmbeddedFonts(() => true)` marked `embed: "used"` so each slide embeds only the faces it draws) or from `fontData: [{ data, family? }]`; `useBundledFonts`, `fontFiles` and `fontDirs` do not exist here and `loadSystemFonts: true` is rejected. A face the registry never loaded is never embedded.
165
+ - **Vector PDF** (default) is the same converter the Node export uses (selectable real text in embedded font subsets, paths, gradients, patterns, clips, opacity, links, tagged structure, byte-identical output for the same SVG). Fonts come from the SVG's own `@font-face` data (pass a `fonts` handle with `embeddedFonts` to `renderSvg`; faces marked `embed: "used"` are embedded only on the slides that draw them), from the faces of the `fonts` handle you pass to `svgToPdf` (script faces an SVG does not carry reach the PDF too) or from `fontData: [{ data, family? }]`; `fontFiles` and `fontDirs` do not exist here and system fonts cannot be loaded. A face the handle never loaded is never embedded.
144
166
  - **Pictures** are decoded by the browser (PNG, JPEG, WebP, GIF through `createImageBitmap`, EXIF orientation applied); an upright JPEG is passed through compressed, as in Node. An effect with no PDF form (filter, mask, a nested SVG picture) rasterizes only that element on a canvas and reports `pdf-raster-fallback`.
145
167
  - **Raster PDF** (`mode: "raster"`, `scale` default 2) draws each slide on a canvas as an image.
146
168
  - **PNG** is drawn by the browser's SVG renderer on a canvas (`scale`, `background`, up to 40 megapixels). With the fonts embedded in the SVG it agrees with the Node (resvg) PNG to anti-aliasing (mean channel error 0.43 of 255 on a text slide).
@@ -154,21 +176,19 @@ Complex scripts in raster output: resvg draws a HarfBuzz cluster with the advanc
154
176
  Use `@openpresentation/opf-render/svg` for browser rendering without Node dependencies. Browser-aware bundlers also select this shared SVG implementation for the root import; Node's root import retains PNG/PDF conversion.
155
177
 
156
178
  ```js
157
- import { renderSvg } from '@openpresentation/opf-render/svg';
158
- import { loadBrowserFontRegistry } from '@openpresentation/opf-render/fonts-browser';
179
+ import { renderSlideSvg } from '@openpresentation/opf-render/svg';
180
+ import { loadFonts } from '@openpresentation/opf-render/fonts-browser';
159
181
 
160
- const fonts = await loadBrowserFontRegistry(fontFileEntries);
161
- container.innerHTML = renderSvg(presentation, {
162
- textMeasurement: fonts.textMeasurement,
163
- });
182
+ const fonts = await loadFonts({ faces: fontFileEntries });
183
+ container.innerHTML = renderSlideSvg(presentation, 0, { fonts });
164
184
  // fonts.dispose() when its canvases are unmounted.
165
185
  ```
166
186
 
167
- Each entry contains `url` or `data: Uint8Array`, with optional `family`, `weight`, `italic` and `license`. The loader registers browser FontFaces using the same bytes used for measurement. It fetches only URLs supplied by the host, supports an AbortSignal and custom fetch, and awaits font loading. Use pinned static faces and retain their licenses. For standalone SVG export also pass `embeddedFonts: fonts.embeddedFonts`; embedding is unnecessary for each live draft after browser fonts are loaded.
187
+ Each entry of `faces` contains `url` or `data: Uint8Array`, with optional `family`, `weight`, `italic` and `license`. The loader registers browser FontFaces using the same bytes used for measurement. It fetches only URLs supplied by the host, supports an AbortSignal and custom fetch, and awaits font loading. Use pinned static faces and retain their licenses. A standalone SVG embeds the handle's `embeddedFonts` (the same `{ fonts }` option); embedding is unnecessary for each live draft after browser fonts are loaded.
168
188
 
169
189
  ## Player and `<opf-deck>` (RR-28)
170
190
 
171
- A slideshow player and an embeddable web component, both built on `renderSvg`: the slide a page shows is the slide the preview, the editor and the PDF show, with no second layout engine. They are plain ES modules, typed, framework-free and tree-shakeable, and importing them touches no DOM, so they are safe in Next.js and other server renderers.
191
+ A slideshow player and an embeddable web component, both built on `renderSlideSvg`: the slide a page shows is the slide the preview, the editor and the PDF show, with no second layout engine. They are plain ES modules, typed, framework-free and tree-shakeable, and importing them touches no DOM, so they are safe in Next.js and other server renderers.
172
192
 
173
193
  ```html
174
194
  <script type="module">
@@ -202,11 +222,11 @@ npx opf-preview-fonts public/opf-fonts --scripts Jpan,Arab # plus Noto script
202
222
  <root>/LICENSES.txt every license notice
203
223
  ```
204
224
 
205
- It is the layout the editor playground and the OpenPresentation sites serve, and `fonts="/opf-fonts/"` (or `fontRegistry`, a registry the page already has) loads faces on demand like the renderer's browser host: face level (a plain Aptos deck fetches Roboto Regular, Intos Display Bold and Intos Regular, about 1.5 MB), hash-verified, and one registry per root on a page. If the font root cannot be read the deck still draws, with estimated layout, and the element fires a non-fatal `error` event. A face that is not in the root (a script you did not copy) falls back to a system font.
225
+ It is the layout the editor playground and the OpenPresentation sites serve, and `fonts="/opf-fonts/"` (or a fonts handle the page already has, set as the `fonts` property) loads faces on demand like the renderer's browser host: face level (a plain Aptos deck fetches Roboto Regular, Intos Display Bold and Intos Regular, about 1.5 MB), hash-verified, and one registry per root on a page. If the font root cannot be read the deck still draws, with estimated layout, and the element fires a non-fatal `error` event. A face that is not in the root (a script you did not copy) falls back to a system font.
206
226
 
207
227
  ### `<opf-deck>`
208
228
 
209
- Attributes: `src` (OPF JSON; the other request the element makes), `slide` (1-based; counts the slides that play), `fonts`, `thumbnails` (a strip of slide thumbnails), `controls="none"`, `present` (a Present button), `include-hidden`, `label`, `keyboard="off"`. Instead of `src` set the `document` property (an object or JSON text), or put the document in a child `<script type="application/opf+json">`. Properties and methods: `document`, `slide`, `total`, `fontRegistry`, `renderOptions` (extra `renderSvg` options such as `catalogs` and `imageResolver`), `currentSlide` (`{ slide, total, index, id, title, notes, section }`; `notes` is plain text), `ready`, `next()`, `previous()`, `first()`, `last()`, `goto(n)`, `reload()` and `present(options)`.
229
+ Attributes: `src` (OPF JSON; the other request the element makes), `slide` (1-based; counts the slides that play), `fonts`, `thumbnails` (a strip of slide thumbnails), `controls="none"`, `present` (a Present button), `include-hidden`, `label`, `keyboard="off"`. Instead of `src` set the `document` property (an object or JSON text), or put the document in a child `<script type="application/opf+json">`. Properties and methods: `document`, `slide`, `total`, `fonts` (the font root URL, or a handle that wins over the attribute), `renderOptions` (extra `renderSlideSvg` options such as `catalogs` and `imageResolver`), `currentSlide` (`{ slide, total, index, id, title, notes, section }`; `notes` is plain text), `ready`, `next()`, `previous()`, `first()`, `last()`, `goto(n)`, `reload()` and `present(options)`.
210
230
 
211
231
  Events: `ready`, `slidechange` (the same detail as `currentSlide`; bubbles and is composed), `error` (`{ code, message, fatal }`, not bubbling), `presentstart` and `presentend`. Style it with custom properties (`--opf-deck-fg`, `--opf-deck-border`, `--opf-deck-radius`, `--opf-deck-focus`, `--opf-deck-accent`, `--opf-deck-stage`) and the parts `deck`, `viewport`, `slide`, `bar`, `button`, `previous`, `next`, `present`, `counter`, `thumbnails` and `thumbnail`.
212
232
 
@@ -216,7 +236,7 @@ Accessibility: the element is a labelled region (the deck name) holding a focusa
216
236
 
217
237
  ### Server markup
218
238
 
219
- `renderDeckHtml(deck, options)` returns the tag with the deck's slides as inline SVG inside it. A visitor without JavaScript, a crawler or a reader sees the slides; when the element upgrades its shadow DOM replaces them. `slides: 'first'` (default: the first slide and a list of titles), `'all'` or slide numbers; `embed: true` adds the document as an `application/opf+json` child so the upgrade needs no request (it includes hidden slides and notes, as the `src` file does); `renderOptions` takes a `textMeasurement` for exact widths. In Next.js, render the string from a server component with `dangerouslySetInnerHTML` and call `defineOpfDeck()` from a client component. A strict `style-src` Content-Security-Policy needs `style-src-attr 'unsafe-inline'` for the renderer's `style="white-space:pre"` attributes; the element's own styles use constructable stylesheets.
239
+ `renderDeckHtml(deck, options)` returns the tag with the deck's slides as inline SVG inside it. A visitor without JavaScript, a crawler or a reader sees the slides; when the element upgrades its shadow DOM replaces them. `slides: 'first'` (default: the first slide and a list of titles), `'all'` or slide numbers; `embed: true` adds the document as an `application/opf+json` child so the upgrade needs no request (it includes hidden slides and notes, as the `src` file does); `fonts` (an object) takes a handle, or any object with a `textMeasurement`, for exact widths; a string is the font root URL for the `fonts` attribute. In Next.js, render the string from a server component with `dangerouslySetInnerHTML` and call `defineOpfDeck()` from a client component. A strict `style-src` Content-Security-Policy needs `style-src-attr 'unsafe-inline'` for the renderer's `style="white-space:pre"` attributes; the element's own styles use constructable stylesheets.
220
240
 
221
241
  ### The slideshow
222
242
 
@@ -230,7 +250,7 @@ Not in v1: transitions and builds, links between slides, rich notes, translated
230
250
 
231
251
  ## Open font-scheme families (FF-31)
232
252
 
233
- Font schemes select openly licensed Latin families that no Office substitute covers, and the font policy routes proprietary families to open replacements (Red Hat for Segoe UI and Tahoma and, since FF-43, Barlow, Anton, Figtree, Work Sans, EB Garamond, Archivo Narrow, Libre Caslon Text and Bitter). The office pack vendors them as static faces (`fonts/<family>/`: only the used faces, the upstream OFL notice as `OFL.txt` and a generated `PROVENANCE.json`), so `loadOfficeFontRegistry()` and `prepareNodeFonts({ pack: 'office' })` resolve each to its own face, and a strict registry no longer throws `font-unavailable` for them:
253
+ Font schemes select openly licensed Latin families that no Office substitute covers, and the font policy routes proprietary families to open replacements (Red Hat for Segoe UI and Tahoma and, since FF-43, Barlow, Anton, Figtree, Work Sans, EB Garamond, Archivo Narrow, Libre Caslon Text and Bitter). The office pack vendors them as static faces (`fonts/<family>/`: only the used faces, the upstream OFL notice as `OFL.txt` and a generated `PROVENANCE.json`), so `loadFonts({ pack: 'office' })` resolves each to its own face, and a strict registry no longer throws `font-unavailable` for them:
234
254
 
235
255
  | Family | Vendored from | Faces |
236
256
  |---|---|---|
@@ -261,25 +281,25 @@ Source Sans Pro was renamed Source Sans 3. A request for the old name draws the
261
281
 
262
282
  The 70 vendored faces are 12.0 MiB (FF-43 added 35 faces, 4.6 MiB uncompressed and about 2.4 MB in the tarball; the first 35 were 7.4 MiB) and are the only bytes added; there are no new dependencies. Browser hosts fetch them lazily by family, so the eager list stays the 33 Office and base faces. Every face and notice is checked against its manifest sha256 when loaded (`font-integrity-mismatch`, `font-resource-unavailable`), and `npm run test:packed` verifies the packed copies. `node scripts/update-font-manifest.mjs --vendor` re-downloads each recorded commit (or npm version), reads the license from the shipped notice, refuses a non-permissive one (only OFL-1.1, Apache-2.0, MIT and UFL-1.0 are allowed), copies only the listed faces and rewrites the hashes and provenance; `.gitattributes` keeps the binaries byte-exact.
263
283
 
264
- Embedding: open faces are flagged `embed: "used"`. `registry.embeddedFonts` stays the 33 Office and base faces, while `prepareNodeFonts().options.embeddedFonts` (or `registry.selectEmbeddedFonts`) supplies every face and `renderSvg` embeds an open face only when the slide's text names its family, so a Montserrat slide carries Montserrat and not the whole pack. Raster output reads the files from `fontFiles`. `includeOpenFonts: false` leaves the open families out of the office pack entirely.
284
+ Embedding: open faces are flagged `embed: "used"`. `registry.embeddedFonts` stays the 33 Office and base faces, while the `loadFonts()` handle's `embeddedFonts` (or `registry.selectEmbeddedFonts`) supplies every face and `renderSvg` embeds an open face only when the slide's text names its family, so a Montserrat slide carries Montserrat and not the whole pack. Raster output reads the files from `fontFiles`. `includeOpenFonts: false` leaves the open families out of the office pack entirely.
265
285
 
266
286
  ## Script fonts, lang and right-to-left text (FF-19)
267
287
 
268
288
  Previews itemize text by Unicode script. Each run uses the OOXML script slot its script belongs to (`latin`, `eastAsian` or `complexScript`), as resolved by core `resolveScriptFonts` from the document's language and font scheme. Latin, Greek and Cyrillic text stays in the design font. The text's role picks the major (heading) or minor (body) slots: title, subtitle and tag text is heading, as in the exporter's heading shapes, and all other text is body. Licensed script fonts are never bundled. With a measured registry, a proprietary family (for example Meiryo, Microsoft YaHei, Malgun Gothic, Arabic Typesetting, David, Mangal or Angsana New) is previewed with its designated open replacement from `SCRIPT_FONT_REPLACEMENTS`, and each replacement is recorded in `registry.substitutions` as `visual`. The PPTX keeps the chosen family. If the slot's face has no glyph for a run, the run falls back by coverage to the designated OFL Noto family for its script.
269
289
 
270
- The replacement faces are an optional, hash-pinned font pack: 70 static faces from 37 `@expo-google-fonts/*` packages (SIL OFL 1.1): 63 regular and bold Noto script faces from 31 packages, plus the FF-45 symbol packs (`noto-sans-symbols`, `noto-sans-symbols-2` and `noto-sans-math`, under `Zsym`: the Symbol, Wingdings and Webdings code tables draw with them), the FF-45 emoji pack (`noto-color-emoji`: Noto Color Emoji, COLRv1 and OT-SVG colour glyphs, 24.0 MiB; `noto-emoji`: the monochrome face the raster path draws) under the pseudo-script `Zsye` and the math pack (`stix-two-math`, `noto-sans-math`) under `Zmth` (Noto Sans Math serves both Zmth and Zsym). The pinned script faces total 66.9 MiB, of which 55.9 MiB is CJK; the emoji and math faces add 27.5 MiB and the symbol faces another 1.3 MiB. Installing all 37 packages takes about 361 MiB, because they also ship weights the manifest does not pin. Thirty-six of the 37 are exact optional peer dependencies, so the renderer install does not grow. The exception is `@expo-google-fonts/noto-sans` (Latin, Cyrillic and Greek; regular, bold, italic and bold italic, about 14 MiB installed), which is a pinned runtime dependency because it is the default glyph-fallback face: `loadOfficeFontRegistry` and `prepareNodeFonts({pack: 'office'})` always load it, marked fallback-only (it serves glyph fallback and requests for Noto Sans, never stands in for another family, and is embedded in a standalone SVG only when its text draws it, like the open families (embed "used"); raster output reads it from `fontFiles`). Install only the other scripts you need, then load them. Segoe UI Emoji previews with Noto Color Emoji and Cambria Math with STIX Two Math once their packs are loaded (`scripts: 'auto'` loads them for text with emoji-presentation clusters or mathematical notation, or a scheme that names the family); emoji sequences stay one run and one glyph, browsers draw them in colour, and PNG/PDF output draws the monochrome Noto Emoji because resvg has no colour-glyph support. The emoji pack is opt-in by use: `scripts: 'auto'` (Node) and `ensureScripts` (browsers) load Noto Color Emoji (24 MiB) only for a deck with emoji-presentation text or a scheme that names Segoe UI Emoji, and it is an optional peer dependency, so it is not in the npm tarball; `scripts: 'all'` loads it too, so pass an explicit script list when memory or download size matters. The PPTX keeps the chosen family either way:
290
+ The replacement faces are an optional, hash-pinned font pack: 70 static faces from 37 `@expo-google-fonts/*` packages (SIL OFL 1.1): 63 regular and bold Noto script faces from 31 packages, plus the FF-45 symbol packs (`noto-sans-symbols`, `noto-sans-symbols-2` and `noto-sans-math`, under `Zsym`: the Symbol, Wingdings and Webdings code tables draw with them), the FF-45 emoji pack (`noto-color-emoji`: Noto Color Emoji, COLRv1 and OT-SVG colour glyphs, 24.0 MiB; `noto-emoji`: the monochrome face the raster path draws) under the pseudo-script `Zsye` and the math pack (`stix-two-math`, `noto-sans-math`) under `Zmth` (Noto Sans Math serves both Zmth and Zsym). The pinned script faces total 66.9 MiB, of which 55.9 MiB is CJK; the emoji and math faces add 27.5 MiB and the symbol faces another 1.3 MiB. Installing all 37 packages takes about 361 MiB, because they also ship weights the manifest does not pin. Thirty-six of the 37 are exact optional peer dependencies, so the renderer install does not grow. The exception is `@expo-google-fonts/noto-sans` (Latin, Cyrillic and Greek; regular, bold, italic and bold italic, about 14 MiB installed), which is a pinned runtime dependency because it is the default glyph-fallback face: `loadFonts({ pack: 'office' })` always loads it, marked fallback-only (it serves glyph fallback and requests for Noto Sans, never stands in for another family, and is embedded in a standalone SVG only when its text draws it, like the open families (embed "used"); raster output reads it from `fontFiles`). Install only the other scripts you need, then load them. Segoe UI Emoji previews with Noto Color Emoji and Cambria Math with STIX Two Math once their packs are loaded (`scripts: 'auto'` loads them for text with emoji-presentation clusters or mathematical notation, or a scheme that names the family); emoji sequences stay one run and one glyph, browsers draw them in colour, and PNG/PDF output draws the monochrome Noto Emoji because resvg has no colour-glyph support. The emoji pack is opt-in by use: `scripts: 'auto'` (Node) and `ensureScripts` (browsers) load Noto Color Emoji (24 MiB) only for a deck with emoji-presentation text or a scheme that names Segoe UI Emoji, and it is an optional peer dependency, so it is not in the npm tarball; `scripts: 'all'` loads it too, so pass an explicit script list when memory or download size matters. The PPTX keeps the chosen family either way:
271
291
 
272
292
  ```js
273
- import { prepareNodeFonts } from '@openpresentation/opf-render/fonts-node';
274
- import { renderSvgDeck, svgToPng } from '@openpresentation/opf-render';
293
+ import { loadFonts } from '@openpresentation/opf-render/fonts-node';
294
+ import { renderSvg, svgToPng } from '@openpresentation/opf-render';
275
295
 
276
296
  // npm install @expo-google-fonts/noto-sans-jp@0.4.3 @expo-google-fonts/noto-naskh-arabic@0.4.5
277
- const { options } = await prepareNodeFonts({ pack: 'office', substitutionPolicy: 'visual', scripts: ['Jpan', 'Arab'] });
278
- const slides = renderSvgDeck(presentation, options);
279
- const png = await svgToPng(slides[0], options);
297
+ const fonts = await loadFonts({ pack: 'office', substitutionPolicy: 'visual', scripts: ['Jpan', 'Arab'] });
298
+ const slides = renderSvg(presentation, { fonts });
299
+ const png = await svgToPng(slides[0], { fonts });
280
300
  ```
281
301
 
282
- `scripts` accepts ISO 15924 codes (`Jpan`, `Hans`, `Hant`, `Kore`, `Arab`, `Hebr`, `Deva`, `Beng`, `Thai` and others; see `scriptFontPackages('all')`) or `'all'`. `detectScripts(presentation)` from `/fonts` lists the scripts a document's text needs. Every face and license notice is checked against `BUNDLED_FONT_MANIFEST`. A missing package fails with `font-resource-unavailable` and names the exact version to install. Script faces are left out of `options.embeddedFonts` unless you pass `embedScriptFonts: true`, because a CJK face is 5 to 10 MB. Raster output reads them from `fontFiles`. In a browser, serve the installed packages and load `scriptFontEntries(scripts, { baseUrl })` from `/fonts-browser` with `loadBrowserFontRegistry`. The loader verifies each entry's SHA-256 with Web Crypto before use.
302
+ `scripts` accepts ISO 15924 codes (`Jpan`, `Hans`, `Hant`, `Kore`, `Arab`, `Hebr`, `Deva`, `Beng`, `Thai` and others; see `scriptFontPackages('all')`) or `'all'`. `detectScripts(presentation)` from `/fonts` lists the scripts a document's text needs. Every face and license notice is checked against `BUNDLED_FONT_MANIFEST`. A missing package fails with `font-resource-unavailable` and names the exact version to install. Script faces are left out of `fonts.embeddedFonts` unless you pass `embedScriptFonts: true`, because a CJK face is 5 to 10 MB. Raster output reads them from `fontFiles`. In a browser, serve the installed packages and load `scriptFontEntries(scripts, { baseUrl })` from `/fonts-browser` with `loadFonts({ faces })`. The loader verifies each entry's SHA-256 with Web Crypto before use.
283
303
 
284
304
  ### Load only the script faces a document uses (`scripts: 'auto'`)
285
305
 
@@ -288,27 +308,29 @@ Hosts do not need to detect scripts themselves. `scripts: 'auto'` reads the pres
288
308
  ```js
289
309
  // Node: pass the presentation with scripts: 'auto'. Auto never fails on a package that is not installed
290
310
  // or a script no pinned font serves; it reports them through onDiagnostic and registry.scriptSelection.
291
- const { options, registry } = await prepareNodeFonts({ pack: 'office', substitutionPolicy: 'visual', scripts: 'auto', presentation });
292
- registry.scriptSelection; // { detected, scripts, unavailable, packages, notInstalled }
311
+ const fonts = await loadFonts({ pack: 'office', substitutionPolicy: 'visual', scripts: 'auto', presentation });
312
+ fonts.registry.scriptSelection; // { detected, scripts, unavailable, packages, notInstalled }
313
+
314
+ // Node, later: fonts.ensure(presentation) loads the script faces another presentation needs (resolves { scripts, lazy, uncovered }).
293
315
 
294
316
  // Browser: script faces are fetched lazily, once each, hash-verified, from where the host serves the
295
- // installed @expo-google-fonts packages. Call ensureScripts after every edit and render again afterwards.
296
- const fonts = await loadBrowserFontRegistry(baseEntries, { substitutionPolicy: 'visual', fallbackFamily: 'Roboto', scriptBaseUrl: '/script-fonts/' });
297
- if (fonts.pendingScripts(presentation).length) await fonts.ensureScripts(presentation); // resolves { detected, scripts, unavailable, loaded }
298
- // pendingScripts is synchronous: empty means nothing is missing, so render at once.
317
+ // installed @expo-google-fonts packages. Call ensure after every edit and render again afterwards.
318
+ const browserFonts = await loadBrowserFonts({ faces: baseEntries, substitutionPolicy: 'visual', fallbackFamily: 'Roboto', scriptBaseUrl: '/script-fonts/' });
319
+ if (browserFonts.pending(presentation).length) await browserFonts.ensure(presentation); // resolves { scripts, lazy, uncovered }
320
+ // pending is synchronous: empty means nothing is missing, so render at once.
299
321
  // Text decides, so the analysis does not resolve layouts or catalogs. Pass the render options (renderOptions: { catalogs } to
300
- // loadBrowserFontRegistry, or per call) so a font scheme only the host's catalogs have and that names a script font (Yu Gothic) counts.
322
+ // loadFonts, or per call) so a font scheme only the host's catalogs have and that names a script font (Yu Gothic) counts.
301
323
  ```
302
324
 
303
325
  Where the renderer has per-character glyph fallback (`glyphFallbackFamilies`), auto also loads Noto Sans when the text has Greek or Cyrillic (the chosen Latin face may lack it), and the next CJK face of the fallback chain when a drawn Han, kana or Hangul character is missing from the loaded ones (a Simplified-only hanzi in Japanese text, hanja). That is capped at one fallback package beyond the packages the text's own scripts need: a character no CJK face covers never pulls in all four (10 to 20 MiB each); it is listed in `ensureScripts().uncovered` (Node: `script-glyph-uncovered`, `registry.scriptSelection.uncovered`) and the renderer reports `missing-glyph`. Browser loads are all or nothing per package: a failed fetch, hash check or `FontFace.load()` leaves the registry, the document and `loadedScriptPackages` unchanged and the package pending. One failing package does not lose the others: `ensureScripts` loads every package it can, then rejects with an `OPFFontError` whose `details` are `{ loaded, failed: [{package, code, message}] }`, and the next call retries only the failures. `ensureScripts(presentation, { signal })` and `loadScripts(scripts, { signal })` take a per-call `AbortSignal` (the creation signal is not reused), and `dispose()` during a load leaves no face behind and rejects with `font-registry-disposed`. Script faces carry `embed: "used"`, like the open families: the eager `registry.embeddedFonts` leaves them out, and when you pass them explicitly (`registry.selectEmbeddedFonts(face => face.scripts)`) an SVG embeds one only when the slide's text uses its family, so a Latin slide never carries a CJK face.
304
326
 
305
- `loadBrowserFontRegistry` also accepts `scripts: 'auto'` with `presentation` (loads on creation), and `fonts.loadScripts(['Arab'])` for explicit scripts. A registry grows in place: `registry.addFaces(entries)` registers faces atomically, refreshes the script aliases (Meiryo resolves to Noto Sans JP once its face exists) and drops `registry.substitutions` recorded before the face existed, so hosts keep one `textMeasurement`. Faces are never unloaded before `dispose()`. Measurements planned before a face was added (for example a long-lived `createScriptTextMeasurement` wrapper) do not know it; call `ensureScripts` before creating them. Sizes: one CJK package is 10.4 (Japanese), 20.1 (Simplified Chinese), 13.6 (Traditional Chinese) or 11.8 MiB (Korean); the three Arabic packages total 1.8 MiB and each other script under 1.5 MiB. A Latin-only deck downloads nothing extra.
327
+ `loadFonts` (browser) also accepts `scripts: 'auto'` with `presentation` (loads on creation), and `fonts.registry.loadScripts(['Arab'])` for explicit scripts. A registry grows in place: `registry.addFaces(entries)` registers faces atomically, refreshes the script aliases (Meiryo resolves to Noto Sans JP once its face exists) and drops `registry.substitutions` recorded before the face existed, so hosts keep one `textMeasurement`. Faces are never unloaded before `dispose()`. Measurements planned before a face was added (for example a long-lived `createScriptTextMeasurement` wrapper) do not know it; call `fonts.ensure` before creating them. Sizes: one CJK package is 10.4 (Japanese), 20.1 (Simplified Chinese), 13.6 (Traditional Chinese) or 11.8 MiB (Korean); the three Arabic packages total 1.8 MiB and each other script under 1.5 MiB. A Latin-only deck downloads nothing extra.
306
328
 
307
329
  The renderer measures and paints with the same runs. A line whose runs use different families is drawn as positioned tspans, each with its own measured advance. For consistent line breaks, pass the same measurement to pagination and editing: `createScriptTextMeasurement(registry.textMeasurement, resolveScriptFonts(presentation))`. Without a registry, the SVG names every candidate family in order (for example `Meiryo, Noto Sans JP, sans-serif`), and the host resolves glyphs itself.
308
330
 
309
- The SVG root carries `lang` and `xml:lang` from the document's `language`. Measurement applies the same OpenType language system that a browser selects for that `lang`; Noto Sans KR spacing, for example, differs under `KOR`. Direction is set per paragraph, meaning the text between hard line breaks. The rule is core's `paragraphDirection(text, deckDirection)`, the same function the exporter uses for `a:pPr rtl`. A paragraph is right to left when the deck language is right to left and its first strong character is right to left, or it has none. Strong characters follow UAX #9 P2: isolates are skipped, LRM/RLM/ALM count, and the letters of every right-to-left script, historic ones included, are right to left. The renderer does not keep its own copy of the rule. With a core that lacks the function, every paragraph is left to right, as in export, and a right-to-left deck reports `paragraph-direction-unavailable`. Every wrapped line of a right-to-left paragraph is laid out as a right-to-left isolate (U+2067 ... U+2069), so lines of one paragraph never differ. Measured rich-text fragments are placed from the right edge.
331
+ The SVG root carries `lang` and `xml:lang` from the document's `language`. Measurement applies the same OpenType language system that a browser selects for that `lang`; Noto Sans KR spacing, for example, differs under `KOR`. Direction is set per paragraph, meaning the text between hard line breaks. The rule is core's `paragraphDirection(text, deckDirection)`, the same function the exporter uses for `a:pPr rtl`. A paragraph is right to left when the deck language is right to left and its first strong character is right to left, or it has none. Strong characters follow UAX #9 P2: isolates are skipped, LRM/RLM/ALM count, and the letters of every right-to-left script, historic ones included, are right to left. The renderer does not keep its own copy of the rule. Every wrapped line of a right-to-left paragraph is laid out as a right-to-left isolate (U+2067 ... U+2069), so lines of one paragraph never differ. Measured rich-text fragments are placed from the right edge.
310
332
 
311
- When the installed core has no `resolveScriptFonts` (core 0.11.0 and earlier, which this release no longer accepts by default) and the document names a `language`, the renderer reports `language-preview-unavailable` once through `onDiagnostic`. The preview then uses the design font for every script, sets no `lang` and lays out every paragraph left to right. If the resolver throws, the renderer falls back the same way and reports `language-preview-unresolved`.
333
+ If core's `resolveScriptFonts` throws for the document, the preview uses the design font for every script, sets no `lang`, lays out every paragraph left to right and reports `language-preview-unresolved` once through `onDiagnostic`.
312
334
 
313
335
  A face that lacks a character never fails a preview. As in a browser or PowerPoint font linking, each character the resolved face lacks is measured and drawn with the first loaded face that has it, along a fixed chain (`glyphFallbackFamilies(character, profile)`): the character's own script face, the deck language's script face, Noto Sans (Latin, Cyrillic and Greek), the other CJK faces (Japanese, Simplified, Traditional, Korean), then every other Noto script face. A Greek or Cyrillic word under Georgia (Gelasio) or a script scheme's face (Constantia previews with the open PT Serif, which has Cyrillic, so only its Greek text falls back), Greek under Meiryo, Yu Gothic, Microsoft YaHei or Malgun Gothic, Japanese-only kanji beside Hangul, and Simplified-only hanzi in a Japanese deck therefore draw with Noto Sans or the CJK face that has them, and each word stays in one face when one has all of it. Each substitution is reported once per family pair and path as a `font-glyph-fallback` diagnostic through `onDiagnostic` (`fontFamily`, `fallbackFamily`, `scripts`, `characters`); it is a note, not an error. Pass `glyphFallback: 'none'` to `renderSvg` or `createScriptTextMeasurement` to keep exact faces, so a missing glyph raises `missing-glyph` again. Coverage is checked per grapheme cluster and per word: a base letter and the combining marks after it (NFD Vietnamese, Cyrillic U+0306) move together to the first face that has the whole cluster, and only their word moves, not the sentence. Without a registry, a Greek or Cyrillic run names `Noto Sans` after the design font in its font stack. With `scripts: 'auto'`, the CJK fallback faces are loaded for you: the language's face plus at most one fallback face (opf-render#55's cap). Greek and Cyrillic need no package, because the office registry always carries Noto Sans. Serif decks prefer the serif face of each script where one is pinned (Noto Serif Hebrew, Tibetan); no Noto Serif is bundled for Latin, Cyrillic, Greek or CJK, so their fallback text draws in the sans face. A character that no loaded face has still raises `missing-glyph`. The PPTX export is unchanged: it names the chosen font and PowerPoint links its own fallback fonts.
314
336
 
@@ -337,16 +359,20 @@ These browser entrypoints are included in the published package. For coordinated
337
359
  A deck that declares content variables, or is marked `"template": true`, is resolved by core `resolveVariables` before it is composed, so the preview and the PPTX exporter agree. Pass the values as `variables`:
338
360
 
339
361
  ```js
340
- import { renderSvgDeck } from '@openpresentation/opf-render';
362
+ import { renderSvg } from '@openpresentation/opf-render';
341
363
 
342
- renderSvgDeck(template, { variables: { client: 'Globex', revenue: 1250000 } });
364
+ renderSvg(template, { variables: { client: 'Globex', revenue: 1250000 } });
343
365
  ```
344
366
 
345
- A template previews with each unfilled variable's `example` and reports `variable-example-used` through `onDiagnostic`; a variable with no example keeps its `{{id}}` text. A normal deck with an unfilled required variable throws `OPFRenderError` with code `unfilled-variables`, and a value of the wrong kind throws `invalid-variables`. `resolvePresentation(...).presentation` is the concrete deck. `variables: false` draws the document as authored, with tokens and `var:` references visible (the editor canvas's view of a template, so inline edits never overwrite a token). Decks without content variables are untouched. Needs the core release that ships `resolveVariables`; with an older core the option is ignored. See [templates and variables](https://github.com/OpenPresentation/opf/blob/main/docs/templates-and-variables.md).
367
+ A template previews with each unfilled variable's `example` and reports `variable-example-used` through `onDiagnostic` (a built-in variable such as `{{speaker.name}}` whose document field is missing reports `variable-builtin-missing`; built-ins read the document's own `speaker`, `organization` and `name`, and a template preview keeps an absent one visible); a variable with no example keeps its `{{id}}` text. A normal deck with an unfilled required variable throws `OPFRenderError` with code `unfilled-variables`, and a value of the wrong kind throws `invalid-variables`. `resolvePresentation(...).presentation` is the concrete deck. `variables: false` draws the document as authored, with tokens and `var:` references visible (the editor canvas's view of a template, so inline edits never overwrite a token). Decks without content variables are untouched. See [templates and variables](https://github.com/OpenPresentation/opf/blob/main/docs/templates-and-variables.md).
346
368
 
347
369
  ### Symbol-encoded families: Symbol, Wingdings, Webdings (FF-45)
348
370
 
349
- Symbol, Wingdings, Wingdings 2, Wingdings 3 and Webdings keep their glyphs at the 224 codes 0x20..0xFF of a Microsoft Symbol cmap, not at Unicode code points, and none of them may be bundled. A run or design font in one of them no longer fails with `font-encoding-required`: each character is normalised to its code (the private-use character U+F0xx that Office writes for Insert > Symbol and `a:sym` runs, or the Windows-1252 character of the code, so "l" and U+F06C are the same Wingdings bullet) and mapped through core's reversible, version-specific tables (`@openpresentation/opf` `spec/reference/symbol-font-encodings.json`, snapshot `src/symbol-encodings.js`; `mapSymbolText('Wingdings', 'l')` gives U+26AB) to the Unicode equivalent, which the first loaded face of the family's chain draws: `SYMBOL_PREVIEW_FACES` is Noto Sans Symbols 2, Noto Sans Symbols, Noto Sans Math, Noto Sans for the dingbat fonts and Noto Sans (Greek letters, digits, punctuation) first for Symbol. The three symbol packages are optional peers of the script pack under the key `Zsym` (`scripts: ['Zsym']`; `scripts: 'auto'` selects it when a run, `design.fonts` or a font scheme names one of the families; `registry.ensureScripts(presentation)` in a browser): `@expo-google-fonts/noto-sans-symbols-2`, `noto-sans-symbols` and `noto-sans-math`, OFL-1.1, together about 1.2 MiB. Every code the tables map (1059 of 1120: Symbol 189, Wingdings 222, Wingdings 2 217, Wingdings 3 208, Webdings 223) draws a real glyph when the pack is loaded; the 61 codes without a Unicode equivalent (the Wingdings 0xFF Windows logo, unassigned codes) draw the placeholder U+25A1. Each glyph is one positioned tspan at the advance of the verified Windows font (Wingdings 5.01, Wingdings 2 and 3 1.55, Webdings 5.01, Symbol 5.01), in measurement and drawing alike, so line breaks and bullet gaps follow PowerPoint; the open glyph keeps its shape and is compressed to its code's advance only when wider, never stretched. Characters that are not codes (a CJK letter in a Wingdings run) draw as themselves. Without the pack, Symbol's Greek letters, digits and punctuation draw with the office pack's Noto Sans, the other codes draw U+FFFD, and the `font-glyph-fallback` diagnostic (`codes`, `placeholder`) names the pack; a registry with no chain face and no `fallbackFamily` throws `font-encoding-required`, naming it too. `resolveFont` and `resolveStyle` report `symbolEncoding` with the substitute face, so exporters keep writing the chosen family and the original characters, and the PPTX names Wingdings with its codes. The symbol faces also end the glyph fallback chain. Appearance is the open face's, not Microsoft's; native PowerPoint verification of the exported runs is separate (FF-46).
371
+ Symbol, Wingdings, Wingdings 2, Wingdings 3 and Webdings keep their glyphs at the 224 codes 0x20..0xFF of a Microsoft Symbol cmap, not at Unicode code points, and none of them may be bundled. A run or design font in one of them no longer fails with `font-encoding-required`: each character is normalised to its code (the private-use character U+F0xx that Office writes for Insert > Symbol and `a:sym` runs, or the Windows-1252 character of the code, so "l" and U+F06C are the same Wingdings bullet) and mapped through core's reversible, version-specific tables (`@openpresentation/opf` `spec/reference/symbol-font-encodings.json`, read through `@openpresentation/opf/symbol-font-encodings`, not copied here; `mapSymbolText('Wingdings', 'l')` gives U+26AB) to the Unicode equivalent, which the first loaded face of the family's chain draws: `SYMBOL_PREVIEW_FACES` is Noto Sans Symbols 2, Noto Sans Symbols, Noto Sans Math, Noto Sans for the dingbat fonts and Noto Sans (Greek letters, digits, punctuation) first for Symbol. The three symbol packages are optional peers of the script pack under the key `Zsym` (`scripts: ['Zsym']`; `scripts: 'auto'` selects it when a run, `design.fonts` or a font scheme names one of the families; `fonts.ensure(presentation)`): `@expo-google-fonts/noto-sans-symbols-2`, `noto-sans-symbols` and `noto-sans-math`, OFL-1.1, together about 1.2 MiB. Every code the tables map (1059 of 1120: Symbol 189, Wingdings 222, Wingdings 2 217, Wingdings 3 208, Webdings 223) draws a real glyph when the pack is loaded; the 61 codes without a Unicode equivalent (the Wingdings 0xFF Windows logo, unassigned codes) draw the placeholder U+25A1. Each glyph is one positioned tspan at the advance of the verified Windows font (Wingdings 5.01, Wingdings 2 and 3 1.55, Webdings 5.01, Symbol 5.01), in measurement and drawing alike, so line breaks and bullet gaps follow PowerPoint; the open glyph keeps its shape and is compressed to its code's advance only when wider, never stretched. Characters that are not codes (a CJK letter in a Wingdings run) draw as themselves. Without the pack, Symbol's Greek letters, digits and punctuation draw with the office pack's Noto Sans, the other codes draw U+FFFD, and the `font-glyph-fallback` diagnostic (`codes`, `placeholder`) names the pack; a registry with no chain face and no `fallbackFamily` throws `font-encoding-required`, naming it too. `resolveFont` and `resolveStyle` report `symbolEncoding` with the substitute face, so exporters keep writing the chosen family and the original characters, and the PPTX names Wingdings with its codes. The symbol faces also end the glyph fallback chain. Appearance is the open face's, not Microsoft's; native PowerPoint verification of the exported runs is separate (FF-46).
372
+
373
+ ## Small conveniences (FA-13)
374
+
375
+ `code.highlight` draws one theme-derived band (an SVG `rect`) behind each run of marked lines and dims the unmarked lines; marked lines keep their syntax colours, and every colour stays at least 4.5:1 on what it sits on (core `codeHighlightColors`). A text `design.watermark` is one `text` element in the heading font and the theme text color, centered and rotated 30 degrees counterclockwise at the watermark opacity (core `layoutWatermark`). A `TextRun` with `code: true` draws in the design's code font with a monospace generic fallback, in text, lists and tables. A run with `lang` writes `lang`/`xml:lang` on its text and is measured and drawn with the script fonts of that language (Han text takes the Japanese or the Simplified Chinese face). `1:1`, `4:5` and `9:16` compose at 7.5 x 7.5, 7.5 x 9.375 and 7.5 x 13.333 inches.
350
376
 
351
377
  ## Numbered lists (RR-33)
352
378
 
@@ -370,15 +396,19 @@ Dependency policy:
370
396
  - `pdf-lib` assembles raster-mode PDF bytes locally. Metadata timestamps are disabled so repeated PDF output is byte-stable for the same SVG input and options.
371
397
  - Vector PDF output is written by a small deterministic PDF writer in this package, with `pako` for Flate compression (pure JavaScript, so independent of the platform zlib build), `fontkit` for shaping and font metrics and `bidi-js` for the Unicode bidirectional algorithm. No hosted service, network call or system font is involved.
372
398
  - Bundled OFL Roboto and Roboto Mono TTF files provide the default deterministic font fallback.
373
- - `svgToPng` and `svgToPdf` disable system-font loading by default. Hosts that require branded fonts should pass explicit `fontFiles` or `fontDirs`; `loadSystemFonts: true` is an opt-in escape hatch for PNG and raster-mode PDF output and can make it environment-dependent. Vector-mode PDF rejects it (`pdf-system-fonts-unsupported`): it embeds only fonts you supply, so a system font can never be embedded by accident.
399
+ - `svgToPng` and `svgToPdf` disable system-font loading by default. Hosts that require branded fonts should pass a handle with explicit `fontFiles` (or `fontDirs`); `fonts: { loadSystemFonts: true }` is an opt-in escape hatch for PNG and raster-mode PDF output and can make it environment-dependent. Vector-mode PDF rejects it (`pdf-system-fonts-unsupported`): it embeds only fonts you supply, so a system font can never be embedded by accident.
374
400
 
375
- Browser support boundary: `renderSvg`, `renderSvgDeck`, and `resolvePresentation` are browser-importable pure JavaScript APIs. The root `svgToPng` and `svgToPdf` are Node APIs because they depend on the Node builds of resvg and sharp. For a page, `@openpresentation/opf-render/export-browser` has the same two names (see below).
401
+ Browser support boundary: `renderSvg`, `renderSlideSvg`, and `resolvePresentation` are browser-importable pure JavaScript APIs. The root `svgToPng` and `svgToPdf` are Node APIs because they depend on the Node builds of resvg and sharp. For a page, `@openpresentation/opf-render/export-browser` has the same two names (see below).
376
402
 
377
403
  Chartex chart previews (FF-22b) draw the constructs opf-pptx exports as Office 2016 chartex parts: `treemap` (squarified tiles of the first series, one colour per tile, category labels), `histogram` (a lone value column binned like PowerPoint with Scott's rule count and right-closed bins, or one column per category), `pareto` (columns sorted descending with the cumulative-percentage line on a 0-100% axis), `box-and-whisker` (rows grouped by category, one box per series, exclusive quartiles, whiskers within 1.5 IQR, mean markers, outlier points), `waterfall` (floating bars from the running total, increases and decreases in the first two palette colours, connector lines) and `funnel` (centred bars with value labels). `world` is an honest non-geographic preview: one tile per region shaded by value with its name and value. No geography data is shipped; PowerPoint draws the real map from Bing geodata it fetches itself, so the preview and the native map agree on labels, values and the series colour, not on shapes. Each chartex kind also accepts a lone value column (row numbers as categories; histogram and pareto bin the values). Every mark and label keeps a `data-opf-path` (bins and boxes trace to their value column).
378
404
 
405
+ Combo charts (FA-15): `type: "combo"` draws clustered columns with line series (lines with markers) in one plot. Core's `resolveChartData` resolves the plan, so the preview and the PPTX export agree: the series that `chart.line` names (by default the last series) are lines, `chart.secondaryAxis` puts lines on a secondary value axis at the right (at the left right to left) with its own scale and its tick labels in the first secondary series' column format, and the series are drawn and listed in the legend columns first, then the primary-axis lines, then the secondary-axis lines. Gridlines follow the primary axis; `axisTitles.secondary` titles the secondary axis (rotated, inside a right legend); data labels sit outside the end of the columns and above the line points unless `dataLabels.position` says otherwise.
406
+
379
407
  Chart options (RR-35): a chart's optional `axisTitles`, `legend` and `dataLabels` (core `docs/chart-options.md`) are drawn. A named legend position is carved from the chart box before the plot is laid out, axis titles sit beside the axes (the value title rotated 270 degrees), and data labels follow the content (category, value, percent), position and separator the chart type supports. An option a type cannot show is dropped with a `chart-option-adapted` diagnostic; a chart without the fields draws as before.
380
408
 
381
- Chart and table data (RR-54, needs the core release with `resolveChartData`; core `docs/chart-table-data.md`): every chart path plots the data core resolves, so `DataColumn` headers, `{ dataset, fields }` references to the top-level `datasets` map and `chart.mapping` (category, scatter X and series by column name) all draw, and a series is labelled with its column `name`. Chart numbers are core's strict `chartNumber` (`"12%"`, `"(5)"` and `"Q1"` are gaps, `"1e6"` is 1000000). A column's number `format` shapes its data labels and the value-axis tick labels (the first plotted series' format; the scatter X axis follows the X column; percent axes and pie percent labels stay percent; a 100% stacked chart's data labels show the values in their own format, as PowerPoint does), and table cells draw the formatted text. A formatted horizontal axis (horizontal bars, the scatter X axis) pulls its plot in until the first and last tick labels stay inside the chart; a General axis keeps its placement. A dataset chart or table draws exactly its inline equivalent and traces every part to its authored `slides.N.chart` or `slides.N.table` path; a chart with `mapping` traces each part to the authored column. The legacy sketch (a chart type outside the catalog) plots a gap as nothing. A document without the new fields draws byte for byte as before; a `ChartDataSource` still draws the "No chart data" placeholder.
409
+ Chart emphasis (FA-14): `chart.highlight` names the series (data columns) and categories (row labels) that carry the message. Highlighted marks are filled with the deck primary and every other mark with a muted neutral derived from the chart panel and text colors (core `chartHighlightColors`; core `chartHighlightMarks` decides which marks are named: a mark is highlighted when its series or its category is). It colors columns, bars, line series and (for a category highlight) line points, areas, scatter and radar series, and pie and doughnut slices, with legend keys and data label contrast following; the chartex constructs draw as before and the `chart-option-adapted` diagnostic names the dropped part. Series keep their order and a chart without `highlight` draws the same SVG as before.
410
+
411
+ Chart and table data (RR-54, needs the core release with `resolveChartData`; core `docs/chart-table-data.md`): every chart path plots the data core resolves, so `DataColumn` headers, `{ dataset, fields }` references to the top-level `datasets` map and `chart.mapping` (category, scatter X and series by column name) all draw, and a series is labelled with its column `name`. Chart numbers are core's strict `chartNumber` (`"12%"`, `"(5)"` and `"Q1"` are gaps, `"1e6"` is 1000000). A column's number `format` shapes its data labels and the value-axis tick labels (the first plotted series' format; the scatter X axis follows the X column; percent axes and pie percent labels stay percent; a 100% stacked chart's data labels show the values in their own format, as PowerPoint does), and table cells draw the formatted text. A formatted horizontal axis (horizontal bars, the scatter X axis) pulls its plot in until the first and last tick labels stay inside the chart; a General axis keeps its placement. A dataset chart or table draws exactly its inline equivalent and traces every part to its authored `slides.N.chart` or `slides.N.table` path; a chart with `mapping` traces each part to the authored column. The legacy sketch (a chart type outside the catalog) plots a gap as nothing. A document without the new fields draws byte for byte as before; a data source by file or asset (the removed `ChartDataSource`) is an `invalid-opf` error.
382
412
 
383
413
  Classic chart previews keep finite axis coordinates for subnormal values and values up to `Number.MAX_VALUE`, including mixed positive/negative ranges. At these limits, available numeric precision can merge adjacent axis ticks or prevent extra headroom. Authored values are unchanged. Stacked, percentage or pie/doughnut totals that overflow the finite numeric range are still unsupported: rendering throws a `RangeError` naming the chart path and asking the caller to rescale the values. It does not substitute an empty chart or silently discard the overflowing values.
384
414
 
@@ -418,7 +448,7 @@ Version 0.3.0 requires core 0.5.0 and renders rich table cells and headers using
418
448
 
419
449
  Version 0.2.x requires the published `@openpresentation/opf@^0.4.1` for composition, pagination and measured text. A clean `npm ci && npm test` uses registry packages and runs the full raster corpus without sibling checkouts. For coordinated source development, build OPF and run `node scripts/link-ecosystem.mjs` there; `pnpm test:ecosystem` checks editing, rendering, editable PowerPoint geometry and import together.
420
450
 
421
- For actual font measurement, load `loadBundledFontRegistry()` from `@openpresentation/opf-render/fonts-node`, then pass its `textMeasurement` to rendering and its `embeddedFonts` to SVG export. Pass its `fontFiles` to PNG/PDF conversion. The browser-safe `@openpresentation/opf-render/fonts` entry accepts local font bytes. Missing fonts/glyphs fail explicitly; aliases and fallback are opt-in and recorded in `registry.substitutions`. Bundled font license notices travel with embedded SVG fonts.
451
+ For actual font measurement, load `loadFonts()` from `@openpresentation/opf-render/fonts-node` and pass the handle as `{ fonts }` to rendering (it carries the measurement and the faces to embed in an SVG) and to PNG/PDF conversion (its `fontFiles`). The browser-safe `@openpresentation/opf-render/fonts` entry holds the font data and helpers (`FONT_POLICY` and `fontPolicyFor` are core's own; the registry itself is behind `loadFonts`). Missing fonts/glyphs fail explicitly; aliases and fallback are opt-in and recorded in `registry.substitutions`. Bundled font license notices travel with embedded SVG fonts.
422
452
 
423
453
  Raster updates produce an HTML gallery and `artifacts/golden/candidate.json`; they never overwrite the approved baseline automatically (baselines are directories with one file per deck; `npm run golden:promote` writes only the decks that moved). See [baseline review and known limitations](test/golden/README.md).
424
454