@openpresentation/opf-render 0.14.0 → 0.15.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,5 +1,12 @@
1
1
  # OPF Render
2
2
 
3
+ **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
+
5
+ - The renderer bundles, looks up and fetches no catalog. Every entry point (`renderSvg`, `renderSlideSvg`, `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 `renderSvg(deck, { catalogs: [defaultCatalog] })`. `strictReferences: true` fails with `unresolved-reference` instead of falling back.
6
+ - 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
+ - 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
+ - Every content picture is an image block drawn from `item.image`: frame, fit (`cover`, `contain`, `stretch`; default `design.imageFit`, else `cover`), focus, shape mask, border, opacity, recolor and overlay, and a placed block (`placement`) at its edge band.
9
+
3
10
  **0.14.0 (RR-55, with core 0.14.0, opf-pptx 0.14.0 and opf-editor 0.14.0).** One fonts handle, whole-deck and one-slide render functions, and core's slide context. This release renames and deletes outright (no aliases; the CHANGELOG has the old-to-new table):
4
11
 
5
12
  - `renderSvg(deck, options)` returns `string[]`, one SVG per slide; `renderSlideSvg(deck, index, options)` returns the SVG of one slide.
@@ -23,13 +30,13 @@ const pdf = await svgToPdf(slides, { fonts });
23
30
  const pptx = await toPptx(presentation, { fonts });
24
31
  ```
25
32
 
26
- A slide's canvas, layout, theme, colour scheme and font scheme come from core's `resolveSlideContext` (slide design, then deck design, then theme, then the default), so preview, pagination, validation and export resolve them one way. A slide with no `layout`, or one that names an id no catalog has, composes with no layout record. An unknown theme, colour scheme, layout or font scheme id never throws: the context falls back (`minimal`, `cool-horizon`, none, `aptos`) and the render reports `unresolved-theme`, `unresolved-color-scheme`, `unresolved-layout` or `unresolved-font-scheme` through `onDiagnostic`.
33
+ A slide's canvas, layout, theme, colour scheme and font scheme come from core's `resolveSlideContext` (slide design, then deck design, then theme, then the engine default), from the records the document embeds and the catalogs the host registers (`catalogs`), so preview, pagination, validation and export resolve them one way. A slide with no `layout` composes automatically. A reference that resolves nowhere never throws: the slide composes automatically or draws with core's engine defaults, and the render reports `unresolved-reference` (naming the reference and the catalog's source) through `onDiagnostic`; with `strictReferences: true` it throws `OPFRenderError` `unresolved-reference` instead.
27
34
 
28
35
  An option a function does not know is not read: a top-level `textMeasurement` or `embeddedFonts` is ignored, so pass `{ fonts }`.
29
36
 
30
37
  **Aptos previews with Intos.** The Office pack includes Intos, Intos Display, Intos Narrow and Intos Serif (16 faces, about 12 MB, vendored under `fonts/intos/`, SIL OFL 1.1, pinned to upstream commit fef9315c14da9e4b23b4c3cac8e718998d4e4736 and per-file SHA-256, unmodified from the upstream files). They are the metric-compatible previews for Aptos, Aptos Display, Aptos Narrow and Aptos Serif, the default `aptos` font scheme: 0.000% mean and maximum width difference against Aptos 2.01 in all four styles, with equal vertical metrics. The letter shapes are Intos's own, not Aptos's. The exported PPTX still names Aptos; no Aptos file is bundled or embedded. A registry built without these faces (the base pack) has no Aptos substitute: visual policy falls back to Roboto or Carlito. Like the open families, Intos is `embed: "used"`: it is not in `registry.embeddedFonts` (the 33 eager npm faces), the `loadFonts()` handle's `embeddedFonts` supplies it, and an SVG embeds only the Intos faces its text draws. `registry.lazyFonts` lists the 51 vendored faces (35 open, 16 Intos) with package-relative file and sha256, and `loadFonts({ faces, lazyFontsBaseUrl })` from `/fonts-browser` loads them on demand: `await fonts.ensure(presentation)` (the registry's `ensureLazyFonts` and `ensureScripts` are the lower-level calls) fetches and verifies only the faces the document draws, by family, weight and style (a plain Aptos deck: Intos Display Bold and Intos Regular, 2 files, 1.5 MB; an italic run adds Intos Italic, a bold run Intos Bold; the code face only when a slide has code), then adds them to the document and the registry together so measurement and painting never disagree. Faces a host serves itself load the same way: `loadFonts({ faces: startup, extraLazyFonts: [{ family, weight, italic, url, sha256 }] })` adds them to the lazy list (`registry.lazyFonts`, `package: "host"`), so the registry can start with Roboto Regular only (`splitStartupFaces(faces)` splits a manifest into the startup face and the rest) and a document draws, fetches and verifies just the faces it needs, vendored and host faces in one `ensureLazyFonts` call. `sha256` is mandatory, loads are all or nothing, `dispose()` removes the faces, and, like the vendored ones, host faces are `embed: "used"`: they are not in `registry.embeddedFonts` (only the startup faces are), and a standalone SVG embeds one only when passed explicitly through `registry.selectEmbeddedFonts` and the slide's text uses its family. `fonts.pending(presentation)` (and the registry's `pendingLazyFonts`) is the synchronous check hosts gate rendering on, and after an edit it reports just the newly needed faces. The faces are those the renderer itself measures and draws (`presentationFaces(presentation, options, registry)`, one layout-and-paint run with a recording measurement), resolved by the registry's own face selection over the faces it holds plus the vendored ones, so the browser loads exactly what Node draws with every vendored face loaded. A document resolves with the `renderSvg` options the host renders with: pass them as `loadFonts({ faces, renderOptions: { catalogs } })`, or per call (`pendingLazyFonts(presentation, { catalogs })`, `ensureLazyFonts(presentation, { signal, catalogs })`), so a layout or font scheme id that only the host's catalogs know resolves here too; a document that does not resolve throws (rejects) what `renderSvg` throws for it instead of reporting nothing. Node's `loadFonts` keeps loading the whole open pack and Intos eagerly, so nothing there is face level; with `scripts: 'auto'` they accept `renderOptions: { catalogs }` too. Hosts copy the vendored directories next to the page.
31
38
 
32
- Version 0.12.0 requires `@openpresentation/opf` ^0.12.0 (composed font sizes on PowerPoint's 0.01 pt grid, hanging wrap whitespace, promoted regions in reading order, right-to-left decks composed mirrored) and draws what that core composes, plus the preview changes listed in the changelog; use it with opf-pptx 0.12.0 and opf-editor 0.11.0 so preview and export resolve one core. Version 0.11.9 requires `@openpresentation/opf` ^0.11.4 and draws the design fields (deck and organization logos on covers and section slides, header and footer `logo` parts, `design.listBullet: image` picture bullets at the size PowerPoint draws them, the accent font, `contentDirection` and `chartPrimary` through core composition), and the slide tag falls back to the text colour when the scheme primary is under 4.5:1 against the background; furniture images sit on their zone edge. Use it with opf-pptx 0.11.7 and opf-editor 0.10.6 so preview and export resolve one core (no API change; the preview of covers, list slides with picture bullets, chart slides with a primary chart and slides with furniture images changes). Version 0.11.8 draws the slide tag in the scheme primary colour, as opf-pptx 0.11.5 writes it (no API change; the preview of slides with a tag changes). Version 0.11.7 lets a browser registry load the host's own faces on demand (`extraLazyFonts`, `splitStartupFaces`: a registry can start with Roboto Regular only; additive, `registry.lazyFonts` still lists the 94 vendored faces plus the host's). Version 0.11.6 makes the category-axis labels of chart previews rotate and skip instead of wrapping mid-word (no API change; the preview of dense-axis chart slides changes) and requires `@openpresentation/opf` ^0.11.3 (the 70 legacy gallery layout ids and their geometry); use it with PPTX 0.11.4 and editor 0.10.4 so preview and export resolve the same core. Version 0.11.5 loads vendored preview fonts by face, not by family, and lets the font loaders resolve documents with the host's render options (`presentationFaces`, `lazyFacesNeeded`; `pendingLazyFonts`, `ensureLazyFonts`, `pendingScripts` and `ensureScripts` take `catalogs` and throw or reject with the `renderSvg` error for a document that does not resolve; `registry.lazyFonts` still lists 94 faces). Version 0.11.4 previews the seven chartex chart types natively (the world map as a non-geographic tile grid), keeps the Latin Noto Sans replacement for script schemes under `scripts: 'auto'` in Node, shapes Noto Sans Mongolian again and bundles Raleway and Playfair Display (`registry.lazyFonts` lists 94 faces; no API change). Version 0.11.3 previews the classic chart types natively, draws deprecated chart ids as their replacement and scales value axes like Office (no API change; the preview of chart slides changes). Version 0.11.2 makes `scripts: 'auto'` load the script faces a font scheme names and adds `details.loadedFaceHasGlyph` to `missing-glyph` (no export change). Version 0.11.1 adds the open replacement font families the policy routes to and complete Red Hat styles (no API change). Version 0.11.0 requires `@openpresentation/opf` ^0.11.2 (cover centering, Intos Aptos previews, lazy font loading, automatic script fonts and glyph fallback); use it with PPTX 0.11.0 and editor 0.10.0 so preview and export resolve the same core. Version 0.10.0 required `@openpresentation/opf` ^0.11.1 (font policy table, script fonts and right-to-left paragraphs, socials, slide-image treatments, formatted slide-number and date furniture with the host `date` option, per-item alignment). See the changelog for the intentional preview-output changes.
39
+ 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, 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.
33
40
 
34
41
  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.
35
42
 
@@ -43,7 +50,7 @@ Version 0.8.0 adds optional font-registry vector outlines and shared heading/sca
43
50
 
44
51
  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.
45
52
 
46
- 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).
53
+ Deterministic local renderer for Open Presentation Format documents. The shared SVG core implements validation, reference resolution (through core, against the catalogs the host registers), placeholder binding and text layout. Node APIs additionally convert SVG to PNG and to PDF (vector with selectable text by default, or raster-backed).
47
54
 
48
55
  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.
49
56
 
@@ -86,7 +93,7 @@ Code strings that [XML 1.0 cannot represent](https://www.w3.org/TR/xml/#charsets
86
93
  - Public API: `renderSvg(opf, opts)` (every slide), `renderSlideSvg(opf, index, opts)` (one slide), `resolvePresentation(opf, opts)`, `svgToPng(svg, opts)`, `svgToPdf(svgs, opts)` and `loadFonts(opts)` (`/fonts-node`, `/fonts-browser`)
87
94
  - 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)
88
95
 
89
- `renderSvg` returns one SVG string per slide (`skipHidden: true` leaves out slides marked `hidden`, the player's sequence, so the result is then shorter than the deck); `renderSlideSvg` returns the SVG of one slide (a zero-based index; out of range throws `slide-index-out-of-range`). Both check the format at the boundary (core `validate(deck, { only: ['format'] })`; a failure throws `OPFRenderError` `invalid-opf` whose `findings` are the error findings), resolve inline and bundled catalogs locally, and emit byte-stable SVG for the same input. A catalog `source` may be one string or an ordered array (first match wins, the bundled default is appended); the renderer never fetches, so non-bundled sources resolve only from `catalogSources`. Slide background colours (solid, gradient stops, pattern colours) accept the same colour references as text and table colours: a hex value, a `var:` variable, or a colour scheme slot or role such as `accent2` or `primary`.
96
+ `renderSvg` returns one SVG string per slide (`skipHidden: true` leaves out slides marked `hidden`, the player's sequence, so the result is then shorter than the deck); `renderSlideSvg` returns the SVG of one slide (a zero-based index; out of range throws `slide-index-out-of-range`). Both check the format at the boundary (core `validate(deck, { only: ['format'] })`; a failure throws `OPFRenderError` `invalid-opf` whose `findings` are the error findings), resolve references from the document's embedded records and the host's `catalogs` (core `Catalog[]`, never fetched), and emit byte-stable SVG for the same input. Slide background colours (solid, gradient stops, pattern colours) accept the same colour references as text and table colours: a hex value, a `var:` variable, or a colour scheme slot or role such as `accent2` or `primary`.
90
97
 
91
98
  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).
92
99
 
@@ -105,19 +112,19 @@ The installed open font pack works offline. The Aptos family previews with the m
105
112
 
106
113
  Unresolved images produce an `unresolved-asset` diagnostic through `onDiagnostic`, including the OPF path, reason and complete description. The fallback shows a bounded status label when it fits above the selected readability floor, otherwise an icon with the full accessible description. The label is `Image unavailable` over the description (alt, else title, else `Image`) in semibold at 20 px on a 1280 x 720 canvas, and opf-pptx exports the same placeholder as native shapes. It preserves authored opacity, including faint decorative watermarks; a missing image is still missing even when its fallback fits. Use `strictAssets: true` to reject unresolved images. Supply embedded raster data URIs or a synchronous host `imageResolver` to resolve them; the renderer does not fetch URLs or read local paths. Caller descriptions and other metadata override referenced asset metadata through alias chains. These diagnostics identify unresolved sources, not malformed image bytes or native PowerPoint compatibility.
107
114
 
108
- Header/footer images and watermarks fit their complete artwork within their allocated regions. `design.imageFill: "crop"` continues to crop content images; background images retain their own fit policy.
115
+ Header/footer images and watermarks fit their complete artwork within their allocated regions. Image blocks use their own `fit`, else `design.imageFit` (default `cover`), as core resolves it into `item.image.fit`.
109
116
 
110
- An `image/svg+xml` data URI (base64 or text) is drawn as an image wherever a raster is (content, header/footer, watermark, logo, slide image, background, picture bullet), with the raster's box and fit, when it is an `<svg>` in the SVG namespace with an intrinsic size (`width` and `height`, or a `viewBox`); anything else keeps the `Image unavailable` placeholder. opf-pptx exports the same picture as a native SVG picture over a PNG fallback. An SVG used as an image never runs script or loads anything outside its own document, in a browser or in PNG/PDF output; PNG and PDF output draw it with resvg, and its text uses the same bundled fonts.
117
+ An `image/svg+xml` data URI (base64 or text) is drawn as an image wherever a raster is (image block, header/footer, watermark, logo, 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.
111
118
 
112
- 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.
119
+ Image backgrounds (core `geometry.backgroundImage`, FA-22) never move content. They paint in this order: the canvas colour (the colour scheme's default slide background), the picture, the overlay, then watermark, logo and content. `cover` (the default, and what an image source string means) covers the slide and keeps the `focus` point in view, `contain` centers the whole picture, `stretch` fills the slide, and `tile` repeats the picture at its intrinsic size (one picture pixel per reference pixel) from the top-left. `opacity` applies to the picture only. With `trace: true` the picture carries the background's path (`slides.N.design.background`, `design.background` or `theme`).
113
120
 
114
- Slide-image treatments render from core's normalized geometry in the native picture's paint order:
121
+ Image blocks (core `item.image`, FA-22) draw from core's normalized geometry in the native picture's paint order:
115
122
 
116
- 1. The image, clipped by the preset outline that core computes from the DrawingML formula. `recolor` is applied as an sRGB `feColorMatrix` with Rec. 601 luminance weights (grayscale, or duotone from dark to light). `opacity` applies to the image only.
123
+ 1. The picture in `item.image.box` with `fit` and `focus` (a centered cover is the native center crop; another focus uses core's `fitImage` placement), clipped by the preset outline core computes from the DrawingML formula (`imageShape()`). `recolor` is applied as an sRGB `feColorMatrix` with Rec. 601 luminance weights (grayscale, or duotone from dark to light). `opacity` applies to the picture only.
117
124
  2. The centered border stroke on the same outline.
118
- 3. The overlay scrim.
125
+ 3. The overlay, over the frame's shape or an edge band (`data-opf-image-overlay` names its path with `trace: true`).
119
126
 
120
- Unresolved sources draw no treatments. See core `docs/image-treatments.md` for the vocabulary and the unsupported effects: blur, shadows, soft edges and background removal.
127
+ A placed block (`placement`) arrives with its edge band as its region, and headings and body compose in the rest of the slide; with `trace: true` its group carries `data-opf-image-placement`. The 15 pptx.gallery image treatments, written this way, draw the same pixels as the 0.14 renderer drew them (`test/image-treatments.mjs`). Unresolved sources draw the ordinary placeholder and no treatments. See core `docs/image-treatments.md` for the vocabulary and the unsupported effects: blur, shadows, soft edges and background removal.
121
128
 
122
129
  PNG and PDF conversion APIs are async because they load the local raster/PDF engines on demand:
123
130
 
@@ -318,8 +325,8 @@ fonts.registry.scriptSelection; // { detected, scripts, unavailable, packages, n
318
325
  const browserFonts = await loadBrowserFonts({ faces: baseEntries, substitutionPolicy: 'visual', fallbackFamily: 'Roboto', scriptBaseUrl: '/script-fonts/' });
319
326
  if (browserFonts.pending(presentation).length) await browserFonts.ensure(presentation); // resolves { scripts, lazy, uncovered }
320
327
  // pending is synchronous: empty means nothing is missing, so render at once.
321
- // Text decides, so the analysis does not resolve layouts or catalogs. Pass the render options (renderOptions: { catalogs } to
322
- // loadFonts, or per call) so a font scheme only the host's catalogs have and that names a script font (Yu Gothic) counts.
328
+ // Text decides, so the analysis does not resolve layouts. Pass the render options (renderOptions: { catalogs: [defaultCatalog] }
329
+ // to loadFonts, or per call) so a font scheme only the host's catalogs have and that names a script font (Yu Gothic) counts.
323
330
  ```
324
331
 
325
332
  Where the renderer has per-character glyph fallback (`glyphFallbackFamilies`), auto also loads Noto Sans when the text has Greek or Cyrillic (the chosen Latin face may lack it), and the next CJK face of the fallback chain when a drawn Han, kana or Hangul character is missing from the loaded ones (a Simplified-only hanzi in Japanese text, hanja). That is capped at one fallback package beyond the packages the text's own scripts need: a character no CJK face covers never pulls in all four (10 to 20 MiB each); it is listed in `ensureScripts().uncovered` (Node: `script-glyph-uncovered`, `registry.scriptSelection.uncovered`) and the renderer reports `missing-glyph`. Browser loads are all or nothing per package: a failed fetch, hash check or `FontFace.load()` leaves the registry, the document and `loadedScriptPackages` unchanged and the package pending. One failing package does not lose the others: `ensureScripts` loads every package it can, then rejects with an `OPFFontError` whose `details` are `{ loaded, failed: [{package, code, message}] }`, and the next call retries only the failures. `ensureScripts(presentation, { signal })` and `loadScripts(scripts, { signal })` take a per-call `AbortSignal` (the creation signal is not reused), and `dispose()` during a load leaves no face behind and rejects with `font-registry-disposed`. Script faces carry `embed: "used"`, like the open families: the eager `registry.embeddedFonts` leaves them out, and when you pass them explicitly (`registry.selectEmbeddedFonts(face => face.scripts)`) an SVG embeds one only when the slide's text uses its family, so a Latin slide never carries a CJK face.
@@ -391,7 +398,7 @@ The package runtime must stay local and deterministic:
391
398
 
392
399
  Dependency policy:
393
400
 
394
- - `@openpresentation/opf` is the compatibility source for schemas, validation, and bundled catalogs.
401
+ - `@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).
395
402
  - `@resvg/resvg-js` is used only for local SVG rasterization in Node; it makes no network calls and does not require a browser.
396
403
  - `pdf-lib` assembles raster-mode PDF bytes locally. Metadata timestamps are disabled so repeated PDF output is byte-stable for the same SVG input and options.
397
404
  - 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.
package/dist/charts.js CHANGED
@@ -1,10 +1,9 @@
1
- // Chart previews for the OPF chart type catalog (FF-22).
1
+ // Chart previews for the OPF chart types (FF-22).
2
2
  //
3
- // The core catalog keeps one chart type per Office chart construct (`mappings.openxml`).
4
- // The table below is carried here so the preview stays synchronous and
5
- // self-contained; test/chart-catalog.mjs checks it against the installed core
6
- // catalog. Catalog ids render the construct PowerPoint shows for the exported
7
- // chart; any other id returns null so the caller keeps its legacy single-series preview.
3
+ // OPF 0.15 makes chart.type an engine vocabulary: core's schema enum and CHART_TYPES, one type per Office chart construct.
4
+ // The table below is carried here so the preview stays synchronous and self-contained; test/chart-catalog.mjs checks it
5
+ // against core's CHART_TYPES. Each type renders the construct PowerPoint shows for the exported chart; any other value
6
+ // returns null so the caller keeps its legacy single-series preview.
8
7
  import { chartHighlightColors, chartHighlightMarks, chartNumber, chartPaletteForFill, formatDataNumber, numberFormatError, resolveChartData, resolveTextStyle, textColorForFill, textWidthMeasurer } from "@openpresentation/opf/composition";
9
8
  import { drawAxisTitles, drawBarLabel, drawCenteredLabel, drawPointLabel, drawSliceLabel, labelString, outsideLabelReserve, reportOptionDiagnostics, reserveAxisTitles, resolveOptions } from "./chart-options.js";
10
9
 
@@ -81,12 +80,9 @@ export const CHART_TYPES = Object.freeze({
81
80
  combo: { kind: "combo", dir: "col", grouping: "clustered", markers: true }
82
81
  });
83
82
 
84
- const ALIASES = Object.freeze({ donut: "doughnut" });
85
-
86
- /** Resolve a chart type id to its kept catalog id, or null for ids outside the catalog. */
83
+ /** Resolve a chart type (core's CHART_TYPES vocabulary, OPF 0.15) to its preview spec id, or null for any other value. */
87
84
  export function resolveChartType(type) {
88
- const raw = String(type ?? "").trim().toLowerCase();
89
- const id = ALIASES[raw] ?? raw;
85
+ const id = String(type ?? "").trim().toLowerCase();
90
86
  return Object.hasOwn(CHART_TYPES, id) ? id : null;
91
87
  }
92
88
 
@@ -114,7 +110,7 @@ const RENDERERS = {
114
110
  const CHARTEX_KINDS = new Set(["treemap", "histogram", "pareto", "box", "waterfall", "funnel", "map"]);
115
111
 
116
112
  /**
117
- * Render a catalog chart. Returns null when the id is outside the catalog, has
113
+ * Render a chart of a known type. Returns null when the type is outside CHART_TYPES, has
118
114
  * no preview renderer, or carries no inline category-major data, so the caller
119
115
  * can keep its legacy output ("No chart data" for unresolved sources).
120
116
  */
package/dist/index.d.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  import type { RenderFonts, ScriptFonts } from "./fonts.js";
2
- import type { Finding } from "@openpresentation/opf";
2
+ import type { Catalog, Finding, UnresolvedReferenceDiagnostic } from "@openpresentation/opf";
3
3
  import type { SlideComposition, LayoutDiagnostic, TextMeasurement } from "@openpresentation/opf/composition";
4
4
  export declare const packageName = "@openpresentation/opf-render";
5
5
 
@@ -17,19 +17,12 @@ export declare const runtimePolicy: Readonly<{
17
17
  deterministicLocalExecution: true;
18
18
  }>;
19
19
 
20
+ /** What draws when nothing resolves: core's engine defaults (OPF 0.15 ships no catalog records), and the chart type a chart without `type` previews as. */
20
21
  export declare const engineDefaults: Readonly<{
21
- catalogs: Readonly<Record<string, Readonly<{ source: string }>>>;
22
- theme: "minimal";
23
- colorScheme: "cool-horizon";
24
- language: "english";
25
- narrative: "classic-story";
26
- tone: "formal";
27
- audience: "executives";
28
- fontScheme: Readonly<{
29
- pptx: Readonly<{ latin: "aptos"; ea: "microsoft-yahei"; cs: "nirmala-ui" }>;
30
- google: Readonly<{ latin: "roboto"; ea: "noto-sans-sc"; cs: "noto-sans" }>;
31
- }>;
32
- chartTypes: readonly ["stacked-column", "stacked-area", "line-with-markers"];
22
+ theme: typeof import("@openpresentation/opf/composition").ENGINE_DEFAULT_THEME;
23
+ colorScheme: typeof import("@openpresentation/opf/composition").ENGINE_DEFAULT_COLOR_SCHEME;
24
+ fontScheme: typeof import("@openpresentation/opf/composition").ENGINE_DEFAULT_FONT_SCHEME;
25
+ chartType: string;
33
26
  }>;
34
27
 
35
28
  export type RenderDiagnostic = LayoutDiagnostic | {
@@ -45,20 +38,7 @@ export type RenderDiagnostic = LayoutDiagnostic | {
45
38
  code: "unsupported-pattern" | "variable-example-used" | "variable-builtin-missing" | "date-needs-value" | "language-preview-unresolved";
46
39
  path: string;
47
40
  message: string;
48
- } | {
49
- /** A font-scheme id matched no record; the default font scheme (`aptos`) was used as the base. */
50
- code: "unresolved-font-scheme";
51
- path: string;
52
- message: string;
53
- id: string;
54
- fallback: string;
55
- } | {
56
- /** A layout, theme or colour-scheme id matched no record: the slide composed with no layout record, the `minimal` theme or the `cool-horizon` colour scheme. Never an error. */
57
- code: "unresolved-layout" | "unresolved-theme" | "unresolved-color-scheme";
58
- path: string;
59
- message: string;
60
- id: string;
61
- } | {
41
+ } | UnresolvedReferenceDiagnostic | {
62
42
  code: "unresolved-asset";
63
43
  path: string;
64
44
  message: string;
@@ -100,13 +80,18 @@ export interface RenderSvgOptions {
100
80
  */
101
81
  date?: string;
102
82
  trace?: boolean;
103
- catalogs?: Record<string, { records?: unknown[] } | unknown[]>;
104
83
  /**
105
- * Records for catalog sources the host has already fetched, keyed by the source string a document's
106
- * `catalogs.<kind>.source` names (a single source or an ordered array; first match wins, the bundled
107
- * default catalog is appended). The renderer never fetches a source itself.
84
+ * Catalogs the host registered (core `Catalog[]`), passed unchanged to core resolution: a reference the document does not
85
+ * embed resolves against the catalog whose `source` its group names, and the first entry is the default catalog for bare ids
86
+ * when the document omits `catalogs.default`. The renderer bundles and fetches no catalog; register the gallery snapshot with
87
+ * `import { defaultCatalog } from "@openpresentation/opf/catalog"` and `catalogs: [defaultCatalog]`. Omitted: only embedded
88
+ * records resolve, and anything else draws with core's engine defaults (reported as `unresolved-reference`).
89
+ * A malformed value throws core's `OPFCatalogsOptionError` (`code: "invalid-catalogs"`); a reference with an undeclared prefix
90
+ * (`foo:id` without `catalogs.foo`) fails the boundary check (`invalid-opf`, finding `opf/undeclared-catalog`).
108
91
  */
109
- catalogSources?: Record<string, { records?: unknown[] } | unknown[]>;
92
+ catalogs?: readonly Catalog[];
93
+ /** Fail with `unresolved-reference` (an OPFRenderError whose `details.diagnostics` list the references) instead of falling back when a reference resolves nowhere. Default false. */
94
+ strictReferences?: boolean;
110
95
  }
111
96
 
112
97
  export interface SvgToPngOptions {
@@ -233,7 +218,7 @@ export interface ResolvedSlide {
233
218
  index: number;
234
219
  path: string;
235
220
  slide: unknown;
236
- /** The layout record the slide names; absent when it names none or an id no catalog has (the slide then composes with no layout). */
221
+ /** The layout record the slide names; absent when it names none or a reference that resolves nowhere (the slide then composes automatically). */
237
222
  layout?: unknown;
238
223
  design: {
239
224
  theme: unknown;
@@ -92,10 +92,10 @@ export function* drawnStrings(presentation) {
92
92
  /**
93
93
  * The script profile of a presentation: core `resolveScriptFonts` output, exactly what the renderer plans
94
94
  * with. When it throws, the renderer ignores the document language, so the profile is empty and Han text is
95
- * Simplified Chinese, as drawn.
95
+ * Simplified Chinese, as drawn. `renderOptions.catalogs` (the host's registered catalogs) resolve as they do for the renderer.
96
96
  */
97
- export function presentationScriptProfile(presentation) {
98
- try { return resolveScriptFonts(presentation); } catch { /* the renderer falls back the same way */ }
97
+ export function presentationScriptProfile(presentation, renderOptions) {
98
+ try { return resolveScriptFonts(presentation, renderOptions?.catalogs !== undefined ? { catalogs: renderOptions.catalogs } : {}); } catch { /* the renderer falls back the same way */ }
99
99
  return {};
100
100
  }
101
101
 
@@ -123,9 +123,9 @@ function scriptOfFamily(family) {
123
123
  * scheme (`slide.design`), so each such slide is resolved too. `han` is the CJK script the scheme names, which tells
124
124
  * Han-only text apart when the language does not (a Yu Gothic deck whose language is English).
125
125
  *
126
- * `renderOptions` are the host's `renderSvg` options. Core resolves a font scheme id from the document and its bundled catalogs
127
- * only, while the renderer also reads `renderOptions.catalogs`; when a host passes catalogs the families the renderer resolves
128
- * per slide count too, so a scheme that exists only in the host's catalogs and names a script font still loads that font.
126
+ * `renderOptions` are the host's `renderSvg` options: their `catalogs` (core `Catalog[]`) go to core's `resolveScriptFonts`
127
+ * unchanged, and the families the renderer resolves per slide count too, so a scheme that exists only in the host's catalogs
128
+ * and names a script font still loads that font.
129
129
  */
130
130
  function designScripts(presentation, profile, renderOptions) {
131
131
  const scripts = new Set();
@@ -142,7 +142,7 @@ function designScripts(presentation, profile, renderOptions) {
142
142
  if (Array.isArray(presentation?.slides)) {
143
143
  presentation.slides.forEach((slide, slideIndex) => {
144
144
  if (!slide || typeof slide !== "object" || !slide.design || typeof slide.design !== "object") return;
145
- try { note(resolveScriptFonts(presentation, { slideIndex })); } catch { /* the renderer reports it when it draws the slide */ }
145
+ try { note(resolveScriptFonts(presentation, { slideIndex, ...(renderOptions?.catalogs !== undefined ? { catalogs: renderOptions.catalogs } : {}) })); } catch { /* the renderer reports it when it draws the slide */ }
146
146
  });
147
147
  }
148
148
  if (renderOptions?.catalogs) {
@@ -172,7 +172,8 @@ function* namedFontFamilies(value, key) {
172
172
  * `renderSvg` options) matter only for font schemes the host supplies in `renderOptions.catalogs`, which then resolve
173
173
  * like the renderer resolves them; a document that then does not resolve throws what `renderSvg` throws for it.
174
174
  */
175
- export function analyzePresentationScripts(presentation, profile = presentationScriptProfile(presentation), renderOptions) {
175
+ export function analyzePresentationScripts(presentation, profile, renderOptions) {
176
+ profile ??= presentationScriptProfile(presentation, renderOptions);
176
177
  const scripts = new Set(), cjk = new Set();
177
178
  const design = designScripts(presentation, profile, renderOptions);
178
179
  // Han-only text in a deck whose language is not CJK draws with the scheme's own CJK face when the scheme names one.
package/dist/svg.d.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  import type { RenderFonts, ScriptFonts } from "./fonts.js";
2
- import type { Finding } from "@openpresentation/opf";
2
+ import type { Catalog, Finding, UnresolvedReferenceDiagnostic } from "@openpresentation/opf";
3
3
  import type { SlideComposition, LayoutDiagnostic, TextMeasurement } from "@openpresentation/opf/composition";
4
4
  export declare const packageName = "@openpresentation/opf-render";
5
5
 
@@ -17,19 +17,12 @@ export declare const runtimePolicy: Readonly<{
17
17
  deterministicLocalExecution: true;
18
18
  }>;
19
19
 
20
+ /** What draws when nothing resolves: core's engine defaults (OPF 0.15 ships no catalog records), and the chart type a chart without `type` previews as. */
20
21
  export declare const engineDefaults: Readonly<{
21
- catalogs: Readonly<Record<string, Readonly<{ source: string }>>>;
22
- theme: "minimal";
23
- colorScheme: "cool-horizon";
24
- language: "english";
25
- narrative: "classic-story";
26
- tone: "formal";
27
- audience: "executives";
28
- fontScheme: Readonly<{
29
- pptx: Readonly<{ latin: "aptos"; ea: "microsoft-yahei"; cs: "nirmala-ui" }>;
30
- google: Readonly<{ latin: "roboto"; ea: "noto-sans-sc"; cs: "noto-sans" }>;
31
- }>;
32
- chartTypes: readonly ["stacked-column", "stacked-area", "line-with-markers"];
22
+ theme: typeof import("@openpresentation/opf/composition").ENGINE_DEFAULT_THEME;
23
+ colorScheme: typeof import("@openpresentation/opf/composition").ENGINE_DEFAULT_COLOR_SCHEME;
24
+ fontScheme: typeof import("@openpresentation/opf/composition").ENGINE_DEFAULT_FONT_SCHEME;
25
+ chartType: string;
33
26
  }>;
34
27
 
35
28
  export type RenderDiagnostic = LayoutDiagnostic | {
@@ -45,20 +38,7 @@ export type RenderDiagnostic = LayoutDiagnostic | {
45
38
  code: "unsupported-pattern" | "variable-example-used" | "variable-builtin-missing" | "date-needs-value" | "language-preview-unresolved";
46
39
  path: string;
47
40
  message: string;
48
- } | {
49
- /** A font-scheme id matched no record; the default font scheme (`aptos`) was used as the base. */
50
- code: "unresolved-font-scheme";
51
- path: string;
52
- message: string;
53
- id: string;
54
- fallback: string;
55
- } | {
56
- /** A layout, theme or colour-scheme id matched no record: the slide composed with no layout record, the `minimal` theme or the `cool-horizon` colour scheme. Never an error. */
57
- code: "unresolved-layout" | "unresolved-theme" | "unresolved-color-scheme";
58
- path: string;
59
- message: string;
60
- id: string;
61
- } | {
41
+ } | UnresolvedReferenceDiagnostic | {
62
42
  code: "unresolved-asset";
63
43
  path: string;
64
44
  message: string;
@@ -101,13 +81,18 @@ export interface RenderSvgOptions {
101
81
  */
102
82
  date?: string;
103
83
  trace?: boolean;
104
- catalogs?: Record<string, { records?: unknown[] } | unknown[]>;
105
84
  /**
106
- * Records for catalog sources the host has already fetched, keyed by the source string a document's
107
- * `catalogs.<kind>.source` names (a single source or an ordered array; first match wins, the bundled
108
- * default catalog is appended). The renderer never fetches a source itself.
85
+ * Catalogs the host registered (core `Catalog[]`), passed unchanged to core resolution: a reference the document does not
86
+ * embed resolves against the catalog whose `source` its group names, and the first entry is the default catalog for bare ids
87
+ * when the document omits `catalogs.default`. The renderer bundles and fetches no catalog; register the gallery snapshot with
88
+ * `import { defaultCatalog } from "@openpresentation/opf/catalog"` and `catalogs: [defaultCatalog]`. Omitted: only embedded
89
+ * records resolve, and anything else draws with core's engine defaults (reported as `unresolved-reference`).
90
+ * A malformed value throws core's `OPFCatalogsOptionError` (`code: "invalid-catalogs"`); a reference with an undeclared prefix
91
+ * (`foo:id` without `catalogs.foo`) fails the boundary check (`invalid-opf`, finding `opf/undeclared-catalog`).
109
92
  */
110
- catalogSources?: Record<string, { records?: unknown[] } | unknown[]>;
93
+ catalogs?: readonly Catalog[];
94
+ /** Fail with `unresolved-reference` (an OPFRenderError whose `details.diagnostics` list the references) instead of falling back when a reference resolves nowhere. Default false. */
95
+ strictReferences?: boolean;
111
96
  }
112
97
 
113
98
  export interface SvgToPngOptions {
@@ -140,7 +125,7 @@ export interface ResolvedSlide {
140
125
  index: number;
141
126
  path: string;
142
127
  slide: unknown;
143
- /** The layout record the slide names; absent when it names none or an id no catalog has (the slide then composes with no layout). */
128
+ /** The layout record the slide names; absent when it names none or a reference that resolves nowhere (the slide then composes automatically). */
144
129
  layout?: unknown;
145
130
  design: {
146
131
  theme: unknown;
package/dist/svg.js CHANGED
@@ -5,10 +5,12 @@ import {
5
5
  resolveColorRef as resolveCoreColorRef, resolveScriptFonts, paragraphDirection, physicalAlignment, patternRuns,
6
6
  metricTrendMark as coreMetricTrendMark, tokenizeCode, codeLineRuns, codeSyntaxPaletteForScheme, resolveChartData,
7
7
  resolveColorRoles, defaultSlideBackground, timelineMarkerShapes, timelineTextColor, layoutWatermark,
8
- codeHighlightLines, codeLineNumbers, codeHighlightBands, codeHighlightColors
8
+ codeHighlightLines, codeLineNumbers, codeHighlightBands, codeHighlightColors, fitImage, intrinsicImageAspect, intrinsicImageSize,
9
+ ENGINE_DEFAULT_THEME, ENGINE_DEFAULT_COLOR_SCHEME, ENGINE_DEFAULT_FONT_SCHEME, ENGINE_DEFAULT_CHART_TYPES
9
10
  } from "@openpresentation/opf/composition";
11
+ // The renderer is a library: it never imports a catalog. Hosts register theirs (`options.catalogs`, core `Catalog[]`, for
12
+ // example `defaultCatalog` from `@openpresentation/opf/catalog`) and core resolves every reference against them.
10
13
  import {
11
- catalogs as bundledCatalogs,
12
14
  hasContentVariables,
13
15
  isTemplate,
14
16
  resolveSlideContext,
@@ -37,34 +39,13 @@ export const runtimePolicy = Object.freeze({
37
39
  deterministicLocalExecution: true
38
40
  });
39
41
 
42
+ // What draws when nothing resolves: core's engine defaults (OPF 0.15 ships no catalog records), and the chart type a chart
43
+ // without one previews as.
40
44
  export const engineDefaults = Object.freeze({
41
- catalogs: Object.freeze({
42
- narratives: Object.freeze({ source: "https://www.pptx.gallery/narratives" }),
43
- themes: Object.freeze({ source: "https://www.pptx.gallery/themes" }),
44
- colorSchemes: Object.freeze({ source: "https://www.pptx.gallery/color-schemes" }),
45
- fontSchemes: Object.freeze({ source: "https://www.pptx.gallery/font-schemes" }),
46
- languages: Object.freeze({ source: "https://www.pptx.gallery/languages" }),
47
- layouts: Object.freeze({ source: "https://www.pptx.gallery/layouts" }),
48
- chartTypes: Object.freeze({ source: "https://www.pptx.gallery/chart-types" }),
49
- tones: Object.freeze({ source: "https://www.pptx.gallery/tones" }),
50
- audiences: Object.freeze({ source: "https://www.pptx.gallery/audiences" }),
51
- socialPlatforms: Object.freeze({ source: "https://www.pptx.gallery/social-platforms" })
52
- }),
53
- theme: "minimal",
54
- colorScheme: "cool-horizon",
55
- language: "english",
56
- narrative: "classic-story",
57
- tone: "formal",
58
- audience: "executives",
59
- fontScheme: Object.freeze({
60
- pptx: Object.freeze({ latin: "aptos", ea: "microsoft-yahei", cs: "nirmala-ui" }),
61
- google: Object.freeze({ latin: "roboto", ea: "noto-sans-sc", cs: "noto-sans" })
62
- }),
63
- chartTypes: Object.freeze([
64
- "stacked-column",
65
- "stacked-area",
66
- "line-with-markers"
67
- ])
45
+ theme: ENGINE_DEFAULT_THEME,
46
+ colorScheme: ENGINE_DEFAULT_COLOR_SCHEME,
47
+ fontScheme: ENGINE_DEFAULT_FONT_SCHEME,
48
+ chartType: ENGINE_DEFAULT_CHART_TYPES[0]
68
49
  });
69
50
 
70
51
  /**
@@ -164,7 +145,6 @@ const FIELD_TYPE = Object.freeze({
164
145
  });
165
146
 
166
147
  const DEFAULT_DIMENSIONS = { width: 1280, height: 720 };
167
- const DEFAULT_SOURCE_PREFIX = "https://www.pptx.gallery/";
168
148
 
169
149
 
170
150
  function parseInput(input) {
@@ -220,8 +200,8 @@ function resolveTemplateInput(presentation, options) {
220
200
 
221
201
  // The boundary check is the format check only (schema and the cross-field rules a schema cannot say): layout, accessibility
222
202
  // and content findings are `validate`'s job, and drawing a slide reports what it cannot fit.
223
- function assertValidBoundary(presentation) {
224
- const report = validate(presentation, { only: ["format"] });
203
+ function assertValidBoundary(presentation, options) {
204
+ const report = validate(presentation, { only: ["format"], ...(options.catalogs !== undefined ? { catalogs: options.catalogs } : {}) });
225
205
  if (!report.valid) {
226
206
  throw new OPFRenderError("invalid-opf", "OPF validation failed.", {
227
207
  findings: report.findings.filter((finding) => finding.severity === "error"),
@@ -246,63 +226,6 @@ function isPlainObject(value) {
246
226
  return Boolean(value) && typeof value === "object" && !Array.isArray(value);
247
227
  }
248
228
 
249
- function normalizeSourceRecords(value) {
250
- if (!value) return [];
251
- if (Array.isArray(value)) return value;
252
- if (Array.isArray(value.records)) return value.records;
253
- return [];
254
- }
255
-
256
- function defaultCatalogFor(kind) {
257
- return Array.isArray(bundledCatalogs[kind]) ? bundledCatalogs[kind] : [];
258
- }
259
-
260
- // CatalogEntry.source is one source or an ordered search path (first match wins; the
261
- // default catalog is appended by the callers). An array concatenates each source's
262
- // records in order, so the findById chain keeps the first match. Non-string entries are
263
- // ignored. Nothing is fetched: a source resolves from options.catalogSources or, for the
264
- // bundled default prefixes, from the bundled snapshot.
265
- function sourceRecordsFor(kind, source, options) {
266
- if (Array.isArray(source)) return source.flatMap((entry) => sourceRecordsFor(kind, entry, options));
267
- if (typeof source !== "string" || !source) return [];
268
- const bySource = options.catalogSources?.[source];
269
- if (bySource) return normalizeSourceRecords(bySource);
270
- if (source.startsWith(DEFAULT_SOURCE_PREFIX) || source.startsWith("pkg:@openpresentation/opf/")) {
271
- return defaultCatalogFor(kind);
272
- }
273
- return [];
274
- }
275
-
276
- // The records a host adds to core's `resolveSlideContext`, which looks a layout, theme, colour scheme or font scheme up in the
277
- // deck's inline records, then these, then its bundled catalogs. In the renderer's order they are the records a
278
- // `catalogs.<kind>.source` names (a bundled source, or `options.catalogSources`), then `options.catalogs`.
279
- const CONTEXT_CATALOG_KINDS = ["themes", "colorSchemes", "fontSchemes", "layouts"];
280
- function contextCatalogs(context) {
281
- if (context.contextCatalogs) return context.contextCatalogs;
282
- const out = {};
283
- for (const kind of CONTEXT_CATALOG_KINDS) {
284
- const records = [
285
- ...sourceRecordsFor(kind, context.presentation.catalogs?.[kind]?.source, context.options),
286
- ...normalizeSourceRecords(context.options.catalogs?.[kind]),
287
- ...sourceRecordsFor(kind, engineDefaults.catalogs[kind]?.source, context.options)
288
- ];
289
- if (records.length) out[kind] = records;
290
- }
291
- return (context.contextCatalogs = out);
292
- }
293
-
294
- // Generated socials furniture formats handles in core. Core applies inline
295
- // document records first; the host supplies the rest in resolution order, ahead of the bundled ones.
296
- function socialPlatformRecords(context, bundled) {
297
- const kind = "socialPlatforms";
298
- return [
299
- ...sourceRecordsFor(kind, context.presentation.catalogs?.[kind]?.source, context.options),
300
- ...normalizeSourceRecords(context.options.catalogs?.[kind]),
301
- ...sourceRecordsFor(kind, engineDefaults.catalogs[kind]?.source, context.options),
302
- ...bundled
303
- ];
304
- }
305
-
306
229
  function colorFromScheme(scheme, slot, fallback) {
307
230
  if (!slot) return fallback;
308
231
  if (typeof slot === "string") {
@@ -368,12 +291,15 @@ function resolveBackgroundColor(value, design, fallback) {
368
291
  return resolveColorRefIn(value, { colorScheme: design.colorScheme, colors: { primary, secondary, accent }, variables: design.variables }, fallback);
369
292
  }
370
293
 
294
+ // Image sources the background string shorthand accepts (a cover image); any other string is a theme slot or a hex colour.
295
+ const IMAGE_BACKGROUND_SOURCE = /^(asset:|https:\/\/|data:|\.\/|\.\.\/)/;
296
+
371
297
  // The slide's single background color, or null when there is none (a gradient, or no color the engine can read).
372
298
  // With no definition, or a picture, the scheme's default slide background (core defaultSlideBackground) is the canvas.
373
299
  function resolveBackground(background, colorScheme, design) {
374
300
  const canvas = defaultSlideBackground(colorScheme);
375
301
  if (!background) return canvas;
376
- if (typeof background === "string") return colorFromScheme(colorScheme, background, canvas);
302
+ if (typeof background === "string") return IMAGE_BACKGROUND_SOURCE.test(background) ? canvas : colorFromScheme(colorScheme, background, canvas);
377
303
  if (background.type === "theme") return colorFromScheme(colorScheme, background.slot, canvas);
378
304
  if (background.type === "solid") return resolveBackgroundColor(background.color, design, "#FFFFFF");
379
305
  if (background.type === "gradient") return null;
@@ -410,10 +336,9 @@ const LINK_URL = /^(https?:|mailto:|tel:)/i;
410
336
 
411
337
  // What the renderer paints with, from the records core's `resolveSlideContext` resolved (slide design, then deck design, then
412
338
  // theme, then the engine default, per field: the one order every engine uses). The theme and the colour scheme keep their
413
- // sorted keys, so the output does not depend on the order a catalog lists a field in. An id no catalog has never throws: the
414
- // context falls back (a theme to `minimal`, a colour scheme to `cool-horizon`, a font scheme to `aptos`, a layout to none) and
415
- // reports `unresolved-theme`, `unresolved-color-scheme`, `unresolved-font-scheme` or `unresolved-layout`, which are forwarded
416
- // as render diagnostics.
339
+ // sorted keys, so the output does not depend on the order a catalog lists a field in. A reference that resolves nowhere never
340
+ // throws (unless `strictReferences`): the context falls back to core's engine defaults (ENGINE_DEFAULT_THEME, _COLOR_SCHEME,
341
+ // _FONT_SCHEME; a layout to automatic composition) and reports `unresolved-reference`, which is forwarded as a render diagnostic.
417
342
  function resolveDesign(presentation, slide, slideContext) {
418
343
  const deckDesign = presentation.design ?? {};
419
344
  const slideDesign = slide.design ?? {};
@@ -468,7 +393,7 @@ function resolveDesign(presentation, slide, slideContext) {
468
393
  */
469
394
  function scriptProfile(presentation, index, design, context, language) {
470
395
  let resolved;
471
- try { resolved = resolveScriptFonts(presentation, { slideIndex: index, ...(language === undefined ? {} : { language }) }); }
396
+ try { resolved = resolveScriptFonts(presentation, { slideIndex: index, ...(context.options.catalogs !== undefined ? { catalogs: context.options.catalogs } : {}), ...(language === undefined ? {} : { language }) }); }
472
397
  catch (error) {
473
398
  reportLanguageDiagnostic(context, { code: "language-preview-unresolved", path: "language",
474
399
  message: "Script fonts could not be resolved (" + (error instanceof Error ? error.message : String(error)) + "), so the preview uses the design font for every script, sets no lang and lays out every paragraph left to right." });
@@ -594,16 +519,35 @@ function normalizeFutureContent(slide, slidePath) {
594
519
  });
595
520
  }
596
521
 
522
+ // The core catalog options every resolution takes: the host's catalogs, unchanged, and its strict reference policy.
523
+ function catalogOptions(options) {
524
+ return { ...(options.catalogs !== undefined ? { catalogs: options.catalogs } : {}), ...(options.strictReferences === true ? { strictReferences: true } : {}) };
525
+ }
526
+
527
+ function resolveContext(presentation, index, context) {
528
+ try {
529
+ return resolveSlideContext(presentation, index, {
530
+ fonts: { textMeasurement: context.options.textMeasurement },
531
+ ...catalogOptions(context.options),
532
+ date: context.options.date
533
+ });
534
+ } catch (error) {
535
+ // Strict references: core refuses a reference that resolves nowhere (OPFUnresolvedReferenceError) instead of falling back.
536
+ if (Array.isArray(error?.diagnostics) && error.diagnostics.some(entry => entry?.code === "unresolved-reference")) {
537
+ throw new OPFRenderError("unresolved-reference", error.message, { diagnostics: error.diagnostics, path: error.diagnostics[0].path, cause: error.message });
538
+ }
539
+ throw error;
540
+ }
541
+ }
542
+
597
543
  function bindSlide(presentation, slide, index, context) {
598
544
  const slidePath = `slides.${index}`;
599
545
  // Core resolves the slide's canvas, layout, theme, colour scheme, font scheme and family names the one way every engine must
600
- // (slide design, deck design, theme, default); `fonts` carries the measurement the host loaded. A slide with no `layout`, or one
601
- // that names an id no catalog has, composes with no layout record.
602
- const slideContext = resolveSlideContext(presentation, index, {
603
- fonts: { textMeasurement: context.options.textMeasurement },
604
- catalogs: contextCatalogs(context),
605
- date: context.options.date
606
- });
546
+ // (slide design, deck design, theme, engine default), from the records the document embeds and the catalogs the host
547
+ // registered (`options.catalogs`, passed unchanged); `fonts` carries the measurement the host loaded. A slide with no
548
+ // `layout` composes automatically; one whose reference resolves nowhere does too, with an `unresolved-reference` diagnostic,
549
+ // or the render fails under `strictReferences`.
550
+ const slideContext = resolveContext(presentation, index, context);
607
551
  const layout = slideContext.options.layout;
608
552
  const placeholders = Array.isArray(layout?.placeholders) ? layout.placeholders : [];
609
553
  const titleBindings = [];
@@ -670,7 +614,7 @@ function bindSlide(presentation, slide, index, context) {
670
614
  const named = design.fonts[role], resolved = resolveTextStyle({fontFamily:named,fontWeight:role === "heading" ? 700 : 400},textMeasurement).fontFamily;
671
615
  design.fonts[role] = fontPolicyFor(named)?.replacement?.weight !== undefined && resolved.toLowerCase() !== named.toLowerCase() ? named : resolved;
672
616
  }
673
- const geometry = composeSlide(slide, { ...slideContext.options, fontFamilies: design.fonts, textRasterPadding:context.options.textRasterPadding, textMeasurement, socialPlatforms: socialPlatformRecords(context, slideContext.options.socialPlatforms) });
617
+ const geometry = composeSlide(slide, { ...slideContext.options, fontFamilies: design.fonts, textRasterPadding:context.options.textRasterPadding, textMeasurement });
674
618
  return {
675
619
  scriptFonts,
676
620
  scriptFontsFor,
@@ -708,7 +652,7 @@ export function resolvePresentation(input, options = {}) {
708
652
  function resolveDeck(input, options) {
709
653
  const presentation = resolveTemplateInput(parseInput(input), options);
710
654
  if (options.validate !== false) {
711
- assertValidBoundary(presentation);
655
+ assertValidBoundary(presentation, options);
712
656
  }
713
657
 
714
658
  const context = { presentation, options };
@@ -757,7 +701,6 @@ function renderResolvedSlide(resolved, slideIndex, options) {
757
701
  const title = (Array.isArray(bound.slide.title) ? flattenText(bound.slide.title) : bound.slide.title) ?? resolved.presentation.name ?? `Slide ${slideIndex + 1}`;
758
702
  const content = [
759
703
  renderBackground(bound, width, height, options),
760
- renderSlideImage(bound, options),
761
704
  renderBranding(bound, resolved.presentation, width, height, options),
762
705
  ...renderSlideContent(bound, width, height, options),
763
706
  // RR-34: the footnote area core reserved above the footer band, after the content and before the furniture.
@@ -793,6 +736,8 @@ function renderResolvedSlide(resolved, slideIndex, options) {
793
736
 
794
737
  function renderBackground(bound, width, height, options) {
795
738
  const background = bound.design.background;
739
+ // An image background (core SlideComposition.backgroundImage) fills the canvas and moves nothing.
740
+ if (bound.geometry.backgroundImage) return renderBackgroundImage(bound.geometry.backgroundImage, bound, width, height, options);
796
741
  if (isPlainObject(background) && background.type === "gradient") {
797
742
  const id = `opf-s${bound.index + 1}-background`;
798
743
  const stops = Array.isArray(background.gradient?.stops) ? background.gradient.stops : [];
@@ -821,15 +766,6 @@ function renderBackground(bound, width, height, options) {
821
766
  })
822
767
  ].join("\n");
823
768
  }
824
-
825
- if(isPlainObject(background) && background.type==='image'){
826
- const imageItem={value:background.image,path:`${bound.path}.design.background.image`};
827
- if(background.image.fit==='tile'){
828
- const size=Math.min(width,height)/4,id=`opf-s${bound.index+1}-image-tile`;
829
- return tag('g',{opacity:background.opacity??1},tag('defs',{},tag('pattern',{id,width:size,height:size,patternUnits:'userSpaceOnUse'},renderImage(imageItem,{x:0,y:0,width:size,height:size},bound,options)))+tag('rect',{width,height,fill:`url(#${id})`}));
830
- }
831
- return tag('g',{opacity:background.opacity??1},renderImage(imageItem,{x:0,y:0,width,height},bound,{...options,imageFit:background.image.fit??'cover'}));
832
- }
833
769
  if(isPlainObject(background) && background.type==='pattern'){
834
770
  const pattern=background.pattern??{},id=`opf-s${bound.index+1}-pattern`,color=resolveBackgroundColor(pattern.foregroundColor,bound.design,bound.design.colors.text),preset=pattern.preset;
835
771
  // RR-07: every DrawingML preset (core PATTERN_PRESETS) is an 8x8 bitmap, one unit per 1/96 inch, anchored at the slide origin.
@@ -844,43 +780,81 @@ function renderBackground(bound, width, height, options) {
844
780
  y: 0,
845
781
  width,
846
782
  height,
847
- opacity: typeof background==='object'?background?.opacity??1:1,
783
+ opacity: isPlainObject(background) && background.type !== 'image' ? background.opacity ?? 1 : 1,
848
784
  fill: bound.design.backgroundColor ?? bound.design.colors.background,
849
785
  ...traceAttrs(options, `${bound.path}.design.background`)
850
786
  });
851
787
  }
852
788
 
853
- // design.slideImage: the shared composition frame, beneath branding and content.
854
- // crop covers the frame and fit centers the whole image, matching native a:srcRect.
855
- // Treatments mirror the native picture: the preset mask (core outline), recolor
856
- // and alphaModFix on the pixels only, the centered line, then the overlay shape.
857
- function renderSlideImage(bound, options) {
858
- const image = bound.geometry.slideImage;
859
- if (!image) return '';
860
- const trace = options.trace ? { 'data-opf-slide-image': image.path, 'data-opf-slide-image-position': image.position } : {};
861
- const value = image.alt === undefined ? image.value : { ...normalizeAsset(image.value), alt: image.alt };
862
- const picture = renderImage({ value, path: image.sourcePath }, image.box, bound, { ...options, imageFit: image.fill === 'crop' ? 'cover' : 'contain' });
863
- // Unresolved sources keep the ordinary placeholder without treatments, like the export.
864
- if (!picture.startsWith('<image')) return tag('g', trace, picture);
865
- const id = `opf-s${bound.index + 1}-slide-image`, shape = image.shape, defs = [];
866
- const masked = shape && shape.preset !== 'rect';
867
- if (masked) defs.push(tag('clipPath', { id: `${id}-clip` }, tag('path', { d: shape.path })));
868
- const matrix = slideImageRecolorMatrix(image.recolor, bound);
869
- if (matrix) defs.push(tag('filter', { id: `${id}-recolor`, 'color-interpolation-filters': 'sRGB' }, tag('feColorMatrix', { type: 'matrix', values: matrix })));
870
- let pixels = matrix || image.opacity !== undefined ? tag('g', { filter: matrix ? `url(#${id}-recolor)` : undefined, opacity: image.opacity === undefined ? undefined : preciseNumber(image.opacity) }, picture) : picture;
871
- if (masked) pixels = tag('g', { 'clip-path': `url(#${id}-clip)` }, pixels);
872
- const children = [defs.length ? tag('defs', {}, defs.join('')) : '', pixels];
789
+ // FA-22 image background, in the paint order of the geometry contract: the canvas colour (the colour scheme's default slide
790
+ // background), the picture (recolor and opacity on its pixels only, as for an image block), then the overlay (the whole canvas
791
+ // or an edge band). cover, contain
792
+ // and stretch use the shared fit math with the focus point; tile repeats the picture at its intrinsic size (1 picture px = 1
793
+ // reference px) from the canvas top-left. alt is the picture's accessible name; without alt the picture is decorative.
794
+ function renderBackgroundImage(background, bound, width, height, options) {
795
+ const canvas = tag("rect", { x: 0, y: 0, width, height, fill: bound.design.backgroundColor ?? bound.design.colors.background, ...traceAttrs(options, `${bound.path}.design.background`) });
796
+ const item = { value: background.alt === undefined ? background.src : { src: background.src, alt: background.alt }, path: background.path };
797
+ const drawOptions = { ...options, imageDecorative: background.alt === undefined };
798
+ const picture = background.fit === "tile"
799
+ ? renderTiledImage(item, background.box, bound, drawOptions)
800
+ : renderImage(item, background.box, bound, { ...drawOptions, imageFit: background.fit, imageFocus: background.focus, imagePicture: background.picture });
801
+ // An unresolved source draws the ordinary placeholder at the background's opacity (as 0.14 did), without recolor or overlay.
802
+ if (isImagePlaceholder(picture)) return [canvas, background.opacity !== undefined ? tag("g", { opacity: background.opacity }, picture) : picture].join("\n");
803
+ const id = `opf-s${bound.index + 1}-background`, matrix = imageRecolorMatrix(background.recolor, bound);
804
+ const defs = matrix ? tag("defs", {}, tag("filter", { id: `${id}-recolor`, "color-interpolation-filters": "sRGB" }, tag("feColorMatrix", { type: "matrix", values: matrix }))) : "";
805
+ const pixels = matrix || background.opacity !== undefined ? tag("g", { filter: matrix ? `url(#${id}-recolor)` : undefined, opacity: background.opacity === undefined ? undefined : preciseNumber(background.opacity) }, picture) : picture;
806
+ return [canvas, defs, pixels, background.overlay ? renderImageOverlay(background.overlay, bound, options) : ""].filter(Boolean).join("\n");
807
+ }
808
+
809
+ // tile: a pattern cell of the picture's intrinsic size, anchored at the canvas origin.
810
+ function renderTiledImage(item, box, bound, options) {
811
+ const { source, drawable } = resolveImageSource(item, bound, options);
812
+ if (!drawable) return renderImage(item, box, bound, options);
813
+ const size = intrinsicImageSize(source) ?? { width: Math.min(box.width, box.height) / 4, height: Math.min(box.width, box.height) / 4 };
814
+ const id = `opf-s${bound.index + 1}-background-tile`;
815
+ const cell = tag("image", { x: 0, y: 0, width: stableNumber(size.width), height: stableNumber(size.height), href: source, preserveAspectRatio: "none" });
816
+ return tag("defs", {}, tag("pattern", { id, x: 0, y: 0, width: stableNumber(size.width), height: stableNumber(size.height), patternUnits: "userSpaceOnUse" }, cell))
817
+ + tag("rect", { x: box.x, y: box.y, width: box.width, height: box.height, fill: `url(#${id})`, ...imageAccessibility(normalizeAsset(item.value), options), ...traceAttrs(options, item.path) });
818
+ }
819
+
820
+ // FA-22 image block (core ComposedItem.image): the picture in its frame (image.box) with the block's fit and focus, clipped to
821
+ // the frame's shape unless it is a rectangle, with recolor and opacity on the pixels only; then the line centered on the shape
822
+ // outline; then the overlay (the frame's shape, or an edge band). The outlines are core's imageShape() paths, so the preview
823
+ // draws exactly the native preset geometry, in the paint order the export writes. A placed block (image.placement) arrives
824
+ // with its band as the region and needs nothing else. An unresolved source keeps the ordinary placeholder without treatments.
825
+ function renderImageBlock(item, bound, options) {
826
+ const image = item.image;
827
+ if (!image) throw new OPFRenderError("missing-image-geometry", "Image rendering requires a coordinated core build with image block geometry (ComposedItem.image).", { path: item.path });
828
+ const picture = renderImage(item, image.box, bound, { ...options, imageFit: image.fit, imageFocus: image.focus, imagePicture: image.picture });
829
+ const trace = options.trace && image.placement ? { "data-opf-image-placement": image.placement.edge } : {};
830
+ if (isImagePlaceholder(picture)) return Object.keys(trace).length ? tag("g", trace, picture) : picture;
831
+ const id = `opf-s${bound.index + 1}-image-${item.path.replace(/[^A-Za-z0-9]+/g, "-")}`, shape = image.shape, defs = [];
832
+ const masked = shape && shape.preset !== "rect";
833
+ if (masked) defs.push(tag("clipPath", { id: `${id}-clip` }, tag("path", { d: shape.path })));
834
+ const matrix = imageRecolorMatrix(image.recolor, bound);
835
+ if (matrix) defs.push(tag("filter", { id: `${id}-recolor`, "color-interpolation-filters": "sRGB" }, tag("feColorMatrix", { type: "matrix", values: matrix })));
836
+ let pixels = matrix || image.opacity !== undefined ? tag("g", { filter: matrix ? `url(#${id}-recolor)` : undefined, opacity: image.opacity === undefined ? undefined : preciseNumber(image.opacity) }, picture) : picture;
837
+ if (masked) pixels = tag("g", { "clip-path": `url(#${id}-clip)` }, pixels);
838
+ const children = [defs.length ? tag("defs", {}, defs.join("")) : "", pixels];
873
839
  if (image.border) {
874
- const paint = slideImagePaint(image.border.color, bound, bound.design.colors.border);
875
- children.push(tag('path', { d: shape?.path ?? rectanglePath(image.box), fill: 'none', stroke: paint.color, 'stroke-opacity': paint.alpha < 1 ? preciseNumber(paint.alpha) : undefined, 'stroke-width': stableNumber(image.border.width), 'stroke-linejoin': 'miter', 'stroke-miterlimit': 8 }));
876
- }
877
- if (image.overlay) {
878
- const paint = slideImagePaint(image.overlay.color, bound, bound.design.colors.text);
879
- children.push(tag('path', { d: image.overlay.shape?.path ?? rectanglePath(image.overlay.box), fill: paint.color, 'fill-opacity': preciseNumber(paint.alpha * image.overlay.opacity), ...(options.trace ? { 'data-opf-slide-image-overlay': `${image.path}.overlay` } : {}) }));
840
+ const paint = imagePaint(image.border.color, bound, bound.design.colors.border);
841
+ children.push(tag("path", { d: shape?.path ?? rectanglePath(image.box), fill: "none", stroke: paint.color, "stroke-opacity": paint.alpha < 1 ? preciseNumber(paint.alpha) : undefined, "stroke-width": stableNumber(image.border.width), "stroke-linejoin": "miter", "stroke-miterlimit": 8 }));
880
842
  }
881
- return tag('g', trace, children.filter(Boolean).join(''));
843
+ if (image.overlay) children.push(renderImageOverlay(image.overlay, bound, options));
844
+ if (children.length === 2 && !children[0] && !Object.keys(trace).length) return pixels;
845
+ return tag("g", trace, children.filter(Boolean).join(""));
882
846
  }
883
847
 
848
+ // One overlay (shared by image backgrounds and image blocks): its outline filled with the colour, the colour's alpha times the
849
+ // overlay opacity.
850
+ function renderImageOverlay(overlay, bound, options) {
851
+ const paint = imagePaint(overlay.color, bound, bound.design.colors.text);
852
+ return tag("path", { d: overlay.shape?.path ?? rectanglePath(overlay.box), fill: paint.color, "fill-opacity": preciseNumber(paint.alpha * overlay.opacity), ...(options.trace ? { "data-opf-image-overlay": overlay.path } : {}) });
853
+ }
854
+
855
+ // The placeholder renderImage draws for a source it cannot resolve.
856
+ const isImagePlaceholder = svg => svg.startsWith("<g") && svg.includes('data-opf-asset-status="unresolved"');
857
+
884
858
  // Native alpha and color-matrix values keep 1/100000 precision; three decimals would drift.
885
859
  const preciseNumber = value => String(Math.round(value * 1e6) / 1e6);
886
860
 
@@ -889,18 +863,18 @@ function rectanglePath(box) {
889
863
  }
890
864
 
891
865
  // ColorRef -> opaque #RRGGBB plus the AA byte as alpha, as the native srgbClr + alpha.
892
- function slideImagePaint(value, bound, fallback) {
866
+ function imagePaint(value, bound, fallback) {
893
867
  const hex = resolveColorRef(value, bound, fallback) ?? fallback;
894
868
  const raw = normalizeColor(hex, fallback).slice(1);
895
869
  return { color: `#${raw.slice(0, 6).toUpperCase()}`, alpha: raw.length === 8 ? parseInt(raw.slice(6), 16) / 255 : 1 };
896
870
  }
897
871
 
898
872
  // Rec. 601 luminance on sRGB values; duotone maps it linearly from dark to light.
899
- function slideImageRecolorMatrix(recolor, bound) {
873
+ function imageRecolorMatrix(recolor, bound) {
900
874
  if (!recolor) return undefined;
901
875
  const weights = [0.299, 0.587, 0.114];
902
876
  const channels = recolor.type === 'duotone'
903
- ? [slideImagePaint(recolor.dark, bound, '#000000'), slideImagePaint(recolor.light, bound, '#FFFFFF')].map(paint => [1, 3, 5].map(at => parseInt(paint.color.slice(at, at + 2), 16) / 255))
877
+ ? [imagePaint(recolor.dark, bound, '#000000'), imagePaint(recolor.light, bound, '#FFFFFF')].map(paint => [1, 3, 5].map(at => parseInt(paint.color.slice(at, at + 2), 16) / 255))
904
878
  : [[0, 0, 0], [1, 1, 1]];
905
879
  const [dark, light] = channels;
906
880
  const rows = [0, 1, 2].map(channel => [...weights.map(weight => preciseNumber((light[channel] - dark[channel]) * weight)), 0, preciseNumber(dark[channel])].join(' '));
@@ -942,7 +916,7 @@ function renderPayload(item, box, bound, options) {
942
916
  case "table":
943
917
  return renderTable(item, box, bound, options);
944
918
  case "image":
945
- return renderImage(item, box, bound, options);
919
+ return renderImageBlock(item, bound, options);
946
920
  case "video":
947
921
  return renderMedia(item, box, bound, options);
948
922
  case "code":
@@ -1025,6 +999,13 @@ function resolveImageSource(item, bound, options) {
1025
999
  return { asset, source: svg ?? source, drawable, missingReference };
1026
1000
  }
1027
1001
 
1002
+ // The accessible name of a drawn picture: its alt text, else the caller's label; a decorative picture (an image background
1003
+ // without alt) is hidden from assistive technology.
1004
+ function imageAccessibility(asset, options) {
1005
+ if (options.imageDecorative) return { "aria-hidden": "true" };
1006
+ return { role: "img", "aria-label": asset.alt ?? options.imageLabel ?? "Image" };
1007
+ }
1008
+
1028
1009
  const SVG_NAMESPACE = "http://www.w3.org/2000/svg";
1029
1010
 
1030
1011
  // The base64 data URI of a drawable SVG, else null. Drawable: a data URI whose type is image/svg+xml, whose text is
@@ -1171,12 +1152,29 @@ function resolveBulletImage(bulletImage, bound, options) {
1171
1152
  return undefined;
1172
1153
  }
1173
1154
 
1155
+ // A drawable picture in `box` with `options.imageFit`: cover (the default for content), contain or stretch. cover takes
1156
+ // `options.imageFocus`: centered, it is the native center crop; anywhere else the picture is placed by core's fit math
1157
+ // (`options.imagePicture`, core's ImageFitPlacement when core read the aspect, else fitImage on core's intrinsicImageAspect
1158
+ // of the resolved source, for pictures a host imageResolver supplies) and
1159
+ // clipped to the box by a nested viewport. A logo keeps its anchor (`options.imageAnchor`).
1174
1160
  function renderImage(item, box, bound, options) {
1175
1161
  const { asset, source, drawable, missingReference } = resolveImageSource(item, bound, options);
1176
1162
  if (drawable) {
1163
+ const fit = options.imageFit ?? "contain", focus = options.imageFocus;
1164
+ const attrs = { href: source, ...imageAccessibility(asset, options), ...traceAttrs(options, item.path), ...generatedAttrs(options) };
1165
+ if (fit === "cover" && focus && !options.imageAnchor && (focus.x !== 0.5 || focus.y !== 0.5)) {
1166
+ // The drawable source (after asset references and the host imageResolver): core reads its size (intrinsicImageAspect).
1167
+ const aspect = options.imagePicture ? undefined : intrinsicImageAspect(source);
1168
+ const placement = options.imagePicture ?? (aspect ? fitImage(box, "cover", aspect, focus) : undefined);
1169
+ if (placement) {
1170
+ const picture = placement.image;
1171
+ return tag("svg", { x: box.x, y: box.y, width: box.width, height: box.height, overflow: "hidden" },
1172
+ tag("image", { x: stableNumber(picture.x - box.x), y: stableNumber(picture.y - box.y), width: stableNumber(picture.width), height: stableNumber(picture.height), preserveAspectRatio: "none", ...attrs }));
1173
+ }
1174
+ }
1177
1175
  return tag("image", { x: box.x, y: box.y, width: box.width, height: box.height,
1178
- href: source, preserveAspectRatio: options.imageAnchor === "left" ? "xMinYMid meet" : options.imageAnchor === "right" ? "xMaxYMid meet" : (options.imageFit ?? (bound.geometry.design.imageFill === "crop" ? "cover" : "contain")) === "cover" ? "xMidYMid slice" : "xMidYMid meet", role: "img", "aria-label": asset.alt ?? options.imageLabel ?? "Image",
1179
- ...traceAttrs(options, item.path), ...generatedAttrs(options) });
1176
+ preserveAspectRatio: options.imageAnchor === "left" ? "xMinYMid meet" : options.imageAnchor === "right" ? "xMaxYMid meet" : fit === "cover" ? "xMidYMid slice" : fit === "stretch" ? "none" : "xMidYMid meet",
1177
+ ...attrs });
1180
1178
  }
1181
1179
  const reason=missingReference?'missing-reference':!asset.src?'missing-source':'unsupported-source';
1182
1180
  const message=missingReference?`Image asset ${missingReference} is missing; supply the referenced asset or resolve it in the host.`:'Image requires an embedded raster or SVG data URI, or a host imageResolver.';
@@ -1398,7 +1396,7 @@ function renderQuote(item, box, bound, options) {
1398
1396
  function renderQuotePhoto(photo, item, bound, options) {
1399
1397
  const picture = renderImage({ value: photo.value, path: photo.path }, photo.box, bound, { ...options, imageFit: 'cover' });
1400
1398
  // An unresolved source keeps the ordinary placeholder, unmasked, like the export.
1401
- if (!picture.startsWith('<image')) return picture;
1399
+ if (isImagePlaceholder(picture)) return picture;
1402
1400
  const id = `opf-s${bound.index + 1}-quote-photo-${item.path.replace(/[^A-Za-z0-9]+/g, '-')}`;
1403
1401
  return tag('g', {}, tag('defs', {}, tag('clipPath', { id }, tag('path', { d: photo.shape.path }))) + tag('g', { 'clip-path': `url(#${id})` }, picture));
1404
1402
  }
@@ -1516,7 +1514,7 @@ function renderChart(item, box, bound, options) {
1516
1514
  const rendered = renderCatalogChart(item, box, bound, options, { tag, traceAttrs, stableNumber, renderTextBox, reportDiagnostic });
1517
1515
  if (rendered) return rendered;
1518
1516
  const chart = item.value ?? {};
1519
- const chartType = chart.type ?? engineDefaults.chartTypes[0];
1517
+ const chartType = chart.type ?? engineDefaults.chartType;
1520
1518
  const data = inlineChartRows(legacyChartData(chart, bound));
1521
1519
  // A dataset-backed chart has no data.rows of its own: its parts report the chart's authored path.
1522
1520
  const part = isDatasetChart(item, bound) ? () => item.path : (path) => path;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@openpresentation/opf-render",
3
- "version": "0.14.0",
3
+ "version": "0.15.0",
4
4
  "description": "Deterministic local OPF renderer for SVG, PNG, and PDF output.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -138,7 +138,7 @@
138
138
  "@expo-google-fonts/roboto": "0.4.3",
139
139
  "@expo-google-fonts/roboto-mono": "0.4.2",
140
140
  "@expo-google-fonts/tinos": "0.4.2",
141
- "@openpresentation/opf": "^0.14.0",
141
+ "@openpresentation/opf": "^0.15.1",
142
142
  "@resvg/resvg-js": "^2.6.2",
143
143
  "bidi-js": "1.1.0",
144
144
  "fontkit": "^2.0.4",