@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 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. Faces are embedded whole, not as glyph subsets. `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`.
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, widths and shaping are the vector PDF's (the same layout of the renderer's pinned lines), so 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 38 KB at the median (58 KB at p90, 98 KB at most) against 0.5 to 4 MB with 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), labelled `role="img"` with its text for assistive technology unless it is `aria-hidden`; links stay `<a>`; underline and line-through are rectangles from the font's own metrics. Text in a colour or bitmap font (emoji) stays text, reported as `text-as-paths-kept-text`, and the SVG embeds just that text's faces. The trade-offs, which make it the choice for thumbnails, previews and portable SVG files and not for an editing surface: the text is no longer text (it cannot be selected, copied, searched or translated, and assistive technology reads one label per text element instead of the text itself); a caret, selection and input method need live text; 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).
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(/^&quot;|&quot;$|^["']|["']$/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
- let svg = renderSlideSvg(deck, index, { ...options.renderOptions, fonts: renderFonts, ...(fontMode === "external" ? { embedFonts: false } : {}), ...(date ? { date } : {}) });
539
- if (fontMode === "shared") svg = svg.replace(/<style>(@font-face[\s\S]*?)<\/style>/g, (_, css) => {
540
- // These rules are produced by renderSlideSvg after its used-face selection and validation.
541
- for (const rule of css.split("\n")) sharedRules.add(rule);
542
- return "";
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 shared = sharedRules.size ? `<style>${[...sharedRules].join("\n")}</style>` : "";
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
+ }
@@ -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";
@@ -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
- export async function loadFonts({ faces = [], ...options } = {}) {
284
- const registry = await loadRegistry(faces, options);
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
  /**
@@ -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. */
@@ -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 selected, searched or edited in place, and each outlined element is a `role="img"` group labelled with its text for
68
- * assistive technology. Each element keeps its `data-opf-*` trace. Text in a colour or bitmap font stays text
69
- * (`text-as-paths-kept-text`). Use it for thumbnails, previews and portable SVG files, not for an editing surface or the
70
- * vector PDF (which keeps real text from `<text>` SVG). Default false.
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
- current = { chunk: chunkInfo, 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 };
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
- const glyphs = shape(run.face, run.text, { features: featuresOf(style), language, direction });
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;
@@ -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 selected, searched or edited in place, and each outlined element is a `role="img"` group labelled with its text for
68
- * assistive technology. Each element keeps its `data-opf-*` trace. Text in a colour or bitmap font stays text
69
- * (`text-as-paths-kept-text`). Use it for thumbnails, previews and portable SVG files, not for an editing surface or the
70
- * vector PDF (which keeps real text from `<text>` SVG). Default false.
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 drawn = drawnFaces(content.join("\n"));
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
- return fonts.filter(font => font?.embed === "always" || wanted(font));
1878
- }
1879
-
1880
- const FONT_WEIGHT_KEYWORDS = { normal: "400", bold: "700" };
1881
- /** family (lowercase) to the set of "weight|style" pairs the markup draws text in. */
1882
- function drawnFaces(markup) {
1883
- const drawn = new Map();
1884
- const tokens = /<(\/?)([A-Za-z][\w:.-]*)((?:[^>"']|"[^"]*"|'[^']*')*?)(\/?)>|<!--[\s\S]*?-->|<!\[CDATA\[[\s\S]*?\]\]>|[^<]+/g;
1885
- const stack = [{ families: [], weight: "400", style: "normal", text: false }];
1886
- const attribute = (attributes, name) => { const found = new RegExp(`\\s${name}\\s*=\\s*(?:"([^"]*)"|'([^']*)')`).exec(attributes); return found ? found[1] ?? found[2] : undefined; };
1887
- for (const [token, close, name, attributes, selfClose] of markup.matchAll(tokens)) {
1888
- const top = stack.at(-1);
1889
- if (name === undefined) {
1890
- 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); }
1891
- continue;
1892
- }
1893
- if (close) { if (stack.length > 1) stack.pop(); continue; }
1894
- const family = attribute(attributes, "font-family"), weight = attribute(attributes, "font-weight"), style = attribute(attributes, "font-style");
1895
- const next = {
1896
- families: family === undefined ? top.families : family.split(",").map(item => item.trim().replace(/^&quot;|&quot;$|^["']|["']$/g, "").toLowerCase()).filter(Boolean),
1897
- weight: weight === undefined ? top.weight : FONT_WEIGHT_KEYWORDS[weight.trim()] ?? weight.trim(),
1898
- style: style === undefined ? top.style : /italic|oblique/.test(style) ? "italic" : "normal",
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
@@ -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 { decodeEntities, escapeAttribute, parseXml } from "./pdf-xml.js";
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
- return new FontLibrary({ genericFamilies: () => [fallback] });
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 keep = (family) => {
99
- if (reported.has(family)) return;
100
- reported.add(family);
101
- report({ code: "text-as-paths-kept-text", fontFamily: family, message: `'${family}' is a colour or bitmap font, which has no plain outlines; its text stays text and the SVG embeds the face.` });
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", words = logicalText(runs);
157
- const label = hidden || !words ? [] : [["role", "img"], ["aria-label", words]];
158
- const groups = [];
167
+ const hidden = node.attrs["aria-hidden"] === "true";
168
+ const groups = [], outlined = [];
159
169
  for (const run of runs) {
160
- const drawn = drawRun(run, defs);
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
- return element("g", [...attrs, ...label], body);
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
- // The text in reading order: runs are placed in visual order, so a right-to-left chunk is read from its logical text.
175
- function logicalText(runs) {
176
- const chunks = [];
177
- for (const run of runs) if (!chunks.includes(run.chunk)) chunks.push(run.chunk);
178
- return chunks.map((chunk) => chunk.logical).join(" ").replace(/\s+/g, " ").trim();
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.0",
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",