@openpresentation/opf-render 0.11.9 → 0.12.2

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
@@ -2,7 +2,7 @@
2
2
 
3
3
  **Aptos previews with Intos.** The Office pack includes Intos, Intos Display, Intos Narrow and Intos Serif (16 faces, about 12 MB, vendored under `fonts/intos/`, SIL OFL 1.1, pinned to upstream commit fef9315c14da9e4b23b4c3cac8e718998d4e4736 and per-file SHA-256, unmodified from the upstream files). They are the metric-compatible previews for Aptos, Aptos Display, Aptos Narrow and Aptos Serif, the default `aptos` font scheme: 0.000% mean and maximum width difference against Aptos 2.01 in all four styles, with equal vertical metrics. The letter shapes are Intos's own, not Aptos's. The exported PPTX still names Aptos; no Aptos file is bundled or embedded. A registry built without these faces (the base pack) has no Aptos substitute: visual policy falls back to Roboto or Carlito. Like the open families, Intos is `embed: "used"`: it is not in `registry.embeddedFonts` (the 33 eager npm faces), `prepareNodeFonts().options.embeddedFonts` supplies it, and an SVG embeds only the Intos faces its text draws. `registry.lazyFonts` lists the 51 vendored faces (35 open, 16 Intos) with package-relative file and sha256, and `loadBrowserFontRegistry(faces, {lazyFontsBaseUrl})` loads them on demand: `await registry.ensureLazyFonts(presentation)` fetches and verifies only the faces the document draws, by family, weight and style (a plain Aptos deck: Intos Display Bold and Intos Regular, 2 files, 1.5 MB; an italic run adds Intos Italic, a bold run Intos Bold; the code face only when a slide has code), then adds them to the document and the registry together so measurement and painting never disagree. Faces a host serves itself load the same way: `loadBrowserFontRegistry(startup, { extraLazyFonts: [{ family, weight, italic, url, sha256 }] })` adds them to the lazy list (`registry.lazyFonts`, `package: "host"`), so the registry can start with Roboto Regular only (`splitStartupFaces(faces)` splits a manifest into the startup face and the rest) and a document draws, fetches and verifies just the faces it needs, vendored and host faces in one `ensureLazyFonts` call. `sha256` is mandatory, loads are all or nothing, `dispose()` removes the faces, and, like the vendored ones, host faces are `embed: "used"`: they are not in `registry.embeddedFonts` (only the startup faces are), and a standalone SVG embeds one only when passed explicitly through `registry.selectEmbeddedFonts` and the slide's text uses its family. `registry.pendingLazyFonts(presentation)` is the synchronous check hosts gate rendering on, and after an edit it reports just the newly needed faces. The faces are those the renderer itself measures and draws (`presentationFaces(presentation, options, registry)`, one layout-and-paint run with a recording measurement), resolved by the registry's own face selection over the faces it holds plus the vendored ones, so the browser loads exactly what Node draws with every vendored face loaded. A document resolves with the `renderSvg` options the host renders with: pass them as `loadBrowserFontRegistry(entries, { renderOptions: { catalogs } })`, or per call (`pendingLazyFonts(presentation, { catalogs })`, `ensureLazyFonts(presentation, { signal, catalogs })`), so a layout or font scheme id that only the host's catalogs know resolves here too; a document that does not resolve throws (rejects) what `renderSvg` throws for it instead of reporting nothing. Node's `prepareNodeFonts` and `loadOfficeFontRegistry` keep loading the whole open pack and Intos eagerly, so nothing there is face level; with `scripts: 'auto'` they accept `renderOptions: { catalogs }` too. Hosts copy the vendored directories next to the page.
4
4
 
5
- Version 0.11.9 requires `@openpresentation/opf` ^0.11.4 and draws the design fields (deck and organization logos on covers and section slides, header and footer `logo` parts, `design.listBullet: image` picture bullets at the size PowerPoint draws them, the accent font, `contentDirection` and `chartPrimary` through core composition), and the slide tag falls back to the text colour when the scheme primary is under 4.5:1 against the background; furniture images sit on their zone edge. Use it with opf-pptx 0.11.7 and opf-editor 0.10.6 so preview and export resolve one core (no API change; the preview of covers, list slides with picture bullets, chart slides with a primary chart and slides with furniture images changes). Version 0.11.8 draws the slide tag in the scheme primary colour, as opf-pptx 0.11.5 writes it (no API change; the preview of slides with a tag changes). Version 0.11.7 lets a browser registry load the host's own faces on demand (`extraLazyFonts`, `splitStartupFaces`: a registry can start with Roboto Regular only; additive, `registry.lazyFonts` still lists the 94 vendored faces plus the host's). Version 0.11.6 makes the category-axis labels of chart previews rotate and skip instead of wrapping mid-word (no API change; the preview of dense-axis chart slides changes) and requires `@openpresentation/opf` ^0.11.3 (the 70 legacy gallery layout ids and their geometry); use it with PPTX 0.11.4 and editor 0.10.4 so preview and export resolve the same core. Version 0.11.5 loads vendored preview fonts by face, not by family, and lets the font loaders resolve documents with the host's render options (`presentationFaces`, `lazyFacesNeeded`; `pendingLazyFonts`, `ensureLazyFonts`, `pendingScripts` and `ensureScripts` take `catalogs` and throw or reject with the `renderSvg` error for a document that does not resolve; `registry.lazyFonts` still lists 94 faces). Version 0.11.4 previews the seven chartex chart types natively (the world map as a non-geographic tile grid), keeps the Latin Noto Sans replacement for script schemes under `scripts: 'auto'` in Node, shapes Noto Sans Mongolian again and bundles Raleway and Playfair Display (`registry.lazyFonts` lists 94 faces; no API change). Version 0.11.3 previews the classic chart types natively, draws deprecated chart ids as their replacement and scales value axes like Office (no API change; the preview of chart slides changes). Version 0.11.2 makes `scripts: 'auto'` load the script faces a font scheme names and adds `details.loadedFaceHasGlyph` to `missing-glyph` (no export change). Version 0.11.1 adds the open replacement font families the policy routes to and complete Red Hat styles (no API change). Version 0.11.0 requires `@openpresentation/opf` ^0.11.2 (cover centering, Intos Aptos previews, lazy font loading, automatic script fonts and glyph fallback); use it with PPTX 0.11.0 and editor 0.10.0 so preview and export resolve the same core. Version 0.10.0 required `@openpresentation/opf` ^0.11.1 (font policy table, script fonts and right-to-left paragraphs, socials, slide-image treatments, formatted slide-number and date furniture with the host `date` option, per-item alignment). See the changelog for the intentional preview-output changes.
5
+ Version 0.12.0 requires `@openpresentation/opf` ^0.12.0 (composed font sizes on PowerPoint's 0.01 pt grid, hanging wrap whitespace, promoted regions in reading order, right-to-left decks composed mirrored) and draws what that core composes, plus the preview changes listed in the changelog; use it with opf-pptx 0.12.0 and opf-editor 0.11.0 so preview and export resolve one core. Version 0.11.9 requires `@openpresentation/opf` ^0.11.4 and draws the design fields (deck and organization logos on covers and section slides, header and footer `logo` parts, `design.listBullet: image` picture bullets at the size PowerPoint draws them, the accent font, `contentDirection` and `chartPrimary` through core composition), and the slide tag falls back to the text colour when the scheme primary is under 4.5:1 against the background; furniture images sit on their zone edge. Use it with opf-pptx 0.11.7 and opf-editor 0.10.6 so preview and export resolve one core (no API change; the preview of covers, list slides with picture bullets, chart slides with a primary chart and slides with furniture images changes). Version 0.11.8 draws the slide tag in the scheme primary colour, as opf-pptx 0.11.5 writes it (no API change; the preview of slides with a tag changes). Version 0.11.7 lets a browser registry load the host's own faces on demand (`extraLazyFonts`, `splitStartupFaces`: a registry can start with Roboto Regular only; additive, `registry.lazyFonts` still lists the 94 vendored faces plus the host's). Version 0.11.6 makes the category-axis labels of chart previews rotate and skip instead of wrapping mid-word (no API change; the preview of dense-axis chart slides changes) and requires `@openpresentation/opf` ^0.11.3 (the 70 legacy gallery layout ids and their geometry); use it with PPTX 0.11.4 and editor 0.10.4 so preview and export resolve the same core. Version 0.11.5 loads vendored preview fonts by face, not by family, and lets the font loaders resolve documents with the host's render options (`presentationFaces`, `lazyFacesNeeded`; `pendingLazyFonts`, `ensureLazyFonts`, `pendingScripts` and `ensureScripts` take `catalogs` and throw or reject with the `renderSvg` error for a document that does not resolve; `registry.lazyFonts` still lists 94 faces). Version 0.11.4 previews the seven chartex chart types natively (the world map as a non-geographic tile grid), keeps the Latin Noto Sans replacement for script schemes under `scripts: 'auto'` in Node, shapes Noto Sans Mongolian again and bundles Raleway and Playfair Display (`registry.lazyFonts` lists 94 faces; no API change). Version 0.11.3 previews the classic chart types natively, draws deprecated chart ids as their replacement and scales value axes like Office (no API change; the preview of chart slides changes). Version 0.11.2 makes `scripts: 'auto'` load the script faces a font scheme names and adds `details.loadedFaceHasGlyph` to `missing-glyph` (no export change). Version 0.11.1 adds the open replacement font families the policy routes to and complete Red Hat styles (no API change). Version 0.11.0 requires `@openpresentation/opf` ^0.11.2 (cover centering, Intos Aptos previews, lazy font loading, automatic script fonts and glyph fallback); use it with PPTX 0.11.0 and editor 0.10.0 so preview and export resolve the same core. Version 0.10.0 required `@openpresentation/opf` ^0.11.1 (font policy table, script fonts and right-to-left paragraphs, socials, slide-image treatments, formatted slide-number and date furniture with the host `date` option, per-item alignment). See the changelog for the intentional preview-output changes.
6
6
 
7
7
  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.
8
8
 
@@ -10,18 +10,20 @@ Version 0.8.1 required core 0.10.1, including metric, quote and timeline layout
10
10
 
11
11
  Unfinished prepared shaping work is preserved in the [September 15 roadmap](docs/roadmap-shaping-20260915.md); it is not part of the published runtime.
12
12
 
13
- Version 0.8.0 and this checkout require Node 24 (`24.x`). Use `.nvmrc` for local development. Earlier published versions retain their original engine declarations. Browser entrypoints remain browser-safe; native application compatibility is verified separately.
13
+ This checkout supports Node 22 or later (`engines.node` `>=22`; CI tests Node 22, 24 and 26). Versions 0.8.0 to 0.12.0 declared `24.x`, so npm on Node 22 or 26 silently installed an older release instead (RR-20); upgrade past 0.12.0. Use `.nvmrc` (Node 24) for local development. Earlier published versions retain their original engine declarations. Browser entrypoints remain browser-safe; native application compatibility is verified separately.
14
14
 
15
15
  Version 0.8.0 adds optional font-registry vector outlines and shared heading/scalar/rich line placement. Pass identical `textMeasurement` and `textRasterPadding` options to preview, pagination and export; padding defaults to one scaled reference pixel. SVG consumes accepted origins without remeasuring. See the [source contract and limits](https://github.com/OpenPresentation/opf/blob/f94125a1ff95bf0974a055fe1c081d348dad54f2/docs/plans/text-placement.md). Native raster certification remains separate.
16
16
 
17
17
  Version 0.8.0 requires core 0.10.0 and renders accepted quote and code geometry without fitting it again. Code filename/language/body parts preserve source whitespace, literal tabs and metadata case and expose trace targets for editing. The [43 reviewed code raster changes](docs/evidence/shared-code/raster-review.json) retain the other 762 corpus hashes. Coordinated releases PPTX 0.8.0 and editor 0.7.0 add native source recovery and editing. Glyph containment and separation do not establish native pixel equivalence.
18
18
 
19
- Deterministic local renderer for Open Presentation Format documents. The shared SVG core implements validation, catalog resolution, placeholder binding and text layout. Node APIs additionally convert SVG to PNG and raster-backed PDF.
19
+ Deterministic local renderer for Open Presentation Format documents. The shared SVG core implements validation, catalog resolution, placeholder binding and text layout. Node APIs additionally convert SVG to PNG and to PDF (vector with selectable text by default, or raster-backed).
20
20
 
21
21
  In version 0.8.0, `design.contentBox` uses core's shared padded geometry. The card renders at `item.frameBox`; its payload uses `item.box` and accepted internals. Card padding participates in composition scoring, strict overflow and pagination. This requires core 0.10.0.
22
22
 
23
23
  Version 0.8.0 paints plain and rich text, titles, subtitles and tags using composition's accepted fits and resolved styles. Painting does not measure those payloads again. Supply a font registry during composition: reusing an estimated fit cannot correct spacing when the actual drawing font has different advances.
24
24
 
25
+ Citations, footnotes and captions (core RR-34 fields `cite`, `footnote`, `references` and `caption`) draw from the same composed geometry: a marker is a superscript segment after its run (traced as `data-opf-segment="marker"` without source offsets), the slide's footnote area is a rule and `<n> <text>` lines in the muted colour above the footer band, and a caption is a band inside its block's region. Decks without those fields render as before.
26
+
25
27
  Version 0.8.0 includes `prepareNodeFonts` in `/fonts-node`. It verifies all selected font files and license notices against the versioned `BUNDLED_FONT_MANIFEST`, including exact npm versions and SHA-256 hashes. Carlito is not an npm dependency: the package ships the unmodified google/fonts Carlito files and their OFL notice in `fonts/carlito`, pinned to a google/fonts commit and verified by the same hashes. The returned options configure layout, SVG, editing, PPTX, and Node raster output with the same font inputs:
26
28
 
27
29
  ```js
@@ -55,9 +57,12 @@ Code strings that [XML 1.0 cannot represent](https://www.w3.org/TR/xml/#charsets
55
57
  - License: MIT
56
58
  - Compatibility target: `@openpresentation/opf`
57
59
  - Public API: `renderSvg(opf, opts)`, `renderSvgDeck(opf, opts)`, `resolvePresentation(opf, opts)`, `svgToPng(svg, opts)`, and `svgToPdf(svgs, opts)`
60
+ - 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)
58
61
 
59
62
  `renderSvg` renders a single slide selected by `opts.slideIndex` (default `0`). `renderSvgDeck` returns one SVG string per slide. Both validate OPF at the boundary via `@openpresentation/opf`, resolve inline and bundled catalogs locally, and emit byte-stable SVG for the same input. A catalog `source` may be one string or an ordered array (first match wins, the bundled default is appended); the renderer never fetches, so non-bundled sources resolve only from `catalogSources`. Slide background colours (solid, gradient stops, pattern colours) accept the same colour references as text and table colours: a hex value, a `var:` variable, or a colour scheme slot or role such as `accent2` or `primary`.
60
63
 
64
+ The preview draws three accepted spec fields from tables shared with opf-pptx through core (`@openpresentation/opf`): `code.language` colours code (comments, strings, numbers, keywords, names and types, in theme-derived colours kept at 4.5:1 or more on the code panel; an unknown language stays plain and the text is never changed), `metric.trend` draws an arrow beside the trend word (green up, red down, neutral flat, with "Trend: up" as its text alternative) and colours the delta and trend text, and every one of the 54 DrawingML preset pattern backgrounds draws from its 8 x 8 bitmap (one pixel per 1/96 inch from the slide's top-left). With a core that lacks these exports the preview draws plain, without a new diagnostic. `scripts/derive-pattern-bitmaps.mjs` compares a PowerPoint export of the pattern slides with core's tiles (ECMA-376 names the presets without defining their pixels; core's tiles were measured with it from desktop PowerPoint, Office 365, Windows, 2026-10-01).
65
+
61
66
  ```js
62
67
  import { renderSvgDeck } from "@openpresentation/opf-render";
63
68
  import { loadOfficeFontRegistry } from "@openpresentation/opf-render/fonts-node";
@@ -79,6 +84,8 @@ Unresolved images produce an `unresolved-asset` diagnostic through `onDiagnostic
79
84
 
80
85
  Header/footer images and watermarks fit their complete artwork within their allocated regions. `design.imageFill: "crop"` continues to crop content picture placeholders; background images retain their own fit policy.
81
86
 
87
+ An `image/svg+xml` data URI (base64 or text) is drawn as an image wherever a raster is (content, header/footer, watermark, logo, slide image, background, picture bullet), with the raster's box and fit, when it is an `<svg>` in the SVG namespace with an intrinsic size (`width` and `height`, or a `viewBox`); anything else keeps the `Image unavailable` placeholder. opf-pptx exports the same picture as a native SVG picture over a PNG fallback. An SVG used as an image never runs script or loads anything outside its own document, in a browser or in PNG/PDF output; PNG and PDF output draw it with resvg, and its text uses the same bundled fonts.
88
+
82
89
  A slide-level image (`design.slideImage`, composed by core as `geometry.slideImage`) is drawn at the shared composition frame, beneath branding and content. `crop` (the slide image default) covers the frame from the center and `fit` centers the whole image, matching the coordinated PPTX `a:srcRect` export. With `trace: true`, `data-opf-slide-image` names the configuring design path and the `<image>` carries the asset's source path. Unresolved slide images use the ordinary placeholder and `unresolved-asset` diagnostic.
83
90
 
84
91
  Slide-image treatments render from core's normalized geometry in the native picture's paint order:
@@ -102,7 +109,45 @@ const png = await svgToPng(svgs[0], rasterOptions);
102
109
  const pdf = await svgToPdf(svgs, rasterOptions);
103
110
  ```
104
111
 
105
- `svgToPng` returns PNG bytes for one SVG. `svgToPdf` accepts one SVG or an array of SVGs and returns PDF bytes with one slide per page. The SVG page `width`/`height` or `viewBox` determines the PDF page size; `scale` controls raster density only.
112
+ `svgToPng` returns PNG bytes for one SVG. `svgToPdf` accepts one SVG or an array of SVGs and returns PDF bytes with one slide per page. The SVG page `width`/`height` or `viewBox` determines the PDF page size. **Units: one SVG pixel is one PDF point (1/72 inch), in both modes**, so a 1280 x 720 slide is a 1280 x 720 pt page (17.8 x 10 in, not the 13.33 x 7.5 in PowerPoint prints); a viewer or printer scales it to the paper; `scale` controls raster density only. `svgToPdf` has two modes: `"vector"` (the default, see below) and `"raster"` (each slide an image, the output of earlier releases): pass `{ mode: "raster" }` for the image-only compatibility output.
113
+
114
+ ### Vector PDF with selectable text
115
+
116
+ `svgToPdf(svgs, { mode: "vector" })` converts the same SVG the preview draws, without a second layout pass: every line, position and width comes from the SVG.
117
+
118
+ - Shapes, lines, polylines, paths and rounded rectangles are PDF paths; linear and radial gradients are PDF shadings and hatch/tile `<pattern>`s are tiling patterns; `clip-path`, stroke dashes, group `opacity` (a transparency-group form) and per-element opacity are native. Pictures are image XObjects (PNG with alpha as an SMask, unoriented JPEG passed through, WebP and oriented JPEG decoded as in raster mode; identical pictures are stored once).
119
+ - Text is real text: TrueType subsets (the font program, a `ToUnicode` map and a `CIDToGIDMap`) of the same font files the PNG preview draws with, positioned glyph by glyph (kerning, ligatures, combining marks and mixed scripts as fontkit shapes them), with the SVG's `textLength` and `text-anchor`, underline and strike-through. The Unicode bidirectional algorithm orders right-to-left and mixed-direction lines. Text is selectable, searchable and copies as the authored text. The encoding is the one Chromium writes, so PDFium (Chrome, Edge), poppler and pdf.js read it back the way they read a Chrome-printed page: runs are drawn in visual order, right-to-left runs are marked `/ReversedChars`, and a glyph or cluster the glyph map cannot give (an Arabic ligature or mirrored bracket, a reordered Indic or Khmer syllable, a no-break space) carries its logical text as `/ActualText`, one span per glyph or cluster. A mark the font split off a letter (Arabic dots) is drawn as a filled outline, so no extractor sees an extra character. There is no hidden text layer and no outline-only text. Known reader limits, shared with Chrome's own PDFs: PDFium duplicates some Thai and Burmese marks, PDFium reorders the runs of a mixed-direction line, and pdf.js ignores `/ActualText` (it reports reordered Indic and Khmer clusters in drawing order).
120
+ - Fonts: only the bundled pack (unless `useBundledFonts: false`), `fontFiles`, `fontDirs` and the SVG's own `@font-face` data are used; `loadSystemFonts: true` is rejected in vector mode because system fonts could be proprietary. A face whose OS/2 `fsType` forbids embedding is never embedded (a diagnostic says so); one that forbids subsetting is embedded whole. A requested family that has no face is drawn with the family the PNG preview draws (`defaultFontFamily`, `sansSerifFamily`, `monospaceFamily`, `serifFamily`) and reported.
121
+ - Document metadata: `metadata: { title, author, subject, keywords, language, creator, creationDate }`. The language defaults to the first SVG's `lang`. No date is written unless you supply one, there is no random data, and the file identifier is a hash of the content, so identical input gives identical bytes on every machine.
122
+ - Accessibility basics (not a conformance claim): the catalog carries `/Lang`, `/MarkInfo`, `/DisplayDocTitle` (with a title) and XMP metadata; with `tagged` (default) the structure tree lists, per slide in source order, headings (the `.title` placeholder), paragraphs, figures with their `aria-label` as `/Alt` and link elements bound to their link annotations, and decoration (backgrounds, rules, bullets) is marked as an artifact. Not done: PDF/UA or PDF/A claims, list and table structure, per-span languages; text inside a translucent group is not in the structure tree.
123
+ - 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).
124
+ - Arbitrary SVG is accepted, not only the renderer's: CSS named colours, `hsl()`, `rgb()`, `style=` attributes, `<style>` rules with type, class and id selectors, percentage geometry, `<symbol>` through `<use>`, nested `<svg>`, `<switch>`, gradient and pattern fills on text, `clip-rule` are drawn; features that are not drawn (`rotate`, `dominant-baseline`, `baseline-shift`, `text-transform`, `font-variant`, `paint-order`, `mix-blend-mode`, `writing-mode`, markers, `spreadMethod` reflect/repeat, `textPath`, CSS combinators) are reported as `pdf-unsupported-feature`, `pdf-unsupported-paint` or `pdf-unsupported-css`, and a character no supplied font has is reported as `pdf-glyph-missing`; with `strict: true` each of these throws. Input is bounded: at most 50,000 elements per page (a `<use>` expansion counts every copy; `pdf-expansion-limit`), group nesting of 256, XML nesting of 1,000, and 40 megapixels per picture.
125
+ - Effects with no PDF form (SVG `filter`, `mask`, nested SVG pictures) rasterize that one element, at `rasterFallbackScale` (default 2), and say so through `onDiagnostic` (`pdf-raster-fallback`, with the `data-opf-path` of the element). With `strict: true` the export throws instead. A vector export never turns a whole slide into an image silently.
126
+
127
+ `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.
128
+
129
+ ```js
130
+ const pdf = await svgToPdf(svgs, {
131
+ fontFiles: fonts.fontFiles, useBundledFonts: false,
132
+ metadata: { title: "Quarterly review", author: "Finance", language: "en-GB" },
133
+ onDiagnostic: (d) => d.code === "pdf-raster-fallback" && console.warn(d.path, d.reason),
134
+ });
135
+ ```
136
+
137
+ The default changed from raster to vector in the release that added this section; callers that need the previous output pass `mode: "raster"`. Vector mode ignores `scale` and `dpi`. The page is painted white by default, as raster mode composites on white; `background: "none"` leaves it unpainted and any other colour paints that.
138
+
139
+ ## PNG and PDF in a browser
140
+
141
+ `@openpresentation/opf-render/export-browser` exports `svgToPdf(svgs, options)` and `svgToPng(svg, options)` for a page. They take the SVG the preview draws and need no Node module, no network and no system font (a bundle of this entry contains no sharp, resvg, fs or crypto, and the vector converter's SHA-256 is plain JavaScript).
142
+
143
+ - **Vector PDF** (default) is the same converter the Node export uses (selectable real text in embedded font subsets, paths, gradients, patterns, clips, opacity, links, tagged structure, byte-identical output for the same SVG). Fonts come from the SVG's own `@font-face` data (pass `embeddedFonts` to `renderSvg`, for example `registry.selectEmbeddedFonts(() => true)` marked `embed: "used"` so each slide embeds only the faces it draws) or from `fontData: [{ data, family? }]`; `useBundledFonts`, `fontFiles` and `fontDirs` do not exist here and `loadSystemFonts: true` is rejected. A face the registry never loaded is never embedded.
144
+ - **Pictures** are decoded by the browser (PNG, JPEG, WebP, GIF through `createImageBitmap`, EXIF orientation applied); an upright JPEG is passed through compressed, as in Node. An effect with no PDF form (filter, mask, a nested SVG picture) rasterizes only that element on a canvas and reports `pdf-raster-fallback`.
145
+ - **Raster PDF** (`mode: "raster"`, `scale` default 2) draws each slide on a canvas as an image.
146
+ - **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).
147
+ - `signal` (an `AbortSignal`, checked between pages) and `onProgress({ page, pages })` serve large decks; the same two options also work in the Node `svgToPdf`. The browser export yields to the page between slides.
148
+
149
+ 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.
150
+ 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).
106
151
 
107
152
  ## Browser preview and fonts
108
153
 
@@ -121,6 +166,68 @@ container.innerHTML = renderSvg(presentation, {
121
166
 
122
167
  Each entry contains `url` or `data: Uint8Array`, with optional `family`, `weight`, `italic` and `license`. The loader registers browser FontFaces using the same bytes used for measurement. It fetches only URLs supplied by the host, supports an AbortSignal and custom fetch, and awaits font loading. Use pinned static faces and retain their licenses. For standalone SVG export also pass `embeddedFonts: fonts.embeddedFonts`; embedding is unnecessary for each live draft after browser fonts are loaded.
123
168
 
169
+ ## Player and `<opf-deck>` (RR-28)
170
+
171
+ A slideshow player and an embeddable web component, both built on `renderSvg`: the slide a page shows is the slide the preview, the editor and the PDF show, with no second layout engine. They are plain ES modules, typed, framework-free and tree-shakeable, and importing them touches no DOM, so they are safe in Next.js and other server renderers.
172
+
173
+ ```html
174
+ <script type="module">
175
+ import '@openpresentation/opf-render/element/define'; // registers <opf-deck>
176
+ </script>
177
+ <opf-deck src="/deck.opf.json" fonts="/opf-fonts/" thumbnails present></opf-deck>
178
+ ```
179
+
180
+ ```js
181
+ // A framework page: register on the client, when you choose.
182
+ import { defineOpfDeck } from '@openpresentation/opf-render/element';
183
+ useEffect(() => { defineOpfDeck(); }, []);
184
+ // ...and render <opf-deck src="/deck.opf.json" fonts="/opf-fonts/" />.
185
+ ```
186
+
187
+ Entry points (package `exports`): `/element` (`defineOpfDeck`, `getOpfDeckElement`, `renderDeckHtml`, `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.
188
+
189
+ ### Fonts: one self-hosted directory
190
+
191
+ Layout is estimated and text uses the visitor's system sans-serif until you give the element a font root, a directory you serve from your own origin. Nothing is ever requested from a font CDN, and the only requests an `<opf-deck>` makes are its `src` and the font files below.
192
+
193
+ ```sh
194
+ npx opf-preview-fonts public/opf-fonts # base and vendored faces (about 34 MiB), every SHA-256 verified
195
+ npx opf-preview-fonts public/opf-fonts --scripts Jpan,Arab # plus Noto script faces (large; `all` for every script)
196
+ ```
197
+
198
+ ```
199
+ <root>/base/<package>/<file> the eager Office and base faces (Roboto Regular loads at startup, the rest on demand)
200
+ <root>/lazy/fonts/<family>/<file> the vendored faces (Intos for the default Aptos scheme, the open families)
201
+ <root>/scripts/<package>/<file> the Noto script faces
202
+ <root>/LICENSES.txt every license notice
203
+ ```
204
+
205
+ It is the layout the editor playground and the OpenPresentation sites serve, and `fonts="/opf-fonts/"` (or `fontRegistry`, a registry the page already has) loads faces on demand like the renderer's browser host: face level (a plain Aptos deck fetches Roboto Regular, Intos Display Bold and Intos Regular, about 1.5 MB), hash-verified, and one registry per root on a page. If the font root cannot be read the deck still draws, with estimated layout, and the element fires a non-fatal `error` event. A face that is not in the root (a script you did not copy) falls back to a system font.
206
+
207
+ ### `<opf-deck>`
208
+
209
+ Attributes: `src` (OPF JSON; the other request the element makes), `slide` (1-based; counts the slides that play), `fonts`, `thumbnails` (a strip of slide thumbnails), `controls="none"`, `present` (a Present button), `include-hidden`, `label`, `keyboard="off"`. Instead of `src` set the `document` property (an object or JSON text), or put the document in a child `<script type="application/opf+json">`. Properties and methods: `document`, `slide`, `total`, `fontRegistry`, `renderOptions` (extra `renderSvg` options such as `catalogs` and `imageResolver`), `currentSlide` (`{ slide, total, index, id, title, notes, section }`; `notes` is plain text), `ready`, `next()`, `previous()`, `first()`, `last()`, `goto(n)`, `reload()` and `present(options)`.
210
+
211
+ Events: `ready`, `slidechange` (the same detail as `currentSlide`; bubbles and is composed), `error` (`{ code, message, fatal }`, not bubbling), `presentstart` and `presentend`. Style it with custom properties (`--opf-deck-fg`, `--opf-deck-border`, `--opf-deck-radius`, `--opf-deck-focus`, `--opf-deck-accent`, `--opf-deck-stage`) and the parts `deck`, `viewport`, `slide`, `bar`, `button`, `previous`, `next`, `present`, `counter`, `thumbnails` and `thumbnail`.
212
+
213
+ 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.
214
+
215
+ 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).
216
+
217
+ ### Server markup
218
+
219
+ `renderDeckHtml(deck, options)` returns the tag with the deck's slides as inline SVG inside it. A visitor without JavaScript, a crawler or a reader sees the slides; when the element upgrades its shadow DOM replaces them. `slides: 'first'` (default: the first slide and a list of titles), `'all'` or slide numbers; `embed: true` adds the document as an `application/opf+json` child so the upgrade needs no request (it includes hidden slides and notes, as the `src` file does); `renderOptions` takes a `textMeasurement` for exact widths. In Next.js, render the string from a server component with `dangerouslySetInnerHTML` and call `defineOpfDeck()` from a client component. A strict `style-src` Content-Security-Policy needs `style-src-attr 'unsafe-inline'` for the renderer's `style="white-space:pre"` attributes; the element's own styles use constructable stylesheets.
220
+
221
+ ### The slideshow
222
+
223
+ `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.
224
+
225
+ 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`.
226
+
227
+ 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.
228
+
229
+ 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.
230
+
124
231
  ## Open font-scheme families (FF-31)
125
232
 
126
233
  Font schemes select openly licensed Latin families that no Office substitute covers, and the font policy routes proprietary families to open replacements (Red Hat for Segoe UI and Tahoma and, since FF-43, Barlow, Anton, Figtree, Work Sans, EB Garamond, Archivo Narrow, Libre Caslon Text and Bitter). The office pack vendors them as static faces (`fonts/<family>/`: only the used faces, the upstream OFL notice as `OFL.txt` and a generated `PROVENANCE.json`), so `loadOfficeFontRegistry()` and `prepareNodeFonts({ pack: 'office' })` resolve each to its own face, and a strict registry no longer throws `font-unavailable` for them:
@@ -160,7 +267,7 @@ Embedding: open faces are flagged `embed: "used"`. `registry.embeddedFonts` stay
160
267
 
161
268
  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.
162
269
 
163
- The replacement faces are an optional, hash-pinned font pack: 63 static regular and bold faces from 31 `@expo-google-fonts/noto-*` packages (SIL OFL 1.1). The pinned faces total 66.9 MiB, of which 55.9 MiB is CJK. Installing all 31 packages takes about 325.8 MiB, because they also ship weights the manifest does not pin. Thirty of the 31 are exact optional peer dependencies, so the renderer install does not grow. The exception is `@expo-google-fonts/noto-sans` (Latin, Cyrillic and Greek; regular, bold, italic and bold italic, about 14 MiB installed), which is a pinned runtime dependency because it is the default glyph-fallback face: `loadOfficeFontRegistry` and `prepareNodeFonts({pack: 'office'})` always load it, marked fallback-only (it serves glyph fallback and requests for Noto Sans, never stands in for another family, and is embedded in a standalone SVG only when its text draws it, like the open families (embed "used"); raster output reads it from `fontFiles`). Install only the other scripts you need, then load them:
270
+ The replacement faces are an optional, hash-pinned font pack: 70 static faces from 37 `@expo-google-fonts/*` packages (SIL OFL 1.1): 63 regular and bold Noto script faces from 31 packages, plus the FF-45 symbol packs (`noto-sans-symbols`, `noto-sans-symbols-2` and `noto-sans-math`, under `Zsym`: the Symbol, Wingdings and Webdings code tables draw with them), the FF-45 emoji pack (`noto-color-emoji`: Noto Color Emoji, COLRv1 and OT-SVG colour glyphs, 24.0 MiB; `noto-emoji`: the monochrome face the raster path draws) under the pseudo-script `Zsye` and the math pack (`stix-two-math`, `noto-sans-math`) under `Zmth` (Noto Sans Math serves both Zmth and Zsym). The pinned script faces total 66.9 MiB, of which 55.9 MiB is CJK; the emoji and math faces add 27.5 MiB and the symbol faces another 1.3 MiB. Installing all 37 packages takes about 361 MiB, because they also ship weights the manifest does not pin. Thirty-six of the 37 are exact optional peer dependencies, so the renderer install does not grow. The exception is `@expo-google-fonts/noto-sans` (Latin, Cyrillic and Greek; regular, bold, italic and bold italic, about 14 MiB installed), which is a pinned runtime dependency because it is the default glyph-fallback face: `loadOfficeFontRegistry` and `prepareNodeFonts({pack: 'office'})` always load it, marked fallback-only (it serves glyph fallback and requests for Noto Sans, never stands in for another family, and is embedded in a standalone SVG only when its text draws it, like the open families (embed "used"); raster output reads it from `fontFiles`). Install only the other scripts you need, then load them. Segoe UI Emoji previews with Noto Color Emoji and Cambria Math with STIX Two Math once their packs are loaded (`scripts: 'auto'` loads them for text with emoji-presentation clusters or mathematical notation, or a scheme that names the family); emoji sequences stay one run and one glyph, browsers draw them in colour, and PNG/PDF output draws the monochrome Noto Emoji because resvg has no colour-glyph support. The emoji pack is opt-in by use: `scripts: 'auto'` (Node) and `ensureScripts` (browsers) load Noto Color Emoji (24 MiB) only for a deck with emoji-presentation text or a scheme that names Segoe UI Emoji, and it is an optional peer dependency, so it is not in the npm tarball; `scripts: 'all'` loads it too, so pass an explicit script list when memory or download size matters. The PPTX keeps the chosen family either way:
164
271
 
165
272
  ```js
166
273
  import { prepareNodeFonts } from '@openpresentation/opf-render/fonts-node';
@@ -215,7 +322,7 @@ Characters are assigned to slots following PowerPoint where its rules are known:
215
322
 
216
323
  Limits:
217
324
 
218
- - Alignment stays absolute, as authored or composed. RTL decks are not right-aligned automatically.
325
+ - Alignment is logical for right-to-left text (RR-05, needs the core release that composes right-to-left decks): `left` is the start edge, so a right-to-left paragraph is drawn against the right edge, its list markers sit at the right (`text-anchor="end"`), the composition is mirrored (the `left` region and the first table column at the right, cover logo and header/footer zones swapped), tables run right to left and column, line and area charts reverse their categories with the value axis at the right. Every wrapped line takes its paragraph's direction from core (`fit.directions`). A left-to-right deck is drawn exactly as before.
219
326
  - Known differences from PowerPoint: the character tables above are approximations, not PowerPoint's full per-character table. Non-ASCII common characters, such as Latin-1 symbols and other general punctuation, join the neighbouring run instead of following PowerPoint's per-character slot, and PowerPoint's `hint="eastAsia"` run property is not modelled.
220
327
  - Per-run language (`lang` on individual runs) is not modelled. Han text uses kana or Hangul context, then the document language, and defaults to Simplified Chinese.
221
328
  - Faces have no italics, so italic script text uses upright advances. Browsers may slant it synthetically.
@@ -225,6 +332,26 @@ Limits:
225
332
 
226
333
  These browser entrypoints are included in the published package. For coordinated development, the sibling OPF repository's `pnpm pack:ecosystem` prepares local npm tarballs. See the core repository's [live editor guide](https://github.com/OpenPresentation/opf/blob/main/docs/live-editor.md) for installation and the fidelity contract. Identical SVG geometry does not guarantee identical raster pixels across browser engines or PowerPoint.
227
334
 
335
+ ## Templates and variables (RR-32)
336
+
337
+ A deck that declares content variables, or is marked `"template": true`, is resolved by core `resolveVariables` before it is composed, so the preview and the PPTX exporter agree. Pass the values as `variables`:
338
+
339
+ ```js
340
+ import { renderSvgDeck } from '@openpresentation/opf-render';
341
+
342
+ renderSvgDeck(template, { variables: { client: 'Globex', revenue: 1250000 } });
343
+ ```
344
+
345
+ A template previews with each unfilled variable's `example` and reports `variable-example-used` through `onDiagnostic`; a variable with no example keeps its `{{id}}` text. A normal deck with an unfilled required variable throws `OPFRenderError` with code `unfilled-variables`, and a value of the wrong kind throws `invalid-variables`. `resolvePresentation(...).presentation` is the concrete deck. `variables: false` draws the document as authored, with tokens and `var:` references visible (the editor canvas's view of a template, so inline edits never overwrite a token). Decks without content variables are untouched. Needs the core release that ships `resolveVariables`; with an older core the option is ignored. See [templates and variables](https://github.com/OpenPresentation/opf/blob/main/docs/templates-and-variables.md).
346
+
347
+ ### Symbol-encoded families: Symbol, Wingdings, Webdings (FF-45)
348
+
349
+ Symbol, Wingdings, Wingdings 2, Wingdings 3 and Webdings keep their glyphs at the 224 codes 0x20..0xFF of a Microsoft Symbol cmap, not at Unicode code points, and none of them may be bundled. A run or design font in one of them no longer fails with `font-encoding-required`: each character is normalised to its code (the private-use character U+F0xx that Office writes for Insert > Symbol and `a:sym` runs, or the Windows-1252 character of the code, so "l" and U+F06C are the same Wingdings bullet) and mapped through core's reversible, version-specific tables (`@openpresentation/opf` `spec/reference/symbol-font-encodings.json`, snapshot `src/symbol-encodings.js`; `mapSymbolText('Wingdings', 'l')` gives U+26AB) to the Unicode equivalent, which the first loaded face of the family's chain draws: `SYMBOL_PREVIEW_FACES` is Noto Sans Symbols 2, Noto Sans Symbols, Noto Sans Math, Noto Sans for the dingbat fonts and Noto Sans (Greek letters, digits, punctuation) first for Symbol. The three symbol packages are optional peers of the script pack under the key `Zsym` (`scripts: ['Zsym']`; `scripts: 'auto'` selects it when a run, `design.fonts` or a font scheme names one of the families; `registry.ensureScripts(presentation)` in a browser): `@expo-google-fonts/noto-sans-symbols-2`, `noto-sans-symbols` and `noto-sans-math`, OFL-1.1, together about 1.2 MiB. Every code the tables map (1059 of 1120: Symbol 189, Wingdings 222, Wingdings 2 217, Wingdings 3 208, Webdings 223) draws a real glyph when the pack is loaded; the 61 codes without a Unicode equivalent (the Wingdings 0xFF Windows logo, unassigned codes) draw the placeholder U+25A1. Each glyph is one positioned tspan at the advance of the verified Windows font (Wingdings 5.01, Wingdings 2 and 3 1.55, Webdings 5.01, Symbol 5.01), in measurement and drawing alike, so line breaks and bullet gaps follow PowerPoint; the open glyph keeps its shape and is compressed to its code's advance only when wider, never stretched. Characters that are not codes (a CJK letter in a Wingdings run) draw as themselves. Without the pack, Symbol's Greek letters, digits and punctuation draw with the office pack's Noto Sans, the other codes draw U+FFFD, and the `font-glyph-fallback` diagnostic (`codes`, `placeholder`) names the pack; a registry with no chain face and no `fallbackFamily` throws `font-encoding-required`, naming it too. `resolveFont` and `resolveStyle` report `symbolEncoding` with the substitute face, so exporters keep writing the chosen family and the original characters, and the PPTX names Wingdings with its codes. The symbol faces also end the glyph fallback chain. Appearance is the open face's, not Microsoft's; native PowerPoint verification of the exported runs is separate (FF-46).
350
+
351
+ ## Numbered lists (RR-33)
352
+
353
+ An `items` or `bullets` payload with a `numbering` field draws numbers instead of bullets: `{ "items": ["Define", "Build", "Ship"], "numbering": "roman-lower" }` previews as `i.`, `ii.`, `iii.`. The number is core's composed marker (`ListEntryLayout.marker.text`), drawn at core's marker position, baseline and size with the weight and slant core measured it at (a native PowerPoint auto-number takes the first run's bold and italic), at the wider hanging indent core composes for wide markers (`viii.`, `10.`). Bullet lists, picture bullets and every deck without `numbering` draw exactly as before. Needs a core release that composes `numbering`; an older core ignores the field and draws bullets. See core's [numbered lists](https://github.com/OpenPresentation/opf/blob/main/docs/numbered-lists.md).
354
+
228
355
  ## Runtime Policy
229
356
 
230
357
  The package runtime must stay local and deterministic:
@@ -240,14 +367,17 @@ Dependency policy:
240
367
 
241
368
  - `@openpresentation/opf` is the compatibility source for schemas, validation, and bundled catalogs.
242
369
  - `@resvg/resvg-js` is used only for local SVG rasterization in Node; it makes no network calls and does not require a browser.
243
- - `pdf-lib` assembles PDF bytes locally. Metadata timestamps are disabled so repeated PDF output is byte-stable for the same SVG input and options.
370
+ - `pdf-lib` assembles raster-mode PDF bytes locally. Metadata timestamps are disabled so repeated PDF output is byte-stable for the same SVG input and options.
371
+ - 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.
244
372
  - Bundled OFL Roboto and Roboto Mono TTF files provide the default deterministic font fallback.
245
- - `svgToPng` and `svgToPdf` disable system-font loading by default. Hosts that require branded fonts should pass explicit `fontFiles` or `fontDirs`; `loadSystemFonts: true` is an opt-in escape hatch and can make output environment-dependent.
373
+ - `svgToPng` and `svgToPdf` disable system-font loading by default. Hosts that require branded fonts should pass explicit `fontFiles` or `fontDirs`; `loadSystemFonts: true` is an opt-in escape hatch for PNG and raster-mode PDF output and can make it environment-dependent. Vector-mode PDF rejects it (`pdf-system-fonts-unsupported`): it embeds only fonts you supply, so a system font can never be embedded by accident.
246
374
 
247
- Browser support boundary: `renderSvg`, `renderSvgDeck`, and `resolvePresentation` are browser-importable pure JavaScript APIs. `svgToPng` and `svgToPdf` are Node APIs in this package version because they depend on the Node build of resvg.
375
+ Browser support boundary: `renderSvg`, `renderSvgDeck`, and `resolvePresentation` are browser-importable pure JavaScript APIs. The root `svgToPng` and `svgToPdf` are Node APIs because they depend on the Node builds of resvg and sharp. For a page, `@openpresentation/opf-render/export-browser` has the same two names (see below).
248
376
 
249
377
  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).
250
378
 
379
+ Chart options (RR-35): a chart's optional `axisTitles`, `legend` and `dataLabels` (core `docs/chart-options.md`) are drawn. A named legend position is carved from the chart box before the plot is laid out, axis titles sit beside the axes (the value title rotated 270 degrees), and data labels follow the content (category, value, percent), position and separator the chart type supports. An option a type cannot show is dropped with a `chart-option-adapted` diagnostic; a chart without the fields draws as before.
380
+
251
381
  Classic chart previews keep finite axis coordinates for subnormal values and values up to `Number.MAX_VALUE`, including mixed positive/negative ranges. At these limits, available numeric precision can merge adjacent axis ticks or prevent extra headroom. Authored values are unchanged. Stacked, percentage or pie/doughnut totals that overflow the finite numeric range are still unsupported: rendering throws a `RangeError` naming the chart path and asking the caller to rescale the values. It does not substitute an empty chart or silently discard the overflowing values.
252
382
 
253
383
  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).
@@ -274,7 +404,7 @@ npm run golden:update
274
404
 
275
405
  Public npm package publication is handled by `.github/workflows/npm-publish.yml` through npm Trusted Publishing (GitHub Actions OIDC) with npm provenance; no npm token is stored. The owner authorized agents to prepare and publish npm releases whenever a release is required (2026-09-29). This authorization does not waive any gate.
276
406
 
277
- 1. Open a release-prep PR containing only the version bump, `CHANGELOG.md`, dependency ranges, lockfile and current-instruction docs. Publish in dependency order (core, then renderer and PPTX, then editor): refresh this repo's lockfile only after the required `@openpresentation/opf` version is on the registry (`npm install --package-lock-only`), then run `npm run test:packed` against it.
407
+ 1. Open a release-prep PR containing only the version bump, `CHANGELOG.md` (assembled from `changes/` with `node scripts/changelog-fragments.mjs assemble --version X.Y.Z`), dependency ranges, lockfile and current-instruction docs. Publish in dependency order (core, then renderer and PPTX, then editor): refresh this repo's lockfile only after the required `@openpresentation/opf` version is on the registry (`npm install --package-lock-only`), then run `npm run test:packed` against it.
278
408
  2. Merge after CI is green, then publish by pushing the git tag `opf-render-v<version>` (or `@openpresentation/opf-render@v<version>`) at the merge commit. The workflow verifies that the tag matches `package.json` and reruns audit, typecheck, validate, tests, packed and browser checks before `npm publish --access public --provenance`. A manual `workflow_dispatch` runs the same job without the tag check and is a fallback only.
279
409
  3. Verify with `npm view @openpresentation/opf-render@<version> version gitHead dist.attestations` and, from the core repo, `node scripts/test-renderer-publication.mjs <version> <release-commit> <this-checkout>`. Never republish an existing version.
280
410
 
@@ -288,7 +418,7 @@ Version 0.2.x requires the published `@openpresentation/opf@^0.4.1` for composit
288
418
 
289
419
  For actual font measurement, load `loadBundledFontRegistry()` from `@openpresentation/opf-render/fonts-node`, then pass its `textMeasurement` to rendering and its `embeddedFonts` to SVG export. Pass its `fontFiles` to PNG/PDF conversion. The browser-safe `@openpresentation/opf-render/fonts` entry accepts local font bytes. Missing fonts/glyphs fail explicitly; aliases and fallback are opt-in and recorded in `registry.substitutions`. Bundled font license notices travel with embedded SVG fonts.
290
420
 
291
- Raster updates produce an HTML gallery and `artifacts/golden/candidate.json`; they never overwrite the approved baseline automatically. See [baseline review and known limitations](test/golden/README.md).
421
+ Raster updates produce an HTML gallery and `artifacts/golden/candidate.json`; they never overwrite the approved baseline automatically (baselines are directories with one file per deck; `npm run golden:promote` writes only the decks that moved). See [baseline review and known limitations](test/golden/README.md).
292
422
 
293
423
  Version 0.1.1 adds rich-text line offsets and measured boxes to opt-in SVG tracing, including blank lines, for editor caret placement. Normal SVG/PNG output is unchanged.
294
424
 
@@ -296,7 +426,7 @@ Version 0.1.1 adds rich-text line offsets and measured boxes to opt-in SVG traci
296
426
 
297
427
  PNG and PDF conversion now decode embedded WebP image data URIs to static PNG before rasterization. This preserves fit/crop, transparency, EXIF orientation and the first animation frame. The source SVG and OPF document remain unchanged; browser SVG output keeps the original embedded image. Both href and legacy xlink:href are supported, including base64 and percent-encoded data. This step does not resolve local filenames, download remote images or change the existing host imageResolver contract.
298
428
 
299
- The Node-only raster path lazily loads pinned Sharp 0.35.4, requiring Node 24 for this package and normal installation of its optional platform binaries. Browser exports do not include the decoder. Malformed embedded WebP produces image-conversion-failed with its data-opf-path when present, otherwise svg.images.N. Decoding has a 40-megapixel limit. PNG/PDF are static outputs; animation beyond the first frame and original image metadata are not retained.
429
+ The Node-only raster path lazily loads pinned Sharp 0.35.4, requiring Node 22 or later for this package and normal installation of its optional platform binaries. Browser exports do not include the decoder. Malformed embedded WebP produces image-conversion-failed with its data-opf-path when present, otherwise svg.images.N. Decoding has a 40-megapixel limit. PNG/PDF are static outputs; animation beyond the first frame and original image metadata are not retained.
300
430
 
301
431
  Regression checks compare six fixtures against independent Pillow pixels, verify fit/crop, inspect actual PDF image streams and transparency masks, and exercise input encodings and diagnostics. All 805 existing raster baselines remain unchanged. Test fixtures are project-authored and MIT licensed; the decoder and its bundled codecs retain their upstream licenses.
302
432
 
@@ -0,0 +1,45 @@
1
+ // Footnotes, citations and captions (RR-34). Core composition owns every box here: a captioned
2
+ // item carries `item.caption` (the band inside the block region; `item.box` is the media box), a
3
+ // slide whose runs cite or carry footnotes carries `geometry.footnotes` (the area above the footer
4
+ // band), and marker fragments (`kind: "marker"`) are drawn by the rich-text path in svg.js. This
5
+ // module only paints those boxes with the text fits core accepted. The draw helpers of svg.js are
6
+ // injected (`draw`) so the module needs no DOM and svg.js keeps its private helpers.
7
+
8
+ /** The caption band of a captioned item, in the muted text colour at core's caption box. */
9
+ export function renderCaption(item, bound, options, draw) {
10
+ const caption = item.caption;
11
+ if (!caption) return "";
12
+ // A string or TextRun[] caption is its own value; the object form draws its `text`, so an inline
13
+ // edit through the traced path never replaces the position or alignment.
14
+ const path = typeof caption.value === "string" || Array.isArray(caption.value) ? caption.path : `${caption.path}.text`;
15
+ const config = { path, fit: caption.fit, textStyle: caption.textStyle, fontFamily: caption.textStyle?.fontFamily ?? bound.design.fonts.body, fontSize: 15, align: caption.alignment, fill: bound.design.colors.mutedText, diagnosticsHandled: true, options, rich: Array.isArray(caption.text) };
16
+ const body = Array.isArray(caption.text)
17
+ ? draw.renderRichLines(caption.text, caption.fit, caption.box, bound, config)
18
+ : draw.renderTextBox(caption.text, caption.box, bound, config);
19
+ const trace = options.trace ? { "data-opf-caption": caption.position, "data-opf-caption-of": item.path, "data-opf-box-x": caption.box.x, "data-opf-box-y": caption.box.y, "data-opf-box-width": caption.box.width, "data-opf-box-height": caption.box.height } : {};
20
+ return draw.tag("g", trace, body);
21
+ }
22
+
23
+ /**
24
+ * The slide's footnote area: a rule in the border colour, then `<n> <text>` for each note the slide
25
+ * uses, in number order, in the muted text colour. The listed lines are not source text (a reference
26
+ * is listed with its number in front), so they carry no `data-opf-path`; the source of each entry is
27
+ * traced on its group instead.
28
+ */
29
+ export function renderFootnotes(bound, options, draw) {
30
+ const footnotes = bound.geometry.footnotes;
31
+ if (!footnotes) return "";
32
+ const untraced = { ...options, trace: false };
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 });
35
+ const entries = footnotes.entries.map(entry => {
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
+ const body = Array.isArray(entry.value)
38
+ ? draw.renderRichLines(entry.value, entry.fit, entry.box, bound, config)
39
+ : draw.renderTextBox(entry.value, entry.box, bound, config);
40
+ const trace = options.trace ? { "data-opf-footnote": String(entry.number), "data-opf-footnote-kind": entry.kind, "data-opf-footnote-source": entry.sourcePath, ...(entry.id !== undefined ? { "data-opf-footnote-id": entry.id } : {}), "data-opf-box-x": entry.box.x, "data-opf-box-y": entry.box.y, "data-opf-box-width": entry.box.width, "data-opf-box-height": entry.box.height, ...(entry.overflow ? { "data-opf-overflow": "true" } : {}) } : {};
41
+ return draw.tag("g", trace, body);
42
+ });
43
+ const trace = options.trace ? { "data-opf-footnotes": footnotes.path, "data-opf-box-x": footnotes.box.x, "data-opf-box-y": footnotes.box.y, "data-opf-box-width": footnotes.box.width, "data-opf-box-height": footnotes.box.height, "data-opf-footnote-markers": JSON.stringify(footnotes.markers.map(marker => [marker.path, marker.text])) } : {};
44
+ return draw.tag("g", trace, [line, ...entries].join("\n"));
45
+ }
@@ -0,0 +1,177 @@
1
+ // RR-35: chart options in the preview (axis titles, legend position, data labels).
2
+ //
3
+ // Core owns the normalisation and the per-type support table (`resolveChartOptions`); this module owns the drawing.
4
+ // A chart that carries none of the three fields resolves to `active: false` and never reaches any code here, so
5
+ // its SVG is exactly what it was before.
6
+ import * as core from "@openpresentation/opf/composition";
7
+
8
+ const INACTIVE = Object.freeze({ active: false, axisTitles: {}, diagnostics: [] });
9
+
10
+ /**
11
+ * The resolved options of a chart. An older core without `resolveChartOptions` ignores the fields, like any other
12
+ * field it does not know. `spec` is the catalog spec this module's charts.js resolved (`kind`, `grouping`).
13
+ */
14
+ export function resolveOptions(chart, spec) {
15
+ if (typeof core.resolveChartOptions !== "function" || !chart || typeof chart !== "object") return INACTIVE;
16
+ return core.resolveChartOptions(chart, { kind: spec.kind, stacked: spec.grouping === "stacked" || spec.grouping === "percentStacked" });
17
+ }
18
+
19
+ export function reportOptionDiagnostics(c, resolved) {
20
+ for (const diagnostic of resolved.diagnostics) {
21
+ c.svg.reportDiagnostic?.({ ...diagnostic, path: `${c.path}.${diagnostic.option}` }, c.options);
22
+ }
23
+ }
24
+
25
+ /** Which edge carries which axis title: the category axis of a bar or funnel chart is vertical (left). */
26
+ function titleSides(spec) {
27
+ const verticalCategory = (spec.kind === "bar" && spec.dir === "bar") || spec.kind === "funnel";
28
+ return verticalCategory ? { category: "left", value: "bottom" } : { category: "bottom", value: "left" };
29
+ }
30
+
31
+ /**
32
+ * Carve the axis title bands out of the chart box (the legend is carved first, by charts.js, from the outer edge).
33
+ * The renderers lay the plot out inside `c.box`, so they need no knowledge of the bands.
34
+ */
35
+ export function reserveAxisTitles(c, spec, titles) {
36
+ const sides = titleSides(spec);
37
+ const bands = { left: 0, bottom: 0 };
38
+ c.axisTitleBands = [];
39
+ for (const axis of ["category", "value"]) {
40
+ const text = titles[axis];
41
+ if (!text) continue;
42
+ const side = sides[axis];
43
+ bands[side] = c.lineHeight;
44
+ c.axisTitleBands.push({ axis, side, text });
45
+ }
46
+ if (!c.axisTitleBands.length) return;
47
+ const outer = c.box;
48
+ c.axisTitleOuter = outer;
49
+ c.box = { x: outer.x + bands.left, y: outer.y, width: Math.max(1, outer.width - bands.left), height: Math.max(1, outer.height - bands.bottom) };
50
+ }
51
+
52
+ /**
53
+ * Draw the axis titles after the chart: the bottom title centred under the plot, the left title rotated 270 degrees
54
+ * (reading upwards, as PowerPoint draws a vertical axis title) and centred on the plot. `c.plotArea` is the plot the
55
+ * renderer laid out; without one the title centres on the reserved band.
56
+ */
57
+ export function drawAxisTitles(c) {
58
+ if (!c.axisTitleBands?.length) return;
59
+ const outer = c.axisTitleOuter, inner = c.box, plot = c.plotArea;
60
+ for (const { axis, side, text } of c.axisTitleBands) {
61
+ const path = `${c.path}.axisTitles.${axis}`;
62
+ if (side === "bottom") {
63
+ const span = plot ? { x: plot.x, width: plot.width } : { x: inner.x, width: inner.width };
64
+ const rect = { x: span.x, y: outer.y + outer.height - c.lineHeight, width: span.width, height: c.lineHeight };
65
+ c.text(text, rect, path, "center");
66
+ } else {
67
+ const span = plot ? { y: plot.y, height: plot.height } : { y: inner.y, height: inner.height };
68
+ // The rotated text box is laid out horizontally, `length` wide and one line tall, then turned about its centre.
69
+ const length = Math.max(1, span.height), centreX = outer.x + c.lineHeight / 2, centreY = span.y + span.height / 2;
70
+ const rect = { x: centreX - length / 2, y: centreY - c.lineHeight / 2, width: length, height: c.lineHeight };
71
+ const element = c.textElement(text, rect, path, "center");
72
+ if (element) c.children.push(c.svg.tag("g", { transform: `rotate(-90 ${c.num(centreX)} ${c.num(centreY)})` }, element));
73
+ }
74
+ }
75
+ }
76
+
77
+ // ---------------------------------------------------------------------------
78
+ // Data labels
79
+
80
+ const INSIDE = new Set(["center", "inside-end", "inside-base"]);
81
+
82
+ /** The label text for one mark: the selected parts, General number format, integer percent. */
83
+ export function labelString(c, { category, value, share }) {
84
+ const labels = c.dataLabels;
85
+ const parts = {};
86
+ if (category !== undefined && category !== null) parts.category = String(category);
87
+ if (typeof value === "number" && Number.isFinite(value)) parts.value = core.formatChartLabelNumber(value);
88
+ if (typeof share === "number" && Number.isFinite(share)) parts.percent = core.formatChartLabelPercent(share);
89
+ return core.chartLabelText(parts, labels.content, labels.separator);
90
+ }
91
+
92
+ const labelSize = (c, text) => ({ width: c.width(text) + c.fontPx * 0.5, height: c.lineHeight });
93
+
94
+ function drawLabel(c, text, rect, path, fill) {
95
+ if (!text) return;
96
+ const element = c.textElement(text, rect, path, "center", fill);
97
+ if (element) c.children.push(element);
98
+ }
99
+
100
+ /** The text colour for a label placed inside a mark of colour `fill`: the contrasting one; outside marks keep the chart text colour. */
101
+ export function labelFill(c, position, fill) {
102
+ return INSIDE.has(position) && fill ? core.textColorForFill?.(fill, c.labelColor) ?? c.labelColor : c.labelColor;
103
+ }
104
+
105
+ /**
106
+ * A label on a bar or column. `rect` is the mark, `direction` the way the bar grows from its base
107
+ * ("up", "down", "right" or "left"), so inside-end and outside-end follow the sign of the value.
108
+ */
109
+ export function drawBarLabel(c, text, rect, direction, path, fill) {
110
+ const position = c.dataLabels.position ?? "center";
111
+ const { width, height } = labelSize(c, text);
112
+ let x, y;
113
+ const vertical = direction === "up" || direction === "down";
114
+ const toEnd = direction === "up" || direction === "left" ? "start" : "end"; // which edge of the rect is the end
115
+ const centerX = rect.x + rect.width / 2 - width / 2, centerY = rect.y + rect.height / 2 - height / 2;
116
+ if (vertical) {
117
+ x = centerX;
118
+ const endEdge = toEnd === "start" ? rect.y : rect.y + rect.height, baseEdge = toEnd === "start" ? rect.y + rect.height : rect.y;
119
+ const sign = toEnd === "start" ? -1 : 1; // growth direction along y
120
+ if (position === "outside-end") y = sign < 0 ? endEdge - height : endEdge;
121
+ else if (position === "inside-end") y = sign < 0 ? endEdge : endEdge - height;
122
+ else if (position === "inside-base") y = sign < 0 ? baseEdge - height : baseEdge;
123
+ else y = centerY;
124
+ } else {
125
+ y = centerY;
126
+ const endEdge = toEnd === "start" ? rect.x : rect.x + rect.width, baseEdge = toEnd === "start" ? rect.x + rect.width : rect.x;
127
+ const sign = toEnd === "start" ? -1 : 1;
128
+ if (position === "outside-end") x = sign < 0 ? endEdge - width : endEdge;
129
+ else if (position === "inside-end") x = sign < 0 ? endEdge : endEdge - width;
130
+ else if (position === "inside-base") x = sign < 0 ? baseEdge - width : baseEdge;
131
+ else x = centerX;
132
+ }
133
+ drawLabel(c, text, { x, y, width, height }, path, labelFill(c, position, fill));
134
+ }
135
+
136
+ /** A label beside a point mark (line and scatter): above, below, left, right or centred on it. */
137
+ export function drawPointLabel(c, text, point, path) {
138
+ const position = c.dataLabels.position ?? "above";
139
+ const { width, height } = labelSize(c, text);
140
+ const gap = 3 * c.pt + c.fontPx * 0.25;
141
+ const [px, py] = point;
142
+ const at = {
143
+ above: [px - width / 2, py - gap - height],
144
+ below: [px - width / 2, py + gap],
145
+ left: [px - gap - width, py - height / 2],
146
+ right: [px + gap, py - height / 2],
147
+ center: [px - width / 2, py - height / 2],
148
+ }[position] ?? [px - width / 2, py - gap - height];
149
+ drawLabel(c, text, { x: at[0], y: at[1], width, height }, path, c.labelColor);
150
+ }
151
+
152
+ /** A label at the middle of an area (or any mark with no position choice), centred on (x, y). */
153
+ export function drawCenteredLabel(c, text, x, y, path, fill) {
154
+ const { width, height } = labelSize(c, text);
155
+ drawLabel(c, text, { x: x - width / 2, y: y - height / 2, width, height }, path, fill ? labelFill(c, "center", fill) : c.labelColor);
156
+ }
157
+
158
+ /** A label on a pie or doughnut slice at the angle `mid`; `inner` is the ring's inner radius (0 for a pie). */
159
+ export function drawSliceLabel(c, text, { cx, cy, r, inner, mid }, path, fill) {
160
+ const { width, height } = labelSize(c, text);
161
+ const cos = Math.cos(mid), sin = Math.sin(mid);
162
+ const position = inner ? "center" : c.dataLabels.position ?? "outside-end";
163
+ const half = (width * Math.abs(cos) + height * Math.abs(sin)) / 2;
164
+ let radius;
165
+ if (inner) radius = (r + inner) / 2;
166
+ else if (position === "center") radius = r / 2;
167
+ else if (position === "inside-end") radius = Math.max(0, r - half - c.fontPx * 0.25);
168
+ else radius = r + half + c.fontPx * 0.25;
169
+ const x = cx + radius * cos, y = cy + radius * sin;
170
+ drawLabel(c, text, { x: x - width / 2, y: y - height / 2, width, height }, path, position === "outside-end" ? c.labelColor : labelFill(c, position, fill));
171
+ }
172
+
173
+ /** The room a pie needs around it for outside-end labels: the widest label and one line. */
174
+ export function outsideLabelReserve(c, texts) {
175
+ const widest = Math.max(0, ...texts.map((text) => c.width(text) + c.fontPx * 0.5));
176
+ return { x: widest + c.fontPx * 0.25, y: c.lineHeight + c.fontPx * 0.25 };
177
+ }