@openpresentation/opf-render 0.20.0 → 0.20.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # OPF Render
2
2
 
3
- **OPF 0.18 (RR-73, RR-74, unreleased; requires core 0.18).** One shape for every engine: `toX(deck, slides?, options?)`. The second argument is a slide number (one result), a selection (`"1-3"`, `"1,3-5"`, `"2-"`, `[1, 3]`; a list of results) or the options, told apart by type, and slides count from 1. Renamed and removed outright (no aliases); an old option fails with `invalid-render-options` or `invalid-conversion-option` and names its replacement:
3
+ **OPF 0.18 (RR-73, RR-74; requires core 0.18).** One shape for every engine: `toX(deck, slides?, options?)`. The second argument is a slide number (one result), a selection (`"1-3"`, `"1,3-5"`, `"2-"`, `[1, 3]`; a list of results) or the options, told apart by type, and slides count from 1. Renamed and removed outright (no aliases); an old option fails with `invalid-render-options` or `invalid-conversion-option` and names its replacement:
4
4
 
5
5
  | 0.17 | 0.18 |
6
6
  | --- | --- |
@@ -12,14 +12,14 @@
12
12
  | `mode: "raster"` | `raster: true` (vector is the default) |
13
13
  | `fontDirs` | `fonts`: the handle, or font folders (a path or a list) for SVG input |
14
14
 
15
- **OPF 0.15 (FA-23, unreleased; requires core 0.15).** Catalogs are registered by the host, and every picture is an image background or an image block. The breaking changes are listed in `changes/fa-23-opf-0-15.md` (the next CHANGELOG section); the [0.15 design](https://github.com/OpenPresentation/opf/blob/main/docs/programs/format-audit/0.15-design.md) explains them.
15
+ **OPF 0.15 (FA-23; 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 recorded in [CHANGELOG.md](CHANGELOG.md); the [0.15 design](https://github.com/OpenPresentation/opf/blob/main/docs/programs/format-audit/0.15-design.md) explains them.
16
16
 
17
17
  - The renderer bundles, looks up and fetches no catalog. Every entry point (`toSvg`, `resolvePresentation`, the font loaders' `renderOptions`, `/player`, `<opf-deck>`) takes core's `catalogs: Catalog[]` and passes it unchanged to core resolution. Register the gallery snapshot yourself: `import { gallery } from '@openpresentation/gallery'` and `toSvg(deck, { catalogs: [gallery] })`. `strictReferences: true` fails with `unresolved-reference` instead of falling back.
18
18
  - What resolves nowhere draws with core's engine defaults (`engineDefaults` is `{ theme, colorScheme, fontScheme, chartType }`: core's `ENGINE_DEFAULT_THEME`, `ENGINE_DEFAULT_COLOR_SCHEME`, `ENGINE_DEFAULT_FONT_SCHEME`) and is reported once per reference as `unresolved-reference`.
19
19
  - An image background (`design.background: { type: "image", src, alt, fit, focus, opacity, recolor, overlay }`, or an image source string) draws from core's `geometry.backgroundImage`: the canvas colour, the picture (cover, contain, stretch or tile; recolor and opacity on the pixels only), then the overlay (the whole slide or an edge band). `alt` is the picture's accessible name; without it the picture is decorative.
20
20
  - Every content picture is an image block drawn from `item.image`: frame, fit (`cover`, `contain`, `stretch`; default `design.imageFit`, else `cover`), focus, shape mask, border, opacity, recolor and overlay, and a placed block (`placement`) at its edge band.
21
21
 
22
- **Lighter install (RR-63, unreleased, 0.16.0).** `pdf-lib`, `@resvg/resvg-js`, `sharp` and every `@expo-google-fonts/*` font package are optional peer dependencies, loaded on first use: an SVG-only install holds none of them. Add what you export (see [Install](#install-what-to-add-for-what-rr-63)); a missing converter rejects with `converter-missing`. New `/png` and `/pdf` entries beside `/svg`. Rendered output is unchanged.
22
+ **Lighter install (RR-63, 0.16.0).** `pdf-lib`, `@resvg/resvg-js`, `sharp` and every `@expo-google-fonts/*` font package are optional peer dependencies, loaded on first use: an SVG-only install holds none of them. Add what you export (see [Install](#install-what-to-add-for-what-rr-63)); a missing converter rejects with `converter-missing`. New `/png` and `/pdf` entries beside `/svg`. Rendered output is unchanged.
23
23
 
24
24
  **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):
25
25
 
@@ -193,7 +193,7 @@ Installed size, measured on Windows with `npm install --ignore-scripts`, unpacke
193
193
  - Public API: `toSvg(opf, slides?, opts)` (every slide, one slide or a selection), `resolvePresentation(opf, opts)`, `toPng(opf or svg, slides?, opts)`, `toPdf(opf or svgs, slides?, opts)` and `loadFonts(opts)` (`/fonts-node`, `/fonts-browser`)
194
194
  - 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)
195
195
 
196
- `toSvg(deck)` returns one SVG string per slide (`skipHidden: true` leaves out slides marked `hidden`, the player's sequence, so the result is then shorter than the deck); `toSvg(deck, 3)` returns the SVG of the third slide (slides count from 1; out of range throws `slide-out-of-range`), and `toSvg(deck, "1-3")` or `toSvg(deck, [1, 3])` the selected slides, hidden or not (`invalid-slide-selection` for a malformed one). Every form checks the format at the boundary (core `validate(deck, { only: ['format'] })`; a failure throws `OPFRenderError` `invalid-opf` whose `findings` are the error findings), resolve references from the document's embedded records and the host's `catalogs` (core `Catalog[]`, never fetched), and emit byte-stable SVG for the same input. With a `fonts` handle, each SVG embeds as `@font-face` data only the faces of `embeddedFonts` that its own text draws (by family, weight and style; a face flagged `embed: "always"` goes into every SVG), so the handle is safe to pass to every preview: over the 127 core example decks, an office-pack slide carries 2.4 MB on average (4.1 MB at most) instead of every eager face (15 MB), a base-pack slide 0.53 MB instead of 1.76 MB. Each embedded face is also cut to the glyphs the slide draws (RR-65): the Node handle (and a browser handle given `subsetWasm`, the URL of `harfbuzzjs/dist/harfbuzz-subset.wasm` the host serves, or its bytes or compiled module) subsets each face with HarfBuzz's own subsetter (hb-subset from the pinned harfbuzzjs, MIT), keeping every layout feature, so the browser shapes and draws it exactly like the whole face (147 example slides with 384 embedded faces are pixel-identical in a browser) and the bytes are the same for the same slide. Over the 127 core example decks an office-pack slide falls from 2.4 MB to 59 KB on average (2.85 MB to 59 KB at the median, 1.95 GB to 48 MB for all 807 slides), a base-pack slide from 530 KB to 73 KB. Only a face whose license allows a modified version is cut: a bundled face (by sha256 against the pinned manifest) under a permitted license whose family and file names do not contain a Reserved Font Name of its package (a subset is a modified version, owner decision 2026-09-29) and whose OS/2 fsType allows subsetting; Carlito, Raleway, Lora, Playfair Display, PT Serif, Merriweather Sans, Source Sans 3 and every face a host supplies are embedded whole. `subsetFonts: false` embeds whole faces (for an SVG whose text will be edited outside the renderer). A face whose OS/2 fsType forbids embedding (Restricted License 0x0002, or bitmap-only 0x0200, since an SVG carries outlines) is never embedded (RR-76): the SVG names its family without the data, so a viewer without the font draws the generic family, and a `font-embedding-restricted` diagnostic lists the faces left out. `renderDeckHtml` with `fontMode: "shared"` embeds each face once for the page, cut to the characters all of its slides draw (a three-slide Roboto page: 53 KB, against 101 KB with per-slide subsets and 442 KB with whole shared faces). `text: "system"` writes no `@font-face` data at all (the handle still measures), for a browser host whose `loadFonts` handle already added the faces to the page; keep the default where an SVG must stand alone (a file, the browser PDF export). Slide background colours (solid, gradient stops, pattern colours) accept the same colour references as text and table colours: a hex value, a `var:` variable, or a colour scheme slot or role such as `accent2` or `primary`.
196
+ `toSvg(deck)` returns one SVG string per slide (`skipHidden: true` leaves out slides marked `hidden`, the player's sequence, so the result is then shorter than the deck); `toSvg(deck, 3)` returns the SVG of the third slide (slides count from 1; out of range throws `slide-out-of-range`), and `toSvg(deck, "1-3")` or `toSvg(deck, [1, 3])` the selected slides, hidden or not (`invalid-slide-selection` for a malformed one). Every form checks the format at the boundary (core `validate(deck, { only: ['format'] })`; a failure throws `OPFRenderError` `invalid-opf` whose `findings` are the error findings), resolve references from the document's embedded records and the host's `catalogs` (core `Catalog[]`, never fetched), and emit byte-stable SVG for the same input. With a `fonts` handle, each SVG embeds as `@font-face` data only the faces of `embeddedFonts` that its own text draws (by family, weight and style; a face flagged `embed: "always"` goes into every SVG), so the handle is safe to pass to every preview: over the 127 core example decks, an office-pack slide carries 2.4 MB on average (4.1 MB at most) instead of every eager face (15 MB), a base-pack slide 0.53 MB instead of 1.76 MB. Each embedded face is also cut to the glyphs the slide draws (RR-65): the Node handle (and a browser handle given `subsetWasm`, the URL of `harfbuzzjs/dist/harfbuzz-subset.wasm` the host serves, or its bytes or compiled module) subsets each face with HarfBuzz's own subsetter (hb-subset from the pinned harfbuzzjs, MIT), keeping every layout feature, so the browser shapes and draws it exactly like the whole face (147 example slides with 384 embedded faces are pixel-identical in a browser) and the bytes are the same for the same slide. Over the 127 core example decks an office-pack slide falls from 2.4 MB to 59 KB on average (2.85 MB to 59 KB at the median, 1.95 GB to 48 MB for all 807 slides), a base-pack slide from 530 KB to 73 KB. Only a face whose license allows a modified version is cut: a bundled face (by sha256 against the pinned manifest) under a permitted license whose family and file names do not contain a Reserved Font Name of its package (a subset is a modified version, owner decision 2026-09-29) and whose OS/2 fsType allows subsetting; Carlito, Raleway, Lora, Playfair Display, PT Serif, Merriweather Sans, Source Sans 3 and every face a host supplies are embedded whole. `subsetFonts: false` embeds whole faces (for an SVG whose text will be edited outside the renderer). A face whose OS/2 fsType forbids embedding (Restricted License 0x0002, or bitmap-only 0x0200, since an SVG carries outlines) is never embedded (RR-76): the SVG names its family without the data, so a viewer without the font draws the generic family, and a `font-embedding-restricted` diagnostic lists the faces left out. `toHtml` with `fontMode: "shared"` embeds each face once for the page, cut to the characters all of its slides draw (a three-slide Roboto page: 53 KB, against 101 KB with per-slide subsets and 442 KB with whole shared faces). `text: "system"` writes no `@font-face` data at all (the handle still measures), for a browser host whose `loadFonts` handle already added the faces to the page; keep the default where an SVG must stand alone (a file, the browser PDF export). Slide background colours (solid, gradient stops, pattern colours) accept the same colour references as text and table colours: a hex value, a `var:` variable, or a colour scheme slot or role such as `accent2` or `primary`.
197
197
 
198
198
  Accessibility of the SVG (FA-30): the slide root is a labelled container, `role="group"` with `aria-roledescription="slide"` and `aria-label` set to the slide title, never `role="img"`, which would make every text node inside presentational. Titles, body text, list entries, quotes, metrics, table cells, captions, footnotes and header or footer text stay live text a screen reader reaches, in the order the renderer paints them. Purely decorative drawing (the background and its overlay, card frames, table cell fills and borders, dividers, timeline markers, watermarks, and an image whose `alt` is `""`) carries `aria-hidden="true"`. A picture with `alt` is `role="img"` with that label (a header or footer picture without `alt` is hidden); a chart with `alt` is a `role="img"` group, and one with `alt: ""` is hidden. `<opf-deck>`, the player and `toHtml` remove the root's `role`, `aria-roledescription` and `aria-label` (the slide section or figure around the drawing carries the one name), so a slide is announced once (not tested with assistive technology; see Accessibility under `<opf-deck>`).
199
199
 
@@ -263,7 +263,7 @@ const pdf = await toPdf(svgs, { fonts });
263
263
 
264
264
  ```js
265
265
  const pdf = await toPdf(svgs, {
266
- fontFiles: fonts.fontFiles, useBundledFonts: false,
266
+ fonts,
267
267
  metadata: { title: "Quarterly review", author: "Finance", language: "en-GB" },
268
268
  onDiagnostic: (d) => d.code === "pdf-raster-fallback" && console.warn(d.path, d.reason),
269
269
  });
package/dist/charts.js CHANGED
@@ -858,7 +858,7 @@ function drawLines(c, spec, series, stacks, { plot, band, at, categorySlot = (i)
858
858
  }
859
859
  // RR-35: data labels sit beside each point, over every line.
860
860
  if (c.dataLabels) series.forEach((s, j) => stacks[j].forEach((point, i) => {
861
- if (point) drawPointLabel(c, labelString(c, { category: c.rows[i][0] === null || c.rows[i][0] === undefined ? "" : String(c.rows[i][0]), value: s.values[i], format: s.format }), [plot.x + (i + 0.5) * band, at(point.to)], `${c.path}.data.rows.${i}.${s.column}`);
861
+ if (point) drawPointLabel(c, labelString(c, { category: c.rows[i][0] === null || c.rows[i][0] === undefined ? "" : String(c.rows[i][0]), value: s.values[i], format: s.format }), [plot.x + (categorySlot(i) + 0.5) * band, at(point.to)], `${c.path}.data.rows.${i}.${s.column}`);
862
862
  }));
863
863
  }
864
864
 
@@ -874,7 +874,7 @@ function drawAreas(c, series, stacks, { plot, band, at, crossing, categorySlot =
874
874
  if (c.dataLabels) series.forEach((s, j) => stacks[j].forEach((point, i) => {
875
875
  if (!point) return;
876
876
  const lowerEdge = point.from === 0 ? crossing : at(point.from);
877
- drawCenteredLabel(c, labelString(c, { category: c.rows[i][0] === null || c.rows[i][0] === undefined ? "" : String(c.rows[i][0]), value: s.values[i], format: s.format }), plot.x + (i + 0.5) * band, (lowerEdge + at(point.to)) / 2, `${c.path}.data.rows.${i}.${s.column}`, seriesColor(c, j));
877
+ drawCenteredLabel(c, labelString(c, { category: c.rows[i][0] === null || c.rows[i][0] === undefined ? "" : String(c.rows[i][0]), value: s.values[i], format: s.format }), plot.x + (categorySlot(i) + 0.5) * band, (lowerEdge + at(point.to)) / 2, `${c.path}.data.rows.${i}.${s.column}`, seriesColor(c, j));
878
878
  }));
879
879
  }
880
880
 
@@ -1481,7 +1481,7 @@ function renderTreemapChart(c) {
1481
1481
  * values and clamps to the extremes, whiskers reach the furthest values within
1482
1482
  * 1.5 IQR of the box, and values beyond are outliers.
1483
1483
  */
1484
- export function boxStatistics(values) {
1484
+ export function boxStatistics(values, path = "chart") {
1485
1485
  const sorted = values.filter((value) => value !== null && Number.isFinite(value)).sort((a, b) => a - b);
1486
1486
  const n = sorted.length;
1487
1487
  if (!n) return null;
@@ -1494,8 +1494,9 @@ export function boxStatistics(values) {
1494
1494
  };
1495
1495
  const q1 = quantile(0.25), median = quantile(0.5), q3 = quantile(0.75);
1496
1496
  const iqr = q3 - q1;
1497
- const inside = sorted.filter((value) => value >= q1 - 1.5 * iqr && value <= q3 + 1.5 * iqr);
1498
1497
  const mean = sorted.reduce((sum, value) => sum + value, 0) / n;
1498
+ if (![q1, median, q3, iqr, mean].every(Number.isFinite)) throw chartAggregateError(path, "box statistics");
1499
+ const inside = sorted.filter((value) => value >= q1 - 1.5 * iqr && value <= q3 + 1.5 * iqr);
1499
1500
  return { q1, median, q3, mean, low: inside[0] ?? q1, high: inside[inside.length - 1] ?? q3, outliers: sorted.filter((value) => value < q1 - 1.5 * iqr || value > q3 + 1.5 * iqr) };
1500
1501
  }
1501
1502
 
@@ -1512,7 +1513,7 @@ function renderBoxWhiskerChart(c) {
1512
1513
  if (!group) groups.push(group = { name, first: i, rows: [] });
1513
1514
  group.rows.push(i);
1514
1515
  });
1515
- const boxes = series.map((s) => groups.map((group) => ({ stats: boxStatistics(group.rows.map((i) => s.values[i])), rows: group.rows })));
1516
+ const boxes = series.map((s) => groups.map((group) => ({ stats: boxStatistics(group.rows.map((i) => s.values[i]), c.path), rows: group.rows })));
1516
1517
  const all = boxes.flat().flatMap((entry) => entry.stats ? [entry.stats.low, entry.stats.high, ...entry.stats.outliers] : []);
1517
1518
  const dataMin = all.length ? Math.min(...all) : 0, dataMax = all.length ? Math.max(...all) : 1;
1518
1519
  const legendWidth = seriesLegend(c, series);
@@ -1569,7 +1570,7 @@ function renderRegionMapChart(c) {
1569
1570
  const light = mixHex(/^#[0-9A-Fa-f]{6}$/.test(c.surface) ? c.surface : "#FFFFFF", c.colors[0], 0.15);
1570
1571
  categories.forEach((name, i) => {
1571
1572
  const value = values[i];
1572
- const t = value === null ? 0 : max > min ? (value - min) / (max - min) : 1;
1573
+ const t = value === null ? 0 : max > min ? axisFraction(value, {min, max}) : 1;
1573
1574
  const x = area.x + (i % columns) * tileWidth, y = area.y + Math.floor(i / columns) * tileHeight;
1574
1575
  c.mark("rect", { x, y, width: tileWidth, height: tileHeight, fill: value === null ? c.surface : mixHex(light, c.colors[0], t), stroke: "#FFFFFF", "stroke-width": c.pt }, `${c.path}.data.rows.${i}.${column}`);
1575
1576
  const inset = { x: x + fontPx * 0.25, width: Math.max(1, tileWidth - fontPx * 0.5) };
@@ -25,7 +25,7 @@ async function verifyDigest(entry, data, subtle) {
25
25
  }
26
26
 
27
27
  // The registry behind `loadFonts`: fetch only host-selected font files, then use those exact bytes for shaping and CSS.
28
- async function loadRegistry(entries, options = {}) {
28
+ async function loadRegistry(entries, options = {}, startup = {}) {
29
29
  // Validate the host's extra faces first: a bad list must not leave startup faces registered in the document with no registry to dispose.
30
30
  const extraLazy = normalizeExtraLazyFonts(options.extraLazyFonts);
31
31
  const documentRef = options.document ?? globalThis.document;
@@ -63,6 +63,12 @@ async function loadRegistry(entries, options = {}) {
63
63
  const faces = await fetchFaces(entries, options.signal);
64
64
  const registry = createFontRegistry(faces, options),
65
65
  loaded = [];
66
+ let disposed = startup.failed === true;
67
+ const dispose = () => {
68
+ disposed = true;
69
+ for (const face of loaded) documentRef.fonts.delete(face);
70
+ };
71
+ startup.dispose = dispose;
66
72
  // Normalize family names using the registry's parsed font metadata.
67
73
  const described = registry.describeFaces();
68
74
  // Registers browser FontFaces for faces already parsed by the registry; on failure nothing stays registered.
@@ -84,19 +90,21 @@ async function loadRegistry(entries, options = {}) {
84
90
  added.push(face);
85
91
  }),
86
92
  );
93
+ if (disposed) throw new OPFFontError("font-registry-disposed", "The font registry was disposed.");
94
+ loaded.push(...added);
87
95
  for (const face of added) documentRef.fonts.add(face);
88
96
  await documentRef.fonts.ready;
97
+ if (disposed) throw new OPFFontError("font-registry-disposed", "The font registry was disposed.");
89
98
  } catch (error) {
90
99
  for (const face of added) documentRef.fonts.delete(face);
91
100
  throw new OPFFontError("font-load-failed", error.message);
92
101
  }
93
- loaded.push(...added);
94
102
  };
95
103
  await registerBrowserFaces(faces, described);
96
104
 
97
105
  // Script faces (FF-19) are loaded lazily, once a document needs them, and only for the scripts it uses.
98
106
  const scriptPackages = new Set();
99
- let queue = Promise.resolve(), disposed = false;
107
+ let queue = Promise.resolve();
100
108
  // FF-41: the `toSvg` options a document resolves with (`catalogs`, ...). `options.renderOptions` are the defaults; a call's own
101
109
  // options (everything but `signal`) are added over them, so a host that passes its canvas options gets the same resolution here.
102
110
  const renderOptionsFor = (callOptions) => { const { signal: _signal, ...own } = callOptions ?? {}; return { ...options.renderOptions, ...own }; };
@@ -264,12 +272,7 @@ async function loadRegistry(entries, options = {}) {
264
272
  for (const face of loaded) documentRef.fonts.delete(face);
265
273
  throw error;
266
274
  }
267
- return Object.assign(registry, {
268
- dispose() {
269
- disposed = true;
270
- for (const face of loaded) documentRef.fonts.delete(face);
271
- },
272
- });
275
+ return Object.assign(registry, { dispose });
273
276
  }
274
277
 
275
278
  const MAX_ENSURE_ROUNDS = 4;
@@ -306,8 +309,19 @@ async function fetchWasm(source, fetchImpl, signal, name) {
306
309
  }
307
310
 
308
311
  export async function loadFonts({ faces = [], subsetWasm, shapeWasm, ...options } = {}) {
309
- const [registry, subsetter, shaper] = await Promise.all([loadRegistry(faces, options), subsetWasm === undefined ? null : browserSubsetter(subsetWasm, options.fetch, options.signal),
310
- shapeWasm === undefined ? null : browserShaper(shapeWasm, options.fetch, options.signal)]);
312
+ // A sibling WASM failure must reject promptly even if a host font request/ready promise
313
+ // never settles. The private startup guard removes registered faces now and prevents
314
+ // any pending face load from registering later; successful startup stays parallel.
315
+ const startup = {};
316
+ let registry, subsetter, shaper;
317
+ try {
318
+ [registry, subsetter, shaper] = await Promise.all([loadRegistry(faces, options, startup), subsetWasm === undefined ? null : browserSubsetter(subsetWasm, options.fetch, options.signal),
319
+ shapeWasm === undefined ? null : browserShaper(shapeWasm, options.fetch, options.signal)]);
320
+ } catch (error) {
321
+ startup.failed = true;
322
+ startup.dispose?.();
323
+ throw error;
324
+ }
311
325
  // The embedded list is base64 of every eager face: compute it once, and again only when faces were added.
312
326
  let embedded, stale = true;
313
327
  const addFaces = registry.addFaces;
@@ -283,6 +283,9 @@ export async function loadFonts({pack = "base", embedScriptFonts = false, ...opt
283
283
  const selectEmbedded = () => registry.selectEmbeddedFonts(face => embedScriptFonts || !face.scripts || face.fallbackOnly);
284
284
  // The embedded list is base64 of every face: computed on first use, and again after `ensure` added faces.
285
285
  let embeddedFonts, fontFiles = [...registry.fontFiles];
286
+ // Calls mutate one registry: serialize their reads/additions, while returning each
287
+ // call's own result or error. A rejection must not poison later preparation.
288
+ let ensureQueue = Promise.resolve();
286
289
  const refresh = () => { embeddedFonts = undefined; fontFiles = [...registry.fontFiles]; };
287
290
  const [subsetter, shaper] = await Promise.all([nodeHarfbuzz("subsets"), nodeHarfbuzz("shaper")]);
288
291
  for (const [kind, engine] of [["subsets", subsetter], ["shaper", shaper]]) if (!engine) {
@@ -303,9 +306,13 @@ export async function loadFonts({pack = "base", embedScriptFonts = false, ...opt
303
306
  manifest: BUNDLED_FONT_MANIFEST,
304
307
  get substitutions() { return registry.substitutions; },
305
308
  /** Load the script faces the presentation's text needs (see `ensureFonts`); `embeddedFonts` and `fontFiles` then include them. */
306
- async ensure(presentation, callOptions = {}) {
307
- const result = await ensureFonts(registry, loaded, presentation, {onDiagnostic: callOptions.onDiagnostic ?? options.onDiagnostic, renderOptions: {...options.renderOptions, ...callOptions.renderOptions}});
308
- refresh();
309
+ ensure(presentation, callOptions = {}) {
310
+ const result = ensureQueue.then(async () => {
311
+ const prepared = await ensureFonts(registry, loaded, presentation, {onDiagnostic: callOptions.onDiagnostic ?? options.onDiagnostic, renderOptions: {...options.renderOptions, ...callOptions.renderOptions}});
312
+ refresh();
313
+ return prepared;
314
+ });
315
+ ensureQueue = result.catch(() => {});
309
316
  return result;
310
317
  },
311
318
  /** Synchronous: the script packages the presentation needs that are not loaded yet. Empty means `ensure` loads nothing. */
package/dist/index.d.ts CHANGED
@@ -27,6 +27,17 @@ export declare const engineDefaults: Readonly<{
27
27
  }>;
28
28
 
29
29
  export type RenderDiagnostic = LayoutDiagnostic | {
30
+ /** A run stays text because its face cannot safely be written as outlines. */
31
+ code: "text-as-paths-fallback";
32
+ reason: "colour-font" | "restricted" | "shaping";
33
+ fontFamily: string;
34
+ message: string;
35
+ } | {
36
+ /** Faces omitted from the SVG because their OS/2 fsType restricts embedding. */
37
+ code: "font-embedding-restricted";
38
+ faces: { fontFamily: string; weight: number; italic: boolean; fsType: number }[];
39
+ message: string;
40
+ } | {
30
41
  /** A face lacked glyphs for the text; a bundled fallback face draws them in the preview. A note, not an error. */
31
42
  code: "font-glyph-fallback";
32
43
  path?: string;
@@ -738,6 +738,24 @@ class Converter {
738
738
  // Text
739
739
 
740
740
  async text(node, attrs, ctx) {
741
+ // Relative per-character positions need cumulative SVG text layout across
742
+ // chunks, anchors and bidi runs. Do not silently approximate that as run dx.
743
+ const pending = [node];
744
+ while (pending.length) {
745
+ const current = pending.pop();
746
+ const own = attributesOf(current);
747
+ for (const attribute of ["dx", "dy"]) {
748
+ if (own[attribute] === undefined) continue;
749
+ const value = String(own[attribute]).trim();
750
+ const values = value.split(/\s*,\s*|\s+/);
751
+ if (values.length && values.every(item => parseLength(item, { reference: 1 }) === 0)) continue;
752
+ const mark = `${attribute}=${value}`;
753
+ if (ctx.page.reportedAttributes.has(mark)) continue;
754
+ ctx.page.reportedAttributes.add(mark);
755
+ this.unsupported("pdf-unsupported-feature", `${attribute}="${value.slice(0, 40)}" on <${current.name}> is not drawn in the vector PDF; relative text positioning is unsupported.`, { path: current.attrs["data-opf-path"], element: current.name, attribute });
756
+ }
757
+ for (const child of current.children) if (["tspan", "textPath", "tref", "a"].includes(child.name)) pending.push(child);
758
+ }
741
759
  const { runs } = layoutText(node, this.env, ctx.parentStyle, ctx.link);
742
760
  if (!runs.length) return;
743
761
  for (const run of runs) {
package/dist/svg.d.ts CHANGED
@@ -27,6 +27,17 @@ export declare const engineDefaults: Readonly<{
27
27
  }>;
28
28
 
29
29
  export type RenderDiagnostic = LayoutDiagnostic | {
30
+ /** A run stays text because its face cannot safely be written as outlines. */
31
+ code: "text-as-paths-fallback";
32
+ reason: "colour-font" | "restricted" | "shaping";
33
+ fontFamily: string;
34
+ message: string;
35
+ } | {
36
+ /** Faces omitted from the SVG because their OS/2 fsType restricts embedding. */
37
+ code: "font-embedding-restricted";
38
+ faces: { fontFamily: string; weight: number; italic: boolean; fsType: number }[];
39
+ message: string;
40
+ } | {
30
41
  /** A face lacked glyphs for the text; a bundled fallback face draws them in the preview. A note, not an error. */
31
42
  code: "font-glyph-fallback";
32
43
  path?: string;
package/dist/svg.js CHANGED
@@ -1962,6 +1962,7 @@ function trimRichLineEdges(line,textMeasurement) {
1962
1962
  }
1963
1963
 
1964
1964
  function renderRichLines(value,fit,box,bound,config) {
1965
+ for (const run of value) assertXmlText(typeof run === 'string' ? run : run.text, config.path);
1965
1966
  const logicalAlignment=fit.placement?.alignment??config.align??bound.geometry.design.contentAlignment;
1966
1967
  // Each line takes its paragraph's direction from core (every wrapped line shares it) and its physical edge from that.
1967
1968
  const lineAlignment=index=>fit.placement?.lines[index]?.alignment??physicalAlignment(logicalAlignment,fit.directions?.[index]);
@@ -2061,9 +2062,13 @@ function renderRichLines(value,fit,box,bound,config) {
2061
2062
  return tag('g',{...traceAttrs(config.options,config.path),...(config.options.trace?{'data-opf-box-width':box.width,'data-opf-rich-text':config.rich===false?undefined:'true','data-opf-rich-lines':JSON.stringify(lineTrace),'data-opf-rich-spacing':spacing}:{}),...(fit.overflow?{'data-opf-overflow':'true'}:{})},content.join('\n'));
2062
2063
  }
2063
2064
 
2064
- function renderTextBox(text, box, bound, config) {
2065
+ function assertXmlText(text, path) {
2065
2066
  const invalid=/[\u0000-\u0008\u000B\u000C\u000E-\u001F\uD800-\uDFFF\uFFFE\uFFFF]/u.exec(String(text??''));
2066
- if(invalid)throw new OPFRenderError('invalid-text',`Text contains U+${invalid[0].codePointAt(0).toString(16).toUpperCase().padStart(4,'0')} at UTF-16 offset ${invalid.index}, which SVG XML cannot represent.`,{path:config.path});
2067
+ if(invalid)throw new OPFRenderError('invalid-text',`Text contains U+${invalid[0].codePointAt(0).toString(16).toUpperCase().padStart(4,'0')} at UTF-16 offset ${invalid.index}, which SVG XML cannot represent.`,{path});
2068
+ }
2069
+
2070
+ function renderTextBox(text, box, bound, config) {
2071
+ assertXmlText(text, config.path);
2067
2072
  const scale = Math.min(bound.design.dimensions.width, bound.design.dimensions.height) / 720;
2068
2073
  const style = config.textStyle ?? resolveTextStyle({fontFamily:config.fontFamily,fontWeight:config.fontWeight ?? 400,italic:config.italic ?? false,path:config.path},config.options.textMeasurement);
2069
2074
  const fit = config.fit ?? fitText(String(text ?? ""), box, config.fontSize * scale,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@openpresentation/opf-render",
3
- "version": "0.20.0",
3
+ "version": "0.20.4",
4
4
  "description": "Deterministic local OPF renderer for SVG, PNG, and PDF output.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -142,7 +142,7 @@
142
142
  "test:forced-colors-browser": "node test/forced-colors-browser.mjs artifacts/forced-colors"
143
143
  },
144
144
  "dependencies": {
145
- "@openpresentation/opf": "^0.20.1",
145
+ "@openpresentation/opf": "^0.20.5",
146
146
  "bidi-js": "1.1.0",
147
147
  "fontkit": "^2.0.4",
148
148
  "harfbuzzjs": "1.6.3",
@@ -388,13 +388,13 @@
388
388
  "@expo-google-fonts/tinos": "0.4.2",
389
389
  "@hyzyla/pdfium": "2.1.13",
390
390
  "@jspawn/qpdf-wasm": "0.0.2",
391
- "@napi-rs/canvas": "1.0.10",
391
+ "@napi-rs/canvas": "1.0.11",
392
392
  "@resvg/resvg-js": "2.6.2",
393
393
  "axe-core": "4.14.0",
394
394
  "esbuild": "0.28.2",
395
395
  "pdf-lib": "1.17.1",
396
396
  "pdfjs-dist": "6.4.299",
397
- "playwright": "1.63.0",
397
+ "playwright": "1.64.0",
398
398
  "sharp": "0.35.5"
399
399
  },
400
400
  "bin": {