@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 +5 -5
- package/dist/charts.js +7 -6
- package/dist/fonts-browser.js +25 -11
- package/dist/fonts-node.js +10 -3
- package/dist/index.d.ts +11 -0
- package/dist/pdf-vector.js +18 -0
- package/dist/svg.d.ts +11 -0
- package/dist/svg.js +7 -2
- package/package.json +4 -4
package/README.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# OPF Render
|
|
2
2
|
|
|
3
|
-
**OPF 0.18 (RR-73, RR-74
|
|
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
|
|
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,
|
|
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. `
|
|
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
|
-
|
|
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
|
|
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) };
|
package/dist/fonts-browser.js
CHANGED
|
@@ -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()
|
|
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
|
-
|
|
310
|
-
|
|
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;
|
package/dist/fonts-node.js
CHANGED
|
@@ -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
|
-
|
|
307
|
-
const result =
|
|
308
|
-
|
|
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;
|
package/dist/pdf-vector.js
CHANGED
|
@@ -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
|
|
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
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
397
|
+
"playwright": "1.64.0",
|
|
398
398
|
"sharp": "0.35.5"
|
|
399
399
|
},
|
|
400
400
|
"bin": {
|