@openpresentation/opf-render 0.17.0 → 0.18.0

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/dist/element.js CHANGED
@@ -1,4 +1,4 @@
1
- // RR-28: <opf-deck>, a framework-free web component that embeds an OPF deck. It draws the renderer's own SVG (renderSvg), so
1
+ // RR-28: <opf-deck>, a framework-free web component that embeds an OPF deck. It draws the renderer's own SVG (toSvg), so
2
2
  // the slide a page shows is the slide the preview, the editor and the PDF show. Importing this module touches no DOM, so it
3
3
  // is safe on a server (Next.js); `defineOpfDeck()` registers the tag in a browser.
4
4
  import {
@@ -6,7 +6,9 @@ import {
6
6
  prepareSlideSvg, sanitizeSvgTree, slideInfo, slideLabel, svgDimensions, plainText,
7
7
  } from "./deck-runtime.js";
8
8
  import { loadPreviewFonts } from "./preview-fonts.js";
9
- import { renderSlideSvg } from "./svg.js";
9
+ import { toSvg } from "./svg.js";
10
+ import { drawnFaces } from "./drawn-faces.js";
11
+ import { selectSlides, slideArguments } from "./slide-sources.js";
10
12
 
11
13
  export { DeckError } from "./deck-runtime.js";
12
14
  export { loadPreviewFonts, previewBaseFaces, previewFontLayout } from "./preview-fonts.js";
@@ -139,7 +141,7 @@ function createElementClass() {
139
141
  if (had && before === this.getAttribute("fonts") && this.isConnected && this.#built) this.#start();
140
142
  }
141
143
 
142
- /** Extra `renderSvg` options (catalogs, imageResolver, date, ...). Set before the deck loads, or call `reload()`. */
144
+ /** Extra `toSvg` options (catalogs, imageResolver, date, ...). Set before the deck loads, or call `reload()`. */
143
145
  get renderOptions() { return this.#renderOptions; }
144
146
  set renderOptions(value) { this.#renderOptions = value && typeof value === "object" ? value : {}; }
145
147
 
@@ -512,43 +514,56 @@ const escapeHtml = (value) => String(value).replace(/&/g, "&amp;").replace(/</g,
512
514
  * `/fonts-browser`, or one with a `textMeasurement` and `embeddedFonts`). A `fonts` string is the font root URL written to the
513
515
  * element's `fonts` attribute.
514
516
  *
515
- * `slides`: `"first"` (default: the first slide plus a list of titles), `"all"` or a list of 1-based slide numbers.
516
- * `embed` puts the document in an `application/opf+json` script child, so the element upgrades without a request.
517
+ * `toHtml(deck)` draws the first slide plus a list of titles; `toHtml(deck, 3)`, `toHtml(deck, "1-3")` (`"1-"` for every slide) or
518
+ * `toHtml(deck, [1, 3])` draw the selected slides of the presented sequence (counting from 1); the second argument may be the
519
+ * options instead, told apart by type (RR-73). `embed` puts the document in an `application/opf+json` script child, so the element upgrades without a request.
517
520
  * `fontMode`: `"standalone"` (default, embeds per slide), `"shared"` (one set of font rules in the returned tag), or
518
521
  * `"external"` (no font rules; the host must supply CSS with the exact pinned faces the handle measures).
519
522
  */
520
- export function renderDeckHtml(input, options = {}) {
523
+ export function toHtml(input, slides, options) {
524
+ ({ slides, options } = slideArguments(slides, options));
521
525
  const deck = parseDeckDocument(input);
522
526
  const tag = options.tagName ?? OPF_DECK_TAG;
523
527
  const sequence = presentableIndexes(deck, { includeHidden: options.includeHidden });
524
- const wanted = options.slides ?? "first";
525
- const shown = wanted === "all" ? sequence.map((_, position) => position) : wanted === "first" ? [0] : wanted.map((number) => number - 1).filter((position) => position >= 0 && position < sequence.length);
528
+ const shown = slides === undefined ? [0] : selectSlides(slides, sequence.length).map((number) => number - 1);
526
529
  const handle = options.fonts !== null && typeof options.fonts === "object" ? options.fonts : undefined;
527
530
  const fontMode = options.fontMode ?? "standalone";
528
531
  if (!["standalone", "shared", "external"].includes(fontMode)) throw new DeckError("invalid-font-mode", "Choose standalone, shared or external SSR fonts.");
529
- // Every mode measures with the same handle; external writes no @font-face data (RR-61 `embedFonts: false`).
532
+ // Every mode measures with the same handle; external writes no @font-face data (`text: "system"`).
530
533
  const renderFonts = handle ?? options.renderOptions?.fonts;
531
- const sharedRules = new Set();
534
+ const sharedRules = new Set(), sharedCharacters = new Map();
532
535
  const attributes = { src: options.src, slide: options.slide, fonts: handle ? undefined : options.fonts, label: options.label, ...options.attributes };
533
536
  const attributeText = Object.entries(attributes).filter(([, value]) => value !== undefined && value !== false).map(([name, value]) => (value === true ? ` ${name}` : ` ${name}="${escapeHtml(value)}"`)).join("")
534
537
  + (options.thumbnails ? " thumbnails" : "") + (options.present ? " present" : "") + (options.includeHidden ? " include-hidden" : "");
535
538
  const date = options.renderOptions?.date ?? options.date;
536
539
  const figures = shown.map((position) => {
537
540
  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
- });
541
+ // Shared rules are deduplicated across slides, so the slides embed whole faces and the shared set is cut below to what
542
+ // every slide draws (RR-65); per-slide subsets would differ and repeat each face once per slide.
543
+ let svg = toSvg(deck, index + 1, { ...options.renderOptions, fonts: renderFonts, ...(fontMode === "external" ? { text: "system" } : {}), ...(fontMode === "shared" ? { subsetFonts: false } : {}), ...(date ? { date } : {}) });
544
+ if (fontMode === "shared") {
545
+ drawnFaces(svg, sharedCharacters);
546
+ svg = svg.replace(/<style>(@font-face[\s\S]*?)<\/style>/g, (_, css) => {
547
+ // These rules are produced by toSvg after its used-face selection and validation.
548
+ for (const rule of css.split("\n")) sharedRules.add(rule);
549
+ return "";
550
+ });
551
+ }
544
552
  const { width, height } = svgDimensions(svg);
545
553
  const prepared = prepareSlideSvg(svg).replace(/^<svg\b/, `<svg style="display:block;width:100%;height:auto;aspect-ratio:${width} / ${height}"`);
546
554
  const label = slideLabel(slideInfo(deck, index), position + 1, sequence.length);
547
555
  return `<figure role="group" aria-roledescription="slide" aria-label="${escapeHtml(label)}" style="margin:0 0 8px">${prepared}</figure>`;
548
556
  });
549
- const titles = wanted === "first" && sequence.length > 1
557
+ const titles = slides === undefined && sequence.length > 1
550
558
  ? `<nav aria-label="Slides"><ol>${sequence.map((index) => `<li>${escapeHtml(slideInfo(deck, index).title)}</li>`).join("")}</ol></nav>` : "";
551
559
  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>` : "";
560
+ const subsets = options.renderOptions?.subsetFonts === false ? undefined : renderFonts?.subsets;
561
+ const sharedRule = (rule) => {
562
+ const face = /^@font-face\{font-family:"([^"]+)";font-weight:(\d+);font-style:(\w+);src:url\("([^"]+)"\)\}$/.exec(rule);
563
+ const points = face && sharedCharacters.get(face[1].toLowerCase());
564
+ if (!subsets || !points?.size) return rule;
565
+ return rule.replace(face[4], subsets.subsetDataUrl({ family: face[1], weight: Number(face[2]), italic: face[3] === "italic", dataUrl: face[4] }, points));
566
+ };
567
+ const shared = sharedRules.size ? `<style>${[...sharedRules].map(sharedRule).join("\n")}</style>` : "";
553
568
  return `<${tag}${attributeText}>${shared}${figures.join("")}${titles}${embedded}</${tag}>`;
554
569
  }
@@ -1,8 +1,9 @@
1
- import type { PdfDiagnostic, PdfMetadata, SvgToPdfOptions } from "./index.js";
1
+ import type { PdfDiagnostic, PdfMetadata, SlideSelection, SvgSlide, ToPdfOptions, ToSvgOptions } from "./index.js";
2
2
  import type { FontsHandle } from "./fonts.js";
3
3
  export type { PdfDiagnostic, PdfMetadata } from "./index.js";
4
4
 
5
- export interface BrowserSvgToPngOptions {
5
+ /** The options of `toPng` in a browser: the canvas's own, and the `toSvg` options that draw a deck (`fonts` is the browser handle). */
6
+ export interface BrowserToPngOptions extends ToSvgOptions {
6
7
  /** Multiplies the SVG's own size (default 1). The result is limited to 40 megapixels. */
7
8
  scale?: number;
8
9
  /** A CSS colour painted first (default white); `"transparent"` keeps the alpha channel. */
@@ -10,18 +11,19 @@ export interface BrowserSvgToPngOptions {
10
11
  signal?: AbortSignal;
11
12
  }
12
13
 
13
- export interface BrowserSvgToPdfOptions extends Pick<SvgToPdfOptions, "mode" | "background" | "serifFamily" | "metadata" | "tagged" | "compress" | "strict" | "rasterFallbackScale" | "defaultFontFamily" | "sansSerifFamily" | "monospaceFamily" | "onDiagnostic"> {
14
- /** Raster mode: pixel density of each page image (default 2). */
14
+ /** The options of `toPdf` in a browser: the PDF's own, and the `toSvg` options that draw a deck. */
15
+ export interface BrowserToPdfOptions extends Omit<ToSvgOptions, "fonts" | "onDiagnostic">, Pick<ToPdfOptions, "raster" | "background" | "serifFamily" | "metadata" | "tagged" | "compress" | "strict" | "rasterFallbackScale" | "defaultFontFamily" | "sansSerifFamily" | "monospaceFamily" | "onDiagnostic"> {
16
+ /** `raster: true`: pixel density of each page image (default 2). */
15
17
  scale?: number;
16
18
  /**
17
- * Raster mode only: the pdf-lib module (`import * as pdfLib from "pdf-lib"`), an optional peer of the renderer that this entry never imports itself, so a
18
- * bundle of it holds no PDF library. Without it raster mode tries to import "pdf-lib" by name and rejects with `OPFRenderError` `converter-missing`
19
- * when the page cannot. The default vector mode needs no option.
19
+ * `raster: true` only: the pdf-lib module (`import * as pdfLib from "pdf-lib"`), an optional peer of the renderer that this entry never imports itself, so a
20
+ * bundle of it holds no PDF library. Without it a raster PDF tries to import "pdf-lib" by name and rejects with `OPFRenderError` `converter-missing`
21
+ * when the page cannot. The default vector PDF needs no option.
20
22
  */
21
23
  pdfLib?: { PDFDocument: { create(options?: { updateMetadata?: boolean }): Promise<any> } };
22
24
  /**
23
- * The browser fonts handle (`loadFonts` from `/fonts-browser`): every face it holds can be embedded in the PDF, including script faces
24
- * a standalone SVG does not carry. Faces an SVG embeds as `@font-face` data (the `embeddedFonts` of the handle `renderSvg` was given) need no entry.
25
+ * The browser fonts handle (`loadFonts` from `/fonts-browser`): it draws a deck, and every face it holds can be embedded in the PDF, including
26
+ * script faces a standalone SVG does not carry. Faces an SVG embeds as `@font-face` data (the `embeddedFonts` of the handle `toSvg` was given) need no entry.
25
27
  */
26
28
  fonts?: FontsHandle;
27
29
  /** Extra font faces as bytes (TrueType outlines) when there is no handle. */
@@ -32,7 +34,15 @@ export interface BrowserSvgToPdfOptions extends Pick<SvgToPdfOptions, "mode" | "
32
34
  onProgress?: (progress: { page: number; pages: number }) => void;
33
35
  }
34
36
 
35
- export declare function svgToPng(svg: string | Uint8Array, options?: BrowserSvgToPngOptions): Promise<Uint8Array>;
36
- export declare function svgToPdf(svgs: string | Uint8Array | Array<string | Uint8Array>, options?: BrowserSvgToPdfOptions): Promise<Uint8Array>;
37
+ /** PNG with a canvas (RR-73): the arguments and results of the Node `toPng`. */
38
+ export declare function toPng(svg: Uint8Array | `<${string}`, options?: BrowserToPngOptions): Promise<Uint8Array>;
39
+ export declare function toPng(svgs: readonly SvgSlide[], options?: BrowserToPngOptions): Promise<Uint8Array[]>;
40
+ export declare function toPng(deck: unknown, slide: number, options?: BrowserToPngOptions): Promise<Uint8Array>;
41
+ export declare function toPng(deck: unknown, slides: Exclude<SlideSelection, number>, options?: BrowserToPngOptions): Promise<Uint8Array[]>;
42
+ export declare function toPng(deck: object, options?: BrowserToPngOptions): Promise<Uint8Array[]>;
43
+ export declare function toPng(source: string, options?: BrowserToPngOptions): Promise<Uint8Array | Uint8Array[]>;
44
+ /** One PDF (RR-73), a page per slide, of a deck drawn with `toSvg` (every slide or a selection) or of SVG slides. */
45
+ export declare function toPdf(source: unknown, options?: BrowserToPdfOptions): Promise<Uint8Array>;
46
+ export declare function toPdf(deck: unknown, slides: SlideSelection, options?: BrowserToPdfOptions): Promise<Uint8Array>;
37
47
  /** Format of an embedded picture and, for a JPEG, its size, component count and EXIF orientation. */
38
48
  export declare function sniffImage(bytes: Uint8Array): { format: "png" | "webp" | "gif" | "jpeg" | undefined; width?: number; height?: number; channels?: number; orientation?: number; space?: string };
@@ -1,22 +1,35 @@
1
1
  import { svgsToVectorPdf } from "./pdf-vector.js";
2
- import { OPFRenderError, packageName } from "./svg.js";
2
+ import { OPFRenderError, packageName, toSvg } from "./svg.js";
3
+ import { slideArguments, slideSources } from "./slide-sources.js";
3
4
 
4
- // RR-23: PNG and PDF output in a browser. `svgToPng` and `svgToPdf` of the root entry need Node (resvg, sharp); this entry
5
+ // RR-23: PNG and PDF output in a browser. `toPng` and `toPdf` of the root entry need Node (resvg, sharp); this entry
5
6
  // has the same two names for a page. It uses the same vector PDF converter as Node (fontkit, bidi-js and pako, all pure
6
7
  // JavaScript) over the SVG the preview draws, so the text stays real text in the same embedded subsets. What differs from
7
8
  // Node is only how pixels are made: a canvas decodes pictures and draws PNGs. No system font is read (a browser has no way
8
9
  // to hand one over) and nothing is fetched: faces come from the SVG's own @font-face data (the `embeddedFonts` of the fonts handle
9
- // `renderSvg` was given), from the faces the `fonts` handle holds (`loadFonts` from `/fonts-browser`, so script faces the SVG does not
10
+ // `toSvg` was given), from the faces the `fonts` handle holds (`loadFonts` from `/fonts-browser`, so script faces the SVG does not
10
11
  // embed reach the PDF too), or from `fontData`.
11
12
  //
12
- // RR-63: this entry imports no converter. The one optional piece is pdf-lib, for `mode: "raster"` only (the default vector PDF
13
+ // RR-63: this entry imports no converter. The one optional piece is pdf-lib, for `raster: true` only (the default vector PDF
13
14
  // needs nothing): the host imports it and passes it as `options.pdfLib`, so a bundler never has to resolve it.
14
15
 
15
16
  const DEFAULT_DIMENSIONS = { width: 1280, height: 720 };
16
17
  const MAX_PIXELS = 40_000_000;
17
18
 
18
- /** Convert one SVG to PNG bytes with a canvas. `scale` multiplies the SVG's own size; `background` is a CSS colour (default white, `"transparent"` for none). */
19
- export async function svgToPng(svg, options = {}) {
19
+ /**
20
+ * PNG with a canvas (RR-73): a deck drawn with `toSvg` (all slides, a slide number or a selection, as for the Node `toPng`) or SVG
21
+ * the renderer drew; one PNG for a slide number or one SVG, a list otherwise. `scale` multiplies the SVG's own size; `background`
22
+ * is a CSS colour (default white, `"transparent"` for none).
23
+ */
24
+ export async function toPng(source, slides, options) {
25
+ ({ slides, options } = slideArguments(slides, options));
26
+ const { svgs, one } = slideSources(source, slides, options, toSvg);
27
+ const pngs = [];
28
+ for (const svg of svgs) pngs.push(await svgPng(svg, options));
29
+ return one ? pngs[0] : pngs;
30
+ }
31
+
32
+ async function svgPng(svg, options = {}) {
20
33
  const text = svgText(svg);
21
34
  const size = pageSize(text);
22
35
  const scale = positive(options.scale, "scale", 1);
@@ -32,19 +45,19 @@ export async function svgToPng(svg, options = {}) {
32
45
  }
33
46
 
34
47
  /**
35
- * Convert SVG slides to a PDF, one slide per page (1 SVG pixel is 1 PDF point, as in Node). `mode: "vector"` (default) writes
36
- * real text, paths, gradients and images; `"raster"` draws each slide as an image (`scale`, default 2). Takes the options of
37
- * the Node `svgToPdf` that make sense here (`metadata`, `tagged`, `strict`, `compress`, `onDiagnostic`, the generic family
38
- * names, `rasterFallbackScale`), `pdfLib` (raster mode only: `import * as pdfLib from "pdf-lib"`), `fonts` (the handle `loadFonts()` returns: every face it holds can be embedded), `fontData`
48
+ * One PDF (RR-73) of a deck drawn with `toSvg` (all slides or a selection) or of SVG slides, one slide per page (1 SVG pixel is 1
49
+ * PDF point, as in Node). Vector by default (real text, paths, gradients and images); `raster: true` draws each slide as an image
50
+ * (`scale`, default 2). Takes the options of the Node `toPdf` that make sense here (`metadata`, `tagged`, `strict`, `compress`,
51
+ * `onDiagnostic`, the generic family names, `rasterFallbackScale`), `pdfLib` (`raster: true` only: `import * as pdfLib from "pdf-lib"`), `fonts` (the handle `loadFonts()` returns: every face it holds can be embedded), `fontData`
39
52
  * (`[{ data, family? }]`, extra face bytes without a handle), `signal` (an AbortSignal, checked between pages) and
40
53
  * `onProgress({ page, pages })`.
41
54
  */
42
- export async function svgToPdf(svgs, options = {}) {
43
- const inputs = (Array.isArray(svgs) ? svgs : [svgs]).map(svgText);
44
- if (!inputs.length) throw new OPFRenderError("empty-pdf", "svgToPdf requires at least one SVG slide.");
45
- const mode = options.mode ?? "vector";
46
- if (mode !== "vector" && mode !== "raster") throw new OPFRenderError("invalid-conversion-option", 'mode must be "vector" or "raster".', { option: "mode", value: mode });
47
- if (mode === "raster") return rasterPdf(inputs, options);
55
+ export async function toPdf(source, slides, options) {
56
+ ({ slides, options } = slideArguments(slides, options));
57
+ const inputs = slideSources(source, slides, options, toSvg).svgs.map(svgText);
58
+ if (!inputs.length) throw new OPFRenderError("empty-pdf", "toPdf needs at least one slide.");
59
+ if (options.raster !== undefined && typeof options.raster !== "boolean") throw new OPFRenderError("invalid-conversion-option", "raster must be true or false.", { option: "raster", value: options.raster });
60
+ if (options.raster === true) return rasterPdf(inputs, options);
48
61
  const scale = positive(options.rasterFallbackScale, "rasterFallbackScale", 2);
49
62
  const fontCss = fontFaceCss(inputs);
50
63
  return svgsToVectorPdf(inputs, {
@@ -68,7 +81,7 @@ export async function svgToPdf(svgs, options = {}) {
68
81
  producer: packageName,
69
82
  ErrorClass: OPFRenderError,
70
83
  // The element's own text has no fonts in a standalone SVG image: give it the faces the slides embed.
71
- rasterize: async (svg, factor) => ({ png: await svgToPng(fontCss && /<text[\s>]/.test(svg) ? svg.replace(/(<svg\b[^>]*>)/, `$1<style>${fontCss}</style>`) : svg, { scale: factor, background: "transparent", signal: options.signal }) }),
84
+ rasterize: async (svg, factor) => ({ png: await svgPng(fontCss && /<text[\s>]/.test(svg) ? svg.replace(/(<svg\b[^>]*>)/, `$1<style>${fontCss}</style>`) : svg, { scale: factor, background: "transparent", signal: options.signal }) }),
72
85
  });
73
86
  }
74
87
 
@@ -81,7 +94,7 @@ async function rasterPdf(inputs, options) {
81
94
  for (const [index, svg] of inputs.entries()) {
82
95
  options.signal?.throwIfAborted?.();
83
96
  const size = pageSize(svg);
84
- const png = await svgToPng(svg, { scale, background: options.background ?? "#FFFFFF", signal: options.signal });
97
+ const png = await svgPng(svg, { scale, background: options.background ?? "#FFFFFF", signal: options.signal });
85
98
  const image = await pdf.embedPng(png);
86
99
  pdf.addPage([size.width, size.height]).drawImage(image, { x: 0, y: 0, width: size.width, height: size.height });
87
100
  options.onProgress?.({ page: index + 1, pages: inputs.length });
@@ -90,14 +103,14 @@ async function rasterPdf(inputs, options) {
90
103
  return pdf.save({ addDefaultPage: false, useObjectStreams: false });
91
104
  }
92
105
 
93
- // pdf-lib for raster mode when the host did not pass it. The specifier is not a literal, so a browser bundler leaves it alone (and
106
+ // pdf-lib for `raster: true` when the host did not pass it. The specifier is not a literal, so a browser bundler leaves it alone (and
94
107
  // never fails for a host that does not install pdf-lib); it resolves where the page can import it by name, for example with an import map.
95
108
  async function importPdfLib() {
96
109
  const name = "pdf-lib";
97
110
  try {
98
111
  return await import(/* webpackIgnore: true */ /* @vite-ignore */ name);
99
112
  } catch (error) {
100
- throw new OPFRenderError("converter-missing", 'Raster-mode PDF output needs pdf-lib, an optional peer dependency of @openpresentation/opf-render: run `npm install pdf-lib@^1.17.1` and pass it as the pdfLib option (import * as pdfLib from "pdf-lib"). The default vector mode needs nothing.', { package: "pdf-lib", range: "^1.17.1", install: "npm install pdf-lib@^1.17.1", cause: error instanceof Error ? error.message : String(error) });
113
+ throw new OPFRenderError("converter-missing", 'A raster PDF (raster: true) needs pdf-lib, an optional peer dependency of @openpresentation/opf-render: run `npm install pdf-lib@^1.17.1` and pass it as the pdfLib option (import * as pdfLib from "pdf-lib"). The default vector PDF needs nothing.', { package: "pdf-lib", range: "^1.17.1", install: "npm install pdf-lib@^1.17.1", cause: error instanceof Error ? error.message : String(error) });
101
114
  }
102
115
  }
103
116
 
@@ -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 `toSvg` 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
+ }