@openpresentation/opf-render 0.16.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,10 +152,12 @@ 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 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
+
159
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).
160
162
 
161
163
  ```js
@@ -254,6 +256,13 @@ container.innerHTML = renderSlideSvg(presentation, 0, { fonts });
254
256
 
255
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.
256
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
+
257
266
  ## Player and `<opf-deck>` (RR-28)
258
267
 
259
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.
@@ -489,6 +498,8 @@ renderSvg(template, { variables: { client: 'Globex', revenue: 1250000 } });
489
498
 
490
499
  A template previews with each unfilled variable's `example` and reports `variable-example-used` through `onDiagnostic` (a built-in variable such as `{{speaker.name}}` whose document field is missing reports `variable-builtin-missing`; built-ins read the document's own `speaker`, `organization` and `name`, and a template preview keeps an absent one visible); a variable with no example keeps its `{{id}}` text. A normal deck with an unfilled required variable throws `OPFRenderError` with code `unfilled-variables`, and a value of the wrong kind throws `invalid-variables`. `resolvePresentation(...).presentation` is the concrete deck. `variables: false` draws the document as authored, with tokens and `var:` references visible (the editor canvas's view of a template, so inline edits never overwrite a token). Decks without content variables are untouched. See [templates and variables](https://github.com/OpenPresentation/opf/blob/main/docs/templates-and-variables.md).
491
500
 
501
+ Slide-scoped built-ins (FA-31): `{{slide.number}}`, `{{slide.section}}` and `{{deck.slideCount}}` work in any string of a slide (titles, body text, table cells, notes) and in header and footer `text`. The deck-wide pass leaves them as written; the renderer draws each slide from core's substituted slide (`resolveSlideContext(...).slide`), numbered by its position in the deck you render (run core `paginate` first and the numbers and the count are the paginated ones), so the body and the footer show the same number. A zone's `text` is one furniture part (`data-opf-furniture-field="text"`); the former `organization`, `speaker`, `section` and `slideNumber` flags are gone, so write `{{organization.name}}` or `{{slide.number}}` in the `text`.
502
+
492
503
  ### Symbol-encoded families: Symbol, Wingdings, Webdings (FF-45)
493
504
 
494
505
  Symbol, Wingdings, Wingdings 2, Wingdings 3 and Webdings keep their glyphs at the 224 codes 0x20..0xFF of a Microsoft Symbol cmap, not at Unicode code points, and none of them may be bundled. A run or design font in one of them no longer fails with `font-encoding-required`: each character is normalised to its code (the private-use character U+F0xx that Office writes for Insert > Symbol and `a:sym` runs, or the Windows-1252 character of the code, so "l" and U+F06C are the same Wingdings bullet) and mapped through core's reversible, version-specific tables (`@openpresentation/opf` `spec/reference/symbol-font-encodings.json`, read through `@openpresentation/opf/symbol-font-encodings`, not copied here; `mapSymbolText('Wingdings', 'l')` gives U+26AB) to the Unicode equivalent, which the first loaded face of the family's chain draws: `SYMBOL_PREVIEW_FACES` is Noto Sans Symbols 2, Noto Sans Symbols, Noto Sans Math, Noto Sans for the dingbat fonts and Noto Sans (Greek letters, digits, punctuation) first for Symbol. The three symbol packages are optional peers of the script pack under the key `Zsym` (`scripts: ['Zsym']`; `scripts: 'auto'` selects it when a run, `design.fonts` or a font scheme names one of the families; `fonts.ensure(presentation)`): `@expo-google-fonts/noto-sans-symbols-2`, `noto-sans-symbols` and `noto-sans-math`, OFL-1.1, together about 1.2 MiB. Every code the tables map (1059 of 1120: Symbol 189, Wingdings 222, Wingdings 2 217, Wingdings 3 208, Webdings 223) draws a real glyph when the pack is loaded; the 61 codes without a Unicode equivalent (the Wingdings 0xFF Windows logo, unassigned codes) draw the placeholder U+25A1. Each glyph is one positioned tspan at the advance of the verified Windows font (Wingdings 5.01, Wingdings 2 and 3 1.55, Webdings 5.01, Symbol 5.01), in measurement and drawing alike, so line breaks and bullet gaps follow PowerPoint; the open glyph keeps its shape and is compressed to its code's advance only when wider, never stretched. Characters that are not codes (a CJK letter in a Wingdings run) draw as themselves. Without the pack, Symbol's Greek letters, digits and punctuation draw with the office pack's Noto Sans, the other codes draw U+FFFD, and the `font-glyph-fallback` diagnostic (`codes`, `placeholder`) names the pack; a registry with no chain face and no `fallbackFamily` throws `font-encoding-required`, naming it too. `resolveFont` and `resolveStyle` report `symbolEncoding` with the substitute face, so exporters keep writing the chosen family and the original characters, and the PPTX names Wingdings with its codes. The symbol faces also end the glyph fallback chain. Appearance is the open face's, not Microsoft's; native PowerPoint verification of the exported runs is separate (FF-46).
@@ -2,6 +2,7 @@
2
2
  // decides which slides play (hidden ones do not), renders each slide once with the renderer's own SVG output (renderSlideSvg:
3
3
  // there is no second layout engine) behind a face-level font gate, and synchronizes windows over a BroadcastChannel.
4
4
  // Nothing here fetches anything but the font files the host's font root serves.
5
+ import { resolveSlideVariables } from "@openpresentation/opf";
5
6
  import { renderSlideSvg } from "./svg.js";
6
7
 
7
8
  /** Error raised for a deck that cannot be read, rendered or loaded. */
@@ -49,7 +50,10 @@ export function presentableIndexes(document, { includeHidden = false } = {}) {
49
50
 
50
51
  /** Facts about one slide for a counter, a label, a thumbnail or the speaker view. `notes` is plain text only. */
51
52
  export function slideInfo(document, index) {
52
- const slide = isObject(document?.slides?.[index]) ? document.slides[index] : {};
53
+ const authored = isObject(document?.slides?.[index]) ? document.slides[index] : {};
54
+ // FA-31: the strings a counter, label or speaker view shows hold this slide's number, section and the deck's slide count, as the drawn slide does.
55
+ const { title: shownTitle, subtitle, tag, notes, section } = resolveSlideVariables({ title: authored.title, subtitle: authored.subtitle, tag: authored.tag, notes: authored.notes, section: authored.section }, { slideNumber: Math.max(1, index + 1), slideCount: Math.max(1, document?.slides?.length ?? 1) });
56
+ const slide = { ...authored, title: shownTitle, subtitle, tag, notes, section };
53
57
  const title = plainText(slide.title).trim() || plainText(slide.subtitle).trim() || plainText(slide.tag).trim();
54
58
  return {
55
59
  index,
@@ -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
+ }
@@ -1,6 +1,6 @@
1
1
  import { create } from "fontkit";
2
2
  import { FONT_COMPATIBILITY, disabledFeaturesFor } from "./font-compatibility.js";
3
- import { adjustedFontSize, openTypeLanguage, scriptFontAliases, sizeAdjustFor } from "./script-fonts.js";
3
+ import { adjustedFontSize, createScriptFonts, openTypeLanguage, scriptFontAliases, sizeAdjustFor } from "./script-fonts.js";
4
4
  import { fontPolicyFor } from "@openpresentation/opf/font-policy";
5
5
  import { BUNDLED_FONT_MANIFEST } from "./font-manifest.js";
6
6
  import { SYMBOL_SCRIPT, isSymbolEncodedFamily, symbolEncodingFamily, symbolPreviewFaces } from "./symbol-fonts.js";
@@ -289,9 +289,12 @@ export function createFontRegistry(entries, options = {}) {
289
289
  const {value,scale}=metrics(text,size,style,true),bounds=value.outline,drawn=adjustedFontSize(size,scale===1?undefined:scale);
290
290
  return bounds===null?null:{x:bounds.x*drawn,y:bounds.y*drawn,width:bounds.width*drawn,height:bounds.height*drawn};
291
291
  };
292
+ // resolveFont lets exporters tell a substitute from the chosen family (FF-31). forScripts (opf#485) is the script planner core
293
+ // validate asks for each slide's measurement, so its layout checks measure script runs in the faces this renderer draws.
294
+ const textMeasurement={measure,resolveStyle,outlineBounds,resolveFont:style=>resolve(style).resolution};
295
+ textMeasurement.forScripts=profile=>createScriptFonts(profile,textMeasurement).textMeasurement;
292
296
  return {
293
- // resolveFont lets exporters tell a substitute from the chosen family (FF-31).
294
- textMeasurement: {measure,resolveStyle,outlineBounds,resolveFont:style=>resolve(style).resolution},
297
+ textMeasurement,
295
298
  resolveFont(style) { return resolve(style).resolution; },
296
299
  clearSubstitutions() { substitutions.clear(); },
297
300
  get substitutions() { return [...substitutions.values()]; },
@@ -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";
@@ -2,6 +2,9 @@ import { createFontRegistry, OPFFontError } from "./font-registry.js";
2
2
  import { lazyFontEntries, lazyFontList, normalizeExtraLazyFonts } from "./lazy-font-list.js";
3
3
  import { lazyFacesNeeded } from "./lazy-fonts.js";
4
4
  import { BUNDLED_FONT_MANIFEST } from "./font-manifest.js";
5
+ import { textOutlines } from "./text-paths.js";
6
+ import { createSubsetter, fontSubsets } from "./font-subset.js";
7
+ import { instantiateShaper } from "./hb-shape.js";
5
8
  import { analyzePresentationScripts, nextFallbackPackage, scriptFontPackages, scriptPackageEntries, scriptSelectionOf, uncoveredCjkCharacters } from "./script-font-pack.js";
6
9
  export { autoScriptSelection, detectPresentationScripts, scriptFontEntries, scriptFontPackages } from "./script-font-pack.js";
7
10
  export { lazyFontEntries, lazyFontList, splitStartupFaces } from "./lazy-font-list.js";
@@ -279,8 +282,32 @@ const MAX_ENSURE_ROUNDS = 4;
279
282
  * (`substitutionPolicy`, `aliases`, `fallbackFamily`, `themeFonts`, `strictGlyphs`) are as for the registry (see `fonts-browser.d.ts`).
280
283
  * `dispose()` removes every face from the document.
281
284
  */
282
- export async function loadFonts({ faces = [], ...options } = {}) {
283
- 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)]);
284
311
  // The embedded list is base64 of every eager face: compute it once, and again only when faces were added.
285
312
  let embedded, stale = true;
286
313
  const addFaces = registry.addFaces;
@@ -293,6 +320,10 @@ export async function loadFonts({ faces = [], ...options } = {}) {
293
320
  textMeasurement: registry.textMeasurement,
294
321
  get embeddedFonts() { if (stale) { embedded = registry.embeddedFonts; stale = false; } return embedded; },
295
322
  registry,
323
+ // RR-64: the outline engine `renderSvg(deck, { fonts, textAsPaths: true })` draws text with, over this registry's faces.
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) } : {}),
296
327
  manifest: BUNDLED_FONT_MANIFEST,
297
328
  get substitutions() { return registry.substitutions; },
298
329
  /**
@@ -6,6 +6,9 @@ import { createHash } from "node:crypto";
6
6
  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
+ import { textOutlines } from "./text-paths.js";
10
+ import { createSubsetter, fontSubsets } from "./font-subset.js";
11
+ import { createShaper } from "./hb-shape.js";
9
12
  export { BUNDLED_FONT_MANIFEST } from "./font-manifest.js";
10
13
  import { analyzePresentationScripts, autoScriptSelection, nextFallbackPackage, scriptFontPackages, scriptSelectionOf, uncoveredCjkCharacters } from "./script-font-pack.js";
11
14
  export { autoScriptSelection, detectPresentationScripts, scriptFontPackages } from "./script-font-pack.js";
@@ -246,6 +249,23 @@ async function ensureFonts(registry, loaded, presentation, {onDiagnostic, render
246
249
  return {scripts: loaded.packages.slice(before), lazy: [], uncovered: selection.uncovered ? [...selection.uncovered] : []};
247
250
  }
248
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
+
249
269
  /**
250
270
  * The fonts handle: one set of verified font inputs for layout, SVG, editor, PPTX and Node raster export. Pass it as `{ fonts }` to
251
271
  * `renderSvg`, `renderSlideSvg`, `svgToPng`, `svgToPdf`, core `paginate` and `validate`, and `toPptx`.
@@ -265,6 +285,7 @@ export async function loadFonts({pack = "base", embedScriptFonts = false, ...opt
265
285
  // The embedded list is base64 of every face: computed on first use, and again after `ensure` added faces.
266
286
  let embeddedFonts, fontFiles = [...registry.fontFiles];
267
287
  const refresh = () => { embeddedFonts = undefined; fontFiles = [...registry.fontFiles]; };
288
+ const [subsetter, shaper] = await Promise.all([nodeSubsetter(), nodeShaper()]);
268
289
  return {
269
290
  textMeasurement: registry.textMeasurement,
270
291
  get embeddedFonts() { return (embeddedFonts ??= selectEmbedded()); },
@@ -272,6 +293,10 @@ export async function loadFonts({pack = "base", embedScriptFonts = false, ...opt
272
293
  useBundledFonts: false,
273
294
  loadSystemFonts: false,
274
295
  registry,
296
+ // RR-64: the outline engine `renderSvg(deck, { fonts, textAsPaths: true })` draws text with, over this registry's faces.
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) } : {}),
275
300
  manifest: BUNDLED_FONT_MANIFEST,
276
301
  get substitutions() { return registry.substitutions; },
277
302
  /** Load the script faces the presentation's text needs (see `ensureFonts`); `embeddedFonts` and `fontFiles` then include them. */
package/dist/fonts.d.ts CHANGED
@@ -49,7 +49,15 @@ export interface RenderFonts {
49
49
  useBundledFonts?: boolean;
50
50
  /** Whether the raster conversion loads system fonts. Default false (the output never depends on the machine). */
51
51
  loadSystemFonts?: boolean;
52
+ /** The outline engine `textAsPaths: true` draws text with (RR-64); a `loadFonts` handle carries one over its registry's faces. */
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;
52
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 }
59
+ /** Draws a slide's text as glyph outlines (RR-64). Read by `renderSvg`; hosts pass the handle and `textAsPaths: true`. */
60
+ export interface TextOutlines { outlineSlideText(content: string[], options: object): { content: string[]; defs: string } }
53
61
  /** What `fonts.ensure(presentation)` loaded: the script packages and vendored faces that were missing, and drawn CJK characters no face covers. */
54
62
  export interface EnsureResult { scripts: string[]; lazy: LazyFont[]; uncovered: string[] }
55
63
  /** The fonts handle `loadFonts()` returns from `/fonts-node` and `/fonts-browser`: pass it as `{ fonts }` to every deck-level function. */
@@ -58,6 +66,8 @@ export interface FontsHandle extends RenderFonts {
58
66
  readonly embeddedFonts: EmbeddedFont[];
59
67
  /** The face registry behind the handle (shaping, resolution, lazy and script loading). */
60
68
  readonly registry: FontRegistry;
69
+ /** The outline engine for `textAsPaths: true` (RR-64), over this registry's faces, including faces `ensure` loads later. */
70
+ readonly outlines: TextOutlines;
61
71
  /** The pinned manifest of the bundled font packages. */
62
72
  readonly manifest: { readonly version: number; readonly packages: readonly object[] };
63
73
  /** The substitutions made so far (a requested family drawn with another face), as `registry.substitutions`. */