@openpresentation/opf-render 0.15.0 → 0.16.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
@@ -7,6 +7,8 @@
7
7
  - An image background (`design.background: { type: "image", src, alt, fit, focus, opacity, recolor, overlay }`, or an image source string) draws from core's `geometry.backgroundImage`: the canvas colour, the picture (cover, contain, stretch or tile; recolor and opacity on the pixels only), then the overlay (the whole slide or an edge band). `alt` is the picture's accessible name; without it the picture is decorative.
8
8
  - Every content picture is an image block drawn from `item.image`: frame, fit (`cover`, `contain`, `stretch`; default `design.imageFit`, else `cover`), focus, shape mask, border, opacity, recolor and overlay, and a placed block (`placement`) at its edge band.
9
9
 
10
+ **Lighter install (RR-63, unreleased, 0.16.0).** `pdf-lib`, `@resvg/resvg-js`, `sharp` and every `@expo-google-fonts/*` font package are optional peer dependencies, loaded on first use: an SVG-only install holds none of them. Add what you export (see [Install](#install-what-to-add-for-what-rr-63)); a missing converter rejects with `converter-missing`. New `/png` and `/pdf` entries beside `/svg`. Rendered output is unchanged.
11
+
10
12
  **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):
11
13
 
12
14
  - `renderSvg(deck, options)` returns `string[]`, one SVG per slide; `renderSlideSvg(deck, index, options)` returns the SVG of one slide.
@@ -84,16 +86,75 @@ Version 0.8.0 resolves OpenType preferred-family groups, so a Roboto request at
84
86
 
85
87
  Code strings that [XML 1.0 cannot represent](https://www.w3.org/TR/xml/#charsets) reject rendering with `invalid-code-text`, the OPF field path and UTF-16 offset. Input JSON stays unchanged. Tabs, line endings and valid supplementary Unicode remain accepted; schema validity and XML serialization do not certify font coverage or native fidelity.
86
88
 
89
+ ## Install: what to add for what (RR-63)
90
+
91
+ `npm install @openpresentation/opf-render` brings the SVG renderer, layout and text measurement (`@openpresentation/opf`, `fontkit`, `bidi-js`, `pako`) and the vendored font files under `fonts/`. The heavy pieces are **optional peer dependencies**, so you install only what your output needs. npm 7+ and pnpm do not install optional peers; add them yourself:
92
+
93
+ | You want | Import | Also install |
94
+ | --- | --- | --- |
95
+ | SVG in a browser (the host serves its own font files) | `@openpresentation/opf-render/svg` (+ `/fonts-browser`) | nothing |
96
+ | SVG in Node, no fonts handle (estimated widths) | `@openpresentation/opf-render/svg` | nothing |
97
+ | SVG in Node with exact text measurement | `/svg` and `loadFonts` from `/fonts-node` | the font packages of the pack (below) |
98
+ | PNG in Node | `/png` (or the root) | `npm install @resvg/resvg-js` (`sharp` too for WebP and rotated JPEG pictures) and the fonts of the pack |
99
+ | PDF in Node (vector, the default) | `/pdf` (or the root) | the fonts of the pack; `sharp` for pictures; `@resvg/resvg-js` for the rare element drawn as an image (a filter, a mask) |
100
+ | PDF in Node, `mode: "raster"` | `/pdf` (or the root) | `pdf-lib`, `@resvg/resvg-js`, `sharp` and the fonts of the pack |
101
+ | PNG and PDF in a browser | `/export-browser` | nothing (`mode: "raster"` needs `pdf-lib`, see below) |
102
+ | Slideshow, `<opf-deck>`, server markup | `/player`, `/element`, `/element/define` | nothing |
103
+
104
+ The font packages are pinned exactly (`@expo-google-fonts/*`, each a pinned optional peer; what each pack holds is described under `pack` above and in [Script fonts](#script-fonts-lang-and-right-to-left-text-ff-19)). `loadFonts()` (pack `base`) needs `@expo-google-fonts/roboto@0.4.3` and `roboto-mono@0.4.2`; `loadFonts({ pack: 'office' })` also needs `arimo`, `caladea`, `cousine`, `gelasio`, `tinos` and `noto-sans`. A script face (`scripts: ['Jpan']`, `scripts: 'auto'`) needs its own `@expo-google-fonts/noto-*` package. The `fonts/` directory (the vendored Carlito, Intos and open families, about 28 MB) ships inside the package.
105
+
106
+ Converter ranges: `@resvg/resvg-js@^2.6.2`, `sharp@^0.35.5`, `pdf-lib@^1.17.1`.
107
+
108
+ ### The format entries
109
+
110
+ | Entry | What it is | Runs in |
111
+ | --- | --- | --- |
112
+ | `@openpresentation/opf-render/svg` | `renderSvg`, `renderSlideSvg`, `resolvePresentation`, `OPFRenderError` and the types. No converter, no Node module. | browser and Node |
113
+ | `@openpresentation/opf-render/png` | `svgToPng` | Node |
114
+ | `@openpresentation/opf-render/pdf` | `svgToPdf` | Node |
115
+ | `@openpresentation/opf-render/export-browser` | `svgToPng` and `svgToPdf` for a page (canvas) | browser |
116
+ | `@openpresentation/opf-render/element`, `/element/define`, `/player` | `renderDeckHtml`, `<opf-deck>`, `present` (the HTML output) | browser (`renderDeckHtml` also on a server) |
117
+ | `@openpresentation/opf-render` | all of `/svg`, plus `svgToPng` and `svgToPdf` | Node (a browser bundler picks `/svg`) |
118
+
119
+ There is no `/html` entry: it would only repeat `/element` (`renderDeckHtml`, `defineOpfDeck`) and `/player` (`present`) under a second name. A bundler that bundles Node code (a server framework) sees the converters' dynamic `import()` in the root and in `/png` and `/pdf`; an app that only draws SVG should import `/svg`, which has none, and an app that exports should list the converters as installed (or external) in its bundler.
120
+
121
+ ### A missing converter or font package
122
+
123
+ An export that needs a converter that is not installed rejects with `OPFRenderError` code `converter-missing`. `details` holds `package`, `range`, `install` (the command), `purpose` and `installed` (`false` when the package is absent; `true` when it is installed but cannot load, for example a native binary missing for the platform):
124
+
125
+ ```js
126
+ try { await svgToPng(svg); }
127
+ catch (error) {
128
+ // error.code === 'converter-missing'
129
+ // error.message: '@resvg/resvg-js is not installed. It is an optional peer dependency of @openpresentation/opf-render, used for PNG output, ...: run `npm install @resvg/resvg-js@^2.6.2`.'
130
+ }
131
+ ```
132
+
133
+ `svgToPng` needs `@resvg/resvg-js`; a WebP or rotated JPEG picture in it also needs `sharp`. `svgToPdf` (vector) needs no converter for text and shapes, `sharp` for pictures and `@resvg/resvg-js` for an element it has to draw as an image (reported as `pdf-raster-fallback`); `mode: "raster"` needs `pdf-lib` and `@resvg/resvg-js`. A picture is never silently dropped from a PDF because `sharp` is absent: the export rejects instead.
134
+
135
+ A font pack whose `@expo-google-fonts/*` packages are not all installed rejects `loadFonts` with `OPFFontError` code `font-resource-unavailable`; `details.packages` and the message name every missing package with the `npm install` command. `scripts: 'auto'` still reports a missing script package as the `script-font-not-installed` diagnostic instead of failing. In a browser, `export-browser` imports no converter: `mode: "raster"` PDF takes the module as an option (`svgToPdf(svgs, { mode: 'raster', pdfLib })` with `import * as pdfLib from 'pdf-lib'`), so a bundle of the entry holds no PDF library; without it raster mode rejects with `converter-missing`.
136
+
137
+ Installed size, measured on Windows with `npm install --ignore-scripts`, unpacked. sharp brings its 19 MiB libvips build on each platform; of the SVG-only install, 28 MiB is the vendored font files under `fonts/`, 13 MiB core and 6 MiB fontkit:
138
+
139
+ | Install | 0.15.0 | with RR-63 |
140
+ | --- | --- | --- |
141
+ | SVG only, in a browser (`/svg` + `/fonts-browser`) | 135 MiB | **56 MiB** |
142
+ | Node SVG and PNG (base pack) | 135 MiB | **89 MiB** |
143
+ | Everything (office pack, all converters) | 135 MiB | 135 MiB |
144
+
87
145
  ## Scope
88
146
 
89
147
  - Package: `@openpresentation/opf-render`
90
148
  - Repository: `OpenPresentation/opf-render`
91
149
  - License: MIT
92
150
  - Compatibility target: `@openpresentation/opf`
151
+ - Entry points: root, `/svg`, `/png`, `/pdf`, `/export-browser`, `/fonts-node`, `/fonts-browser`, `/fonts`, `/player`, `/element`, `/element/define`, `/preview-fonts`, `/preview-fonts-node` (see [Install](#install-what-to-add-for-what-rr-63))
93
152
  - 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`)
94
153
  - 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)
95
154
 
96
- `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 references from the document's embedded records and the host's `catalogs` (core `Catalog[]`, never fetched), and emit byte-stable SVG for the same input. 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`.
155
+ `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 references from the document's embedded records and the host's `catalogs` (core `Catalog[]`, never fetched), and emit byte-stable SVG for the same input. With a `fonts` handle, each SVG embeds as `@font-face` data only the faces of `embeddedFonts` that its own text draws (by family, weight and style; a face flagged `embed: "always"` goes into every SVG), so the handle is safe to pass to every preview: over the 127 core example decks, an office-pack slide carries 2.4 MB on average (4.1 MB at most) instead of every eager face (15 MB), a base-pack slide 0.53 MB instead of 1.76 MB. Faces are embedded whole, not as glyph subsets. `embedFonts: false` writes no `@font-face` data at all (the handle still measures), for a browser host whose `loadFonts` handle already added the faces to the page; keep the default where an SVG must stand alone (a file, the browser PDF export). 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`.
156
+
157
+ Accessibility of the SVG (FA-30): the slide root is a labelled container, `role="group"` with `aria-roledescription="slide"` and `aria-label` set to the slide title, never `role="img"`, which would make every text node inside presentational. Titles, body text, list entries, quotes, metrics, table cells, captions, footnotes and header or footer text stay live text a screen reader reaches, in the order the renderer paints them. Purely decorative drawing (the background and its overlay, card frames, table cell fills and borders, dividers, timeline markers, watermarks, and an image whose `alt` is `""`) carries `aria-hidden="true"`. A picture with `alt` is `role="img"` with that label (a header or footer picture without `alt` is hidden); a chart with `alt` is a `role="img"` group, and one with `alt: ""` is hidden. `<opf-deck>`, the player and `renderDeckHtml` remove the root's `role`, `aria-roledescription` and `aria-label` (the slide section or figure around the drawing carries the one name), so a slide is announced once.
97
158
 
98
159
  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).
99
160
 
@@ -169,7 +230,7 @@ The default changed from raster to vector in the release that added this section
169
230
 
170
231
  `@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).
171
232
 
172
- - **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.
233
+ - **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`; each face is embedded only on the slides that draw it, so the PDF gets every face its pages draw), 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.
173
234
  - **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`.
174
235
  - **Raster PDF** (`mode: "raster"`, `scale` default 2) draws each slide on a canvas as an image.
175
236
  - **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).
@@ -191,7 +252,7 @@ container.innerHTML = renderSlideSvg(presentation, 0, { fonts });
191
252
  // fonts.dispose() when its canvases are unmounted.
192
253
  ```
193
254
 
194
- 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.
255
+ 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 faces of the handle's `embeddedFonts` that its text draws (the same `{ fonts }` option); embedding is unnecessary for each live draft after browser fonts are loaded.
195
256
 
196
257
  ## Player and `<opf-deck>` (RR-28)
197
258
 
@@ -204,6 +265,8 @@ A slideshow player and an embeddable web component, both built on `renderSlideSv
204
265
  <opf-deck src="/deck.opf.json" fonts="/opf-fonts/" thumbnails present></opf-deck>
205
266
  ```
206
267
 
268
+ This HTML must be processed by a bundler (for example Vite); ordinary static HTML cannot resolve a bare npm import. Serve your own `/deck.opf.json` and the font root copied below.
269
+
207
270
  ```js
208
271
  // A framework page: register on the client, when you choose.
209
272
  import { defineOpfDeck } from '@openpresentation/opf-render/element';
@@ -239,18 +302,71 @@ Events: `ready`, `slidechange` (the same detail as `currentSlide`; bubbles and i
239
302
 
240
303
  Hidden slides (`hidden: true`) are skipped everywhere, so the counter, the `slide` attribute and the player's number-then-Enter all count the sequence that plays; `include-hidden` plays them all. Navigation: the buttons, ArrowLeft, ArrowRight, Page Up, Page Down, Home and End while the slide has focus (Up, Down and Space are left to the page), a horizontal swipe on touch, and the thumbnails. A deck whose `language` is right to left (and a page that sets `dir` or CSS `direction`) turns the controls and the Left and Right keys around.
241
304
 
242
- Accessibility: the element is a labelled region (the deck name) holding a focusable group named `Slide 3 of 8: Revenue grew`; slide text is live SVG text in the order the renderer paints it (title, then content, then furniture), not an image, so a screen reader reads it; slide changes made by keyboard or thumbnail are announced through a polite live region; thumbnails are a list of buttons (one tab stop, arrow keys inside, `aria-current`) whose drawings are hidden from assistive technology; the buttons keep their focus ring and `forced-colors` support; and animation is limited to a hover colour that `prefers-reduced-motion: reduce` removes. The test suite runs axe-core (WCAG 2.0 to 2.2 A and AA plus best practice) over the element, the player and the speaker view with no violations. There are no transitions or builds (deferred, opf#250).
305
+ Accessibility: the element is a labelled region (the deck name) holding a focusable group named `Slide 3 of 8: Revenue grew`; slide text is live SVG text in the order the renderer paints it (tag, title, content, footnotes, then header and footer), not an image, so a screen reader reads it; slide changes made by keyboard or thumbnail are announced through a polite live region; thumbnails are a list of buttons (one tab stop, arrow keys inside, `aria-current`) whose drawings are hidden from assistive technology; the buttons keep their focus ring and `forced-colors` support; and animation is limited to a hover colour that `prefers-reduced-motion: reduce` removes. The test suite runs axe-core (WCAG 2.0 to 2.2 A and AA plus best practice) over the element, the player and the speaker view with no violations. There are no transitions or builds (deferred, opf#250).
243
306
 
244
307
  ### Server markup
245
308
 
246
309
  `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.
247
310
 
311
+ Each slide's SVG embeds only the resolved faces it draws (see `renderSvg` above), including bold, italic and glyph fallback, so loading a whole pack for measurement does not put that pack into every slide. `renderDeckHtml` accepts `fontMode: 'standalone' | 'shared' | 'external'`:
312
+
313
+ - `standalone` (default) embeds fonts in each slide, so its SVG remains self-contained when extracted.
314
+ - `shared` emits the drawn font rules once inside the returned tag, retaining license metadata in the SVGs. Keep the returned markup together; an extracted SVG needs those shared rules to travel with it.
315
+ - `external` emits no font rules (the slides render with `embedFonts: false`). Supply page CSS with the exact pinned, licensed faces (including replacements and fallback faces) used by the handle, from your own origin. Keep the same handle for layout; omitting it changes layout to estimates. Copy license notices with the served files. An offline page needs those local files available too.
316
+
317
+ Shared and external rules apply at page scope. A strict CSP must permit their inline style or self-hosted stylesheet and the matching `font-src` (`data:` for embedded fonts). These modes change delivery, while measurement, text positions and font selection stay the same. The Node loader's large script faces still require `embedScriptFonts: true` for self-contained SVG or shared SSR; otherwise supply them through external CSS.
318
+
319
+ For a lightweight initial page, pre-render measured markup on the server and enable controls on demand. Here the server owns the fonts and the client owns the upgrade button:
320
+
321
+ ```js
322
+ // Server/build step: write this HTML into your page, followed by the upgrade button.
323
+ import {loadFonts} from '@openpresentation/opf-render/fonts-node';
324
+ import {renderDeckHtml} from '@openpresentation/opf-render/element';
325
+ const fonts = await loadFonts({pack: 'office', scripts: 'auto', presentation: deck, embedScriptFonts: true});
326
+ const html = renderDeckHtml(deck, {
327
+ fonts, slides: 'all', fontMode: 'shared', embed: true,
328
+ attributes: {id: 'deck', fonts: '/opf-fonts/'},
329
+ });
330
+ // <button id="upgrade" type="button">Enable slide controls</button>
331
+ ```
332
+
333
+ ```js
334
+ // Bundler-processed client entry; do not also import /element/define at startup.
335
+ document.querySelector('#upgrade').addEventListener('click', async event => {
336
+ const button = event.currentTarget;
337
+ button.disabled = true;
338
+ try {
339
+ const {defineOpfDeck} = await import('@openpresentation/opf-render/element');
340
+ defineOpfDeck();
341
+ await document.querySelector('#deck').ready;
342
+ button.hidden = true;
343
+ } finally { button.disabled = false; }
344
+ });
345
+ ```
346
+
347
+ Copy the self-hosted font root as above, including any script packs the deck draws. No JavaScript is needed to read the pre-rendered slides; the upgrade loads the strict byte-matched measurement engine and fonts when requested. A page that only needs readable slides can omit the client entry and button entirely. A live element without a font handle/root uses estimated widths and system fonts; that is a different fidelity contract.
348
+
349
+ The reproducible production fixture (`npm run build` then `node scripts/measure-web-delivery.mjs`) measures minified split ESM over local HTTP gzip, with actual font requests counted separately. On 2026-10-08 (Node 24, Playwright Chromium, OPF 0.15), its three-slide Roboto deck measured:
350
+
351
+ | Delivery | HTML gzip | JavaScript gzip | External font requests |
352
+ |---|---:|---:|---|
353
+ | Standalone SSR, no scripts | 686,617 B | 0 B | None; fonts are in HTML |
354
+ | Shared SSR, no scripts | 229,260 B | 0 B | None; fonts are in HTML |
355
+ | External SSR, no scripts | 1,021 B | 0 B | Two faces, 191,628 B gzip, plus host CSS |
356
+ | Shared SSR, immediate upgrade | 229,286 B | 500,021 B | Two faces, 191,628 B gzip |
357
+ | Shared SSR, before deferred upgrade | 229,322 B | 199 B | None |
358
+ | Shared SSR, after deferred upgrade | 229,322 B | 500,355 B total | Two faces, 191,628 B gzip |
359
+
360
+ The base and Office pack standalone SVGs both contain two Roboto rules (430,764 B / 228,481 B gzip). All three SSR modes render identical pixels with JavaScript disabled, an extracted standalone SVG paints the same offline, and strict interactive upgrades preserve those pixels. These numbers describe this local candidate and fixture, vary with the deck and bundler, and are recorded in [the production delivery report](docs/evidence/rr-59-web-delivery.json). The measurement-enabled JavaScript cost remains about 500 KB gzip. Copying the approximately 34 MiB font root does not make the browser fetch it all: this fixture fetches only Roboto Regular and Bold; other documents fetch their own required faces.
361
+
248
362
  ### The slideshow
249
363
 
250
364
  `present(source, options)` takes an OPF document, its URL or an `<opf-deck>` element and covers the page with a full-screen player (call it from a click or key handler; full screen and the speaker view need a user gesture). It resolves with a `PlayerSession` (`slide`, `total`, `next()`, `previous()`, `first()`, `last()`, `goto(n)`, `blank('black' | 'white' | 'none')`, `openPresenterView()`, `close()`; events `slidechange`, `blank`, `presenterview`, `close`). One show per document: a second call returns the running one.
251
365
 
252
366
  Keys: Right, Down, Page Down, Space, Enter and `N` are next; Left, Up, Page Up, Backspace, Shift+Space and `P` are previous; Home and End; a slide number then Enter (Escape clears it); `B` and `W` toggle a black or white screen (any navigation key brings the slide back first); `S` opens the speaker view; `F` toggles full screen; Escape leaves (leaving full screen any other way ends the show too). Click or tap advances (the left third goes back) and a horizontal swipe navigates. The player is a modal dialog: the rest of the page is inert, focus stays inside it and returns to where it was when the show ends, and an element that started it follows the show and fires `slidechange`.
253
367
 
368
+ `element.present({returnFocus: target})` honors the explicit target; otherwise it captures the invoking element before asynchronous loading (including the Present button in its shadow root). If that target is removed, focus falls back to the deck viewport. Fetched deck failures include the source URL, status and content type in the error detail; HTML responses explain likely SPA fallback routing. A valid JSON body is accepted even if the server labels it with another content type.
369
+
254
370
  The speaker view opens in a second window (`S`, the button, or `presenterView: true`): the current and next slide (the last slide says so), the speaker notes, the section, a timer (against the deck's `duration` in minutes, with pause and reset) and the clock, previous, next and blank buttons, the same keys, and a notes size control. Notes are the OPF `notes` string and are shown as plain text only (rich notes are deferred, opf#251). The windows follow each other over a `BroadcastChannel` named from the deck, so any number of windows of one deck on one origin stay in step (a logical clock settles two changes that cross), and `present(deck, { role: 'presenter' })` makes a second tab or window the speaker view. Pass `channel` to name the channel yourself or `false` for none. The audience window and the popup are driven by this page, so closing or navigating the page ends both.
255
371
 
256
372
  Not in v1: transitions and builds, links between slides, rich notes, translated interface strings (the controls are English) and a Window Management (multi-screen) placement of the two windows.
@@ -294,7 +410,7 @@ Embedding: open faces are flagged `embed: "used"`. `registry.embeddedFonts` stay
294
410
 
295
411
  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.
296
412
 
297
- 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:
413
+ 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. All 37 are exact optional peer dependencies, so the renderer install does not grow (RR-63: `@expo-google-fonts/noto-sans`, the default Latin, Cyrillic and Greek glyph-fallback face, about 14 MiB installed, was the one pinned runtime dependency and is now an optional peer like the rest; the office pack names it in its install error). 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:
298
414
 
299
415
  ```js
300
416
  import { loadFonts } from '@openpresentation/opf-render/fonts-node';
@@ -399,13 +515,13 @@ The package runtime must stay local and deterministic:
399
515
  Dependency policy:
400
516
 
401
517
  - `@openpresentation/opf` is the compatibility source for schemas, validation, resolution and composition. The renderer imports no catalog data; hosts register catalogs (`@openpresentation/opf/catalog` holds the gallery snapshot).
402
- - `@resvg/resvg-js` is used only for local SVG rasterization in Node; it makes no network calls and does not require a browser.
403
- - `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.
518
+ - `@resvg/resvg-js` (optional peer, RR-63) is used only for local SVG rasterization in Node; it makes no network calls and does not require a browser. `sharp` (optional peer) decodes WebP, rotated JPEG and the pictures of a vector PDF. Both load on first use; see [Install](#install-what-to-add-for-what-rr-63).
519
+ - `pdf-lib` (optional peer, RR-63) assembles raster-mode PDF bytes locally. Metadata timestamps are disabled so repeated PDF output is byte-stable for the same SVG input and options.
404
520
  - 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.
405
- - Bundled OFL Roboto and Roboto Mono TTF files provide the default deterministic font fallback.
521
+ - Bundled OFL Roboto and Roboto Mono TTF files (the optional peers `@expo-google-fonts/roboto` and `roboto-mono`, which Node `loadFonts()` and the PNG/PDF default read) provide the default deterministic font fallback.
406
522
  - `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.
407
523
 
408
- 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).
524
+ Browser support boundary: `renderSvg`, `renderSlideSvg`, and `resolvePresentation` are browser-importable pure JavaScript APIs. The root `svgToPng` and `svgToPdf` (also `/png` and `/pdf`) are Node APIs because they depend on the Node builds of resvg and sharp, which are optional peers loaded on first use (`converter-missing` when absent). For a page, `@openpresentation/opf-render/export-browser` has the same two names (see below).
409
525
 
410
526
  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).
411
527
 
@@ -31,7 +31,7 @@ export function renderFootnotes(bound, options, draw) {
31
31
  if (!footnotes) return "";
32
32
  const untraced = { ...options, trace: false };
33
33
  const { rule } = footnotes;
34
- const line = draw.tag("line", { x1: rule.x, y1: rule.y + rule.thickness / 2, x2: rule.x + rule.width, y2: rule.y + rule.thickness / 2, stroke: bound.design.colors.border, "stroke-width": rule.thickness });
34
+ const line = draw.tag("line", { "aria-hidden": "true", x1: rule.x, y1: rule.y + rule.thickness / 2, x2: rule.x + rule.width, y2: rule.y + rule.thickness / 2, stroke: bound.design.colors.border, "stroke-width": rule.thickness });
35
35
  const entries = footnotes.entries.map(entry => {
36
36
  const config = { path: undefined, fit: entry.fit, textStyle: entry.textStyle, fontFamily: entry.textStyle?.fontFamily ?? bound.design.fonts.body, fontSize: 13, align: "left", fill: bound.design.colors.mutedText, diagnosticsHandled: true, options: untraced, rich: Array.isArray(entry.value) };
37
37
  const body = Array.isArray(entry.value)
@@ -0,0 +1,41 @@
1
+ // RR-63: the native and heavy converters are optional peer dependencies, loaded the first time an export needs them. A host that
2
+ // only draws SVG (a browser, a server that never exports) installs none of them. Each loader names its package with a literal
3
+ // `import()` so a Node bundler or file tracer still sees it; nothing here is reachable from the browser entries
4
+ // (`/svg`, `/fonts-browser`, `/export-browser`), and `scripts/build-browser-check.mjs` asserts that.
5
+ import { OPFRenderError } from "./svg.js";
6
+
7
+ /** What each converter is for and the range the renderer is tested with (the same ranges as `peerDependencies` in package.json). */
8
+ export const CONVERTERS = Object.freeze({
9
+ "@resvg/resvg-js": { range: "^2.6.2", purpose: "PNG output, raster-mode PDF and the raster fallback of vector PDF", load: () => import("@resvg/resvg-js") },
10
+ sharp: { range: "^0.35.5", purpose: "decoding WebP and rotated JPEG pictures for PNG output, and pictures in vector PDF output", load: () => import("sharp") },
11
+ "pdf-lib": { range: "^1.17.1", purpose: "raster-mode PDF output (`mode: \"raster\"`)", load: () => import("pdf-lib") },
12
+ });
13
+
14
+ const cache = new Map();
15
+
16
+ /** The module namespace of a converter, or an `OPFRenderError` with code `converter-missing` that names the package to install. */
17
+ export function loadConverter(name) {
18
+ const converter = CONVERTERS[name];
19
+ if (!converter) throw new TypeError(`Unknown converter ${name}.`);
20
+ if (!cache.has(name)) {
21
+ const pending = converter.load().catch((error) => {
22
+ cache.delete(name);
23
+ throw converterMissing(name, converter, error);
24
+ });
25
+ cache.set(name, pending);
26
+ }
27
+ return cache.get(name);
28
+ }
29
+
30
+ function converterMissing(name, converter, error) {
31
+ const cause = error instanceof Error ? error.message : String(error);
32
+ const absent = (error?.code === "ERR_MODULE_NOT_FOUND" || error?.code === "MODULE_NOT_FOUND") && (cause.includes(`'${name}'`) || cause.includes(`"${name}"`));
33
+ const install = `npm install ${name}@${converter.range}`;
34
+ return new OPFRenderError("converter-missing", absent
35
+ ? `${name} is not installed. It is an optional peer dependency of @openpresentation/opf-render, used for ${converter.purpose}: run \`${install}\`.`
36
+ : `${name} is installed but could not be loaded (${cause}). It is used for ${converter.purpose}: reinstall it with \`${install}\` for this platform.`,
37
+ { package: name, range: converter.range, install, purpose: converter.purpose, installed: !absent, cause });
38
+ }
39
+
40
+ /** True for the error `loadConverter` raises: callers that wrap errors let it through unchanged. */
41
+ export const isConverterMissing = (error) => error?.code === "converter-missing";
@@ -10,6 +10,7 @@ export class DeckError extends Error {
10
10
  super(message, options);
11
11
  this.name = "DeckError";
12
12
  this.code = code;
13
+ if (options?.source) this.details = {source: options.source, status: options.status, contentType: options.contentType};
13
14
  }
14
15
  }
15
16
 
@@ -117,15 +118,17 @@ export function svgDimensions(svg) {
117
118
 
118
119
  /**
119
120
  * The renderer's SVG made responsive and readable: the fixed `width` and `height` go (the viewBox scales it), and the root's
120
- * `role="img"` plus `aria-label` go too, so the slide's own text nodes reach a screen reader in document order, which is the
121
- * reading order (title, then content, then furniture) the renderer paints. The slide section carries the accessible name.
121
+ * labelled-container attributes (`role="group"`, `aria-roledescription="slide"`, `aria-label`) go too, because the slide section or figure around it
122
+ * carries the one accessible name, so a slide is announced once. The slide's own text nodes reach a screen reader in document order,
123
+ * which is the reading order (tag, title, content, footnotes, then header and footer) the renderer paints; purely decorative drawing
124
+ * keeps its `aria-hidden`.
122
125
  * `idPrefix` renames the ids the slide defines (gradients, patterns, clip paths) so a thumbnail of a slide and the slide
123
126
  * itself never share an id in one tree.
124
127
  */
125
128
  export function prepareSlideSvg(svg, { idPrefix } = {}) {
126
129
  const match = /^<svg\b[^>]*>/.exec(svg);
127
130
  if (!match) return svg;
128
- let root = match[0].replace(/\s(?:width|height|role|aria-label)="[^"]*"/g, "");
131
+ let root = match[0].replace(/\s(?:width|height|role|aria-roledescription|aria-label)="[^"]*"/g, "");
129
132
  root = root.replace(/^<svg\b/, '<svg class="opf-slide" preserveAspectRatio="xMidYMid meet" focusable="false"');
130
133
  let rest = svg.slice(match[0].length);
131
134
  if (idPrefix) {
package/dist/element.d.ts CHANGED
@@ -13,6 +13,10 @@ export interface OpfDeckErrorDetail {
13
13
  message: string;
14
14
  /** False when the deck still shows (for example the fonts did not load and the layout is estimated). */
15
15
  fatal: boolean;
16
+ /** URL, HTTP status and response content type when a fetched deck fails. */
17
+ source?: string;
18
+ status?: number;
19
+ contentType?: string;
16
20
  }
17
21
  export interface OpfDeckEventMap {
18
22
  ready: CustomEvent<{ total: number; slide: number }>;
@@ -71,6 +75,8 @@ export interface RenderDeckHtmlOptions {
71
75
  slide?: number;
72
76
  /** A string is the font root URL written to the tag's `fonts` attribute; an object (a `loadFonts()` handle, or any `RenderFonts`) draws the markup with those fonts and writes no attribute. */
73
77
  fonts?: string | RenderFonts;
78
+ /** Font delivery for server markup: standalone embeds faces per slide (default); shared embeds each rule once; external omits rules and requires matching host CSS. All use the supplied handle for measurement. */
79
+ fontMode?: "standalone" | "shared" | "external";
74
80
  label?: string;
75
81
  thumbnails?: boolean;
76
82
  present?: boolean;
package/dist/element.js CHANGED
@@ -168,19 +168,22 @@ function createElementClass() {
168
168
  /** Open the full-screen slideshow (needs a user gesture to enter full screen). Loads the player on first use. */
169
169
  async present(options = {}) {
170
170
  if (this.#session && !this.#session.closed) return this.#session;
171
+ // Capture before loading the player: activeElement in the document is the shadow host, not its invoking button.
172
+ let returnFocus = options.returnFocus ?? this.ownerDocument.activeElement;
173
+ if (!options.returnFocus) while (returnFocus?.shadowRoot?.activeElement) returnFocus = returnFocus.shadowRoot.activeElement;
171
174
  await this.#readyPromise;
172
175
  if (!this.#store || !this.#cursor) throw new DeckError("not-ready", "The deck is not loaded yet.");
173
176
  const { present } = await import("./player.js");
174
177
  const session = await present(this.#deck, {
175
178
  store: this.#store, fonts: this.#fonts ?? this.#resolvedFonts, renderOptions: this.#renderOptions,
176
- includeHidden: this.includeHidden, startSlide: this.#cursor.number, returnFocus: this.#parts.viewport, ...options,
179
+ includeHidden: this.includeHidden, startSlide: this.#cursor.number, ...options, returnFocus,
177
180
  });
178
181
  this.#session = session;
179
182
  session.addEventListener("slidechange", (event) => { if (this.#cursor && event.detail?.index !== undefined && this.#cursor.gotoIndex(event.detail.index)) { this.#show({ announce: false }); this.#emitChange(); } });
180
183
  session.addEventListener("close", () => {
181
184
  if (this.#session === session) this.#session = undefined;
182
185
  this.#show({ announce: false });
183
- this.#parts.viewport?.focus({ preventScroll: true });
186
+ if (!returnFocus?.isConnected) this.#parts.viewport?.focus({ preventScroll: true });
184
187
  this.dispatchEvent(new CustomEvent("presentend", { detail: { slide: this.#cursor?.number } }));
185
188
  });
186
189
  this.dispatchEvent(new CustomEvent("presentstart", { detail: { slide: this.#cursor?.number } }));
@@ -253,9 +256,15 @@ function createElementClass() {
253
256
  if (this.#documentValue !== undefined) return parseDeckDocument(this.#documentValue);
254
257
  const src = this.getAttribute("src");
255
258
  if (src) {
256
- const response = await fetch(new URL(src, this.ownerDocument.baseURI), { signal, credentials: "same-origin" });
257
- if (!response.ok) throw new DeckError("fetch-failed", `The deck could not be loaded (${response.status}).`);
258
- return parseDeckDocument(await response.text());
259
+ const source = new URL(src, this.ownerDocument.baseURI).href;
260
+ const response = await fetch(source, { signal, credentials: "same-origin" });
261
+ const details = {source, status: response.status, contentType: response.headers.get("content-type") ?? ""};
262
+ if (!response.ok) throw new DeckError("fetch-failed", `The deck could not be loaded (${response.status}) from ${source}.`, details);
263
+ const text = await response.text();
264
+ try { return parseDeckDocument(text); } catch (cause) {
265
+ const hint = /^\s*(?:<!doctype\s+html|<html\b|<head\b|<body\b)/i.test(text) ? " The response is HTML; check the deck URL and the host's SPA fallback route." : "";
266
+ throw new DeckError("invalid-document", `Invalid OPF from ${source} (${details.contentType || "unknown content type"}): ${cause.message}${hint}`, {...details, cause});
267
+ }
259
268
  }
260
269
  const inline = this.querySelector(':scope > script[type="application/opf+json"], :scope > script[type="application/json"]');
261
270
  if (inline) return parseDeckDocument(inline.textContent ?? "");
@@ -263,7 +272,8 @@ function createElementClass() {
263
272
  }
264
273
 
265
274
  #report(error, fatal) {
266
- this.dispatchEvent(new CustomEvent("error", { detail: { code: error.code, message: error.message, fatal } }));
275
+ const {source, status, contentType} = error.details ?? {};
276
+ this.dispatchEvent(new CustomEvent("error", { detail: { code: error.code, message: error.message, fatal, ...(source ? {source, status, contentType} : {}) } }));
267
277
  }
268
278
 
269
279
  #prefetch() {
@@ -504,6 +514,8 @@ const escapeHtml = (value) => String(value).replace(/&/g, "&amp;").replace(/</g,
504
514
  *
505
515
  * `slides`: `"first"` (default: the first slide plus a list of titles), `"all"` or a list of 1-based slide numbers.
506
516
  * `embed` puts the document in an `application/opf+json` script child, so the element upgrades without a request.
517
+ * `fontMode`: `"standalone"` (default, embeds per slide), `"shared"` (one set of font rules in the returned tag), or
518
+ * `"external"` (no font rules; the host must supply CSS with the exact pinned faces the handle measures).
507
519
  */
508
520
  export function renderDeckHtml(input, options = {}) {
509
521
  const deck = parseDeckDocument(input);
@@ -512,13 +524,23 @@ export function renderDeckHtml(input, options = {}) {
512
524
  const wanted = options.slides ?? "first";
513
525
  const shown = wanted === "all" ? sequence.map((_, position) => position) : wanted === "first" ? [0] : wanted.map((number) => number - 1).filter((position) => position >= 0 && position < sequence.length);
514
526
  const handle = options.fonts !== null && typeof options.fonts === "object" ? options.fonts : undefined;
527
+ const fontMode = options.fontMode ?? "standalone";
528
+ if (!["standalone", "shared", "external"].includes(fontMode)) throw new DeckError("invalid-font-mode", "Choose standalone, shared or external SSR fonts.");
529
+ // Every mode measures with the same handle; external writes no @font-face data (RR-61 `embedFonts: false`).
530
+ const renderFonts = handle ?? options.renderOptions?.fonts;
531
+ const sharedRules = new Set();
515
532
  const attributes = { src: options.src, slide: options.slide, fonts: handle ? undefined : options.fonts, label: options.label, ...options.attributes };
516
533
  const attributeText = Object.entries(attributes).filter(([, value]) => value !== undefined && value !== false).map(([name, value]) => (value === true ? ` ${name}` : ` ${name}="${escapeHtml(value)}"`)).join("")
517
534
  + (options.thumbnails ? " thumbnails" : "") + (options.present ? " present" : "") + (options.includeHidden ? " include-hidden" : "");
518
535
  const date = options.renderOptions?.date ?? options.date;
519
536
  const figures = shown.map((position) => {
520
537
  const index = sequence[position];
521
- const svg = renderSlideSvg(deck, index, { ...options.renderOptions, ...(handle ? { fonts: handle } : {}), ...(date ? { date } : {}) });
538
+ let svg = renderSlideSvg(deck, index, { ...options.renderOptions, fonts: renderFonts, ...(fontMode === "external" ? { embedFonts: false } : {}), ...(date ? { date } : {}) });
539
+ if (fontMode === "shared") svg = svg.replace(/<style>(@font-face[\s\S]*?)<\/style>/g, (_, css) => {
540
+ // These rules are produced by renderSlideSvg after its used-face selection and validation.
541
+ for (const rule of css.split("\n")) sharedRules.add(rule);
542
+ return "";
543
+ });
522
544
  const { width, height } = svgDimensions(svg);
523
545
  const prepared = prepareSlideSvg(svg).replace(/^<svg\b/, `<svg style="display:block;width:100%;height:auto;aspect-ratio:${width} / ${height}"`);
524
546
  const label = slideLabel(slideInfo(deck, index), position + 1, sequence.length);
@@ -527,5 +549,6 @@ export function renderDeckHtml(input, options = {}) {
527
549
  const titles = wanted === "first" && sequence.length > 1
528
550
  ? `<nav aria-label="Slides"><ol>${sequence.map((index) => `<li>${escapeHtml(slideInfo(deck, index).title)}</li>`).join("")}</ol></nav>` : "";
529
551
  const embedded = options.embed ? `<script type="application/opf+json">${JSON.stringify(deck).replace(/</g, "\\u003c").replace(/\u2028/g, "\\u2028").replace(/\u2029/g, "\\u2029")}</script>` : "";
530
- return `<${tag}${attributeText}>${figures.join("")}${titles}${embedded}</${tag}>`;
552
+ const shared = sharedRules.size ? `<style>${[...sharedRules].join("\n")}</style>` : "";
553
+ return `<${tag}${attributeText}>${shared}${figures.join("")}${titles}${embedded}</${tag}>`;
531
554
  }
@@ -13,6 +13,12 @@ export interface BrowserSvgToPngOptions {
13
13
  export interface BrowserSvgToPdfOptions extends Pick<SvgToPdfOptions, "mode" | "background" | "serifFamily" | "metadata" | "tagged" | "compress" | "strict" | "rasterFallbackScale" | "defaultFontFamily" | "sansSerifFamily" | "monospaceFamily" | "onDiagnostic"> {
14
14
  /** Raster mode: pixel density of each page image (default 2). */
15
15
  scale?: number;
16
+ /**
17
+ * Raster mode only: the pdf-lib module (`import * as pdfLib from "pdf-lib"`), an optional peer of the renderer that this entry never imports itself, so a
18
+ * bundle of it holds no PDF library. Without it raster mode tries to import "pdf-lib" by name and rejects with `OPFRenderError` `converter-missing`
19
+ * when the page cannot. The default vector mode needs no option.
20
+ */
21
+ pdfLib?: { PDFDocument: { create(options?: { updateMetadata?: boolean }): Promise<any> } };
16
22
  /**
17
23
  * The browser fonts handle (`loadFonts` from `/fonts-browser`): every face it holds can be embedded in the PDF, including script faces
18
24
  * a standalone SVG does not carry. Faces an SVG embeds as `@font-face` data (the `embeddedFonts` of the handle `renderSvg` was given) need no entry.
@@ -8,6 +8,9 @@ import { OPFRenderError, packageName } from "./svg.js";
8
8
  // to hand one over) and nothing is fetched: faces come from the SVG's own @font-face data (the `embeddedFonts` of the fonts handle
9
9
  // `renderSvg` was given), from the faces the `fonts` handle holds (`loadFonts` from `/fonts-browser`, so script faces the SVG does not
10
10
  // embed reach the PDF too), or from `fontData`.
11
+ //
12
+ // RR-63: this entry imports no converter. The one optional piece is pdf-lib, for `mode: "raster"` only (the default vector PDF
13
+ // needs nothing): the host imports it and passes it as `options.pdfLib`, so a bundler never has to resolve it.
11
14
 
12
15
  const DEFAULT_DIMENSIONS = { width: 1280, height: 720 };
13
16
  const MAX_PIXELS = 40_000_000;
@@ -32,7 +35,7 @@ export async function svgToPng(svg, options = {}) {
32
35
  * Convert SVG slides to a PDF, one slide per page (1 SVG pixel is 1 PDF point, as in Node). `mode: "vector"` (default) writes
33
36
  * real text, paths, gradients and images; `"raster"` draws each slide as an image (`scale`, default 2). Takes the options of
34
37
  * the Node `svgToPdf` that make sense here (`metadata`, `tagged`, `strict`, `compress`, `onDiagnostic`, the generic family
35
- * names, `rasterFallbackScale`), `fonts` (the handle `loadFonts()` returns: every face it holds can be embedded), `fontData`
38
+ * names, `rasterFallbackScale`), `pdfLib` (raster mode only: `import * as pdfLib from "pdf-lib"`), `fonts` (the handle `loadFonts()` returns: every face it holds can be embedded), `fontData`
36
39
  * (`[{ data, family? }]`, extra face bytes without a handle), `signal` (an AbortSignal, checked between pages) and
37
40
  * `onProgress({ page, pages })`.
38
41
  */
@@ -70,7 +73,7 @@ export async function svgToPdf(svgs, options = {}) {
70
73
  }
71
74
 
72
75
  async function rasterPdf(inputs, options) {
73
- const { PDFDocument } = await import("pdf-lib");
76
+ const { PDFDocument } = options.pdfLib ?? await importPdfLib();
74
77
  const pdf = await PDFDocument.create({ updateMetadata: false });
75
78
  pdf.setCreator(packageName);
76
79
  pdf.setProducer(packageName);
@@ -87,6 +90,17 @@ async function rasterPdf(inputs, options) {
87
90
  return pdf.save({ addDefaultPage: false, useObjectStreams: false });
88
91
  }
89
92
 
93
+ // pdf-lib for raster mode when the host did not pass it. The specifier is not a literal, so a browser bundler leaves it alone (and
94
+ // never fails for a host that does not install pdf-lib); it resolves where the page can import it by name, for example with an import map.
95
+ async function importPdfLib() {
96
+ const name = "pdf-lib";
97
+ try {
98
+ return await import(/* webpackIgnore: true */ /* @vite-ignore */ name);
99
+ } catch (error) {
100
+ throw new OPFRenderError("converter-missing", 'Raster-mode PDF output needs pdf-lib, an optional peer dependency of @openpresentation/opf-render: run `npm install pdf-lib@^1.17.1` and pass it as the pdfLib option (import * as pdfLib from "pdf-lib"). The default vector mode needs nothing.', { package: "pdf-lib", range: "^1.17.1", install: "npm install pdf-lib@^1.17.1", cause: error instanceof Error ? error.message : String(error) });
101
+ }
102
+ }
103
+
90
104
  // ---------------------------------------------------------------------------------------------------------------------------
91
105
  // Canvas
92
106
 
@@ -295,8 +295,8 @@ export function createFontRegistry(entries, options = {}) {
295
295
  resolveFont(style) { return resolve(style).resolution; },
296
296
  clearSubstitutions() { substitutions.clear(); },
297
297
  get substitutions() { return [...substitutions.values()]; },
298
- // Faces flagged embed:"used" (the open pack, FF-31) are not in this eager list: supplied with selectEmbeddedFonts (or
299
- // the `loadFonts` handle's embeddedFonts) they are embedded only in SVGs whose text names their family.
298
+ // Faces flagged embed:"used" (the open pack, FF-31) are not in this eager list: they come with selectEmbeddedFonts (or the
299
+ // `loadFonts` handle's embeddedFonts). Whichever list it is given, the SVG writer embeds a face only where its text draws it (RR-61).
300
300
  get embeddedFonts() { return embedded(face=>face.embed!=="used"); },
301
301
  /** Parsed face metadata in entry order, without encoding font bytes. */
302
302
  describeFaces: ()=>faces.map(face=>({family:face.family,weight:face.weight,italic:face.italic,...(face.scripts?{scripts:[...face.scripts]}:{}),...(face.fallbackOnly?{fallbackOnly:true}:{})})),
@@ -320,5 +320,5 @@ export function createFontRegistry(entries, options = {}) {
320
320
  return made.map(face=>({family:face.family,weight:face.weight,italic:face.italic,...(face.scripts?{scripts:[...face.scripts]}:{})}));
321
321
  },
322
322
  };
323
- function embedded(predicate) { return faces.filter(face=>predicate({family:face.family,weight:face.weight,italic:face.italic,scripts:face.scripts,embed:face.embed,fallbackOnly:face.fallbackOnly})).map(face=>({family:face.family,weight:face.weight,italic:face.italic,...(face.license ? {license:face.license} : {}),...(face.embed==="used" ? {embed:"used"} : {}),dataUrl:`data:font/${face.format};base64,${base64(face.data)}`})); }
323
+ function embedded(predicate) { return faces.filter(face=>predicate({family:face.family,weight:face.weight,italic:face.italic,scripts:face.scripts,embed:face.embed,fallbackOnly:face.fallbackOnly})).map(face=>({family:face.family,weight:face.weight,italic:face.italic,...(face.license ? {license:face.license} : {}),...(face.embed ? {embed:face.embed} : {}),dataUrl:`data:font/${face.format};base64,${base64(face.data)}`})); }
324
324
  }