@openpresentation/opf-render 0.17.1 → 0.18.1
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 +157 -70
- package/dist/deck-runtime.js +4 -4
- package/dist/element.d.ts +12 -9
- package/dist/element.js +28 -19
- package/dist/export-browser.d.ts +21 -11
- package/dist/export-browser.js +35 -20
- package/dist/font-manifest.js +17 -17
- package/dist/font-registry.js +31 -0
- package/dist/font-subset.js +1 -1
- package/dist/fonts-browser.d.ts +26 -25
- package/dist/fonts-browser.js +12 -12
- package/dist/fonts-node.d.ts +15 -11
- package/dist/fonts-node.js +24 -21
- package/dist/fonts.d.ts +14 -7
- package/dist/hb-shape.js +16 -6
- package/dist/index.d.ts +51 -30
- package/dist/index.js +1 -1
- package/dist/lazy-fonts.js +10 -10
- package/dist/node-resolve.js +24 -0
- package/dist/pdf-fonts.js +1 -1
- package/dist/pdf-text.js +4 -4
- package/dist/pdf-vector.js +15 -5
- package/dist/pdf-writer.js +5 -2
- package/dist/pdf.d.ts +2 -2
- package/dist/pdf.js +3 -3
- package/dist/player.d.ts +3 -3
- package/dist/player.js +9 -3
- package/dist/png.d.ts +2 -2
- package/dist/png.js +2 -2
- package/dist/preview-fonts-node.js +3 -6
- package/dist/raster-text.js +33 -2
- package/dist/raster.js +47 -38
- package/dist/script-font-pack.js +5 -5
- package/dist/script-fonts.js +4 -2
- package/dist/slide-sources.js +47 -0
- package/dist/svg.d.ts +24 -29
- package/dist/svg.js +50 -31
- package/dist/text-paths.js +16 -4
- package/package.json +23 -20
package/README.md
CHANGED
|
@@ -1,8 +1,20 @@
|
|
|
1
1
|
# OPF Render
|
|
2
2
|
|
|
3
|
+
**OPF 0.18 (RR-73, RR-74, unreleased; requires core 0.18).** One shape for every engine: `toX(deck, slides?, options?)`. The second argument is a slide number (one result), a selection (`"1-3"`, `"1,3-5"`, `"2-"`, `[1, 3]`; a list of results) or the options, told apart by type, and slides count from 1. Renamed and removed outright (no aliases); an old option fails with `invalid-render-options` or `invalid-conversion-option` and names its replacement:
|
|
4
|
+
|
|
5
|
+
| 0.17 | 0.18 |
|
|
6
|
+
| --- | --- |
|
|
7
|
+
| `renderSvg(deck, options)` | `toSvg(deck, options)` |
|
|
8
|
+
| `renderSlideSvg(deck, index, options)` (from 0) | `toSvg(deck, index + 1, options)` (from 1; `slide-out-of-range`) |
|
|
9
|
+
| `svgToPng(svg, options)`, `svgToPdf(svgs, options)` | `toPng` and `toPdf`: they take a deck (and draw it with `toSvg`) or SVG |
|
|
10
|
+
| `renderDeckHtml(deck, { slides })` | `toHtml(deck, slides?, options?)` (`"1-"` for every slide) |
|
|
11
|
+
| `embedFonts: false`, `textAsPaths: true` | `text: "system"`, `text: "paths"` (default `"fonts"`) |
|
|
12
|
+
| `mode: "raster"` | `raster: true` (vector is the default) |
|
|
13
|
+
| `fontDirs` | `fonts`: the handle, or font folders (a path or a list) for SVG input |
|
|
14
|
+
|
|
3
15
|
**OPF 0.15 (FA-23, unreleased; requires core 0.15).** Catalogs are registered by the host, and every picture is an image background or an image block. The breaking changes are listed in `changes/fa-23-opf-0-15.md` (the next CHANGELOG section); the [0.15 design](https://github.com/OpenPresentation/opf/blob/main/docs/programs/format-audit/0.15-design.md) explains them.
|
|
4
16
|
|
|
5
|
-
- The renderer bundles, looks up and fetches no catalog. Every entry point (`
|
|
17
|
+
- The renderer bundles, looks up and fetches no catalog. Every entry point (`toSvg`, `resolvePresentation`, the font loaders' `renderOptions`, `/player`, `<opf-deck>`) takes core's `catalogs: Catalog[]` and passes it unchanged to core resolution. Register the gallery snapshot yourself: `import { defaultCatalog } from '@openpresentation/opf/catalog'` and `toSvg(deck, { catalogs: [defaultCatalog] })`. `strictReferences: true` fails with `unresolved-reference` instead of falling back.
|
|
6
18
|
- What resolves nowhere draws with core's engine defaults (`engineDefaults` is `{ theme, colorScheme, fontScheme, chartType }`: core's `ENGINE_DEFAULT_THEME`, `ENGINE_DEFAULT_COLOR_SCHEME`, `ENGINE_DEFAULT_FONT_SCHEME`) and is reported once per reference as `unresolved-reference`.
|
|
7
19
|
- 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
20
|
- 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.
|
|
@@ -19,16 +31,16 @@
|
|
|
19
31
|
|
|
20
32
|
```js
|
|
21
33
|
import { loadFonts } from '@openpresentation/opf-render/fonts-node';
|
|
22
|
-
import {
|
|
34
|
+
import { toSvg, toPng, toPdf } from '@openpresentation/opf-render';
|
|
23
35
|
import { paginate } from '@openpresentation/opf/pagination';
|
|
24
36
|
import { toPptx } from '@openpresentation/opf-pptx';
|
|
25
37
|
|
|
26
38
|
const fonts = await loadFonts({ pack: 'office', substitutionPolicy: 'visual' });
|
|
27
39
|
const { presentation } = paginate(opf, { fonts });
|
|
28
|
-
const slides =
|
|
29
|
-
const first =
|
|
30
|
-
const png = await
|
|
31
|
-
const pdf = await
|
|
40
|
+
const slides = toSvg(presentation, { fonts }); // every slide
|
|
41
|
+
const first = toSvg(presentation, 1, { fonts }); // one slide
|
|
42
|
+
const png = await toPng(slides[0], { fonts });
|
|
43
|
+
const pdf = await toPdf(slides, { fonts });
|
|
32
44
|
const pptx = await toPptx(presentation, { fonts });
|
|
33
45
|
```
|
|
34
46
|
|
|
@@ -36,9 +48,9 @@ A slide's canvas, layout, theme, colour scheme and font scheme come from core's
|
|
|
36
48
|
|
|
37
49
|
An option a function does not know is not read: a top-level `textMeasurement` or `embeddedFonts` is ignored, so pass `{ fonts }`.
|
|
38
50
|
|
|
39
|
-
**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 `
|
|
51
|
+
**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 `toSvg` 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 `toSvg` 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.
|
|
40
52
|
|
|
41
|
-
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 `
|
|
53
|
+
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 `toSvg` 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, 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.
|
|
42
54
|
|
|
43
55
|
Version 0.9.0 required `@openpresentation/opf` ^0.11.0 and resolved content ColorRef / `variables` through core `resolveColorRef()`. Authored `#RRGGBB` paint stays authored. It retains the existing rendering APIs.
|
|
44
56
|
|
|
@@ -64,7 +76,7 @@ Citations, footnotes and captions (core RR-34 fields `cite`, `footnote`, `refere
|
|
|
64
76
|
|
|
65
77
|
```js
|
|
66
78
|
import { loadFonts } from '@openpresentation/opf-render/fonts-node';
|
|
67
|
-
import {
|
|
79
|
+
import { toSvg, toPng } from '@openpresentation/opf-render';
|
|
68
80
|
import { paginate } from '@openpresentation/opf/pagination';
|
|
69
81
|
import { toPptx } from '@openpresentation/opf-pptx';
|
|
70
82
|
|
|
@@ -72,8 +84,8 @@ const fonts = await loadFonts({
|
|
|
72
84
|
pack: 'office', substitutionPolicy: 'visual',
|
|
73
85
|
});
|
|
74
86
|
const { presentation } = paginate(opf, { fonts });
|
|
75
|
-
const slides =
|
|
76
|
-
const png = await
|
|
87
|
+
const slides = toSvg(presentation, { fonts });
|
|
88
|
+
const png = await toPng(slides[0], { fonts });
|
|
77
89
|
const pptx = await toPptx(presentation, { fonts });
|
|
78
90
|
console.log(fonts.substitutions);
|
|
79
91
|
```
|
|
@@ -86,6 +98,31 @@ Version 0.8.0 resolves OpenType preferred-family groups, so a Roboto request at
|
|
|
86
98
|
|
|
87
99
|
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.
|
|
88
100
|
|
|
101
|
+
## Pass `fonts`, otherwise the estimate
|
|
102
|
+
|
|
103
|
+
Text measurement comes from one place: the `fonts` handle that `loadFonts()` (from `/fonts-node` or `/fonts-browser`) returns. Pass the same handle to preview, validation, pagination and export, so that every step measures with the faces the preview draws:
|
|
104
|
+
|
|
105
|
+
```js
|
|
106
|
+
import { loadFonts } from '@openpresentation/opf-render/fonts-node';
|
|
107
|
+
import { toSvg, toPng, toPdf } from '@openpresentation/opf-render';
|
|
108
|
+
import { validate } from '@openpresentation/opf';
|
|
109
|
+
import { paginate } from '@openpresentation/opf/pagination';
|
|
110
|
+
import { toPptx } from '@openpresentation/opf-pptx';
|
|
111
|
+
|
|
112
|
+
const fonts = await loadFonts({ pack: 'office' });
|
|
113
|
+
const report = validate(deck, { fonts }); // overflow findings use the real widths
|
|
114
|
+
const { presentation } = paginate(deck, { fonts }); // page breaks use the real widths
|
|
115
|
+
const svgs = toSvg(presentation, { fonts }); // line breaks use the real widths
|
|
116
|
+
const png = await toPng(presentation, 1, { fonts });
|
|
117
|
+
const pdf = await toPdf(presentation, { fonts });
|
|
118
|
+
const pptx = await toPptx(presentation, { fonts }); // the same lines in editable PowerPoint text
|
|
119
|
+
```
|
|
120
|
+
|
|
121
|
+
One handle carries the measurement for core's `validate`, `paginate` and slide context, for `toSvg`, `toPng` and `toPdf`, and for opf-pptx's `toPptx`. The same handle gives identical text geometry across those engines: every engine reads core's one composition, so the measurement decides the lines. [opf-pptx's `layout-parity` test](https://github.com/OpenPresentation/opf-pptx/blob/main/test/layout-parity.mjs) checks that the exported PowerPoint paragraphs agree with this renderer's SVG and with core's composed items, with and without `fonts`.
|
|
122
|
+
|
|
123
|
+
A call without `fonts` is not an error. It uses core's built-in estimate instead of measured widths: 0.54 em per character, 0.62 em for capitals and digits, 0.32 em for a space, 1 em for CJK characters and zero for combining marks. The estimate is deterministic and needs no font files, but it is too narrow for some scripts (opf#566 tracks improving it), so line breaks, overflow findings and page breaks from an estimated run can differ from what the faces actually draw. Treat an estimated preview as a draft. Core's Node `convert` and the `opf` CLI prepare an office-pack handle for you; a library call to `toSvg`, `toPng` or `toPdf` never loads fonts on its own, so pass the handle you want. Compare [opf#364](https://github.com/OpenPresentation/opf/issues/364).
|
|
124
|
+
|
|
125
|
+
|
|
89
126
|
## Install: what to add for what (RR-63)
|
|
90
127
|
|
|
91
128
|
`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:
|
|
@@ -97,8 +134,8 @@ Code strings that [XML 1.0 cannot represent](https://www.w3.org/TR/xml/#charsets
|
|
|
97
134
|
| SVG in Node with exact text measurement | `/svg` and `loadFonts` from `/fonts-node` | the font packages of the pack (below) |
|
|
98
135
|
| 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
136
|
| 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, `
|
|
101
|
-
| PNG and PDF in a browser | `/export-browser` | nothing (`
|
|
137
|
+
| PDF in Node, `raster: true` | `/pdf` (or the root) | `pdf-lib`, `@resvg/resvg-js`, `sharp` and the fonts of the pack |
|
|
138
|
+
| PNG and PDF in a browser | `/export-browser` | nothing (`raster: true` needs `pdf-lib`, see below) |
|
|
102
139
|
| Slideshow, `<opf-deck>`, server markup | `/player`, `/element`, `/element/define` | nothing |
|
|
103
140
|
|
|
104
141
|
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.
|
|
@@ -109,30 +146,32 @@ Converter ranges: `@resvg/resvg-js@^2.6.2`, `sharp@^0.35.5`, `pdf-lib@^1.17.1`.
|
|
|
109
146
|
|
|
110
147
|
| Entry | What it is | Runs in |
|
|
111
148
|
| --- | --- | --- |
|
|
112
|
-
| `@openpresentation/opf-render/svg` | `
|
|
113
|
-
| `@openpresentation/opf-render/png` | `
|
|
114
|
-
| `@openpresentation/opf-render/pdf` | `
|
|
115
|
-
| `@openpresentation/opf-render/export-browser` | `
|
|
116
|
-
| `@openpresentation/opf-render/element`, `/element/define`, `/player` | `
|
|
117
|
-
| `@openpresentation/opf-render` | all of `/svg`, plus `
|
|
149
|
+
| `@openpresentation/opf-render/svg` | `toSvg`, `resolvePresentation`, `OPFRenderError` and the types. No converter, no Node module. | browser and Node |
|
|
150
|
+
| `@openpresentation/opf-render/png` | `toPng` | Node |
|
|
151
|
+
| `@openpresentation/opf-render/pdf` | `toPdf` | Node |
|
|
152
|
+
| `@openpresentation/opf-render/export-browser` | `toPng` and `toPdf` for a page (canvas) | browser |
|
|
153
|
+
| `@openpresentation/opf-render/element`, `/element/define`, `/player` | `toHtml`, `<opf-deck>`, `present` (the HTML output) | browser (`toHtml` also on a server) |
|
|
154
|
+
| `@openpresentation/opf-render` | all of `/svg`, plus `toPng` and `toPdf` | Node (a browser bundler picks `/svg`) |
|
|
118
155
|
|
|
119
|
-
There is no `/html` entry: it would only repeat `/element` (`
|
|
156
|
+
There is no `/html` entry: it would only repeat `/element` (`toHtml`, `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.
|
|
157
|
+
|
|
158
|
+
A server bundle needs no bundler config for `/fonts-node` and `/preview-fonts-node` either: a Next.js app (Turbopack or webpack) that imports them builds and renders what plain Node renders (`npm run test:next-bundle` checks this against Next.js 16). They read harfbuzzjs's `.wasm` files, the installed `@expo-google-fonts/*` packages and this package's `fonts/` directory from disk at run time, through Node's own resolution, so a bundler does not trace them. A deployment that ships only traced files (Next.js `output: "standalone"`, serverless functions) must include those packages, for example with `outputFileTracingIncludes`. Without harfbuzzjs, `loadFonts` still loads the fonts and reports `harfbuzz-unavailable` through `onDiagnostic`: an SVG then embeds whole faces, and outlines are shaped with fontkit.
|
|
120
159
|
|
|
121
160
|
### A missing converter or font package
|
|
122
161
|
|
|
123
162
|
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
163
|
|
|
125
164
|
```js
|
|
126
|
-
try { await
|
|
165
|
+
try { await toPng(svg); }
|
|
127
166
|
catch (error) {
|
|
128
167
|
// error.code === 'converter-missing'
|
|
129
168
|
// 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
169
|
}
|
|
131
170
|
```
|
|
132
171
|
|
|
133
|
-
`
|
|
172
|
+
`toPng` needs `@resvg/resvg-js`; a WebP or rotated JPEG picture in it also needs `sharp`. `toPdf` (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`); `raster: true` 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
173
|
|
|
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:
|
|
174
|
+
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: a raster PDF takes the module as an option (`toPdf(svgs, { raster: true, pdfLib })` with `import * as pdfLib from 'pdf-lib'`), so a bundle of the entry holds no PDF library; without it a raster PDF rejects with `converter-missing`.
|
|
136
175
|
|
|
137
176
|
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
177
|
|
|
@@ -149,23 +188,23 @@ Installed size, measured on Windows with `npm install --ignore-scripts`, unpacke
|
|
|
149
188
|
- License: MIT
|
|
150
189
|
- Compatibility target: `@openpresentation/opf`
|
|
151
190
|
- 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))
|
|
152
|
-
- Public API: `
|
|
191
|
+
- Public API: `toSvg(opf, slides?, opts)` (every slide, one slide or a selection), `resolvePresentation(opf, opts)`, `toPng(opf or svg, slides?, opts)`, `toPdf(opf or svgs, slides?, opts)` and `loadFonts(opts)` (`/fonts-node`, `/fonts-browser`)
|
|
153
192
|
- 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)
|
|
154
193
|
|
|
155
|
-
`
|
|
194
|
+
`toSvg(deck)` 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); `toSvg(deck, 3)` returns the SVG of the third slide (slides count from 1; out of range throws `slide-out-of-range`), and `toSvg(deck, "1-3")` or `toSvg(deck, [1, 3])` the selected slides, hidden or not (`invalid-slide-selection` for a malformed one). Every form checks 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. Each embedded face is also cut to the glyphs the slide draws (RR-65): the Node handle (and a browser handle given `subsetWasm`, the URL of `harfbuzzjs/dist/harfbuzz-subset.wasm` the host serves, or its bytes or compiled module) subsets each face with HarfBuzz's own subsetter (hb-subset from the pinned harfbuzzjs, MIT), keeping every layout feature, so the browser shapes and draws it exactly like the whole face (147 example slides with 384 embedded faces are pixel-identical in a browser) and the bytes are the same for the same slide. Over the 127 core example decks an office-pack slide falls from 2.4 MB to 59 KB on average (2.85 MB to 59 KB at the median, 1.95 GB to 48 MB for all 807 slides), a base-pack slide from 530 KB to 73 KB. Only a face whose license allows a modified version is cut: a bundled face (by sha256 against the pinned manifest) under a permitted license whose family and file names do not contain a Reserved Font Name of its package (a subset is a modified version, owner decision 2026-09-29) and whose OS/2 fsType allows subsetting; Carlito, Raleway, Lora, Playfair Display, PT Serif, Merriweather Sans, Source Sans 3 and every face a host supplies are embedded whole. `subsetFonts: false` embeds whole faces (for an SVG whose text will be edited outside the renderer). A face whose OS/2 fsType forbids embedding (Restricted License 0x0002, or bitmap-only 0x0200, since an SVG carries outlines) is never embedded (RR-76): the SVG names its family without the data, so a viewer without the font draws the generic family, and a `font-embedding-restricted` diagnostic lists the faces left out. `renderDeckHtml` with `fontMode: "shared"` embeds each face once for the page, cut to the characters all of its slides draw (a three-slide Roboto page: 53 KB, against 101 KB with per-slide subsets and 442 KB with whole shared faces). `text: "system"` 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
195
|
|
|
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 `
|
|
196
|
+
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 `toHtml` 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 (not tested with assistive technology; see Accessibility under `<opf-deck>`).
|
|
158
197
|
|
|
159
|
-
Text as outlines (RR-64): `
|
|
198
|
+
Text as outlines (RR-64): `toSvg(deck, { fonts, text: "paths" })` draws every `<text>` as glyph outlines, so the SVG needs no font and looks the same in every browser, image viewer and design tool. Each glyph is a `<use>` of an outline kept once per slide in `<defs>` (in font units, under an id made of the face's content hash and the glyph id, so SVGs inlined on one page never mix up glyphs); positions and widths are the vector PDF's layout of the renderer's pinned lines, and the glyphs are shaped by HarfBuzz, the shaper browsers use (the Node handle always; a browser handle given `shapeWasm`, the host's copy of `harfbuzzjs/dist/harfbuzz.wasm`; fontkit otherwise). Over the 30 script corpora every outlined glyph has HarfBuzz's id and position (fontkit, which the layout measures with, picks other glyphs on 10 samples: Bengali, Thai, Lao, Khmer, Myanmar, Syriac and Japanese combining marks); a run inside a pinned width is scaled to it, as the browser scales text. Over the core example decks resvg draws an outlined slide like its text (mean channel difference at most 0.006 of 255) and an outlined slide is about 40 KB at the median (60 KB at p90, 106 KB at most) against 0.5 to 4 MB with whole embedded faces. It needs the fonts handle `loadFonts()` returns, which carries the outline engine as `fonts.outlines` (`text-as-paths-needs-fonts` otherwise); the handle's faces are parsed for outlining once, on the first outlined slide. Each outlined element is a group that keeps the element's attributes and `data-opf-*` trace (a tspan's trace stays on a nested group); its glyphs are hidden from assistive technology and each outlined line is also real text, invisible (`fill="none"`, the generic family, so no face is embedded for it) and pinned to the drawn width, in reading order: a screen reader reads the words as text (the FA-30 tree, checked with axe), and selection, copy and find-in-page work. Links stay `<a>`; underline and line-through are rectangles from the font's own metrics. A run stays text where outlines would not be faithful, reported as `text-as-paths-fallback` with its `reason`: a colour or bitmap font (`colour-font`, emoji), a face whose fsType restricts embedding (`restricted`), or a run with no pinned width on which HarfBuzz and the layout's measurement disagree by more than 0.1 px (`shaping`); the SVG embeds just those runs' faces. The trade-offs, which make it the choice for thumbnails, previews and portable SVG files and not for an editing surface: the drawn text is not editable (a caret and an input method need live text in its own font); a vector PDF made from an outlined SVG has paths instead of selectable text, so `toPdf` should get the `<text>` SVG; browsers draw outlines without the hinting they give text, so small text looks a little lighter than browser text (resvg already draws text as outlines); and in `/fonts-browser` the outline engine adds about 85 KB (minified) to the bundle. Plan and later phases: [opf-render#164](https://github.com/OpenPresentation/opf-render/issues/164).
|
|
160
199
|
|
|
161
200
|
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).
|
|
162
201
|
|
|
163
202
|
```js
|
|
164
|
-
import {
|
|
203
|
+
import { toSvg } from "@openpresentation/opf-render";
|
|
165
204
|
import { loadFonts } from "@openpresentation/opf-render/fonts-node";
|
|
166
205
|
|
|
167
206
|
const fonts = await loadFonts({ pack: "office", substitutionPolicy: "visual" });
|
|
168
|
-
const svgs =
|
|
207
|
+
const svgs = toSvg(opf, { trace: true, fonts });
|
|
169
208
|
console.log(fonts.substitutions);
|
|
170
209
|
```
|
|
171
210
|
|
|
@@ -192,24 +231,24 @@ A placed block (`placement`) arrives with its edge band as its region, and headi
|
|
|
192
231
|
PNG and PDF conversion APIs are async because they load the local raster/PDF engines on demand:
|
|
193
232
|
|
|
194
233
|
```js
|
|
195
|
-
import {
|
|
234
|
+
import { toSvg, toPdf, toPng } from "@openpresentation/opf-render";
|
|
196
235
|
import { loadFonts } from "@openpresentation/opf-render/fonts-node";
|
|
197
236
|
|
|
198
237
|
const fonts = await loadFonts({ pack: "office", substitutionPolicy: "visual" });
|
|
199
|
-
const svgs =
|
|
200
|
-
const png = await
|
|
201
|
-
const pdf = await
|
|
238
|
+
const svgs = toSvg(opf, { fonts });
|
|
239
|
+
const png = await toPng(svgs[0], { fonts, scale: 1 });
|
|
240
|
+
const pdf = await toPdf(svgs, { fonts });
|
|
202
241
|
```
|
|
203
242
|
|
|
204
|
-
`
|
|
243
|
+
`toPng` returns PNG bytes for one slide or one SVG, and a list for a deck, a selection or a list of SVGs. `toPdf` takes a deck (every slide, or a selection: `toPdf(deck, "2-4")`), one SVG or a list of SVGs and returns PDF bytes with one slide per page. Given a deck, both draw it with `toSvg` and the same options (the fonts handle measures it); SVG input takes no selection. 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. `toPdf` is vector by default (see below); `raster: true` draws each slide as an image, the output of earlier releases.
|
|
205
244
|
|
|
206
245
|
### Vector PDF with selectable text
|
|
207
246
|
|
|
208
|
-
`
|
|
247
|
+
`toPdf(svgs)` converts the same SVG the preview draws, without a second layout pass: every line, position and width comes from the SVG.
|
|
209
248
|
|
|
210
249
|
- 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).
|
|
211
|
-
- 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
|
|
212
|
-
- Fonts: only the bundled pack (unless the handle says `useBundledFonts: false`), the handle's `fontFiles
|
|
250
|
+
- 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 HarfBuzz, the browser's shaper, shapes them: with the fonts handle `loadFonts()` returns, which carries the shaper as `fonts.outlines.shape`, or, without a handle, the bundled one the conversion loads; a browser handle needs `shapeWasm`; fontkit shapes otherwise. Over the 30 script corpora every glyph the PDF shows has HarfBuzz's id and position (opf-render#188), where fontkit drew other glyphs on 10 samples: Bengali, Thai, Lao, Khmer, Myanmar, Syriac and Japanese combining marks), 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).
|
|
251
|
+
- Fonts: only the bundled pack (unless the handle says `useBundledFonts: false`), the handle's `fontFiles` (or the font folders given as `fonts`) and the SVG's own `@font-face` data are used; a handle with `loadSystemFonts: true` is rejected for a vector PDF 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.
|
|
213
252
|
- 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.
|
|
214
253
|
- 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.
|
|
215
254
|
- 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).
|
|
@@ -219,24 +258,24 @@ const pdf = await svgToPdf(svgs, { fonts });
|
|
|
219
258
|
`onDiagnostic` also reports each embedded face (`pdf-font-embedded`: family, weight, glyph count, size, subset or full, `fsType`, license text), font substitution and per-character fallback. Output is byte-identical across runs and operating systems for the same SVG, font files and options.
|
|
220
259
|
|
|
221
260
|
```js
|
|
222
|
-
const pdf = await
|
|
261
|
+
const pdf = await toPdf(svgs, {
|
|
223
262
|
fontFiles: fonts.fontFiles, useBundledFonts: false,
|
|
224
263
|
metadata: { title: "Quarterly review", author: "Finance", language: "en-GB" },
|
|
225
264
|
onDiagnostic: (d) => d.code === "pdf-raster-fallback" && console.warn(d.path, d.reason),
|
|
226
265
|
});
|
|
227
266
|
```
|
|
228
267
|
|
|
229
|
-
The default changed from raster to vector in the release that added this section; callers that need the previous output pass `
|
|
268
|
+
The default changed from raster to vector in the release that added this section; callers that need the previous output pass `raster: true`. A vector PDF ignores `scale` and `dpi`. The page is painted white by default, as a raster PDF composites on white; `background: "none"` leaves it unpainted and any other colour paints that.
|
|
230
269
|
|
|
231
270
|
## PNG and PDF in a browser
|
|
232
271
|
|
|
233
|
-
`@openpresentation/opf-render/export-browser` exports `
|
|
272
|
+
`@openpresentation/opf-render/export-browser` exports `toPdf` and `toPng` for a page, with the arguments of the Node functions. They take a deck (drawn with the browser fonts handle) or 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).
|
|
234
273
|
|
|
235
|
-
- **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 `
|
|
274
|
+
- **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 `toSvg`; 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 `toPdf` (script faces an SVG does not carry reach the PDF too) or from `fontData: [{ data, family? }]`; `fontFiles` and font folders do not exist here and system fonts cannot be loaded. A face the handle never loaded is never embedded.
|
|
236
275
|
- **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`.
|
|
237
|
-
- **Raster PDF** (`
|
|
276
|
+
- **Raster PDF** (`raster: true`, `scale` default 2) draws each slide on a canvas as an image.
|
|
238
277
|
- **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).
|
|
239
|
-
- `signal` (an `AbortSignal`, checked between pages) and `onProgress({ page, pages })` serve large decks; the same two options also work in the Node `
|
|
278
|
+
- `signal` (an `AbortSignal`, checked between pages) and `onProgress({ page, pages })` serve large decks; the same two options also work in the Node `toPdf`. The browser export yields to the page between slides.
|
|
240
279
|
|
|
241
280
|
Verified in Chromium by `test/export-browser.mjs` (`npm run test:export-browser`), which also checks the text pdf.js extracts, the eight JPEG orientations, offline operation and determinism. Safari and Firefox are not exercised by this repository's CI.
|
|
242
281
|
Complex scripts in raster output: resvg draws a HarfBuzz cluster with the advance of its widest glyph, so Indic, Thai, Lao, Khmer and Myanmar text lost the advance of vowel signs and ran together, and it ignores the SVG `lang` (Korean spacing). The raster path therefore rewrites its private copy of such text cluster by cluster at fontkit's positions (`src/raster-text.js`): Devanagari, Gujarati, Oriya, Tamil, Kannada and Sinhala as fontkit glyph outlines, the other scripts and Korean as single clusters resvg shapes in isolation. The emitted SVG is unchanged, text in other scripts is rasterized exactly as before, and `test/script-corpora-raster.mjs` holds every script to a HarfBuzz outline reference (the fixture's `rasterLimits` records what still differs: the KOR punctuation forms).
|
|
@@ -246,26 +285,40 @@ Complex scripts in raster output: resvg draws a HarfBuzz cluster with the advanc
|
|
|
246
285
|
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.
|
|
247
286
|
|
|
248
287
|
```js
|
|
249
|
-
import {
|
|
288
|
+
import { toSvg } from '@openpresentation/opf-render/svg';
|
|
250
289
|
import { loadFonts } from '@openpresentation/opf-render/fonts-browser';
|
|
251
290
|
|
|
252
291
|
const fonts = await loadFonts({ faces: fontFileEntries });
|
|
253
|
-
container.innerHTML =
|
|
292
|
+
container.innerHTML = toSvg(presentation, 1, { fonts });
|
|
254
293
|
// fonts.dispose() when its canvases are unmounted.
|
|
255
294
|
```
|
|
256
295
|
|
|
257
296
|
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.
|
|
258
297
|
|
|
259
|
-
Subset embedded faces in the browser (RR-65): pass `subsetWasm` to `loadFonts` to cut each face a standalone SVG embeds to the glyphs its slide draws, as the Node handle always does. It is the `harfbuzz-subset.wasm` file of the `harfbuzzjs` dependency (`harfbuzzjs/dist/harfbuzz-subset.wasm`, MIT), served by the host like the font files: its URL, its bytes or a compiled `WebAssembly.Module`. The loader instantiates it asynchronously and fetches nothing else; without it a browser handle embeds whole faces. Live previews in a page that already holds the faces should use `
|
|
298
|
+
Subset embedded faces in the browser (RR-65): pass `subsetWasm` to `loadFonts` to cut each face a standalone SVG embeds to the glyphs its slide draws, as the Node handle always does. It is the `harfbuzz-subset.wasm` file of the `harfbuzzjs` dependency (`harfbuzzjs/dist/harfbuzz-subset.wasm`, MIT), served by the host like the font files: its URL, its bytes or a compiled `WebAssembly.Module`. The loader instantiates it asynchronously and fetches nothing else; without it a browser handle embeds whole faces. Live previews in a page that already holds the faces should use `text: "system"` instead, which embeds nothing.
|
|
260
299
|
|
|
261
300
|
```js
|
|
262
301
|
const fonts = await loadFonts({ faces: fontFileEntries, subsetWasm: '/vendor/harfbuzz-subset.wasm' });
|
|
263
|
-
const standalone =
|
|
302
|
+
const standalone = toSvg(presentation, 1, { fonts }); // each @font-face is a glyph subset
|
|
264
303
|
```
|
|
265
304
|
|
|
305
|
+
### Safari and WebKit: no kerning in SVG text (known limitation)
|
|
306
|
+
|
|
307
|
+
WebKit (Safari, and Playwright's WebKit) applies no kerning to SVG `<text>`, and neither `font-kerning` nor `font-feature-settings` changes that. Its canvas and HTML text do kern, and they match the renderer's metrics.
|
|
308
|
+
|
|
309
|
+
The effect is small:
|
|
310
|
+
|
|
311
|
+
- **Natural width:** a line's natural width in WebKit is up to about 1% wider than the renderer's layout (8 px on a long Arabic line, about 3 px on a title). Chromium is within 0.015 px.
|
|
312
|
+
- **Line ends:** the renderer pins each line with `textLength`, so line ends stay where the layout put them. WebKit compresses the unkerned glyphs a little to fit, and one title ends about 3 px short.
|
|
313
|
+
|
|
314
|
+
Measured on 2026-10-02 on a Mac (opf-render#118, `docs/evidence/mac-checks-20261002/` in the core repository).
|
|
315
|
+
|
|
316
|
+
- **When this matters:** a host that needs WebKit to place every glyph exactly as Chromium and PowerPoint do should draw with `text: "paths"` (see [Text as outlines](#scope)). The renderer positions each glyph itself, with HarfBuzz's kerned advances, so no browser text layout is involved. The invisible text layer still serves selection, copy and screen readers (the structure; not tested with assistive technology). The editing canvas stays on real text.
|
|
317
|
+
- **Default output:** the renderer does not write per-glyph `x` or `dx` positions into `<text>` to work around WebKit. It would make every SVG larger and change the selection and editing paths of every browser, for a difference `textLength` already holds to the line.
|
|
318
|
+
|
|
266
319
|
## Player and `<opf-deck>` (RR-28)
|
|
267
320
|
|
|
268
|
-
A slideshow player and an embeddable web component, both built on `
|
|
321
|
+
A slideshow player and an embeddable web component, both built on `toSvg`: 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.
|
|
269
322
|
|
|
270
323
|
```html
|
|
271
324
|
<script type="module">
|
|
@@ -283,7 +336,7 @@ useEffect(() => { defineOpfDeck(); }, []);
|
|
|
283
336
|
// ...and render <opf-deck src="/deck.opf.json" fonts="/opf-fonts/" />.
|
|
284
337
|
```
|
|
285
338
|
|
|
286
|
-
Entry points (package `exports`): `/element` (`defineOpfDeck`, `getOpfDeckElement`, `
|
|
339
|
+
Entry points (package `exports`): `/element` (`defineOpfDeck`, `getOpfDeckElement`, `toHtml`, `loadPreviewFonts`), `/element/define` (importing it registers the tag; the only file with a side effect), `/player` (`present`), `/preview-fonts` (the font root layout and `loadPreviewFonts`) and `/preview-fonts-node` (`copyPreviewFonts`, also the `opf-preview-fonts` command). The element loads the player on first use, so a page that only embeds decks does not carry slideshow code.
|
|
287
340
|
|
|
288
341
|
### Fonts: one self-hosted directory
|
|
289
342
|
|
|
@@ -305,23 +358,25 @@ It is the layout the editor playground and the OpenPresentation sites serve, and
|
|
|
305
358
|
|
|
306
359
|
### `<opf-deck>`
|
|
307
360
|
|
|
308
|
-
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 `
|
|
361
|
+
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 `toSvg` 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)`.
|
|
309
362
|
|
|
310
363
|
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`.
|
|
311
364
|
|
|
312
365
|
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.
|
|
313
366
|
|
|
314
|
-
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
|
|
367
|
+
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 can reach it (the DOM structure; see the testing statement below); 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 have `forced-colors` styles; 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).
|
|
368
|
+
|
|
369
|
+
What has and has not been tested (opf-render#170). **Not tested with assistive technology:** no screen reader (NVDA, JAWS, VoiceOver, TalkBack) has been run against `<opf-deck>`, the player or the SVG, so the announcements, reading order and focus behaviour described above are the documented DOM and ARIA structure, checked with axe-core, the browser's accessibility tree and focus-order assertions, not a recorded screen-reader pass. **Forced colors, verified with Chromium's emulation only:** `test/forced-colors-browser.mjs` runs the element (the website-starter embed styled with the site's `--opf-deck-*` tokens, and a bare `<opf-deck>`) and the slideshow with `page.emulateMedia({ forcedColors: 'active' })` in both the light and the dark colour scheme, and asserts as computed style and as screenshot pixels that the slide boundary, the Previous, Next and Present buttons, the thumbnails and the current thumbnail, and the slideshow's and the speaker view's controls, notes and slide boxes (including Exit) keep a visible frame in a system colour, that every focused control draws a solid focus ring of at least 2 px in a system colour that differs from `Canvas`, and that a disabled control is `GrayText` rather than faded. It last passed on 2026-10-10 with opf-render 0.18.0, Playwright 1.63.0 and Microsoft Edge 154 (Chromium) on Windows 11, and runs in CI in the pinned Playwright Chromium. This is an emulation, not the Windows contrast theme: the real contrast themes (and macOS Increase Contrast) were not run, and no system setting was changed. The slide drawing is the SVG's own colours (forced colors restyle the HTML around it, not the SVG fills); only its frame is guaranteed visible.
|
|
315
370
|
|
|
316
371
|
### Server markup
|
|
317
372
|
|
|
318
|
-
`
|
|
373
|
+
`toHtml(deck, slides?, 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. `toHtml(deck)` draws the first slide and a list of titles; `toHtml(deck, '1-')` every slide, and `toHtml(deck, 3)`, `'1-3'` or `[1, 3]` those slides of the presented sequence; `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.
|
|
319
374
|
|
|
320
|
-
Each slide's SVG embeds only the resolved faces it draws (see `
|
|
375
|
+
Each slide's SVG embeds only the resolved faces it draws (see `toSvg` above), including bold, italic and glyph fallback, so loading a whole pack for measurement does not put that pack into every slide. `toHtml` accepts `fontMode: 'standalone' | 'shared' | 'external'`:
|
|
321
376
|
|
|
322
377
|
- `standalone` (default) embeds fonts in each slide, so its SVG remains self-contained when extracted.
|
|
323
378
|
- `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.
|
|
324
|
-
- `external` emits no font rules (the slides render with `
|
|
379
|
+
- `external` emits no font rules (the slides render with `text: "system"`). 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.
|
|
325
380
|
|
|
326
381
|
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.
|
|
327
382
|
|
|
@@ -330,12 +385,9 @@ For a lightweight initial page, pre-render measured markup on the server and ena
|
|
|
330
385
|
```js
|
|
331
386
|
// Server/build step: write this HTML into your page, followed by the upgrade button.
|
|
332
387
|
import {loadFonts} from '@openpresentation/opf-render/fonts-node';
|
|
333
|
-
import {
|
|
388
|
+
import {toHtml} from '@openpresentation/opf-render/element';
|
|
334
389
|
const fonts = await loadFonts({pack: 'office', scripts: 'auto', presentation: deck, embedScriptFonts: true});
|
|
335
|
-
const html =
|
|
336
|
-
fonts, slides: 'all', fontMode: 'shared', embed: true,
|
|
337
|
-
attributes: {id: 'deck', fonts: '/opf-fonts/'},
|
|
338
|
-
});
|
|
390
|
+
const html = toHtml(deck, '1-', {fonts, fontMode: 'shared', embed: true, attributes: {id: 'deck', fonts: '/opf-fonts/'}});
|
|
339
391
|
// <button id="upgrade" type="button">Enable slide controls</button>
|
|
340
392
|
```
|
|
341
393
|
|
|
@@ -413,7 +465,7 @@ Source Sans Pro was renamed Source Sans 3. A request for the old name draws the
|
|
|
413
465
|
|
|
414
466
|
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.
|
|
415
467
|
|
|
416
|
-
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 `
|
|
468
|
+
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 `toSvg` 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.
|
|
417
469
|
|
|
418
470
|
## Script fonts, lang and right-to-left text (FF-19)
|
|
419
471
|
|
|
@@ -423,12 +475,12 @@ The replacement faces are an optional, hash-pinned font pack: 70 static faces fr
|
|
|
423
475
|
|
|
424
476
|
```js
|
|
425
477
|
import { loadFonts } from '@openpresentation/opf-render/fonts-node';
|
|
426
|
-
import {
|
|
478
|
+
import { toSvg, toPng } from '@openpresentation/opf-render';
|
|
427
479
|
|
|
428
|
-
// npm install @expo-google-fonts/noto-sans-jp@0.4.
|
|
480
|
+
// npm install @expo-google-fonts/noto-sans-jp@0.4.4 @expo-google-fonts/noto-naskh-arabic@0.4.5
|
|
429
481
|
const fonts = await loadFonts({ pack: 'office', substitutionPolicy: 'visual', scripts: ['Jpan', 'Arab'] });
|
|
430
|
-
const slides =
|
|
431
|
-
const png = await
|
|
482
|
+
const slides = toSvg(presentation, { fonts });
|
|
483
|
+
const png = await toPng(slides[0], { fonts });
|
|
432
484
|
```
|
|
433
485
|
|
|
434
486
|
`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.
|
|
@@ -464,7 +516,7 @@ The SVG root carries `lang` and `xml:lang` from the document's `language`. Measu
|
|
|
464
516
|
|
|
465
517
|
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`.
|
|
466
518
|
|
|
467
|
-
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 `
|
|
519
|
+
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 `toSvg` 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.
|
|
468
520
|
|
|
469
521
|
Characters are assigned to slots following PowerPoint where its rules are known:
|
|
470
522
|
|
|
@@ -491,9 +543,9 @@ These browser entrypoints are included in the published package. For coordinated
|
|
|
491
543
|
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`:
|
|
492
544
|
|
|
493
545
|
```js
|
|
494
|
-
import {
|
|
546
|
+
import { toSvg } from '@openpresentation/opf-render';
|
|
495
547
|
|
|
496
|
-
|
|
548
|
+
toSvg(template, { variables: { client: 'Globex', revenue: 1250000 } });
|
|
497
549
|
```
|
|
498
550
|
|
|
499
551
|
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).
|
|
@@ -528,11 +580,11 @@ Dependency policy:
|
|
|
528
580
|
- `@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).
|
|
529
581
|
- `@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).
|
|
530
582
|
- `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.
|
|
531
|
-
- 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), `
|
|
583
|
+
- 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), HarfBuzz (the fonts handle's `harfbuzz.wasm` of the pinned harfbuzzjs) for shaping, `fontkit` for font metrics (and for shaping without a shaper) and `bidi-js` for the Unicode bidirectional algorithm. No hosted service, network call or system font is involved.
|
|
532
584
|
- 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.
|
|
533
|
-
- `
|
|
585
|
+
- `toPng` and `toPdf` disable system-font loading by default. Hosts that require branded fonts should pass a handle with explicit `fontFiles` (or font folders as `fonts`); `fonts: { loadSystemFonts: true }` is an opt-in escape hatch for PNG and raster PDF output and can make it environment-dependent. A vector PDF rejects it (`pdf-system-fonts-unsupported`): it embeds only fonts you supply, so a system font can never be embedded by accident.
|
|
534
586
|
|
|
535
|
-
Browser support boundary: `
|
|
587
|
+
Browser support boundary: `toSvg` and `resolvePresentation` are browser-importable pure JavaScript APIs. The root `toPng` and `toPdf` (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).
|
|
536
588
|
|
|
537
589
|
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).
|
|
538
590
|
|
|
@@ -548,6 +600,41 @@ Classic chart previews keep finite axis coordinates for subnormal values and val
|
|
|
548
600
|
|
|
549
601
|
Category-axis labels follow PowerPoint's automatic labelling instead of wrapping inside a band (column, bar, line and area charts, including stacked and 100%, and the chartex histogram, pareto, waterfall, funnel and box-and-whisker previews). A label is never broken inside a word: it stays on one line, or splits at its spaces onto two lines when a band is too narrow, so `Category 1` cannot become `Ca` over `t 1`. When the labels do not fit horizontally they turn to -45 degrees, then -90 degrees (the plot area shrinks to make room, by at most 40% of the chart height, and a slanted first label must stay inside the chart), and when even that collides the axis draws every n-th label (Office's automatic `tickLblSkip`): n is the smallest interval that leaves no two drawn labels overlapping, the first label is always drawn, and ties go to the less rotated arrangement. In horizontal bar charts and funnels, where the category axis is vertical, rows shorter than a line of text are skipped the same way, and the label gutter grows with the longest label up to 30% of the chart width. Only a label wider than that gutter (or wider than the whole chart on a horizontal axis) is ellipsized, and then the renderer reports one `chart-label-truncated` diagnostic per chart with the affected label paths in `labels`; nothing is shortened silently, and no arrangement raises `text-overflow` for an axis label. Radar spoke labels use the same one-line, two-line or ellipsis rule and leave out a spoke label that would collide with one already drawn. Rotated labels are wrapped in a `<g transform="rotate(...)">` around their usual text group, so the traced `data-opf-box-*` attributes describe the unrotated box. PowerPoint applies its own automatic rotation and skip to the exported chart: opf-pptx writes an automatic axis text body (`<a:bodyPr/>`, no `rot`) and no `tickLblSkip` for these axes, so nothing forces a wrap, and the preview and the export make the same kind of choice, not identical ones (the export uses 9 pt labels, the preview the readability floor).
|
|
550
602
|
|
|
603
|
+
## Long-lived workers: measured memory and latency (opf-render#171)
|
|
604
|
+
|
|
605
|
+
A render or export service keeps one fonts handle per worker process and reuses it ("load fonts once per worker"). `npm run bench:soak` (`scripts/soak.mjs`; manual or scheduled, never part of the default CI gate) measures that pattern. Each worker process holds one `loadFonts({ pack: 'office', substitutionPolicy: 'visual', scripts: 'auto' })` handle for its whole life, blocks the network (a guard counts and refuses any attempt) and repeats jobs: `fonts.ensure(deck)`, `toSvg`, `toPng` of those SVGs and `toPptx` (opf-pptx 0.18.0, which is not a dependency of this package: `npm install --no-save @openpresentation/opf-pptx@0.18.0` and run with `--link-renderer`, since opf-pptx imports the renderer as an optional peer; without opf-pptx the PPTX leg is skipped). The jobs take the 127 core example decks in rotation (5 jobs in 6) and a 30-slide script deck built from the script corpora (every sixth job; one slide per script from Latin to Tibetan, with CJK, Arabic and Indic text, so `ensure` loads the script faces), 500 jobs per worker at concurrency 1 and 4. Memory is sampled after every job following a forced garbage collection (`heapUsed`, the retained heap, and `rss`).
|
|
606
|
+
|
|
607
|
+
Measured 2026-10-10 on an AMD Ryzen 7 9700X (8 cores, 16 threads), 31.6 GiB RAM, Windows 11 (10.0.26200), Node 24.21.0, opf-render 0.18.0 and opf-pptx 0.18.0, on a desktop that other programs also use (an earlier concurrency 1 run overlapped with other load, took twice as long per job and gave the same memory and determinism results; the numbers below are its rerun on a quieter machine). These are measurements, not an SLO; Linux and other hardware will differ.
|
|
608
|
+
|
|
609
|
+
| | Concurrency 1 | Concurrency 4 (500 jobs per worker) |
|
|
610
|
+
| --- | ---: | ---: |
|
|
611
|
+
| Jobs / slides | 500 / 5,191 | 2,000 / 20,583 |
|
|
612
|
+
| Wall time | 13.2 min | 19.0 min |
|
|
613
|
+
| Throughput | 0.63 jobs/s, 6.6 slides/s | 1.75 jobs/s, 18.0 slides/s |
|
|
614
|
+
| `toSvg` per job, p50 / p95 | 0.90 s / 1.11 s | 1.28 s / 1.85 s |
|
|
615
|
+
| `toPng` of the SVGs per job, p50 / p95 | 0.37 s / 1.41 s | 0.48 s / 2.30 s |
|
|
616
|
+
| `toPptx` per job, p50 / p95 | 0.09 s / 0.24 s | 0.15 s / 0.39 s |
|
|
617
|
+
| Whole job, p50 / p95 / max | 1.36 s / 2.72 s / 6.1 s | 1.93 s / 4.34 s / 8.4 s |
|
|
618
|
+
| RSS after warm-up, median per worker | 1,030 MiB | 1,021 to 1,035 MiB |
|
|
619
|
+
| RSS after warm-up, highest sample | 1,774 MiB | 1,766 to 1,785 MiB |
|
|
620
|
+
| Peak RSS of the process | 1,895 MiB | 1,892 to 1,901 MiB |
|
|
621
|
+
| Retained heap after GC, median / highest | 230 / 233 MiB | 230 to 231 / 232 to 233 MiB |
|
|
622
|
+
| Fonts handle load (once per worker) | 0.10 s | 0.21 to 0.25 s |
|
|
623
|
+
|
|
624
|
+
- **Latency.** A job is one whole deck (a core example has at most 10 slides, the script deck 30), so the p95 is the script deck and the largest examples. Per slide, the steady-state p50 at concurrency 1 is 0.15 s for `toSvg` (which embeds a HarfBuzz subset of every face the slide draws), 0.04 s for the PNG and 0.01 s for the PPTX; the script deck takes about 2.7 s a job against 1.3 s for an example. Four workers on 16 threads give 2.8 times the throughput of one (70 % scaling); each worker is about 1.4 times slower than alone. Latency does not drift upward: each job's time over the median of its own deck moves by +6 % at most across the steady-state window (the criterion flags 10 %; it settles by job 250), and falls about 30 % in the four-worker run as V8 tiers up.
|
|
625
|
+
- **Memory.** The heap grows while the process warms up (104 MiB after the first job, 215 MiB after 25 jobs, 227 MiB after 125) and plateaus at 230 to 231 MiB from job 200 on, in every worker. RSS has no trend: it swings between about 0.8 and 1.8 GiB with the PNG and script-deck jobs, because the allocator returns and re-takes large buffers. Over the steady state (jobs 125 to 500) the Theil-Sen slope of RSS is 4 to 15 MiB per 100 jobs with a Mann-Kendall z of 0.9 to 2.3 (the test needs z of 3.29 and a rise of at least 5 % of the median or 16 MiB; the largest rise was 55 MiB of 1,035 MiB), and the retained heap rises 2.3 to 2.5 MiB while it settles (z is high, the rise is far below the threshold). So **no worker shows monotonic growth of RSS or of the retained heap** after the 125-job warm-up, and the data give no reason for a recycle-after-N. Provision for the peak, not the median: **about 2 GiB of RSS per worker** when script-heavy decks are in the mix (the median is about 1 GiB; every handle held 190 font files at the end, because the script faces, once loaded, stay until the handle is dropped). An operator who wants a safety net can recycle a worker after a few thousand jobs; the cost is the 0.1 to 0.25 s font load plus the warm-up.
|
|
626
|
+
- **Determinism.** Every deck came round about four times per worker. All 384 (deck, operation) outputs (the SVG and PNG of every slide, and the PPTX) were byte-identical on every repeat: 1,116 repeat observations at concurrency 1, 5,616 across the four workers at concurrency 4 (workers meet each deck at different points of their own history), and the two concurrency levels produce the same hashes. No network call was made. This covers the 127 example decks and the script deck; for them, no output depended on earlier renders in the same process (the measured-width dependence reported in #125 does not appear).
|
|
627
|
+
|
|
628
|
+
The report (JSON with the per-job records and memory series) and a Markdown summary with the Node version, CPU and RAM go to the git-ignored `artifacts/soak/`; the criterion for monotonic growth is defined in the header of the script.
|
|
629
|
+
|
|
630
|
+
```sh
|
|
631
|
+
npm ci
|
|
632
|
+
npm install --no-save @openpresentation/opf-pptx@0.18.0 # optional: without it the PPTX leg is skipped
|
|
633
|
+
npm run build
|
|
634
|
+
npm run bench:soak -- --link-renderer # 500 jobs per worker at concurrency 1 and 4 (about 35 minutes)
|
|
635
|
+
npm run bench:soak -- --iterations 20 --concurrency 1,2 --decks 6 --script-every 0 # smoke run
|
|
636
|
+
```
|
|
637
|
+
|
|
551
638
|
## Development
|
|
552
639
|
|
|
553
640
|
```sh
|