@openpresentation/opf-render 0.17.0 → 0.17.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +9 -2
- package/dist/drawn-faces.js +38 -0
- package/dist/element.js +21 -8
- package/dist/font-fstype.js +70 -0
- package/dist/font-subset.js +173 -0
- package/dist/fonts-browser.d.ts +11 -0
- package/dist/fonts-browser.js +31 -3
- package/dist/fonts-node.js +23 -1
- package/dist/fonts.d.ts +4 -0
- package/dist/hb-shape.js +121 -0
- package/dist/index.d.ts +16 -4
- package/dist/pdf-text.js +6 -3
- package/dist/pdf-vector.js +3 -0
- package/dist/svg.d.ts +16 -4
- package/dist/svg.js +33 -32
- package/dist/text-paths.js +79 -27
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -152,11 +152,11 @@ Installed size, measured on Windows with `npm install --ignore-scripts`, unpacke
|
|
|
152
152
|
- 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`)
|
|
153
153
|
- Player and embedding (RR-28): `<opf-deck>` and a slideshow with a speaker view, see [Player and `<opf-deck>`](#player-and-opf-deck-rr-28)
|
|
154
154
|
|
|
155
|
-
`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. 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.
|
|
155
|
+
`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. 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). `embedFonts: false` writes no `@font-face` data at all (the handle still measures), for a browser host whose `loadFonts` handle already added the faces to the page; keep the default where an SVG must stand alone (a file, the browser PDF export). Slide background colours (solid, gradient stops, pattern colours) accept the same colour references as text and table colours: a hex value, a `var:` variable, or a colour scheme slot or role such as `accent2` or `primary`.
|
|
156
156
|
|
|
157
157
|
Accessibility of the SVG (FA-30): the slide root is a labelled container, `role="group"` with `aria-roledescription="slide"` and `aria-label` set to the slide title, never `role="img"`, which would make every text node inside presentational. Titles, body text, list entries, quotes, metrics, table cells, captions, footnotes and header or footer text stay live text a screen reader reaches, in the order the renderer paints them. Purely decorative drawing (the background and its overlay, card frames, table cell fills and borders, dividers, timeline markers, watermarks, and an image whose `alt` is `""`) carries `aria-hidden="true"`. A picture with `alt` is `role="img"` with that label (a header or footer picture without `alt` is hidden); a chart with `alt` is a `role="img"` group, and one with `alt: ""` is hidden. `<opf-deck>`, the player and `renderDeckHtml` 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.
|
|
158
158
|
|
|
159
|
-
Text as outlines (RR-64): `renderSvg(deck, { fonts, textAsPaths: true })` draws every `<text>` as glyph outlines, so the SVG needs no font and looks the same in every browser, image viewer and design tool. Each glyph is a `<use>` of an outline kept once per slide in `<defs>` (in font units, under an id made of the face's content hash and the glyph id, so SVGs inlined on one page never mix up glyphs); positions
|
|
159
|
+
Text as outlines (RR-64): `renderSvg(deck, { fonts, textAsPaths: true })` draws every `<text>` as glyph outlines, so the SVG needs no font and looks the same in every browser, image viewer and design tool. Each glyph is a `<use>` of an outline kept once per slide in `<defs>` (in font units, under an id made of the face's content hash and the glyph id, so SVGs inlined on one page never mix up glyphs); positions and widths are the vector PDF's layout of the renderer's pinned lines, and the glyphs are shaped by HarfBuzz, the shaper browsers use (the Node handle always; a browser handle given `shapeWasm`, the host's copy of `harfbuzzjs/dist/harfbuzz.wasm`; fontkit otherwise). Over the 30 script corpora every outlined glyph has HarfBuzz's id and position (fontkit, which the layout measures with, picks other glyphs on 10 samples: Bengali, Thai, Lao, Khmer, Myanmar, Syriac and Japanese combining marks); a run inside a pinned width is scaled to it, as the browser scales text. Over the core example decks resvg draws an outlined slide like its text (mean channel difference at most 0.006 of 255) and an outlined slide is about 40 KB at the median (60 KB at p90, 106 KB at most) against 0.5 to 4 MB with whole embedded faces. It needs the fonts handle `loadFonts()` returns, which carries the outline engine as `fonts.outlines` (`text-as-paths-needs-fonts` otherwise); the handle's faces are parsed for outlining once, on the first outlined slide. Each outlined element is a group that keeps the element's attributes and `data-opf-*` trace (a tspan's trace stays on a nested group); its glyphs are hidden from assistive technology and each outlined line is also real text, invisible (`fill="none"`, the generic family, so no face is embedded for it) and pinned to the drawn width, in reading order: a screen reader reads the words as text (the FA-30 tree, checked with axe), and selection, copy and find-in-page work. Links stay `<a>`; underline and line-through are rectangles from the font's own metrics. A run stays text where outlines would not be faithful, reported as `text-as-paths-fallback` with its `reason`: a colour or bitmap font (`colour-font`, emoji), a face whose fsType restricts embedding (`restricted`), or a run with no pinned width on which HarfBuzz and the layout's measurement disagree by more than 0.1 px (`shaping`); the SVG embeds just those runs' faces. The trade-offs, which make it the choice for thumbnails, previews and portable SVG files and not for an editing surface: the drawn text is not editable (a caret and an input method need live text in its own font); a vector PDF made from an outlined SVG has paths instead of selectable text, so `svgToPdf` should get the `<text>` SVG; browsers draw outlines without the hinting they give text, so small text looks a little lighter than browser text (resvg already draws text as outlines); and in `/fonts-browser` the outline engine adds about 85 KB (minified) to the bundle. Plan and later phases: [opf-render#164](https://github.com/OpenPresentation/opf-render/issues/164).
|
|
160
160
|
|
|
161
161
|
The preview draws three accepted spec fields from tables shared with opf-pptx through core (`@openpresentation/opf`): `code.language` colours code (comments, strings, numbers, keywords, names and types, in theme-derived colours kept at 4.5:1 or more on the code panel; an unknown language stays plain and the text is never changed), `metric.trend` draws an arrow beside the trend word (green up, red down, neutral flat, with "Trend: up" as its text alternative) and colours the delta and trend text, and every one of the 54 DrawingML preset pattern backgrounds draws from its 8 x 8 bitmap (one pixel per 1/96 inch from the slide's top-left). With a core that lacks these exports the preview draws plain, without a new diagnostic. `scripts/derive-pattern-bitmaps.mjs` compares a PowerPoint export of the pattern slides with core's tiles (ECMA-376 names the presets without defining their pixels; core's tiles were measured with it from desktop PowerPoint, Office 365, Windows, 2026-10-01).
|
|
162
162
|
|
|
@@ -256,6 +256,13 @@ container.innerHTML = renderSlideSvg(presentation, 0, { fonts });
|
|
|
256
256
|
|
|
257
257
|
Each entry of `faces` contains `url` or `data: Uint8Array`, with optional `family`, `weight`, `italic` and `license`. The loader registers browser FontFaces using the same bytes used for measurement. It fetches only URLs supplied by the host, supports an AbortSignal and custom fetch, and awaits font loading. Use pinned static faces and retain their licenses. A standalone SVG embeds the faces of the handle's `embeddedFonts` that its text draws (the same `{ fonts }` option); embedding is unnecessary for each live draft after browser fonts are loaded.
|
|
258
258
|
|
|
259
|
+
Subset embedded faces in the browser (RR-65): pass `subsetWasm` to `loadFonts` to cut each face a standalone SVG embeds to the glyphs its slide draws, as the Node handle always does. It is the `harfbuzz-subset.wasm` file of the `harfbuzzjs` dependency (`harfbuzzjs/dist/harfbuzz-subset.wasm`, MIT), served by the host like the font files: its URL, its bytes or a compiled `WebAssembly.Module`. The loader instantiates it asynchronously and fetches nothing else; without it a browser handle embeds whole faces. Live previews in a page that already holds the faces should use `embedFonts: false` instead, which embeds nothing.
|
|
260
|
+
|
|
261
|
+
```js
|
|
262
|
+
const fonts = await loadFonts({ faces: fontFileEntries, subsetWasm: '/vendor/harfbuzz-subset.wasm' });
|
|
263
|
+
const standalone = renderSlideSvg(presentation, 0, { fonts }); // each @font-face is a glyph subset
|
|
264
|
+
```
|
|
265
|
+
|
|
259
266
|
## Player and `<opf-deck>` (RR-28)
|
|
260
267
|
|
|
261
268
|
A slideshow player and an embeddable web component, both built on `renderSlideSvg`: the slide a page shows is the slide the preview, the editor and the PDF show, with no second layout engine. They are plain ES modules, typed, framework-free and tree-shakeable, and importing them touches no DOM, so they are safe in Next.js and other server renderers.
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
// The faces and characters an SVG's text draws, read from its markup: the families of each text's font-family list, at the
|
|
2
|
+
// font-weight and font-style the text takes (attributes are inherited down the element tree, as in SVG). RR-61 embeds only the
|
|
3
|
+
// faces a slide draws; RR-65 cuts each to the characters drawn in its family. Shared by the SVG writer and the SSR deck markup.
|
|
4
|
+
|
|
5
|
+
const FONT_WEIGHT_KEYWORDS = { normal: "400", bold: "700" };
|
|
6
|
+
const TEXT_ENTITIES = { amp: "&", lt: "<", gt: ">", quot: '"', apos: "'" };
|
|
7
|
+
export function decodeTextEntities(text) {
|
|
8
|
+
if (!text.includes("&")) return text;
|
|
9
|
+
return text.replace(/&(#x[0-9a-f]+|#\d+|[a-z]+);/gi, (match, code) => code[0] === "#" ? String.fromCodePoint(code[1].toLowerCase() === "x" ? parseInt(code.slice(2), 16) : parseInt(code.slice(1), 10)) : TEXT_ENTITIES[code] ?? match);
|
|
10
|
+
}
|
|
11
|
+
/** family (lowercase) to the set of "weight|style" pairs the markup draws text in; with `characters` (a Map), also the code points each family draws. */
|
|
12
|
+
export function drawnFaces(markup, characters) {
|
|
13
|
+
const drawn = new Map();
|
|
14
|
+
const tokens = /<(\/?)([A-Za-z][\w:.-]*)((?:[^>"']|"[^"]*"|'[^']*')*?)(\/?)>|<!--[\s\S]*?-->|<!\[CDATA\[[\s\S]*?\]\]>|[^<]+/g;
|
|
15
|
+
const stack = [{ families: [], weight: "400", style: "normal", text: false }];
|
|
16
|
+
const attribute = (attributes, name) => { const found = new RegExp(`\\s${name}\\s*=\\s*(?:"([^"]*)"|'([^']*)')`).exec(attributes); return found ? found[1] ?? found[2] : undefined; };
|
|
17
|
+
for (const [token, close, name, attributes, selfClose] of markup.matchAll(tokens)) {
|
|
18
|
+
const top = stack.at(-1);
|
|
19
|
+
if (name === undefined) {
|
|
20
|
+
if (top.text && /\S/.test(token)) for (const family of top.families) { const set = drawn.get(family) ?? new Set(); set.add(`${top.weight}|${top.style}`); drawn.set(family, set); }
|
|
21
|
+
if (characters && top.text && token[0] !== "<") {
|
|
22
|
+
const points = [...decodeTextEntities(token)].map(character => character.codePointAt(0));
|
|
23
|
+
for (const family of top.families) { const set = characters.get(family) ?? new Set(); for (const point of points) set.add(point); characters.set(family, set); }
|
|
24
|
+
}
|
|
25
|
+
continue;
|
|
26
|
+
}
|
|
27
|
+
if (close) { if (stack.length > 1) stack.pop(); continue; }
|
|
28
|
+
const family = attribute(attributes, "font-family"), weight = attribute(attributes, "font-weight"), style = attribute(attributes, "font-style");
|
|
29
|
+
const next = {
|
|
30
|
+
families: family === undefined ? top.families : family.split(",").map(item => item.trim().replace(/^"|"$|^["']|["']$/g, "").toLowerCase()).filter(Boolean),
|
|
31
|
+
weight: weight === undefined ? top.weight : FONT_WEIGHT_KEYWORDS[weight.trim()] ?? weight.trim(),
|
|
32
|
+
style: style === undefined ? top.style : /italic|oblique/.test(style) ? "italic" : "normal",
|
|
33
|
+
text: top.text || name === "text",
|
|
34
|
+
};
|
|
35
|
+
if (!selfClose) stack.push(next);
|
|
36
|
+
}
|
|
37
|
+
return drawn;
|
|
38
|
+
}
|
package/dist/element.js
CHANGED
|
@@ -7,6 +7,7 @@ import {
|
|
|
7
7
|
} from "./deck-runtime.js";
|
|
8
8
|
import { loadPreviewFonts } from "./preview-fonts.js";
|
|
9
9
|
import { renderSlideSvg } from "./svg.js";
|
|
10
|
+
import { drawnFaces } from "./drawn-faces.js";
|
|
10
11
|
|
|
11
12
|
export { DeckError } from "./deck-runtime.js";
|
|
12
13
|
export { loadPreviewFonts, previewBaseFaces, previewFontLayout } from "./preview-fonts.js";
|
|
@@ -528,19 +529,24 @@ export function renderDeckHtml(input, options = {}) {
|
|
|
528
529
|
if (!["standalone", "shared", "external"].includes(fontMode)) throw new DeckError("invalid-font-mode", "Choose standalone, shared or external SSR fonts.");
|
|
529
530
|
// Every mode measures with the same handle; external writes no @font-face data (RR-61 `embedFonts: false`).
|
|
530
531
|
const renderFonts = handle ?? options.renderOptions?.fonts;
|
|
531
|
-
const sharedRules = new Set();
|
|
532
|
+
const sharedRules = new Set(), sharedCharacters = new Map();
|
|
532
533
|
const attributes = { src: options.src, slide: options.slide, fonts: handle ? undefined : options.fonts, label: options.label, ...options.attributes };
|
|
533
534
|
const attributeText = Object.entries(attributes).filter(([, value]) => value !== undefined && value !== false).map(([name, value]) => (value === true ? ` ${name}` : ` ${name}="${escapeHtml(value)}"`)).join("")
|
|
534
535
|
+ (options.thumbnails ? " thumbnails" : "") + (options.present ? " present" : "") + (options.includeHidden ? " include-hidden" : "");
|
|
535
536
|
const date = options.renderOptions?.date ?? options.date;
|
|
536
537
|
const figures = shown.map((position) => {
|
|
537
538
|
const index = sequence[position];
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
539
|
+
// Shared rules are deduplicated across slides, so the slides embed whole faces and the shared set is cut below to what
|
|
540
|
+
// every slide draws (RR-65); per-slide subsets would differ and repeat each face once per slide.
|
|
541
|
+
let svg = renderSlideSvg(deck, index, { ...options.renderOptions, fonts: renderFonts, ...(fontMode === "external" ? { embedFonts: false } : {}), ...(fontMode === "shared" ? { subsetFonts: false } : {}), ...(date ? { date } : {}) });
|
|
542
|
+
if (fontMode === "shared") {
|
|
543
|
+
drawnFaces(svg, sharedCharacters);
|
|
544
|
+
svg = svg.replace(/<style>(@font-face[\s\S]*?)<\/style>/g, (_, css) => {
|
|
545
|
+
// These rules are produced by renderSlideSvg after its used-face selection and validation.
|
|
546
|
+
for (const rule of css.split("\n")) sharedRules.add(rule);
|
|
547
|
+
return "";
|
|
548
|
+
});
|
|
549
|
+
}
|
|
544
550
|
const { width, height } = svgDimensions(svg);
|
|
545
551
|
const prepared = prepareSlideSvg(svg).replace(/^<svg\b/, `<svg style="display:block;width:100%;height:auto;aspect-ratio:${width} / ${height}"`);
|
|
546
552
|
const label = slideLabel(slideInfo(deck, index), position + 1, sequence.length);
|
|
@@ -549,6 +555,13 @@ export function renderDeckHtml(input, options = {}) {
|
|
|
549
555
|
const titles = wanted === "first" && sequence.length > 1
|
|
550
556
|
? `<nav aria-label="Slides"><ol>${sequence.map((index) => `<li>${escapeHtml(slideInfo(deck, index).title)}</li>`).join("")}</ol></nav>` : "";
|
|
551
557
|
const embedded = options.embed ? `<script type="application/opf+json">${JSON.stringify(deck).replace(/</g, "\\u003c").replace(/\u2028/g, "\\u2028").replace(/\u2029/g, "\\u2029")}</script>` : "";
|
|
552
|
-
const
|
|
558
|
+
const subsets = options.renderOptions?.subsetFonts === false ? undefined : renderFonts?.subsets;
|
|
559
|
+
const sharedRule = (rule) => {
|
|
560
|
+
const face = /^@font-face\{font-family:"([^"]+)";font-weight:(\d+);font-style:(\w+);src:url\("([^"]+)"\)\}$/.exec(rule);
|
|
561
|
+
const points = face && sharedCharacters.get(face[1].toLowerCase());
|
|
562
|
+
if (!subsets || !points?.size) return rule;
|
|
563
|
+
return rule.replace(face[4], subsets.subsetDataUrl({ family: face[1], weight: Number(face[2]), italic: face[3] === "italic", dataUrl: face[4] }, points));
|
|
564
|
+
};
|
|
565
|
+
const shared = sharedRules.size ? `<style>${[...sharedRules].map(sharedRule).join("\n")}</style>` : "";
|
|
553
566
|
return `<${tag}${attributeText}>${shared}${figures.join("")}${titles}${embedded}</${tag}>`;
|
|
554
567
|
}
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
// RR-76: what a face's license lets a document embed, from its OS/2 fsType (the OpenType embedding permissions). Bit 0x0002
|
|
2
|
+
// (Restricted License) forbids embedding; 0x0200 (bitmap embedding only) allows only bitmaps, so an outline face cannot be
|
|
3
|
+
// embedded either; 0x0100 (no subsetting) allows the whole face only. 0x0004 (preview and print) and 0x0008 (editable) allow it.
|
|
4
|
+
// A face with no OS/2 table carries no restriction. No imports: the SVG writer and the subsetter both read it.
|
|
5
|
+
|
|
6
|
+
const RESTRICTED = 0x0002, NO_SUBSETTING = 0x0100, BITMAP_ONLY = 0x0200;
|
|
7
|
+
|
|
8
|
+
/** The OS/2 fsType of font bytes, or null when the font has no OS/2 table. */
|
|
9
|
+
export function fsTypeOf(data) {
|
|
10
|
+
if (!data || data.length < 12) return null;
|
|
11
|
+
const view = new DataView(data.buffer, data.byteOffset, data.byteLength);
|
|
12
|
+
const count = view.getUint16(4);
|
|
13
|
+
for (let index = 0; index < count; index++) {
|
|
14
|
+
const at = 12 + index * 16;
|
|
15
|
+
if (at + 16 > data.length) return null;
|
|
16
|
+
if (String.fromCharCode(data[at], data[at + 1], data[at + 2], data[at + 3]) !== "OS/2") continue;
|
|
17
|
+
const offset = view.getUint32(at + 8);
|
|
18
|
+
return offset + 10 <= data.length ? view.getUint16(offset + 8) : null;
|
|
19
|
+
}
|
|
20
|
+
return null;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* The OS/2 fsType of a font data URL (base64), reading only the table directory and the two fsType bytes, so a large face is
|
|
25
|
+
* never decoded whole; null when the font has no OS/2 table or the URL cannot be read.
|
|
26
|
+
*/
|
|
27
|
+
export function dataUrlFsType(dataUrl) {
|
|
28
|
+
const comma = String(dataUrl ?? "").indexOf(",");
|
|
29
|
+
if (comma < 0) return null;
|
|
30
|
+
const base64 = dataUrl.slice(comma + 1);
|
|
31
|
+
const head = decodeRange(base64, 0, 12);
|
|
32
|
+
if (head.length < 12) return null;
|
|
33
|
+
const count = (head[4] << 8) | head[5];
|
|
34
|
+
const directory = decodeRange(base64, 12, 12 + count * 16);
|
|
35
|
+
for (let index = 0; index + 16 <= directory.length; index += 16) {
|
|
36
|
+
if (String.fromCharCode(directory[index], directory[index + 1], directory[index + 2], directory[index + 3]) !== "OS/2") continue;
|
|
37
|
+
const offset = ((directory[index + 8] << 24) | (directory[index + 9] << 16) | (directory[index + 10] << 8) | directory[index + 11]) >>> 0;
|
|
38
|
+
const bytes = decodeRange(base64, offset + 8, offset + 10);
|
|
39
|
+
return bytes.length === 2 ? (bytes[0] << 8) | bytes[1] : null;
|
|
40
|
+
}
|
|
41
|
+
return null;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** Whether the face may be embedded at all (as outline data). */
|
|
45
|
+
export function embeddingAllowed(fsType) {
|
|
46
|
+
return fsType === null || ((fsType & RESTRICTED) === 0 && (fsType & BITMAP_ONLY) === 0);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** Whether the face may be embedded as a subset. */
|
|
50
|
+
export function subsettingAllowed(fsType) {
|
|
51
|
+
return fsType !== null && embeddingAllowed(fsType) && (fsType & NO_SUBSETTING) === 0;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const ALPHABET = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
|
|
55
|
+
const VALUES = new Map([...ALPHABET].map((character, index) => [character, index]));
|
|
56
|
+
|
|
57
|
+
// Bytes [start, end) of base64 text, decoding only the 4-character groups that hold them.
|
|
58
|
+
function decodeRange(base64, start, end) {
|
|
59
|
+
const first = Math.floor(start / 3), last = Math.ceil(end / 3);
|
|
60
|
+
const text = base64.slice(first * 4, last * 4);
|
|
61
|
+
const bytes = [];
|
|
62
|
+
for (let index = 0; index + 4 <= text.length; index += 4) {
|
|
63
|
+
const [a, b, c, d] = [...text.slice(index, index + 4)].map((character) => VALUES.get(character));
|
|
64
|
+
if (a === undefined || b === undefined) break;
|
|
65
|
+
bytes.push((a << 2) | (b >> 4));
|
|
66
|
+
if (c !== undefined) bytes.push(((b & 15) << 4) | (c >> 2));
|
|
67
|
+
if (d !== undefined) bytes.push(((c & 3) << 6) | d);
|
|
68
|
+
}
|
|
69
|
+
return bytes.slice(start - first * 3, end - first * 3);
|
|
70
|
+
}
|
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
// RR-65: glyph subsets for the faces an SVG embeds as @font-face data. HarfBuzz's own subsetter (hb-subset, the
|
|
2
|
+
// `harfbuzz-subset.wasm` of the pinned harfbuzzjs package, MIT) keeps the glyphs a slide's characters reach through the
|
|
3
|
+
// font's layout tables (every GSUB and GPOS feature is kept, so the browser shapes the subset exactly as the whole face),
|
|
4
|
+
// renumbers them and trims cmap, hmtx, GPOS and the rest to them. The module has no imports, so it is instantiated
|
|
5
|
+
// synchronously from its bytes and `renderSvg` stays synchronous; the same input always gives the same bytes.
|
|
6
|
+
//
|
|
7
|
+
// Only a face whose license allows it is subset: a bundled face (matched by the sha256 of its bytes against the pinned
|
|
8
|
+
// manifest) under an allowed license, whose family and file names do not contain one of its package's Reserved Font Names
|
|
9
|
+
// (a subset is a modified version, owner decision 2026-09-29), and whose OS/2 fsType allows subsetting. Every other face
|
|
10
|
+
// (a host's own faces, Carlito, Raleway, ...) is embedded whole, as before.
|
|
11
|
+
|
|
12
|
+
import { BUNDLED_FONT_MANIFEST } from "./font-manifest.js";
|
|
13
|
+
import { sha256Bytes } from "./sha256.js";
|
|
14
|
+
import { fsTypeOf, subsettingAllowed } from "./font-fstype.js";
|
|
15
|
+
|
|
16
|
+
const SUBSET_LICENSES = new Set(["OFL-1.1", "Apache-2.0", "MIT", "UFL-1.0"]);
|
|
17
|
+
const HB_MEMORY_MODE_READONLY = 1;
|
|
18
|
+
const HB_SUBSET_SETS_LAYOUT_FEATURE_TAG = 6;
|
|
19
|
+
const CACHE_LIMIT = 256;
|
|
20
|
+
|
|
21
|
+
/** The manifest faces that may be subset, by sha256: permissive license, no Reserved Font Name in the family or file name. */
|
|
22
|
+
const SUBSETTABLE = (() => {
|
|
23
|
+
const hashes = new Set();
|
|
24
|
+
for (const pkg of BUNDLED_FONT_MANIFEST.packages) {
|
|
25
|
+
if (!SUBSET_LICENSES.has(pkg.license)) continue;
|
|
26
|
+
const reserved = (pkg.reservedFontNames ?? []).map((name) => name.toLowerCase());
|
|
27
|
+
for (const face of pkg.faces) {
|
|
28
|
+
const names = `${face.family} ${face.file}`.toLowerCase();
|
|
29
|
+
if (!reserved.some((name) => names.includes(name)) && /^[0-9a-f]{64}$/.test(face.sha256 ?? "")) hashes.add(face.sha256);
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
return hashes;
|
|
33
|
+
})();
|
|
34
|
+
|
|
35
|
+
/** Whether a face may be subset: a permitted bundled face whose OS/2 fsType allows subsetting (and embedding). */
|
|
36
|
+
export function subsetPermitted(data) {
|
|
37
|
+
return SUBSETTABLE.has(hexDigest(data)) && fsTypeAllowsSubset(data);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** Whether the OS/2 fsType allows embedding a subset: not restricted (0x0002), not "no subsetting" (0x0100), not bitmap-only (0x0200). */
|
|
41
|
+
export function fsTypeAllowsSubset(data) {
|
|
42
|
+
return subsettingAllowed(fsTypeOf(data));
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function hexDigest(data) {
|
|
46
|
+
return [...sha256Bytes(data)].map((byte) => byte.toString(16).padStart(2, "0")).join("");
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* A synchronous subsetter over hb-subset: `subset(data, codePoints)` returns the subset font bytes, or null when HarfBuzz
|
|
51
|
+
* cannot subset the face. `wasm` is the `harfbuzz-subset.wasm` bytes, a compiled WebAssembly.Module or an Instance.
|
|
52
|
+
*/
|
|
53
|
+
export function createSubsetter(wasm) {
|
|
54
|
+
// A browser instantiates asynchronously (large modules may not compile synchronously on the main thread) and passes the instance.
|
|
55
|
+
const instance = wasm instanceof WebAssembly.Instance ? wasm : new WebAssembly.Instance(wasm instanceof WebAssembly.Module ? wasm : new WebAssembly.Module(wasm), {});
|
|
56
|
+
const exports = instance.exports;
|
|
57
|
+
const required = ["memory", "malloc", "free", "hb_blob_create", "hb_blob_destroy", "hb_blob_get_length", "hb_blob_get_data", "hb_face_create", "hb_face_destroy",
|
|
58
|
+
"hb_face_reference_blob", "hb_set_add", "hb_set_clear", "hb_set_invert", "hb_subset_input_create_or_fail", "hb_subset_input_destroy", "hb_subset_input_unicode_set",
|
|
59
|
+
"hb_subset_input_set", "hb_subset_or_fail"];
|
|
60
|
+
for (const name of required) if (!(name in exports)) throw new Error(`harfbuzz-subset.wasm has no export ${name}.`);
|
|
61
|
+
return {
|
|
62
|
+
subset(data, codePoints) {
|
|
63
|
+
const pointer = exports.malloc(data.length);
|
|
64
|
+
if (!pointer) return null;
|
|
65
|
+
new Uint8Array(exports.memory.buffer, pointer, data.length).set(data);
|
|
66
|
+
const blob = exports.hb_blob_create(pointer, data.length, HB_MEMORY_MODE_READONLY, 0, 0);
|
|
67
|
+
const face = exports.hb_face_create(blob, 0);
|
|
68
|
+
const input = exports.hb_subset_input_create_or_fail();
|
|
69
|
+
let result = 0, output = null;
|
|
70
|
+
try {
|
|
71
|
+
if (!input) return null;
|
|
72
|
+
const unicodes = exports.hb_subset_input_unicode_set(input);
|
|
73
|
+
for (const codePoint of codePoints) exports.hb_set_add(unicodes, codePoint);
|
|
74
|
+
// Every layout feature, not only HarfBuzz's default list: the browser shapes the subset like the whole face whatever
|
|
75
|
+
// font-feature-settings the SVG asks for.
|
|
76
|
+
const features = exports.hb_subset_input_set(input, HB_SUBSET_SETS_LAYOUT_FEATURE_TAG);
|
|
77
|
+
exports.hb_set_clear(features);
|
|
78
|
+
exports.hb_set_invert(features);
|
|
79
|
+
result = exports.hb_subset_or_fail(face, input);
|
|
80
|
+
if (!result) return null;
|
|
81
|
+
const subsetBlob = exports.hb_face_reference_blob(result);
|
|
82
|
+
const length = exports.hb_blob_get_length(subsetBlob), at = exports.hb_blob_get_data(subsetBlob, 0);
|
|
83
|
+
output = length && at ? new Uint8Array(exports.memory.buffer, at, length).slice() : null;
|
|
84
|
+
exports.hb_blob_destroy(subsetBlob);
|
|
85
|
+
return output && glyfBeforeLastTable(output);
|
|
86
|
+
} finally {
|
|
87
|
+
if (result) exports.hb_face_destroy(result);
|
|
88
|
+
if (input) exports.hb_subset_input_destroy(input);
|
|
89
|
+
exports.hb_face_destroy(face);
|
|
90
|
+
exports.hb_blob_destroy(blob);
|
|
91
|
+
exports.free(pointer);
|
|
92
|
+
}
|
|
93
|
+
},
|
|
94
|
+
};
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// hb-subset writes glyf as the last table. fontkit reads a 10-byte glyph header even for an empty glyph (a space), so when
|
|
98
|
+
// the last glyph is empty it reads past the end of the file, and the vector PDF and the outline engine (which parse SVG
|
|
99
|
+
// @font-face data with fontkit) would fail. Table order in the file is free: glyf moves before the other tables, every table
|
|
100
|
+
// keeps its bytes and checksum, and head.checkSumAdjustment is recomputed for the new file.
|
|
101
|
+
function glyfBeforeLastTable(font) {
|
|
102
|
+
const view = new DataView(font.buffer, font.byteOffset, font.byteLength);
|
|
103
|
+
const count = view.getUint16(4);
|
|
104
|
+
const tables = [];
|
|
105
|
+
for (let index = 0; index < count; index++) {
|
|
106
|
+
const at = 12 + index * 16;
|
|
107
|
+
tables.push({ entry: at, tag: String.fromCharCode(font[at], font[at + 1], font[at + 2], font[at + 3]), offset: view.getUint32(at + 8), length: view.getUint32(at + 12) });
|
|
108
|
+
}
|
|
109
|
+
const byOffset = [...tables].sort((a, b) => a.offset - b.offset);
|
|
110
|
+
if (byOffset.at(-1)?.tag !== "glyf" || byOffset.length < 2) return font;
|
|
111
|
+
const order = [byOffset.at(-1), ...byOffset.slice(0, -1)];
|
|
112
|
+
const padded = (length) => (length + 3) & ~3;
|
|
113
|
+
const start = 12 + count * 16;
|
|
114
|
+
const out = new Uint8Array(start + order.reduce((sum, table) => sum + padded(table.length), 0));
|
|
115
|
+
out.set(font.subarray(0, start));
|
|
116
|
+
const outView = new DataView(out.buffer);
|
|
117
|
+
let offset = start;
|
|
118
|
+
for (const table of order) {
|
|
119
|
+
out.set(font.subarray(table.offset, table.offset + table.length), offset);
|
|
120
|
+
outView.setUint32(table.entry + 8, offset);
|
|
121
|
+
offset += padded(table.length);
|
|
122
|
+
}
|
|
123
|
+
const head = tables.find((table) => table.tag === "head");
|
|
124
|
+
if (head) {
|
|
125
|
+
const at = outView.getUint32(head.entry + 8) + 8;
|
|
126
|
+
outView.setUint32(at, 0);
|
|
127
|
+
let sum = 0;
|
|
128
|
+
for (let index = 0; index < out.length; index += 4) sum = (sum + outView.getUint32(index)) >>> 0;
|
|
129
|
+
outView.setUint32(at, (0xB1B0AFBA - sum) >>> 0);
|
|
130
|
+
}
|
|
131
|
+
return out;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* The subset engine a fonts handle carries as `subsets` (RR-65): `subsetDataUrl(font, codePoints)` returns the data URL of
|
|
136
|
+
* the embedded face `font` cut to the code points, or its own data URL when the face may not be subset (license, Reserved
|
|
137
|
+
* Font Name, fsType), HarfBuzz cannot subset it, or the subset is not smaller. Faces are found in the registry by family,
|
|
138
|
+
* weight and style; results are cached per face and code point set.
|
|
139
|
+
*/
|
|
140
|
+
export function fontSubsets(registry, subsetter) {
|
|
141
|
+
const permitted = new Map();
|
|
142
|
+
const cache = new Map();
|
|
143
|
+
const bytesOf = (font) => {
|
|
144
|
+
const faces = registry.exportFaces(), described = registry.describeFaces();
|
|
145
|
+
const index = described.findIndex((face) => face.family === font.family && face.weight === font.weight && Boolean(face.italic) === Boolean(font.italic));
|
|
146
|
+
return index >= 0 ? faces[index].data : null;
|
|
147
|
+
};
|
|
148
|
+
return Object.freeze({
|
|
149
|
+
subsetDataUrl(font, codePoints) {
|
|
150
|
+
const points = [...new Set(codePoints)].sort((a, b) => a - b);
|
|
151
|
+
const key = `${font.family}|${font.weight}|${font.italic ? 1 : 0}|${font.dataUrl.length}|${points.join(",")}`;
|
|
152
|
+
if (cache.has(key)) return cache.get(key);
|
|
153
|
+
const data = bytesOf(font);
|
|
154
|
+
let url = font.dataUrl;
|
|
155
|
+
if (data) {
|
|
156
|
+
let allowed = permitted.get(data);
|
|
157
|
+
if (allowed === undefined) { allowed = subsetPermitted(data); permitted.set(data, allowed); }
|
|
158
|
+
const subset = allowed ? subsetter.subset(data, points) : null;
|
|
159
|
+
if (subset && subset.length < data.length) url = `${font.dataUrl.slice(0, font.dataUrl.indexOf(",") + 1)}${base64(subset)}`;
|
|
160
|
+
}
|
|
161
|
+
if (cache.size >= CACHE_LIMIT) cache.delete(cache.keys().next().value);
|
|
162
|
+
cache.set(key, url);
|
|
163
|
+
return url;
|
|
164
|
+
},
|
|
165
|
+
});
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
function base64(bytes) {
|
|
169
|
+
if (typeof Buffer !== "undefined") return Buffer.from(bytes.buffer, bytes.byteOffset, bytes.byteLength).toString("base64");
|
|
170
|
+
let binary = "";
|
|
171
|
+
for (let index = 0; index < bytes.length; index += 0x8000) binary += String.fromCharCode(...bytes.subarray(index, index + 0x8000));
|
|
172
|
+
return btoa(binary);
|
|
173
|
+
}
|
package/dist/fonts-browser.d.ts
CHANGED
|
@@ -81,6 +81,17 @@ export declare function loadFonts(
|
|
|
81
81
|
document?: Document;
|
|
82
82
|
fetch?: typeof fetch;
|
|
83
83
|
signal?: AbortSignal;
|
|
84
|
+
/**
|
|
85
|
+
* RR-65: harfbuzzjs's `harfbuzz-subset.wasm` (the URL the host serves it from, its bytes or a compiled WebAssembly.Module). With
|
|
86
|
+
* it the handle carries `subsets`, and every SVG `renderSvg` draws with this handle embeds each face cut to the characters the
|
|
87
|
+
* slide draws. Without it a browser handle embeds whole faces (the Node handle always subsets).
|
|
88
|
+
*/
|
|
89
|
+
subsetWasm?: string | URL | ArrayBuffer | ArrayBufferView | WebAssembly.Module;
|
|
90
|
+
/**
|
|
91
|
+
* RR-64: harfbuzzjs's `harfbuzz.wasm` (its URL, bytes or a compiled WebAssembly.Module). With it `textAsPaths` shapes outlines with
|
|
92
|
+
* HarfBuzz, as the browser shapes text; without it a browser handle shapes them with fontkit (the Node handle always uses HarfBuzz).
|
|
93
|
+
*/
|
|
94
|
+
shapeWasm?: string | URL | ArrayBuffer | ArrayBufferView | WebAssembly.Module;
|
|
84
95
|
crypto?: { subtle: SubtleCrypto };
|
|
85
96
|
/** Script faces to load once the registry exists: "auto" (with `presentation`), "all" or ISO 15924 codes. Needs `scriptBaseUrl`. */
|
|
86
97
|
scripts?: ScriptSelection | "auto";
|
package/dist/fonts-browser.js
CHANGED
|
@@ -3,6 +3,8 @@ import { lazyFontEntries, lazyFontList, normalizeExtraLazyFonts } from "./lazy-f
|
|
|
3
3
|
import { lazyFacesNeeded } from "./lazy-fonts.js";
|
|
4
4
|
import { BUNDLED_FONT_MANIFEST } from "./font-manifest.js";
|
|
5
5
|
import { textOutlines } from "./text-paths.js";
|
|
6
|
+
import { createSubsetter, fontSubsets } from "./font-subset.js";
|
|
7
|
+
import { instantiateShaper } from "./hb-shape.js";
|
|
6
8
|
import { analyzePresentationScripts, nextFallbackPackage, scriptFontPackages, scriptPackageEntries, scriptSelectionOf, uncoveredCjkCharacters } from "./script-font-pack.js";
|
|
7
9
|
export { autoScriptSelection, detectPresentationScripts, scriptFontEntries, scriptFontPackages } from "./script-font-pack.js";
|
|
8
10
|
export { lazyFontEntries, lazyFontList, splitStartupFaces } from "./lazy-font-list.js";
|
|
@@ -280,8 +282,32 @@ const MAX_ENSURE_ROUNDS = 4;
|
|
|
280
282
|
* (`substitutionPolicy`, `aliases`, `fallbackFamily`, `themeFonts`, `strictGlyphs`) are as for the registry (see `fonts-browser.d.ts`).
|
|
281
283
|
* `dispose()` removes every face from the document.
|
|
282
284
|
*/
|
|
283
|
-
|
|
284
|
-
|
|
285
|
+
// RR-65: the host's copy of harfbuzzjs's `harfbuzz-subset.wasm` (a URL it serves, the bytes or a compiled module), instantiated
|
|
286
|
+
// asynchronously here (a browser may refuse to compile a large module synchronously on the main thread).
|
|
287
|
+
async function browserSubsetter(source, fetchImpl, signal) {
|
|
288
|
+
if (source instanceof WebAssembly.Module) return createSubsetter(await WebAssembly.instantiate(source, {}));
|
|
289
|
+
if (source instanceof ArrayBuffer || ArrayBuffer.isView(source)) return createSubsetter((await WebAssembly.instantiate(source, {})).instance);
|
|
290
|
+
if (typeof source !== "string" && !(source instanceof URL)) throw new OPFFontError("invalid-font-source", "subsetWasm must be the URL of harfbuzz-subset.wasm, its bytes or a compiled WebAssembly.Module.");
|
|
291
|
+
const response = await (fetchImpl ?? globalThis.fetch)(String(source), { signal });
|
|
292
|
+
if (!response.ok) throw new OPFFontError("font-resource-unavailable", `harfbuzz-subset.wasm could not be fetched (${response.status}).`, { url: String(source) });
|
|
293
|
+
return createSubsetter((await WebAssembly.instantiate(await response.arrayBuffer(), {})).instance);
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
// RR-64 phase 2: the host's copy of harfbuzzjs's `harfbuzz.wasm`, instantiated asynchronously, so outlines are shaped by HarfBuzz.
|
|
297
|
+
async function browserShaper(source, fetchImpl, signal) {
|
|
298
|
+
return instantiateShaper(source instanceof WebAssembly.Module || source instanceof ArrayBuffer || ArrayBuffer.isView(source) ? source : await fetchWasm(source, fetchImpl, signal, "harfbuzz.wasm"));
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
async function fetchWasm(source, fetchImpl, signal, name) {
|
|
302
|
+
if (typeof source !== "string" && !(source instanceof URL)) throw new OPFFontError("invalid-font-source", `${name} must be given as a URL, its bytes or a compiled WebAssembly.Module.`);
|
|
303
|
+
const response = await (fetchImpl ?? globalThis.fetch)(String(source), { signal });
|
|
304
|
+
if (!response.ok) throw new OPFFontError("font-resource-unavailable", `${name} could not be fetched (${response.status}).`, { url: String(source) });
|
|
305
|
+
return response.arrayBuffer();
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
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)]);
|
|
285
311
|
// The embedded list is base64 of every eager face: compute it once, and again only when faces were added.
|
|
286
312
|
let embedded, stale = true;
|
|
287
313
|
const addFaces = registry.addFaces;
|
|
@@ -295,7 +321,9 @@ export async function loadFonts({ faces = [], ...options } = {}) {
|
|
|
295
321
|
get embeddedFonts() { if (stale) { embedded = registry.embeddedFonts; stale = false; } return embedded; },
|
|
296
322
|
registry,
|
|
297
323
|
// RR-64: the outline engine `renderSvg(deck, { fonts, textAsPaths: true })` draws text with, over this registry's faces.
|
|
298
|
-
outlines: textOutlines({ registry }),
|
|
324
|
+
outlines: textOutlines({ registry, shaper }),
|
|
325
|
+
// RR-65: with `subsetWasm`, the subset engine that cuts each face an SVG embeds to the characters the slide draws.
|
|
326
|
+
...(subsetter ? { subsets: fontSubsets(registry, subsetter) } : {}),
|
|
299
327
|
manifest: BUNDLED_FONT_MANIFEST,
|
|
300
328
|
get substitutions() { return registry.substitutions; },
|
|
301
329
|
/**
|
package/dist/fonts-node.js
CHANGED
|
@@ -7,6 +7,8 @@ import { createFontRegistry, OPFFontError } from "./font-registry.js";
|
|
|
7
7
|
import { BUNDLED_FONT_MANIFEST } from "./font-manifest.js";
|
|
8
8
|
import { lazyFontList } from "./lazy-font-list.js";
|
|
9
9
|
import { textOutlines } from "./text-paths.js";
|
|
10
|
+
import { createSubsetter, fontSubsets } from "./font-subset.js";
|
|
11
|
+
import { createShaper } from "./hb-shape.js";
|
|
10
12
|
export { BUNDLED_FONT_MANIFEST } from "./font-manifest.js";
|
|
11
13
|
import { analyzePresentationScripts, autoScriptSelection, nextFallbackPackage, scriptFontPackages, scriptSelectionOf, uncoveredCjkCharacters } from "./script-font-pack.js";
|
|
12
14
|
export { autoScriptSelection, detectPresentationScripts, scriptFontPackages } from "./script-font-pack.js";
|
|
@@ -247,6 +249,23 @@ async function ensureFonts(registry, loaded, presentation, {onDiagnostic, render
|
|
|
247
249
|
return {scripts: loaded.packages.slice(before), lazy: [], uncovered: selection.uncovered ? [...selection.uncovered] : []};
|
|
248
250
|
}
|
|
249
251
|
|
|
252
|
+
// RR-65: hb-subset from the pinned harfbuzzjs package (MIT), compiled once per process. The module has no imports, so it is
|
|
253
|
+
// instantiated synchronously and renderSvg stays synchronous. A copy that cannot be loaded leaves the handle without `subsets`
|
|
254
|
+
// (whole faces, as before RR-65): a byte saving never stops fonts from loading.
|
|
255
|
+
let subsetterPromise;
|
|
256
|
+
function nodeSubsetter() {
|
|
257
|
+
subsetterPromise ??= readFile(require.resolve("harfbuzzjs/dist/harfbuzz-subset.wasm")).then(createSubsetter, () => null).catch(() => null);
|
|
258
|
+
return subsetterPromise;
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
// RR-64 phase 2: HarfBuzz's shaper (harfbuzz.wasm of the same package) for text drawn as outlines, so the outlines are shaped as
|
|
262
|
+
// a browser shapes the text. Without it outlines are shaped with fontkit.
|
|
263
|
+
let shaperPromise;
|
|
264
|
+
function nodeShaper() {
|
|
265
|
+
shaperPromise ??= readFile(require.resolve("harfbuzzjs/dist/harfbuzz.wasm")).then(createShaper, () => null).catch(() => null);
|
|
266
|
+
return shaperPromise;
|
|
267
|
+
}
|
|
268
|
+
|
|
250
269
|
/**
|
|
251
270
|
* The fonts handle: one set of verified font inputs for layout, SVG, editor, PPTX and Node raster export. Pass it as `{ fonts }` to
|
|
252
271
|
* `renderSvg`, `renderSlideSvg`, `svgToPng`, `svgToPdf`, core `paginate` and `validate`, and `toPptx`.
|
|
@@ -266,6 +285,7 @@ export async function loadFonts({pack = "base", embedScriptFonts = false, ...opt
|
|
|
266
285
|
// The embedded list is base64 of every face: computed on first use, and again after `ensure` added faces.
|
|
267
286
|
let embeddedFonts, fontFiles = [...registry.fontFiles];
|
|
268
287
|
const refresh = () => { embeddedFonts = undefined; fontFiles = [...registry.fontFiles]; };
|
|
288
|
+
const [subsetter, shaper] = await Promise.all([nodeSubsetter(), nodeShaper()]);
|
|
269
289
|
return {
|
|
270
290
|
textMeasurement: registry.textMeasurement,
|
|
271
291
|
get embeddedFonts() { return (embeddedFonts ??= selectEmbedded()); },
|
|
@@ -274,7 +294,9 @@ export async function loadFonts({pack = "base", embedScriptFonts = false, ...opt
|
|
|
274
294
|
loadSystemFonts: false,
|
|
275
295
|
registry,
|
|
276
296
|
// RR-64: the outline engine `renderSvg(deck, { fonts, textAsPaths: true })` draws text with, over this registry's faces.
|
|
277
|
-
outlines: textOutlines({ registry }),
|
|
297
|
+
outlines: textOutlines({ registry, shaper }),
|
|
298
|
+
// RR-65: the subset engine that cuts each face an SVG embeds to the characters the slide draws.
|
|
299
|
+
...(subsetter ? { subsets: fontSubsets(registry, subsetter) } : {}),
|
|
278
300
|
manifest: BUNDLED_FONT_MANIFEST,
|
|
279
301
|
get substitutions() { return registry.substitutions; },
|
|
280
302
|
/** Load the script faces the presentation's text needs (see `ensureFonts`); `embeddedFonts` and `fontFiles` then include them. */
|
package/dist/fonts.d.ts
CHANGED
|
@@ -51,7 +51,11 @@ export interface RenderFonts {
|
|
|
51
51
|
loadSystemFonts?: boolean;
|
|
52
52
|
/** The outline engine `textAsPaths: true` draws text with (RR-64); a `loadFonts` handle carries one over its registry's faces. */
|
|
53
53
|
outlines?: TextOutlines;
|
|
54
|
+
/** The subset engine (RR-65): each face an SVG embeds is cut to the characters the slide draws. The Node `loadFonts` handle carries one; a browser handle with `subsetWasm`. */
|
|
55
|
+
subsets?: FontSubsets;
|
|
54
56
|
}
|
|
57
|
+
/** Cuts an embedded face to code points (RR-65), with hb-subset; a face whose license, Reserved Font Name or fsType does not allow it keeps its own data URL. */
|
|
58
|
+
export interface FontSubsets { subsetDataUrl(font: EmbeddedFont, codePoints: Iterable<number>): string }
|
|
55
59
|
/** Draws a slide's text as glyph outlines (RR-64). Read by `renderSvg`; hosts pass the handle and `textAsPaths: true`. */
|
|
56
60
|
export interface TextOutlines { outlineSlideText(content: string[], options: object): { content: string[]; defs: string } }
|
|
57
61
|
/** What `fonts.ensure(presentation)` loaded: the script packages and vendored faces that were missing, and drawn CJK characters no face covers. */
|
package/dist/hb-shape.js
ADDED
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
// RR-64 phase 2: HarfBuzz shaping for text drawn as outlines. Browsers shape with HarfBuzz, and fontkit differs from it on some
|
|
2
|
+
// scripts (Thai tone marks, Bengali and Khmer conjunct forms, Myanmar medials, Telugu offsets, Hebrew points, Japanese combining
|
|
3
|
+
// sound marks), so outlines shaped by HarfBuzz are what the browser draws. This drives `harfbuzz.wasm` of the pinned harfbuzzjs
|
|
4
|
+
// package (MIT) through HarfBuzz's C API. The module needs only five small host functions, so it is instantiated synchronously
|
|
5
|
+
// from its bytes and the renderer stays synchronous. Glyphs come back in the shape `pdf-fonts.js` `shape()` returns: left to
|
|
6
|
+
// right, `{gid, codePoints, advance, xOffset, yOffset}` in font units.
|
|
7
|
+
|
|
8
|
+
const HB_MEMORY_MODE_READONLY = 1;
|
|
9
|
+
const HB_DIRECTION = { ltr: 4, rtl: 5 };
|
|
10
|
+
const INFO_SIZE = 20, POSITION_SIZE = 20, FEATURE_SIZE = 16;
|
|
11
|
+
|
|
12
|
+
// The five host functions the module imports, bound to the instance's memory once it exists.
|
|
13
|
+
function hostImports() {
|
|
14
|
+
const state = { memory: null };
|
|
15
|
+
const imports = {
|
|
16
|
+
env: {
|
|
17
|
+
// Emscripten grows the heap through the host: grow the memory to the requested size.
|
|
18
|
+
emscripten_resize_heap: (requested) => {
|
|
19
|
+
try {
|
|
20
|
+
const missing = (requested >>> 0) - state.memory.buffer.byteLength;
|
|
21
|
+
if (missing > 0) state.memory.grow(Math.ceil(missing / 65536));
|
|
22
|
+
return 1;
|
|
23
|
+
} catch { return 0; }
|
|
24
|
+
},
|
|
25
|
+
_abort_js: () => { throw new Error("HarfBuzz aborted."); },
|
|
26
|
+
_setitimer_js: () => 0,
|
|
27
|
+
_emscripten_runtime_keepalive_clear: () => {},
|
|
28
|
+
},
|
|
29
|
+
wasi_snapshot_preview1: { proc_exit: (code) => { throw new Error(`HarfBuzz exited (${code}).`); } },
|
|
30
|
+
};
|
|
31
|
+
return { imports, state };
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** A synchronous HarfBuzz shaper over `harfbuzz.wasm` (its bytes or a compiled WebAssembly.Module): Node. */
|
|
35
|
+
export function createShaper(wasm) {
|
|
36
|
+
const { imports, state } = hostImports();
|
|
37
|
+
return shaperOver(new WebAssembly.Instance(wasm instanceof WebAssembly.Module ? wasm : new WebAssembly.Module(wasm), imports), state);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** The same shaper, instantiated asynchronously (a browser may refuse to compile a large module synchronously). */
|
|
41
|
+
export async function instantiateShaper(wasm) {
|
|
42
|
+
const { imports, state } = hostImports();
|
|
43
|
+
const result = await WebAssembly.instantiate(wasm, imports);
|
|
44
|
+
return shaperOver(result instanceof WebAssembly.Instance ? result : result.instance, state);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function shaperOver(instance, state) {
|
|
48
|
+
const hb = instance.exports;
|
|
49
|
+
const memory = state.memory = hb.memory;
|
|
50
|
+
hb.__wasm_call_ctors?.();
|
|
51
|
+
const fonts = new Map();
|
|
52
|
+
const utf8 = new TextEncoder();
|
|
53
|
+
const string = (text) => {
|
|
54
|
+
const bytes = utf8.encode(text), pointer = hb.malloc(bytes.length + 1);
|
|
55
|
+
const view = new Uint8Array(memory.buffer, pointer, bytes.length + 1);
|
|
56
|
+
view.set(bytes);
|
|
57
|
+
view[bytes.length] = 0;
|
|
58
|
+
return { pointer, length: bytes.length };
|
|
59
|
+
};
|
|
60
|
+
// One HarfBuzz font per face, kept for the shaper's lifetime (faces are few and shaped again and again).
|
|
61
|
+
const fontFor = (key, data) => {
|
|
62
|
+
let font = fonts.get(key);
|
|
63
|
+
if (font) return font;
|
|
64
|
+
const pointer = hb.malloc(data.length);
|
|
65
|
+
new Uint8Array(memory.buffer, pointer, data.length).set(data);
|
|
66
|
+
const blob = hb.hb_blob_create(pointer, data.length, HB_MEMORY_MODE_READONLY, 0, 0);
|
|
67
|
+
const face = hb.hb_face_create(blob, 0);
|
|
68
|
+
font = hb.hb_font_create(face);
|
|
69
|
+
fonts.set(key, font);
|
|
70
|
+
return font;
|
|
71
|
+
};
|
|
72
|
+
return {
|
|
73
|
+
/** Shape `text` with the face (`key` names it, `data` is its bytes); `features` is an object of OpenType feature booleans. */
|
|
74
|
+
shape(key, data, text, { direction = "ltr", language, features } = {}) {
|
|
75
|
+
const font = fontFor(key, data);
|
|
76
|
+
const buffer = hb.hb_buffer_create();
|
|
77
|
+
const units = text.length, textPointer = hb.malloc(units * 2 + 2);
|
|
78
|
+
const view = new Uint16Array(memory.buffer, textPointer, units);
|
|
79
|
+
for (let index = 0; index < units; index++) view[index] = text.charCodeAt(index);
|
|
80
|
+
const allocated = [textPointer];
|
|
81
|
+
try {
|
|
82
|
+
hb.hb_buffer_add_utf16(buffer, textPointer, units, 0, units);
|
|
83
|
+
hb.hb_buffer_set_direction(buffer, HB_DIRECTION[direction] ?? HB_DIRECTION.ltr);
|
|
84
|
+
if (language) { const tag = string(language); allocated.push(tag.pointer); hb.hb_buffer_set_language(buffer, hb.hb_language_from_string(tag.pointer, tag.length)); }
|
|
85
|
+
hb.hb_buffer_guess_segment_properties(buffer);
|
|
86
|
+
const entries = Object.entries(features ?? {});
|
|
87
|
+
let featurePointer = 0;
|
|
88
|
+
if (entries.length) {
|
|
89
|
+
featurePointer = hb.malloc(entries.length * FEATURE_SIZE);
|
|
90
|
+
allocated.push(featurePointer);
|
|
91
|
+
entries.forEach(([tag, on], index) => { const spec = string(`${on ? "+" : "-"}${tag}`); allocated.push(spec.pointer); hb.hb_feature_from_string(spec.pointer, spec.length, featurePointer + index * FEATURE_SIZE); });
|
|
92
|
+
}
|
|
93
|
+
hb.hb_shape(font, buffer, featurePointer, entries.length);
|
|
94
|
+
const lengthPointer = hb.malloc(4);
|
|
95
|
+
allocated.push(lengthPointer);
|
|
96
|
+
const infos = hb.hb_buffer_get_glyph_infos(buffer, lengthPointer);
|
|
97
|
+
const count = new Uint32Array(memory.buffer, lengthPointer, 1)[0];
|
|
98
|
+
const positions = hb.hb_buffer_get_glyph_positions(buffer, 0);
|
|
99
|
+
const data32 = new DataView(memory.buffer);
|
|
100
|
+
const glyphs = [];
|
|
101
|
+
for (let index = 0; index < count; index++) {
|
|
102
|
+
const info = infos + index * INFO_SIZE, position = positions + index * POSITION_SIZE;
|
|
103
|
+
glyphs.push({ gid: data32.getUint32(info, true), cluster: data32.getUint32(info + 8, true), advance: data32.getInt32(position, true), xOffset: data32.getInt32(position + 8, true), yOffset: data32.getInt32(position + 12, true) });
|
|
104
|
+
}
|
|
105
|
+
// The characters of each cluster go to its first glyph in output order; later glyphs of the cluster carry none.
|
|
106
|
+
const starts = [...new Set(glyphs.map((glyph) => glyph.cluster))].sort((a, b) => a - b);
|
|
107
|
+
const seen = new Set();
|
|
108
|
+
for (const glyph of glyphs) {
|
|
109
|
+
if (seen.has(glyph.cluster)) { glyph.codePoints = []; continue; }
|
|
110
|
+
seen.add(glyph.cluster);
|
|
111
|
+
const end = starts[starts.indexOf(glyph.cluster) + 1] ?? units;
|
|
112
|
+
glyph.codePoints = [...text.slice(glyph.cluster, end)].map((character) => character.codePointAt(0));
|
|
113
|
+
}
|
|
114
|
+
return glyphs.map(({ gid, codePoints, advance, xOffset, yOffset }) => ({ gid, codePoints, advance, xOffset, yOffset }));
|
|
115
|
+
} finally {
|
|
116
|
+
hb.hb_buffer_destroy(buffer);
|
|
117
|
+
for (const pointer of allocated) hb.free(pointer);
|
|
118
|
+
}
|
|
119
|
+
},
|
|
120
|
+
};
|
|
121
|
+
}
|
package/dist/index.d.ts
CHANGED
|
@@ -64,12 +64,24 @@ export interface RenderSvgOptions {
|
|
|
64
64
|
* `true` draws every `<text>` as glyph outlines (RR-64): `<use>` of outlines kept once per slide in `<defs>`, so the SVG needs
|
|
65
65
|
* no font and looks the same in every viewer (about 40 KB a slide over the core example decks, against 0.5 to 4 MB of embedded
|
|
66
66
|
* faces). Needs the fonts handle `loadFonts()` returns (`text-as-paths-needs-fonts` otherwise). The text is no longer text: it
|
|
67
|
-
* cannot be
|
|
68
|
-
*
|
|
69
|
-
*
|
|
70
|
-
*
|
|
67
|
+
* cannot be edited in place. The glyphs are shaped by HarfBuzz (the browser's shaper; the Node handle always, a browser handle
|
|
68
|
+
* given `shapeWasm`), are hidden from assistive technology, and each outlined line is also invisible real text pinned to the
|
|
69
|
+
* drawn width, so screen readers, selection, copy and find-in-page get the words. Each element keeps its `data-opf-*` trace.
|
|
70
|
+
* A run stays text, reported as `text-as-paths-fallback` with a `reason`, when its font is a colour or bitmap font
|
|
71
|
+
* (`"colour-font"`), its face's fsType restricts embedding (`"restricted"`), or it has no pinned width and HarfBuzz and the
|
|
72
|
+
* layout's measurement disagree on it (`"shaping"`). Use it for thumbnails, previews and portable SVG files, not for an editing
|
|
73
|
+
* surface or the vector PDF (which keeps real text from `<text>` SVG). Default false.
|
|
71
74
|
*/
|
|
72
75
|
textAsPaths?: boolean;
|
|
76
|
+
/**
|
|
77
|
+
* `false` embeds whole faces (RR-65). By default, with a fonts handle that carries `subsets` (the Node `loadFonts` handle, a browser
|
|
78
|
+
* handle given `subsetWasm`), each face an SVG embeds is cut to the characters the slide draws, keeping every layout feature, so the
|
|
79
|
+
* browser draws it exactly like the whole face (over the core example decks a slide's SVG falls from 2.4 MB to 59 KB on average).
|
|
80
|
+
* Faces whose license does not allow a modified version under their own name (Carlito, Raleway, Lora, Playfair Display, PT Serif,
|
|
81
|
+
* Merriweather Sans, Source Sans 3), whose fsType forbids subsetting, or that a host supplied, are always embedded whole. Set it to
|
|
82
|
+
* false for an SVG whose text will be edited outside the renderer.
|
|
83
|
+
*/
|
|
84
|
+
subsetFonts?: boolean;
|
|
73
85
|
/** `"chain"` (default): a character the resolved face lacks is drawn with the first bundled face that has it, and reported as `font-glyph-fallback`. `"none"`: exact faces, a missing glyph raises `missing-glyph`. */
|
|
74
86
|
glyphFallback?: "chain" | "none";
|
|
75
87
|
/** Unscaled reference-pixel clearance around supplied vector text outlines; default 1. */
|
package/dist/pdf-text.js
CHANGED
|
@@ -166,7 +166,7 @@ function buildRuns(chunk, env, styleIds, faceCache, linkIds, ownerIds) {
|
|
|
166
166
|
for (const char of chunk.chars) { levels.push(embedding.levels[unit]); unit += char.ch.length; }
|
|
167
167
|
const mirrored = bidi.getMirroredCharactersMap(text, embedding.levels);
|
|
168
168
|
const runs = [];
|
|
169
|
-
const chunkInfo = { logical: chunk.chars.filter((char) => !IGNORED.test(char.ch)).map((char) => char.ch).join("") };
|
|
169
|
+
const chunkInfo = { logical: chunk.chars.filter((char) => !IGNORED.test(char.ch)).map((char) => char.ch).join(""), rtl: baseDirection === "rtl" || /^[]/.test(text) };
|
|
170
170
|
let current = null;
|
|
171
171
|
unit = 0;
|
|
172
172
|
chunk.chars.forEach((char, index) => {
|
|
@@ -179,7 +179,8 @@ function buildRuns(chunk, env, styleIds, faceCache, linkIds, ownerIds) {
|
|
|
179
179
|
const owner = env.splitByOwner ? linkId(ownerIds, { node: char.owner }) : 0;
|
|
180
180
|
const key = `${resolved.styleId}|${resolved.face.id}|${level}|${char.scope?.id ?? 0}|${linkId(linkIds, char.link)}|${[...char.decoration].sort().join(",")}|${owner}`;
|
|
181
181
|
if (!current || current.key !== key) {
|
|
182
|
-
|
|
182
|
+
// `start`: the run's first character in logical order, so a reader can put runs back in reading order (RR-64).
|
|
183
|
+
current = { chunk: chunkInfo, start: index, key, face: resolved.face, style: char.style, level, scope: char.scope, link: char.link, decoration: char.decoration, owner: char.owner, chars: [], text: "", logical: "", weight: resolved.weight, italic: resolved.italic };
|
|
183
184
|
runs.push(current);
|
|
184
185
|
}
|
|
185
186
|
current.logical += char.ch;
|
|
@@ -244,7 +245,9 @@ function shapeRun(run, env) {
|
|
|
244
245
|
const size = parseFloat(style["font-size"]) || 16;
|
|
245
246
|
const direction = run.level % 2 === 1 ? "rtl" : "ltr";
|
|
246
247
|
const language = openTypeLanguage(style.lang ?? style["xml:lang"]);
|
|
247
|
-
|
|
248
|
+
// RR-64: outlined text is shaped by HarfBuzz (env.shape), the browser's shaper; the vector PDF keeps fontkit.
|
|
249
|
+
// HarfBuzz takes the BCP 47 tag (`lang`); fontkit the OpenType language system (`language`).
|
|
250
|
+
const glyphs = (env.shape ?? shape)(run.face, run.text, { features: featuresOf(style), language, lang: style.lang ?? style["xml:lang"], direction });
|
|
248
251
|
const scale = size / run.face.upem;
|
|
249
252
|
const letterSpacing = parseLength(style["letter-spacing"], { fontSize: size }) ?? 0;
|
|
250
253
|
const wordSpacing = parseLength(style["word-spacing"], { fontSize: size }) ?? 0;
|
package/dist/pdf-vector.js
CHANGED
|
@@ -744,6 +744,9 @@ class Converter {
|
|
|
744
744
|
run.fill = await this.paint(run.style.fill, run.style, ctx, box, "fill");
|
|
745
745
|
run.stroke = await this.paint(run.style.stroke, run.style, ctx, box, "stroke");
|
|
746
746
|
}
|
|
747
|
+
// Text with neither fill nor stroke draws nothing (an outlined slide's invisible readable layer, RR-64): it adds no font,
|
|
748
|
+
// no glyph and no structure to the PDF.
|
|
749
|
+
if (!runs.some((run) => run.fill || run.stroke)) return;
|
|
747
750
|
// Runs are drawn in visual (left to right) order. Right-to-left runs are marked /ReversedChars so extractors read
|
|
748
751
|
// their characters back to front, and a cluster whose glyph-to-Unicode map cannot give its text (a ligature, a
|
|
749
752
|
// letter split into base and dots, a mirrored bracket) carries /ActualText; a left-to-right run the map cannot
|
package/dist/svg.d.ts
CHANGED
|
@@ -64,12 +64,24 @@ export interface RenderSvgOptions {
|
|
|
64
64
|
* `true` draws every `<text>` as glyph outlines (RR-64): `<use>` of outlines kept once per slide in `<defs>`, so the SVG needs
|
|
65
65
|
* no font and looks the same in every viewer (about 40 KB a slide over the core example decks, against 0.5 to 4 MB of embedded
|
|
66
66
|
* faces). Needs the fonts handle `loadFonts()` returns (`text-as-paths-needs-fonts` otherwise). The text is no longer text: it
|
|
67
|
-
* cannot be
|
|
68
|
-
*
|
|
69
|
-
*
|
|
70
|
-
*
|
|
67
|
+
* cannot be edited in place. The glyphs are shaped by HarfBuzz (the browser's shaper; the Node handle always, a browser handle
|
|
68
|
+
* given `shapeWasm`), are hidden from assistive technology, and each outlined line is also invisible real text pinned to the
|
|
69
|
+
* drawn width, so screen readers, selection, copy and find-in-page get the words. Each element keeps its `data-opf-*` trace.
|
|
70
|
+
* A run stays text, reported as `text-as-paths-fallback` with a `reason`, when its font is a colour or bitmap font
|
|
71
|
+
* (`"colour-font"`), its face's fsType restricts embedding (`"restricted"`), or it has no pinned width and HarfBuzz and the
|
|
72
|
+
* layout's measurement disagree on it (`"shaping"`). Use it for thumbnails, previews and portable SVG files, not for an editing
|
|
73
|
+
* surface or the vector PDF (which keeps real text from `<text>` SVG). Default false.
|
|
71
74
|
*/
|
|
72
75
|
textAsPaths?: boolean;
|
|
76
|
+
/**
|
|
77
|
+
* `false` embeds whole faces (RR-65). By default, with a fonts handle that carries `subsets` (the Node `loadFonts` handle, a browser
|
|
78
|
+
* handle given `subsetWasm`), each face an SVG embeds is cut to the characters the slide draws, keeping every layout feature, so the
|
|
79
|
+
* browser draws it exactly like the whole face (over the core example decks a slide's SVG falls from 2.4 MB to 59 KB on average).
|
|
80
|
+
* Faces whose license does not allow a modified version under their own name (Carlito, Raleway, Lora, Playfair Display, PT Serif,
|
|
81
|
+
* Merriweather Sans, Source Sans 3), whose fsType forbids subsetting, or that a host supplied, are always embedded whole. Set it to
|
|
82
|
+
* false for an SVG whose text will be edited outside the renderer.
|
|
83
|
+
*/
|
|
84
|
+
subsetFonts?: boolean;
|
|
73
85
|
/** `"chain"` (default): a character the resolved face lacks is drawn with the first bundled face that has it, and reported as `font-glyph-fallback`. `"none"`: exact faces, a missing glyph raises `missing-glyph`. */
|
|
74
86
|
glyphFallback?: "chain" | "none";
|
|
75
87
|
/** Unscaled reference-pixel clearance around supplied vector text outlines; default 1. */
|
package/dist/svg.js
CHANGED
|
@@ -22,6 +22,8 @@ import { disabledFeaturesStyle } from "./font-compatibility.js";
|
|
|
22
22
|
import { fontPolicyFor } from "@openpresentation/opf/font-policy";
|
|
23
23
|
import { isDatasetChart, isDatasetTable, renderCatalogChart } from "./charts.js";
|
|
24
24
|
import { renderCaption, renderFootnotes } from "./annotations.js";
|
|
25
|
+
import { drawnFaces } from "./drawn-faces.js";
|
|
26
|
+
import { dataUrlFsType, embeddingAllowed } from "./font-fstype.js";
|
|
25
27
|
|
|
26
28
|
export const packageName = "@openpresentation/opf-render";
|
|
27
29
|
|
|
@@ -641,11 +643,14 @@ function bindSlide(presentation, index, context) {
|
|
|
641
643
|
// `embeddedFonts` list). The renderer reads the two as plain options from here on. `embedFonts: false` (RR-61) keeps the
|
|
642
644
|
// measurement and drops the faces, for a host whose page already holds them.
|
|
643
645
|
// `textAsPaths: true` (RR-64) draws text as glyph outlines with the handle's `outlines` engine (a `loadFonts` handle carries one).
|
|
646
|
+
// `subsetFonts: false` (RR-65) embeds whole faces; by default a handle with a subset engine (`fonts.subsets`) cuts each embedded
|
|
647
|
+
// face to the characters the slide draws.
|
|
644
648
|
function normalizeOptions(options) {
|
|
645
|
-
const { fonts, embedFonts, textAsPaths, ...rest } = options ?? {};
|
|
649
|
+
const { fonts, embedFonts, textAsPaths, subsetFonts, ...rest } = options ?? {};
|
|
650
|
+
if (subsetFonts !== undefined && typeof subsetFonts !== "boolean") throw new OPFRenderError("invalid-render-options", "subsetFonts must be true or false.", { option: "subsetFonts" });
|
|
646
651
|
if (textAsPaths !== undefined && typeof textAsPaths !== "boolean") throw new OPFRenderError("invalid-render-options", "textAsPaths must be true or false.", { option: "textAsPaths" });
|
|
647
652
|
if (textAsPaths && typeof fonts?.outlines?.outlineSlideText !== "function") throw new OPFRenderError("text-as-paths-needs-fonts", "textAsPaths needs the fonts handle loadFonts() returns (from /fonts-node or /fonts-browser), passed as `fonts`.", { option: "textAsPaths" });
|
|
648
|
-
return { ...rest, textMeasurement: fonts?.textMeasurement, embeddedFonts: embedFonts === false ? undefined : fonts?.embeddedFonts, ...(textAsPaths ? { outlines: fonts.outlines } : {}) };
|
|
653
|
+
return { ...rest, textMeasurement: fonts?.textMeasurement, embeddedFonts: embedFonts === false ? undefined : fonts?.embeddedFonts, ...(textAsPaths ? { outlines: fonts.outlines } : {}), ...(subsetFonts !== false && fonts?.subsets ? { subsets: fonts.subsets } : {}) };
|
|
649
654
|
}
|
|
650
655
|
|
|
651
656
|
/**
|
|
@@ -722,7 +727,7 @@ function renderResolvedSlide(resolved, slideIndex, options) {
|
|
|
722
727
|
content = outlined.content;
|
|
723
728
|
glyphs = outlined.defs;
|
|
724
729
|
}
|
|
725
|
-
const children = [glyphs, renderEmbeddedFonts(embeddedFontsFor(options.embeddedFonts, content)), ...content].filter(Boolean);
|
|
730
|
+
const children = [glyphs, renderEmbeddedFonts(embeddedFontsFor(options.embeddedFonts, content, options.subsets, diagnostic => reportDiagnostic(diagnostic, options))), ...content].filter(Boolean);
|
|
726
731
|
// FF-44: Chromium (123+) trims adjacent fullwidth punctuation by default (CSS text-spacing-trim: normal; a sequence such as
|
|
727
732
|
// 「」。 is up to 10 percent narrower), but measurement and PowerPoint advance every such character by its full width, so the browser
|
|
728
733
|
// would stretch the glyphs back to the pinned textLength. space-all keeps the drawn advances equal to the measured ones. Only slides
|
|
@@ -1861,10 +1866,11 @@ function codeSyntax(item, layout, bound, options) {
|
|
|
1861
1866
|
// the SVG names is one the browser can never select, so leaving it out changes no pixel. Only a face flagged embed:"always" is
|
|
1862
1867
|
// embedded in every SVG. RR-59: each drawn style is checked on its own, so a family keeps an exactly matched face and, for
|
|
1863
1868
|
// a drawn style none of its faces matches (a weight the family lacks), every face the browser could choose from.
|
|
1864
|
-
function embeddedFontsFor(fonts = [], content) {
|
|
1869
|
+
function embeddedFontsFor(fonts = [], content, subsets, report = () => {}) {
|
|
1865
1870
|
if (!fonts.length) return fonts;
|
|
1866
1871
|
fonts.forEach(assertEmbeddableFont);
|
|
1867
|
-
const
|
|
1872
|
+
const characters = subsets ? new Map() : undefined;
|
|
1873
|
+
const drawn = drawnFaces(content.join("\n"), characters);
|
|
1868
1874
|
const wanted = font => {
|
|
1869
1875
|
const triples = drawn.get(String(font.family).toLowerCase());
|
|
1870
1876
|
if (!triples) return false;
|
|
@@ -1874,33 +1880,28 @@ function embeddedFontsFor(fonts = [], content) {
|
|
|
1874
1880
|
return matching.length ? matching.includes(font) : true;
|
|
1875
1881
|
});
|
|
1876
1882
|
};
|
|
1877
|
-
|
|
1878
|
-
|
|
1879
|
-
|
|
1880
|
-
|
|
1881
|
-
|
|
1882
|
-
|
|
1883
|
-
|
|
1884
|
-
|
|
1885
|
-
|
|
1886
|
-
|
|
1887
|
-
|
|
1888
|
-
|
|
1889
|
-
|
|
1890
|
-
|
|
1891
|
-
|
|
1892
|
-
|
|
1893
|
-
|
|
1894
|
-
|
|
1895
|
-
|
|
1896
|
-
|
|
1897
|
-
|
|
1898
|
-
|
|
1899
|
-
text: top.text || name === "text",
|
|
1900
|
-
};
|
|
1901
|
-
if (!selfClose) stack.push(next);
|
|
1902
|
-
}
|
|
1903
|
-
return drawn;
|
|
1883
|
+
const drawnOrAlways = fonts.filter(font => font?.embed === "always" || wanted(font));
|
|
1884
|
+
const used = drawnOrAlways.filter(embeddable);
|
|
1885
|
+
const refused = drawnOrAlways.filter(font => !embeddable(font));
|
|
1886
|
+
if (refused.length) report({ code: "font-embedding-restricted", faces: refused.map(font => ({ fontFamily: font.family, weight: font.weight, italic: Boolean(font.italic), fsType: embeddingChecked.get(font).fsType })),
|
|
1887
|
+
message: `${[...new Set(refused.map(font => `'${font.family}'`))].join(", ")} forbid${refused.length === 1 ? "s" : ""} embedding (OS/2 fsType), so the SVG names ${refused.length === 1 ? "it" : "them"} without the font data; a viewer without the font draws the generic family.` });
|
|
1888
|
+
// RR-65: each face cut to the characters the slide draws in its family (the handle's subset engine decides whether the face
|
|
1889
|
+
// may be subset at all). A face no text draws (embed: "always") stays whole.
|
|
1890
|
+
if (!subsets) return used;
|
|
1891
|
+
return used.map(font => {
|
|
1892
|
+
const points = characters.get(String(font.family).toLowerCase());
|
|
1893
|
+
return points?.size ? { ...font, dataUrl: subsets.subsetDataUrl(font, points) } : font;
|
|
1894
|
+
});
|
|
1895
|
+
}
|
|
1896
|
+
|
|
1897
|
+
// RR-76: a face whose OS/2 fsType forbids embedding (restricted, or bitmap-only: an SVG carries outlines) is never written as
|
|
1898
|
+
// @font-face data. The SVG still names its family, so a viewer without the font draws the generic family; a diagnostic says
|
|
1899
|
+
// why. The check reads only the font's table directory and its fsType bytes, once per face object and data URL.
|
|
1900
|
+
const embeddingChecked = new WeakMap();
|
|
1901
|
+
function embeddable(font) {
|
|
1902
|
+
let checked = embeddingChecked.get(font);
|
|
1903
|
+
if (!checked || checked.dataUrl !== font.dataUrl) { const fsType = dataUrlFsType(font.dataUrl); checked = { dataUrl: font.dataUrl, fsType, allowed: embeddingAllowed(fsType) }; embeddingChecked.set(font, checked); }
|
|
1904
|
+
return checked.allowed;
|
|
1904
1905
|
}
|
|
1905
1906
|
|
|
1906
1907
|
// Every supplied face is checked, drawn or not, so a bad entry fails on any slide. The data URI check is remembered per face
|
package/dist/text-paths.js
CHANGED
|
@@ -11,10 +11,11 @@
|
|
|
11
11
|
// the font's own metrics. Text drawn with a colour or bitmap font (COLR, CBDT, sbix, SVG tables) stays <text>, reported as
|
|
12
12
|
// `text-as-paths-kept-text`, and the SVG then embeds that face as usual.
|
|
13
13
|
|
|
14
|
-
import { FontLibrary } from "./pdf-fonts.js";
|
|
15
|
-
import { layoutText } from "./pdf-text.js";
|
|
16
|
-
import { ROOT_STYLE, attributesOf, inheritStyle, parseLength } from "./pdf-style.js";
|
|
17
|
-
import {
|
|
14
|
+
import { FontLibrary, shape as fontkitShape } from "./pdf-fonts.js";
|
|
15
|
+
import { featuresOf, layoutText } from "./pdf-text.js";
|
|
16
|
+
import { ROOT_STYLE, attributesOf, inheritStyle, parseFontFamilies, parseLength } from "./pdf-style.js";
|
|
17
|
+
import { openTypeLanguage } from "./script-fonts.js";
|
|
18
|
+
import { decodeEntities, escapeAttribute, escapeText, parseXml } from "./pdf-xml.js";
|
|
18
19
|
|
|
19
20
|
const TOKENS = /<(\/?)([A-Za-z][\w:.-]*)((?:[^>"']|"[^"]*"|'[^']*')*?)(\/?)>|<!--[\s\S]*?-->|<!\[CDATA\[[\s\S]*?\]\]>|[^<]+/g;
|
|
20
21
|
const ATTRIBUTE = /([^\s=/>"']+)\s*=\s*(?:"([^"]*)"|'([^']*)')/g;
|
|
@@ -58,15 +59,20 @@ export function outlineLibrary(fonts) {
|
|
|
58
59
|
|
|
59
60
|
function newLibrary(fonts) {
|
|
60
61
|
const fallback = fonts?.registry?.fallbackFamily ?? "Roboto";
|
|
61
|
-
|
|
62
|
+
const library = new FontLibrary({ genericFamilies: () => [fallback] });
|
|
63
|
+
library.restricted = new Set();
|
|
64
|
+
return library;
|
|
62
65
|
}
|
|
63
66
|
|
|
64
|
-
// The face is matched by the family, weight and style the registry gives it, the ones the SVG names.
|
|
67
|
+
// The face is matched by the family, weight and style the registry gives it, the ones the SVG names. A CFF face can be outlined
|
|
68
|
+
// (only the PDF cannot subset it); a face whose fsType restricts embedding is never outlined: its text stays text.
|
|
65
69
|
function addFace(library, data, family, described) {
|
|
66
70
|
const face = library.addData(data, "fonts", family);
|
|
67
71
|
if (!face || face.outlineStyled) return;
|
|
68
72
|
if (Number.isInteger(described?.weight)) face.weight = described.weight;
|
|
69
73
|
if (typeof described?.italic === "boolean") face.italic = described.italic;
|
|
74
|
+
if (face.unusable === "pdf-font-unsupported-format" && face.font) face.embeddable = true;
|
|
75
|
+
if (face.unusable === "pdf-font-embedding-restricted") for (const name of face.names) library.restricted.add(name);
|
|
70
76
|
face.outlineStyled = true;
|
|
71
77
|
}
|
|
72
78
|
|
|
@@ -82,23 +88,30 @@ function decodeBase64(text) {
|
|
|
82
88
|
* `rootStyle` holds what the <svg> element gives its children (lang), `report(diagnostic)` receives what cannot be outlined
|
|
83
89
|
* and `fail(code, message)` builds the error thrown when no face draws a text. Returns the new content and the <defs> markup.
|
|
84
90
|
*/
|
|
85
|
-
export function outlineSlideText(content, { library, rootStyle = {}, report = () => {}, fail }) {
|
|
91
|
+
export function outlineSlideText(content, { library, shaper, rootStyle = {}, report = () => {}, fail }) {
|
|
86
92
|
const defs = new Map();
|
|
87
93
|
const reported = new Set();
|
|
88
94
|
const env = {
|
|
89
95
|
fonts: library,
|
|
90
96
|
defaults: [],
|
|
91
97
|
splitByOwner: true,
|
|
98
|
+
// HarfBuzz, the browser's shaper, when the handle carries one (RR-64 phase 2); fontkit otherwise.
|
|
99
|
+
...(shaper ? { shape: (face, text, options) => shaper.shape(face.hash, face.data, text, { ...options, language: options.lang }) } : {}),
|
|
92
100
|
// The renderer reports its own font substitutions and missing glyphs while it lays the slide out.
|
|
93
101
|
diagnostic: () => {},
|
|
94
102
|
missing: () => {},
|
|
95
103
|
unavailable: (families) => fail("text-as-paths-font-unavailable", `No font face can outline text in ${families.length ? `'${families.join("', '")}'` : "the requested family"}; pass the fonts handle the slide was laid out with.`),
|
|
96
104
|
};
|
|
97
105
|
const base = inheritStyle(ROOT_STYLE, rootStyle);
|
|
98
|
-
const
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
106
|
+
const REASONS = {
|
|
107
|
+
"colour-font": "is a colour or bitmap font, which has no plain outlines",
|
|
108
|
+
restricted: "forbids embedding (OS/2 fsType), so its outlines are not written",
|
|
109
|
+
shaping: "shapes differently in HarfBuzz and in the layout's measurement on text with no pinned width",
|
|
110
|
+
};
|
|
111
|
+
const keep = (family, reason) => {
|
|
112
|
+
if (reported.has(`${family}|${reason}`)) return;
|
|
113
|
+
reported.add(`${family}|${reason}`);
|
|
114
|
+
report({ code: "text-as-paths-fallback", reason, fontFamily: family, message: `'${family}' ${REASONS[reason]}; that text stays text and the SVG embeds its face.` });
|
|
102
115
|
};
|
|
103
116
|
const outlined = content.map((markup) => typeof markup === "string" && markup.includes("<text") ? outlineMarkup(markup, base, env, defs, keep) : markup);
|
|
104
117
|
const definitions = [...defs].map(([id, d]) => `<path id="${id}" d="${d}"/>`).join("");
|
|
@@ -150,14 +163,14 @@ function outlineText(source, parentStyle, env, defs, keep) {
|
|
|
150
163
|
const node = parseXml(`<svg>${source}</svg>`)?.children.find((child) => child.name === "text");
|
|
151
164
|
if (!node) return source;
|
|
152
165
|
const { runs } = layoutText(node, env, parentStyle, null);
|
|
153
|
-
const colour = runs.find((run) => COLOUR_TABLES.some((table) => run.face.font.directory?.tables?.[table]));
|
|
154
|
-
if (colour) { keep([...colour.face.names][0]); return source; }
|
|
155
166
|
const attrs = Object.entries(node.attrs).filter(([key]) => !TEXT_ONLY.has(key));
|
|
156
|
-
const hidden = node.attrs["aria-hidden"] === "true"
|
|
157
|
-
const
|
|
158
|
-
const groups = [];
|
|
167
|
+
const hidden = node.attrs["aria-hidden"] === "true";
|
|
168
|
+
const groups = [], outlined = [];
|
|
159
169
|
for (const run of runs) {
|
|
160
|
-
const
|
|
170
|
+
const reason = fallbackReason(run, env);
|
|
171
|
+
if (reason) keep([...run.face.names][0], reason);
|
|
172
|
+
else outlined.push(run);
|
|
173
|
+
const drawn = reason ? keptRun(run) : drawRun(run, defs);
|
|
161
174
|
if (!drawn) continue;
|
|
162
175
|
const owner = run.owner !== node && run.owner ? run.owner : null;
|
|
163
176
|
const last = groups.at(-1);
|
|
@@ -168,14 +181,53 @@ function outlineText(source, parentStyle, env, defs, keep) {
|
|
|
168
181
|
const trace = owner ? Object.entries(owner.attrs).filter(([key]) => key.startsWith("data-")) : [];
|
|
169
182
|
return trace.length ? element("g", trace, parts.join("")) : parts.join("");
|
|
170
183
|
}).join("");
|
|
171
|
-
|
|
184
|
+
// RR-64 phase 3: the glyphs are hidden from assistive technology and the words are real text again, invisible, one <text> per
|
|
185
|
+
// line in reading order, pinned to the drawn width: a screen reader reads text (FA-30), and selection, copy and find work.
|
|
186
|
+
return element("g", attrs, body + (hidden ? "" : readableText(outlined)));
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
// The invisible text layer of the outlined runs (runs that stayed text are text already): per line, the runs in logical order, at
|
|
190
|
+
// the line's left edge (its right edge for a right-to-left line), in the generic family (no face is embedded for it), sized like
|
|
191
|
+
// the first run and stretched to the runs' drawn width.
|
|
192
|
+
function readableText(runs) {
|
|
193
|
+
const lines = [];
|
|
194
|
+
for (const run of runs) { let line = lines.find((item) => item.chunk === run.chunk); if (!line) lines.push(line = { chunk: run.chunk, runs: [] }); line.runs.push(run); }
|
|
195
|
+
return lines.map(({ chunk, runs: parts }) => {
|
|
196
|
+
const words = [...parts].sort((a, b) => a.start - b.start).map((run) => run.logical).join("");
|
|
197
|
+
if (!/\S/.test(words)) return "";
|
|
198
|
+
const left = Math.min(...parts.map((run) => run.x)), width = parts.reduce((sum, run) => sum + run.width, 0), first = parts[0];
|
|
199
|
+
return element("text", [["x", fixed(chunk.rtl ? left + width : left)], ["y", fixed(first.y)], ["font-family", "sans-serif"], ["font-size", fixed(first.size)], ["fill", "none"],
|
|
200
|
+
["direction", chunk.rtl ? "rtl" : undefined], ["xml:space", "preserve"], ["textLength", fixed(width)], ["lengthAdjust", "spacingAndGlyphs"], ["lang", first.style.lang]], escapeText(words));
|
|
201
|
+
}).join("");
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
// Why a run stays text, or null: a colour or bitmap font; a family whose fsType restricts embedding (the run was laid out with
|
|
205
|
+
// another face); or, with HarfBuzz shaping, a run with no pinned width whose HarfBuzz advance differs from the fontkit advance
|
|
206
|
+
// the layout measured by more than 0.1 px (inside a textLength the run is scaled to the measured width, as the browser does).
|
|
207
|
+
function fallbackReason(run, env) {
|
|
208
|
+
if (COLOUR_TABLES.some((table) => run.face.font.directory?.tables?.[table])) return "colour-font";
|
|
209
|
+
for (const family of parseFontFamilies(run.style["font-family"])) {
|
|
210
|
+
if (env.fonts.restricted?.has(family.toLowerCase())) return "restricted";
|
|
211
|
+
if (run.face.names.has(family.toLowerCase())) break;
|
|
212
|
+
}
|
|
213
|
+
if (env.shape && !run.scope) {
|
|
214
|
+
const direction = run.level % 2 === 1 ? "rtl" : "ltr", language = openTypeLanguage(run.style.lang ?? run.style["xml:lang"]);
|
|
215
|
+
const measured = fontkitShape(run.face, run.text, { features: featuresOf(run.style), language, direction }).reduce((sum, glyph) => sum + glyph.advance, 0);
|
|
216
|
+
const shaped = run.glyphs.reduce((sum, glyph) => sum + glyph.advance, 0);
|
|
217
|
+
if (Math.abs(measured - shaped) * run.scale > 0.1) return "shaping";
|
|
218
|
+
}
|
|
219
|
+
return null;
|
|
172
220
|
}
|
|
173
221
|
|
|
174
|
-
//
|
|
175
|
-
function
|
|
176
|
-
const
|
|
177
|
-
|
|
178
|
-
|
|
222
|
+
// A run that stays text, where layoutText placed it: its own family, size and paint, its width pinned with textLength.
|
|
223
|
+
function keptRun(run) {
|
|
224
|
+
const style = run.style, rtl = run.level % 2 === 1;
|
|
225
|
+
const attrs = [["x", fixed(rtl ? run.x + run.width : run.x)], ["y", fixed(run.y)], ["font-family", style["font-family"]], ["font-size", fixed(run.size)],
|
|
226
|
+
["font-weight", style["font-weight"]], ["font-style", style["font-style"] === "normal" ? undefined : style["font-style"]], ["fill", style.fill],
|
|
227
|
+
["fill-opacity", style["fill-opacity"] === "1" ? undefined : style["fill-opacity"]], ["direction", rtl ? "rtl" : undefined], ["xml:space", "preserve"],
|
|
228
|
+
["textLength", fixed(run.width)], ["lengthAdjust", "spacingAndGlyphs"], ["lang", style.lang]];
|
|
229
|
+
const text = `<text${attrs.filter(([, value]) => value !== undefined && value !== null && value !== "").map(([key, value]) => ` ${key}="${escapeAttribute(value)}"`).join("")}>${escapeAttribute(run.logical)}</text>`;
|
|
230
|
+
return run.link ? element("a", Object.entries(run.link.node?.attrs ?? { href: run.link.href }), text) : text;
|
|
179
231
|
}
|
|
180
232
|
|
|
181
233
|
function drawRun(run, defs) {
|
|
@@ -204,9 +256,9 @@ function drawRun(run, defs) {
|
|
|
204
256
|
}
|
|
205
257
|
if (style.visibility && style.visibility !== "visible") paint.push(["visibility", style.visibility]);
|
|
206
258
|
const glyphs = uses.length ? `<g transform="matrix(${precise(scale * scaleX)} 0 0 ${precise(-scale)} ${fixed(run.x)} ${fixed(run.y)})">${uses.join("")}</g>` : "";
|
|
207
|
-
const drawn = element("g", paint, glyphs + decorations.join(""));
|
|
208
|
-
// A link inside the text keeps its element's attributes (href, rel, target).
|
|
209
|
-
return run.link ? element("a", Object.entries(run.link.node?.attrs ?? { href: run.link.href }), drawn) : drawn;
|
|
259
|
+
const drawn = element("g", [...paint, ["aria-hidden", "true"]], glyphs + decorations.join(""));
|
|
260
|
+
// A link inside the text keeps its element's attributes (href, rel, target) and is named by its words.
|
|
261
|
+
return run.link ? element("a", [...Object.entries(run.link.node?.attrs ?? { href: run.link.href }), ["aria-label", run.logical]], drawn) : drawn;
|
|
210
262
|
}
|
|
211
263
|
|
|
212
264
|
// Underline, line-through and overline in the text colour, from the font's own metrics (the vector PDF draws the same).
|
|
@@ -272,7 +324,7 @@ export function textOutlines(fonts) {
|
|
|
272
324
|
outlineSlideText: (content, options) => {
|
|
273
325
|
const library = outlineLibrary(fonts);
|
|
274
326
|
if (!library) throw options.fail("text-as-paths-needs-fonts", "textAsPaths needs font faces to outline: pass the handle loadFonts() returns as `fonts`.");
|
|
275
|
-
return outlineSlideText(content, { ...options, library });
|
|
327
|
+
return outlineSlideText(content, { ...options, library, shaper: fonts.shaper });
|
|
276
328
|
},
|
|
277
329
|
});
|
|
278
330
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@openpresentation/opf-render",
|
|
3
|
-
"version": "0.17.
|
|
3
|
+
"version": "0.17.1",
|
|
4
4
|
"description": "Deterministic local OPF renderer for SVG, PNG, and PDF output.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
@@ -142,6 +142,7 @@
|
|
|
142
142
|
"@openpresentation/opf": "^0.17.0",
|
|
143
143
|
"bidi-js": "1.1.0",
|
|
144
144
|
"fontkit": "^2.0.4",
|
|
145
|
+
"harfbuzzjs": "1.6.2",
|
|
145
146
|
"pako": "1.0.11"
|
|
146
147
|
},
|
|
147
148
|
"peerDependencies": {
|
|
@@ -387,7 +388,6 @@
|
|
|
387
388
|
"@resvg/resvg-js": "2.6.2",
|
|
388
389
|
"axe-core": "4.13.0",
|
|
389
390
|
"esbuild": "0.28.2",
|
|
390
|
-
"harfbuzzjs": "1.6.2",
|
|
391
391
|
"pdf-lib": "1.17.1",
|
|
392
392
|
"pdfjs-dist": "6.3.289",
|
|
393
393
|
"playwright": "1.63.0",
|