@openpresentation/opf-render 0.13.1 → 0.15.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.d.ts CHANGED
@@ -1,4 +1,5 @@
1
- import type { BrowserFontRegistry } from "./fonts-browser.js";
1
+ import type { BrowserFontsHandle } from "./fonts-browser.js";
2
+ import type { RenderFonts } from "./fonts.js";
2
3
  import type { RenderSvgOptions } from "./svg.js";
3
4
  import type { PlayerSession, PresentOptions, PlayerSlideChange } from "./player.js";
4
5
  export { DeckError } from "./player.js";
@@ -32,10 +33,13 @@ export interface OpfDeckElement extends HTMLElement {
32
33
  /** The 1-based number of the showing slide in the sequence that plays; set it to navigate. */
33
34
  slide: number;
34
35
  readonly total: number;
35
- fonts: string;
36
- /** A browser font registry the page already has. Wins over `fonts`. */
37
- fontRegistry: BrowserFontRegistry | undefined;
38
- /** Extra `renderSvg` options (catalogs, imageResolver, date, ...). Set before the deck loads, or call `reload()`. */
36
+ /**
37
+ * The fonts the deck draws with: the URL of the self-hosted font root (the `fonts` attribute), or a browser fonts handle the page
38
+ * already has (`loadFonts` from `/fonts-browser`), which wins over the attribute. The getter returns the handle when one is set, else the
39
+ * attribute's URL (an empty string without either).
40
+ */
41
+ fonts: string | BrowserFontsHandle;
42
+ /** Extra `renderSlideSvg` options (catalogs, imageResolver, date, ...). Set before the deck loads, or call `reload()`. */
39
43
  renderOptions: Partial<RenderSvgOptions>;
40
44
  includeHidden: boolean;
41
45
  thumbnails: boolean;
@@ -65,7 +69,8 @@ export interface RenderDeckHtmlOptions {
65
69
  includeHidden?: boolean;
66
70
  src?: string;
67
71
  slide?: number;
68
- fonts?: string;
72
+ /** A string is the font root URL written to the tag's `fonts` attribute; an object (a `loadFonts()` handle, or any `RenderFonts`) draws the markup with those fonts and writes no attribute. */
73
+ fonts?: string | RenderFonts;
69
74
  label?: string;
70
75
  thumbnails?: boolean;
71
76
  present?: boolean;
package/dist/element.js CHANGED
@@ -6,7 +6,7 @@ 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 { renderSvg } from "./svg.js";
9
+ import { renderSlideSvg } from "./svg.js";
10
10
 
11
11
  export { DeckError } from "./deck-runtime.js";
12
12
  export { loadPreviewFonts, previewBaseFaces, previewFontLayout } from "./preview-fonts.js";
@@ -74,7 +74,7 @@ function createElementClass() {
74
74
  return class OpfDeckElement extends Base {
75
75
  static get observedAttributes() { return ["src", "slide", "fonts", "thumbnails", "controls", "present", "include-hidden", "label", "keyboard"]; }
76
76
 
77
- #root; #parts = {}; #built = false; #store; #cursor; #deck; #generation = 0; #abort; #documentValue; #fontRegistry; #renderOptions = {};
77
+ #root; #parts = {}; #built = false; #store; #cursor; #deck; #generation = 0; #abort; #documentValue; #fonts; #renderOptions = {};
78
78
  #readyPromise = Promise.resolve(); #state = "idle"; #error; #observer; #thumbsBuilt = false; #pointer; #session; #announce = false;
79
79
  #slideAttribute; #sheet;
80
80
 
@@ -124,13 +124,20 @@ function createElementClass() {
124
124
  /** Number of slides in the sequence that plays. */
125
125
  get total() { return this.#cursor?.total ?? 0; }
126
126
 
127
- /** URL of the self-hosted font root (see the package README). Without it, layout uses estimated widths and system fonts. */
128
- get fonts() { return this.getAttribute("fonts") ?? ""; }
129
- set fonts(value) { if (value) this.setAttribute("fonts", String(value)); else this.removeAttribute("fonts"); }
130
-
131
- /** A browser font registry the page already has (`loadBrowserFontRegistry`). Wins over `fonts`. */
132
- get fontRegistry() { return this.#fontRegistry; }
133
- set fontRegistry(value) { this.#fontRegistry = value || undefined; if (this.isConnected && this.#built) this.#start(); }
127
+ /**
128
+ * The fonts the deck draws with: the URL of the self-hosted font root (the `fonts` attribute; see the package README), or a
129
+ * browser fonts handle the page already has (`loadFonts` from `/fonts-browser`), which wins over the attribute. Without either,
130
+ * layout uses estimated widths and system fonts.
131
+ */
132
+ get fonts() { return this.#fonts ?? this.getAttribute("fonts") ?? ""; }
133
+ set fonts(value) {
134
+ if (value && typeof value === "object") { this.#fonts = value; if (this.isConnected && this.#built) this.#start(); return; }
135
+ const had = this.#fonts !== undefined, before = this.getAttribute("fonts");
136
+ this.#fonts = undefined;
137
+ if (value) this.setAttribute("fonts", String(value)); else this.removeAttribute("fonts");
138
+ // Dropping a handle changes what draws even when the attribute keeps its value.
139
+ if (had && before === this.getAttribute("fonts") && this.isConnected && this.#built) this.#start();
140
+ }
134
141
 
135
142
  /** Extra `renderSvg` options (catalogs, imageResolver, date, ...). Set before the deck loads, or call `reload()`. */
136
143
  get renderOptions() { return this.#renderOptions; }
@@ -165,7 +172,7 @@ function createElementClass() {
165
172
  if (!this.#store || !this.#cursor) throw new DeckError("not-ready", "The deck is not loaded yet.");
166
173
  const { present } = await import("./player.js");
167
174
  const session = await present(this.#deck, {
168
- store: this.#store, fonts: this.#fontRegistry ?? this.#resolvedRegistry, renderOptions: this.#renderOptions,
175
+ store: this.#store, fonts: this.#fonts ?? this.#resolvedFonts, renderOptions: this.#renderOptions,
169
176
  includeHidden: this.includeHidden, startSlide: this.#cursor.number, returnFocus: this.#parts.viewport, ...options,
170
177
  });
171
178
  this.#session = session;
@@ -182,7 +189,7 @@ function createElementClass() {
182
189
 
183
190
  // --- loading ----------------------------------------------------------------------------------------------------
184
191
 
185
- #resolvedRegistry;
192
+ #resolvedFonts;
186
193
 
187
194
  #start() {
188
195
  this.#abort?.abort();
@@ -202,22 +209,22 @@ function createElementClass() {
202
209
  const deck = await this.#readDocument(signal);
203
210
  if (stale()) return;
204
211
  parseDeckDocument(deck);
205
- let registry = this.#fontRegistry;
206
- const root = this.fonts;
212
+ let registry = this.#fonts;
213
+ const root = this.getAttribute("fonts");
207
214
  if (!registry && root) {
208
215
  try { registry = await loadPreviewFonts(root, { signal }); } catch (cause) {
209
216
  if (stale()) return;
210
217
  this.#report(new DeckError("fonts-unavailable", `The fonts at ${root} could not be loaded; the deck draws with estimated layout and system fonts.`, { cause }), false);
211
218
  }
212
219
  }
213
- this.#resolvedRegistry = registry;
220
+ this.#resolvedFonts = registry;
214
221
  const date = this.#renderOptions.date ?? localIsoDate();
215
222
  let store = createDeckStore({ document: deck, fonts: registry, renderOptions: this.#renderOptions, date });
216
223
  try { await store.ready(signal); } catch (cause) {
217
224
  if (stale()) return;
218
225
  if (!registry) throw cause;
219
226
  this.#report(cause, false);
220
- this.#resolvedRegistry = undefined;
227
+ this.#resolvedFonts = undefined;
221
228
  store = createDeckStore({ document: deck, renderOptions: this.#renderOptions, date });
222
229
  }
223
230
  if (stale()) return;
@@ -491,7 +498,9 @@ const escapeHtml = (value) => String(value).replace(/&/g, "&amp;").replace(/</g,
491
498
  /**
492
499
  * Server-side markup for a deck: the slides as inline SVG inside the tag, which a page that has not loaded JavaScript (or a
493
500
  * crawler, or a reader) shows as it is, and which the element replaces with its own shadow DOM when it upgrades. Pure and
494
- * synchronous; layout uses estimated widths unless you pass `renderOptions.textMeasurement` (and `embeddedFonts`).
501
+ * synchronous; layout uses estimated widths unless you pass a fonts handle as `fonts` (an object: `loadFonts()` from
502
+ * `/fonts-browser`, or one with a `textMeasurement` and `embeddedFonts`). A `fonts` string is the font root URL written to the
503
+ * element's `fonts` attribute.
495
504
  *
496
505
  * `slides`: `"first"` (default: the first slide plus a list of titles), `"all"` or a list of 1-based slide numbers.
497
506
  * `embed` puts the document in an `application/opf+json` script child, so the element upgrades without a request.
@@ -502,13 +511,14 @@ export function renderDeckHtml(input, options = {}) {
502
511
  const sequence = presentableIndexes(deck, { includeHidden: options.includeHidden });
503
512
  const wanted = options.slides ?? "first";
504
513
  const shown = wanted === "all" ? sequence.map((_, position) => position) : wanted === "first" ? [0] : wanted.map((number) => number - 1).filter((position) => position >= 0 && position < sequence.length);
505
- const attributes = { src: options.src, slide: options.slide, fonts: options.fonts, label: options.label, ...options.attributes };
514
+ const handle = options.fonts !== null && typeof options.fonts === "object" ? options.fonts : undefined;
515
+ const attributes = { src: options.src, slide: options.slide, fonts: handle ? undefined : options.fonts, label: options.label, ...options.attributes };
506
516
  const attributeText = Object.entries(attributes).filter(([, value]) => value !== undefined && value !== false).map(([name, value]) => (value === true ? ` ${name}` : ` ${name}="${escapeHtml(value)}"`)).join("")
507
517
  + (options.thumbnails ? " thumbnails" : "") + (options.present ? " present" : "") + (options.includeHidden ? " include-hidden" : "");
508
518
  const date = options.renderOptions?.date ?? options.date;
509
519
  const figures = shown.map((position) => {
510
520
  const index = sequence[position];
511
- const svg = renderSvg(deck, { ...options.renderOptions, ...(date ? { date } : {}), slideIndex: index });
521
+ const svg = renderSlideSvg(deck, index, { ...options.renderOptions, ...(handle ? { fonts: handle } : {}), ...(date ? { date } : {}) });
512
522
  const { width, height } = svgDimensions(svg);
513
523
  const prepared = prepareSlideSvg(svg).replace(/^<svg\b/, `<svg style="display:block;width:100%;height:auto;aspect-ratio:${width} / ${height}"`);
514
524
  const label = slideLabel(slideInfo(deck, index), position + 1, sequence.length);
@@ -1,4 +1,5 @@
1
1
  import type { PdfDiagnostic, PdfMetadata, SvgToPdfOptions } from "./index.js";
2
+ import type { FontsHandle } from "./fonts.js";
2
3
  export type { PdfDiagnostic, PdfMetadata } from "./index.js";
3
4
 
4
5
  export interface BrowserSvgToPngOptions {
@@ -12,7 +13,12 @@ export interface BrowserSvgToPngOptions {
12
13
  export interface BrowserSvgToPdfOptions extends Pick<SvgToPdfOptions, "mode" | "background" | "serifFamily" | "metadata" | "tagged" | "compress" | "strict" | "rasterFallbackScale" | "defaultFontFamily" | "sansSerifFamily" | "monospaceFamily" | "onDiagnostic"> {
13
14
  /** Raster mode: pixel density of each page image (default 2). */
14
15
  scale?: number;
15
- /** Extra font faces as bytes (TrueType outlines). Faces an SVG embeds as `@font-face` data (renderSvg's `embeddedFonts`) need no entry here. */
16
+ /**
17
+ * The browser fonts handle (`loadFonts` from `/fonts-browser`): every face it holds can be embedded in the PDF, including script faces
18
+ * 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.
19
+ */
20
+ fonts?: FontsHandle;
21
+ /** Extra font faces as bytes (TrueType outlines) when there is no handle. */
16
22
  fontData?: Array<{ data: Uint8Array; family?: string }>;
17
23
  /** Checked between pages; an abort rejects with the signal's reason (an `AbortError`). */
18
24
  signal?: AbortSignal;
@@ -5,8 +5,9 @@ import { OPFRenderError, packageName } from "./svg.js";
5
5
  // 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
6
  // JavaScript) over the SVG the preview draws, so the text stays real text in the same embedded subsets. What differs from
7
7
  // 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
- // to hand one over) and nothing is fetched: faces come from the SVG's own @font-face data (renderSvg's `embeddedFonts`) or
9
- // from `fontData`.
8
+ // 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
+ // embed reach the PDF too), or from `fontData`.
10
11
 
11
12
  const DEFAULT_DIMENSIONS = { width: 1280, height: 720 };
12
13
  const MAX_PIXELS = 40_000_000;
@@ -31,21 +32,21 @@ export async function svgToPng(svg, options = {}) {
31
32
  * Convert SVG slides to a PDF, one slide per page (1 SVG pixel is 1 PDF point, as in Node). `mode: "vector"` (default) writes
32
33
  * real text, paths, gradients and images; `"raster"` draws each slide as an image (`scale`, default 2). Takes the options of
33
34
  * the Node `svgToPdf` that make sense here (`metadata`, `tagged`, `strict`, `compress`, `onDiagnostic`, the generic family
34
- * names, `rasterFallbackScale`) and `fontData` (`[{ data, family? }]`, extra face bytes), `signal` (an AbortSignal, checked between
35
- * pages) and `onProgress({ page, pages })`.
35
+ * names, `rasterFallbackScale`), `fonts` (the handle `loadFonts()` returns: every face it holds can be embedded), `fontData`
36
+ * (`[{ data, family? }]`, extra face bytes without a handle), `signal` (an AbortSignal, checked between pages) and
37
+ * `onProgress({ page, pages })`.
36
38
  */
37
39
  export async function svgToPdf(svgs, options = {}) {
38
40
  const inputs = (Array.isArray(svgs) ? svgs : [svgs]).map(svgText);
39
41
  if (!inputs.length) throw new OPFRenderError("empty-pdf", "svgToPdf requires at least one SVG slide.");
40
42
  const mode = options.mode ?? "vector";
41
43
  if (mode !== "vector" && mode !== "raster") throw new OPFRenderError("invalid-conversion-option", 'mode must be "vector" or "raster".', { option: "mode", value: mode });
42
- if (options.loadSystemFonts === true) throw new OPFRenderError("pdf-system-fonts-unsupported", "A browser cannot hand system fonts to the PDF export: supply faces as @font-face data or `fontData`.", { option: "loadSystemFonts" });
43
44
  if (mode === "raster") return rasterPdf(inputs, options);
44
45
  const scale = positive(options.rasterFallbackScale, "rasterFallbackScale", 2);
45
46
  const fontCss = fontFaceCss(inputs);
46
47
  return svgsToVectorPdf(inputs, {
47
48
  fontFiles: [], fontDirs: [],
48
- fontData: Array.isArray(options.fontData) ? options.fontData : [],
49
+ fontData: [...(options.fonts?.registry?.exportFaces?.() ?? []), ...(Array.isArray(options.fontData) ? options.fontData : [])],
49
50
  imageCodec: canvasImageCodec(),
50
51
  defaultFontFamily: options.defaultFontFamily ?? "Roboto",
51
52
  sansSerifFamily: options.sansSerifFamily ?? options.defaultFontFamily ?? "Roboto",
@@ -1,8 +1,8 @@
1
- import { FONT_POLICY } from "./font-policy.js";
1
+ import { FONT_POLICY } from "@openpresentation/opf/font-policy";
2
2
  /** Curated substitution policy. Metric means upstream intent, not universal pixel identity. */
3
3
  const metric = (requestedFamily, substitutes, source, note = "Standard regular, bold, italic and bold italic styles; verify coverage and font versions.") => ({requestedFamily, substitutes, compatibility:"metric", weights:[400,700], source, note});
4
4
  const visual = (requestedFamily, substitutes, note = "Approximate appearance; measure again and expect reflow.") => ({requestedFamily, substitutes, compatibility:"visual", note});
5
- // Rules before FF-31. Families that the OPF font policy table (src/font-policy.js) lists take their
5
+ // Rules before FF-31. Families that the OPF font policy table (core `FONT_POLICY`) lists take their
6
6
  // rule from the table below; the remaining legacy rules keep working for other families.
7
7
  const LEGACY = [
8
8
  metric("Calibri",["Carlito"],"https://github.com/googlefonts/carlito"),
@@ -58,7 +58,7 @@ const describe = replacement => replacement.measured
58
58
  * preview never needs a download. Replacements drive measurement and drawing only; exporters keep
59
59
  * writing the chosen family.
60
60
  */
61
- const FROM_POLICY = FONT_POLICY.filter(row => row.replacement).map(row => ({
61
+ const FROM_POLICY = FONT_POLICY.families.filter(row => row.replacement).map(row => ({
62
62
  requestedFamily: row.family,
63
63
  substitutes: [row.replacement.family, ...(row.alternates ?? [])],
64
64
  compatibility: row.replacement.compatibility,
@@ -80,7 +80,7 @@ export const FONT_COMPATIBILITY = Object.freeze([...FROM_POLICY, ...LEGACY.filte
80
80
  // The row's metric claim is measured that way, so the renderer must shape (fonts.js) and draw (svg.js)
81
81
  // the face that way too. Keyed by the replacement family: a Georgia deck is measured and drawn with
82
82
  // Gelasio, and Gelasio requested by name is treated identically, so measurement and drawing never disagree.
83
- const DISABLED_FEATURES = new Map(FONT_POLICY.filter(row => row.replacement?.disabledFeatures?.length).map(row => [row.replacement.family.toLowerCase(), Object.freeze([...row.replacement.disabledFeatures])]));
83
+ const DISABLED_FEATURES = new Map(FONT_POLICY.families.filter(row => row.replacement?.disabledFeatures?.length).map(row => [row.replacement.family.toLowerCase(), Object.freeze([...row.replacement.disabledFeatures])]));
84
84
  /** OpenType feature tags to turn off for a preview face, from the font policy; undefined when none. */
85
85
  export function disabledFeaturesFor(family) {
86
86
  return typeof family === "string" ? DISABLED_FEATURES.get(family.trim().toLowerCase()) : undefined;
@@ -0,0 +1,324 @@
1
+ import { create } from "fontkit";
2
+ import { FONT_COMPATIBILITY, disabledFeaturesFor } from "./font-compatibility.js";
3
+ import { adjustedFontSize, openTypeLanguage, scriptFontAliases, sizeAdjustFor } from "./script-fonts.js";
4
+ import { fontPolicyFor } from "@openpresentation/opf/font-policy";
5
+ import { BUNDLED_FONT_MANIFEST } from "./font-manifest.js";
6
+ import { SYMBOL_SCRIPT, isSymbolEncodedFamily, symbolEncodingFamily, symbolPreviewFaces } from "./symbol-fonts.js";
7
+
8
+ // The font registry the loaders (`loadFonts` in fonts-node.js and fonts-browser.js) build: faces in, one text measurement out.
9
+ // It is not a public entry point; callers load fonts with `loadFonts` and read `handle.registry`. `/fonts` (fonts.js) re-exports
10
+ // the data and helpers a host may read.
11
+ export class OPFFontError extends Error {
12
+ constructor(code, message, details = {}) { super(message); this.name = "OPFFontError"; this.code = code; this.details = details; }
13
+ }
14
+ const key = (family, weight, italic) => `${family.toLowerCase()}:${weight}:${!!italic}`;
15
+ function base64(data) {
16
+ let binary = "";
17
+ for (let i = 0; i < data.length; i += 32768) binary += String.fromCharCode(...data.subarray(i, i + 32768));
18
+ return btoa(binary);
19
+ }
20
+ function validFamily(family) {
21
+ if (typeof family !== "string" || !family.trim() || /[\u0000-\u001f"'\\<>;]/.test(family)) throw new OPFFontError("invalid-font-family", "Font family must be a nonempty plain name.");
22
+ return family;
23
+ }
24
+ // The open families load with the office pack (loadFonts({pack:"office"})).
25
+
26
+ const packFor = family => { const pack = BUNDLED_FONT_MANIFEST.packages.find(pkg=>pkg.renamedFrom?.toLowerCase()===family.toLowerCase() || pkg.faces.some(face=>face.family.toLowerCase()===family.toLowerCase()))?.pack; return pack==="open" ? "office" : pack; };
27
+ const percent = value => `${(value*100).toFixed(1)}%`;
28
+ /** FF-31: say why a family has no face and what the caller can do, from the OPF font policy. */
29
+ function unavailableFontError(family, style, policy) {
30
+ const row = fontPolicyFor(family), details = {path:style.path, fontFamily:family, substitutionPolicy:policy};
31
+ const hook = `supply licensed '${family}' font files (loadFonts({faces}))`;
32
+ if (!row) return new OPFFontError("font-unavailable", `No local font face for '${family}'. '${family}' is not in the OPF font policy table, so there is no declared replacement: ${hook}, add an alias, or set fallbackFamily.`, {...details, licenseClass:"unknown"});
33
+ Object.assign(details, {licenseClass:row.licenseClass});
34
+ if (!row.replacement) {
35
+ const pack = packFor(family);
36
+ return new OPFFontError("font-unavailable", `No local font face for '${family}'. '${family}' is ${row.licenseClass === "open" ? `openly licensed (${row.license})` : row.license}${pack ? `; load the '${pack}' font pack` : `; no pinned renderer pack ships it yet, so supply its font files (for example from @expo-google-fonts/${family.toLowerCase().replace(/ +/g,"-")})`}.`, {...details, ...(pack?{pack}:{})});
37
+ }
38
+ const {family:replacement, compatibility, measured} = row.replacement;
39
+ const candidates = [replacement, ...(row.alternates ?? [])], packs = [...new Set(candidates.map(packFor).filter(Boolean))];
40
+ Object.assign(details, {replacement, replacementCompatibility:compatibility, candidates, ...(packs.length?{packs}:{}), ...(measured?{measured}:{}), ...(row.replacement.decision?{decision:row.replacement.decision}:{})});
41
+ const delta = measured ? ` (measured mean width difference ${percent(measured.meanAbsWidthDelta)}, max ${percent(measured.maxAbsWidthDelta)})` : " (not measured against the real font)";
42
+ const owner = `'${family}' is ${row.license} and OPF never bundles or embeds it.`;
43
+ if (compatibility === "visual" && policy !== "visual")
44
+ return new OPFFontError("font-unavailable", `No local font face for '${family}'. ${owner} Its declared replacement ${replacement} is visual only${delta}, which substitutionPolicy '${policy}' does not allow. Pass substitutionPolicy: 'visual' to preview with ${candidates.join(" or ")}, or ${hook}. The PPTX names '${family}' either way.`, details);
45
+ if (policy === "none")
46
+ return new OPFFontError("font-unavailable", `No local font face for '${family}'. ${owner} Its declared ${compatibility} replacement is ${replacement}${delta}; substitutionPolicy 'none' does not allow it. Pass substitutionPolicy: '${compatibility}', or ${hook}.`, details);
47
+ return new OPFFontError("font-unavailable", `No local font face for '${family}'. ${owner} None of its replacements (${candidates.join(", ")}) is loaded${packs.length ? `; load the ${packs.map(pack=>"'"+pack+"'").join(" or ")} font pack` : ""}, or ${hook}. The PPTX names '${family}' either way.`, details);
48
+ }
49
+ /** Local font files only. The caller explicitly chooses aliases and fallback. */
50
+ // fontkit 2.0.4 cannot decode every OpenType lookup: its GSUB type 8 (reverse chaining contextual single substitution) struct lacks the
51
+ // backtrackGlyphCount field, so the lookup fails with "Not a fixed size" when a feature that references it is applied, and it has no processor
52
+ // for the type anyway. Noto Sans Mongolian 3.x has such a lookup under calt and rclt in every script, so every shaping of the face threw (FF-44).
53
+ // A lookup that cannot be decoded is treated as one that applies nothing (no subtables), so the rest of the feature still runs: advances equal
54
+ // a browser's for the face's Latin and Mongolian samples (test/mongolian-shaping.mjs). Applied only after shaping failed, once per font.
55
+ const lookupsGuarded = new WeakSet();
56
+ export function skipUndecodableLookups(font) {
57
+ if (lookupsGuarded.has(font)) return false;
58
+ lookupsGuarded.add(font);
59
+ let guarded = false;
60
+ for (const tag of ["GSUB", "GPOS"]) {
61
+ let list;
62
+ try { list = font[tag]?.lookupList; } catch { continue; }
63
+ if (typeof list?.get !== "function") continue;
64
+ const get = list.get.bind(list);
65
+ list.get = index => { try { return get(index); } catch { return { lookupType: 1, flags: {}, subTables: [] }; } };
66
+ guarded = true;
67
+ }
68
+ return guarded;
69
+ }
70
+
71
+ /**
72
+ * The face a registry draws for `family` (a validated concrete family: theme tokens are already resolved) in `style`, chosen
73
+ * from `faces` ({family, familyGroup, weight, italic, fallbackOnly?, scripts?}) under the substitution policy, aliases and
74
+ * fallback family, or an OPFFontError when none applies. Shared by the registry's resolution and by the lazy loader's face
75
+ * planning (lazy-fonts.js), so the browser loads exactly the faces Node would draw with everything loaded.
76
+ */
77
+ export function pickFace(faces, family, style, {policy = "none", aliases = new Map(), fallbackFamily} = {}) {
78
+ const findFamily = name => faces.filter(face=>face.family.toLowerCase()===name.toLowerCase() || face.familyGroup.toLowerCase()===name.toLowerCase());
79
+ const weight = style.fontWeight ?? 400;
80
+ let matching = findFamily(family), compatibility = "exact", rule, targetWeight = weight, styleFallback = false, via = "family", encoding;
81
+ // FF-45: a symbol-encoded family (Symbol, Wingdings, Wingdings 2, Wingdings 3, Webdings) whose own face is not loaded
82
+ // draws the Unicode equivalent of each code with the first loaded open symbol face (symbol-fonts.js: Noto Sans Symbols 2,
83
+ // Noto Sans Symbols, Noto Sans Math, or the office pack's Noto Sans). The resolved style carries the encoding so the
84
+ // planner maps every character; the PPTX keeps the family and the original codes. Without any such face the caller's
85
+ // fallback family applies, else the request fails as before (font-encoding-required) and names the pack to load.
86
+ if (!matching.length && isSymbolEncodedFamily(family)) {
87
+ for (const candidate of symbolPreviewFaces(family)) { const pool = findFamily(candidate); if (pool.length) { matching = pool; break; } }
88
+ if (matching.length) { compatibility = "visual"; via = "encoding"; encoding = symbolEncodingFamily(family); }
89
+ else if (!fallbackFamily) throw new OPFFontError("font-encoding-required", `Font '${family}' is symbol-encoded and no open symbol face is loaded: load the symbol faces (loadFonts with scripts: ['${SYMBOL_SCRIPT}'], or scripts: 'auto' with the presentation) or supply '${family}' itself.`, {path:style.path,fontFamily:family,scripts:[SYMBOL_SCRIPT],faces:[...symbolPreviewFaces(family)]});
90
+ }
91
+ if (!matching.length && aliases.has(family.toLowerCase())) {
92
+ matching = findFamily(aliases.get(family.toLowerCase())); compatibility = "visual"; via = "alias";
93
+ }
94
+ if (!matching.length && policy!=="none") {
95
+ const candidate = FONT_COMPATIBILITY.find(entry=>entry.requestedFamily.toLowerCase()===family.toLowerCase());
96
+ const tier = candidate?.compatibility==="metric" && !candidate.weights.includes(weight) ? "visual" : candidate?.compatibility;
97
+ // A declared visual replacement is used in visual mode, and in metric mode only when the
98
+ // policy keeps it as a metric-mode fallback (Cambria -> Caladea); it is still reported visual.
99
+ // In metric mode the fallback applies only where the pre-FF-31 metric rule did: the declared
100
+ // replacement at weights 400 and 700, normal or italic, with an exact face weight.
101
+ const metricFallback = policy!=="visual" && candidate?.metricModeFallback===true && [400,700].includes(weight);
102
+ const allowVisual = policy==="visual" || metricFallback;
103
+ if (candidate && (allowVisual || tier==="metric")) {
104
+ // A family that names its weight (Segoe UI Semibold, Arial Black) selects that weight in
105
+ // the replacement; its bold style link still selects bold (FF-31).
106
+ const wanted = candidate.weight ? (weight>=600 ? Math.max(candidate.weight,700) : candidate.weight) : weight;
107
+ for (const [index, substitute] of candidate.substitutes.entries()) {
108
+ // Only the declared replacement can carry the row's metric claim; an alternate is visual.
109
+ const substituteTier = index===0 ? tier : "visual";
110
+ if (substituteTier==="visual" && !(policy==="visual" || metricFallback && index===0)) continue;
111
+ const exactWeight = substituteTier==="metric" || policy!=="visual";
112
+ const pool = findFamily(substitute).filter(face=>!face.fallbackOnly && (!exactWeight || face.weight===weight));
113
+ let available = pool.filter(face=>face.italic===!!style.italic);
114
+ // Visual replacements without the requested style draw the other one and say so.
115
+ if (!available.length && substituteTier==="visual" && policy==="visual" && pool.length) { available = pool.filter(face=>!face.italic); styleFallback = available.length>0; }
116
+ if (available.length) { matching=available; compatibility=substituteTier; rule={...candidate, substituteIndex:index}; targetWeight=wanted; via="replacement"; break; }
117
+ }
118
+ }
119
+ }
120
+ // FF-45: Cambria Math previews with STIX Two Math (the math pack, loaded as a script face: the alias above resolves it
121
+ // under every policy). Without the pack the policy error below names the pack; the former `math-font-required` failure is gone.
122
+ if (!matching.length && fallbackFamily) { matching=findFamily(fallbackFamily); compatibility="generic"; via="fallback"; }
123
+ if (!matching.length) throw unavailableFontError(family, style, policy);
124
+ const styled = styleFallback ? matching : matching.filter(face=>face.italic===!!style.italic);
125
+ // Script replacement faces have no italics; use upright glyphs and advances. RR-17 (FF-41): so does a visual preview of a
126
+ // single-style display face (Anton, Bebas Neue: the families Impact and the open font schemes name), reported visual; PowerPoint
127
+ // synthesizes the slant of such a face too, so the advances agree and the preview no longer refuses the deck. A strict registry
128
+ // (policy none) still reports font-style-unavailable.
129
+ if (!styled.length && style.italic && matching.length && (matching.every(face=>face.scripts) || policy==="visual" && matching.every(face=>!face.italic))) { if (compatibility!=="generic") compatibility="visual"; }
130
+ else matching = styled;
131
+ if (!matching.length) throw new OPFFontError("font-style-unavailable", `No ${style.italic ? "italic" : "upright"} face for '${family}'.`, {path:style.path});
132
+ matching.sort((a,b)=>Math.abs(a.weight-targetWeight)-Math.abs(b.weight-targetWeight) || a.weight-b.weight);
133
+ const face = matching[0];
134
+ if ((face.weight!==targetWeight || styleFallback) && compatibility!=="generic") compatibility="visual";
135
+ return {face, compatibility, rule, styleFallback, via, ...(encoding ? {encoding} : {})};
136
+ }
137
+
138
+ export function createFontRegistry(entries, options = {}) {
139
+ if (!Array.isArray(entries) || !entries.length) throw new OPFFontError("empty-font-registry", "Supply at least one font file.");
140
+ const makeFace = entry => {
141
+ if (!(entry.data instanceof Uint8Array)) throw new OPFFontError("invalid-font-data", "Font data must be a Uint8Array.");
142
+ const data = entry.data.slice();
143
+ let font;
144
+ try { font = create(data, entry.postscriptName); } catch (error) { throw new OPFFontError("invalid-font-data", error.message); }
145
+ if (!font?.layout || !font.unitsPerEm) throw new OPFFontError("font-collection", "Select one font from a collection with postscriptName.");
146
+ const family = validFamily(entry.family ?? font.familyName);
147
+ // OpenType groups Medium/SemiBold/ExtraBold under a preferred family, while
148
+ // retaining separate legacy families for four-style native font selectors.
149
+ // An explicit caller rename owns its namespace and must not add this alias.
150
+ const familyGroup = entry.family && entry.family.toLowerCase() !== font.familyName?.toLowerCase()
151
+ ? family : validFamily(font.getName?.('preferredFamily', 'en') ?? family);
152
+ const linking = font['OS/2']?.fsSelection ?? font.head?.macStyle;
153
+ const fontFace = linking && typeof linking.bold === 'boolean' && typeof linking.italic === 'boolean'
154
+ ? {family:validFamily(font.getName?.('fontFamily', 'en') ?? font.familyName ?? family),bold:linking.bold,italic:linking.italic} : undefined;
155
+ const weight = entry.weight ?? 400;
156
+ const italic = entry.italic ?? !!font.italicAngle;
157
+ if (!Number.isInteger(weight) || weight < 1 || weight > 1000) throw new OPFFontError("invalid-font-weight", "Font weight must be between 1 and 1000.");
158
+ const signature = String.fromCharCode(...data.subarray(0,4));
159
+ const format = signature === "OTTO" ? "otf" : signature === "wOFF" ? "woff" : signature === "wOF2" ? "woff2" : "ttf";
160
+ // Script replacement faces (FF-19) declare the ISO 15924 scripts they serve.
161
+ const scripts = Array.isArray(entry.scripts) && entry.scripts.length ? Object.freeze(entry.scripts.map(String)) : undefined;
162
+ // "used": a bundled face is embedded in an SVG only when the slide's text names its family.
163
+ if (entry.embed !== undefined && entry.embed !== "always" && entry.embed !== "used") throw new OPFFontError("invalid-font-embed", "Font embed must be 'always' or 'used'.");
164
+ // A fallback-only face (the default Noto Sans) serves glyph fallback and explicit requests by its own name,
165
+ // but never stands in for another family, so loading it does not change what any other font previews with.
166
+ return {family,familyGroup,fontFace,weight,italic,font,data,format,license:entry.license,embed:entry.embed,scripts,fallbackOnly:entry.fallbackOnly===true,cache:new Map()};
167
+ };
168
+ const faces = [], duplicates = new Set(), familySlots = new Map();
169
+ // Adds faces atomically: every check runs before any face is registered.
170
+ const register = added => {
171
+ const ids = new Set(duplicates), slots = new Map(familySlots);
172
+ for (const face of added) {
173
+ const id = key(face.family,face.weight,face.italic);
174
+ if (ids.has(id)) throw new OPFFontError("duplicate-font-face", `Duplicate face: ${id}`);
175
+ ids.add(id);
176
+ }
177
+ for (const face of added) for (const family of new Set([face.family,face.familyGroup])) {
178
+ const id=key(family,face.weight,face.italic),previous=slots.get(id);
179
+ if(previous && previous!==face) throw new OPFFontError('ambiguous-font-face', `Multiple physical faces match '${family}' at weight ${face.weight}.`, {fontFamily:family,fontWeight:face.weight,italic:face.italic});
180
+ slots.set(id,face);
181
+ }
182
+ faces.push(...added);
183
+ for (const id of ids) duplicates.add(id);
184
+ for (const [id,face] of slots) familySlots.set(id,face);
185
+ };
186
+ register(entries.map(makeFace));
187
+ const substitutions = new Map();
188
+ // Loaded script replacement faces stand in for the proprietary script fonts
189
+ // they replace (Meiryo -> Noto Sans JP). Caller aliases take precedence.
190
+ let aliases = new Map();
191
+ const buildAliases = () => {
192
+ const scriptAliases = scriptFontAliases(new Set(faces.filter(face=>face.scripts&&!face.fallbackOnly).map(face=>face.family)));
193
+ aliases = new Map(Object.entries({...scriptAliases,...options.aliases}).map(([from,to])=>[validFamily(from).toLowerCase(),validFamily(to)]));
194
+ };
195
+ buildAliases();
196
+ const policy = options.substitutionPolicy ?? "none";
197
+ if (!["none","metric","visual"].includes(policy)) throw new OPFFontError("invalid-font-policy", "substitutionPolicy must be none, metric, or visual.");
198
+ if (options.fallbackFamily) validFamily(options.fallbackFamily);
199
+ const resolve = style => {
200
+ const requested = validFamily(style?.fontFamily);
201
+ const weight = style.fontWeight ?? 400;
202
+ if (!Number.isFinite(weight) || weight < 1 || weight > 1000) throw new OPFFontError("invalid-font-weight", "Font weight must be between 1 and 1000.");
203
+ // DrawingML theme tokens are resolved before family lookup, never as literal fonts.
204
+ const themeKey = {"+mj-lt":"majorLatin","+mn-lt":"minorLatin","+mj-ea":"majorEastAsia","+mn-ea":"minorEastAsia","+mj-cs":"majorComplexScript","+mn-cs":"minorComplexScript"}[requested.toLowerCase()];
205
+ if (requested.startsWith("+") && !themeKey) throw new OPFFontError("unresolved-theme-font", `Unknown theme font '${requested}'.`, {path:style.path});
206
+ const family = themeKey ? options.themeFonts?.[themeKey] : requested;
207
+ if (!family || family.startsWith("+")) throw new OPFFontError("unresolved-theme-font", `Supply a concrete theme family for '${requested}'.`, {path:style.path});
208
+ validFamily(family);
209
+ const {face, compatibility, rule, styleFallback, via, encoding} = pickFace(faces, family, style, {policy, aliases, fallbackFamily:options.fallbackFamily});
210
+ // FF-31: `substitute` is true whenever the face is not the chosen family itself (policy
211
+ // replacement, alias, script replacement or fallback). Exporters keep writing sourceFamily.
212
+ const substitute = via!=="family", policyRow = substitute ? fontPolicyFor(family) : undefined;
213
+ // RR-38: a policy row can scale its replacement's size (Arabic Typesetting -> Noto Naskh Arabic: 0.64) so measured and drawn
214
+ // advances approximate the real font's. It holds only for the face the multiplier was measured on.
215
+ const sizeAdjust = sizeAdjustFor(policyRow, face.family);
216
+ const measured = rule?.measured && rule.substituteIndex===0 ? rule.measured : undefined;
217
+ const resolution={requestedFamily:requested,sourceFamily:family,resolvedFamily:face.family,requestedWeight:weight,resolvedWeight:face.weight,italic:face.italic,compatibility,substitute,...(styleFallback?{styleFallback:true}:{}),...(face.fontFace?{fontFace:{...face.fontFace}}:{}),...(style.path?{path:style.path}:{}),...(rule?{source:rule.source,note:rule.note,...(rule.decision?{decision:rule.decision}:{})}:{}),...(measured?{measured:{...measured}}:{}),...(policyRow?{licenseClass:policyRow.licenseClass,availability:[...policyRow.availability]}:{}),...(sizeAdjust?{sizeAdjust}:{}),...(encoding?{symbolEncoding:encoding}:{})};
218
+ if (face.family.toLowerCase()!==family.toLowerCase() || face.weight!==weight || face.italic!==!!style.italic) substitutions.set(JSON.stringify([requested,weight,!!style.italic,style.path]),resolution);
219
+ return {face,resolution};
220
+ };
221
+ const resolveFace = style => resolve(style).face;
222
+ const resolveStyle = style => {
223
+ const {face,resolution}=resolve(style),resolved={...style,fontFamily:face.family,fontWeight:face.weight,italic:face.italic};
224
+ // Never carry a stale selection from a previously resolved style.
225
+ delete resolved.fontFace;
226
+ delete resolved.symbolEncoding;
227
+ if(face.fontFace) resolved.fontFace={...face.fontFace};
228
+ // FF-45: the planner maps a symbol-encoded family's codes (symbol-fonts.js); the face only lends its glyphs.
229
+ if(resolution.symbolEncoding) resolved.symbolEncoding=resolution.symbolEncoding;
230
+ return resolved;
231
+ };
232
+ const metrics = (text,size,style,includeOutline=false) => {
233
+ if (typeof text!=='string'||!Number.isFinite(size)||size<=0) throw new OPFFontError('invalid-text-measurement','Text measurement requires a string and a positive finite font size.');
234
+ const {face,resolution}=resolve(style);
235
+ // A BCP-47 `lang` selects the font's OpenType language system, as a browser
236
+ // does for an element's lang (FF-19). Without it, shaping is unchanged.
237
+ const language=openTypeLanguage(style.lang),key=language?`${language}\u0000${text}`:text;
238
+ let value=face.cache.get(key);
239
+ if (value===undefined) {
240
+ if (options.strictGlyphs!==false) for(const character of text) {
241
+ if (/\p{Default_Ignorable_Code_Point}/u.test(character)) continue;
242
+ const code=character.codePointAt(0);
243
+ if (!face.font.hasGlyphForCodePoint(code)) {
244
+ // No loaded face has it: the script faces for this text are not loaded (glyph fallback needs a face to fall back to).
245
+ const loadedElsewhere=faces.some(other=>other!==face&&other.font.hasGlyphForCodePoint(code));
246
+ throw new OPFFontError("missing-glyph", `Font '${face.family}' cannot display U+${code.toString(16).toUpperCase()}.${loadedElsewhere?"":" No loaded font has it, so glyph fallback has no face to fall back to; if it is a script character, load that script's font (loadFonts with scripts: 'auto' and the presentation, or fonts.ensure(presentation))."}`, {path:style.path,fontFamily:face.family,character,...(loadedElsewhere?{}:{loadedFaceHasGlyph:false})});
247
+ }
248
+ }
249
+ }
250
+ if(value===undefined||includeOutline&&!Object.hasOwn(value,'outline')) {
251
+ // FF-31: features a policy row turns off in its replacement (Gelasio for Georgia: liga, clig), the
252
+ // same ones the SVG output turns off, so measured and drawn advances agree.
253
+ const off=disabledFeaturesFor(face.family),policyFeatures=off&&Object.fromEntries(off.map(tag=>[tag,false]));
254
+ const shape=extra=>{const features=policyFeatures||extra?{...policyFeatures,...extra}:undefined;return language?face.font.layout(text,features,undefined,language):features?face.font.layout(text,features):face.font.layout(text);};
255
+ let run;
256
+ try { run=shape(); }
257
+ catch (error) {
258
+ // A lookup fontkit cannot decode (GSUB type 8 in Noto Sans Mongolian) applies nothing; shape again with the rest (FF-44).
259
+ if (skipUndecodableLookups(face.font)) { try { run=shape(); } catch { /* the mark retry below */ } }
260
+ // fontkit rejects some mark-attachment lookups (null anchors, for example
261
+ // Gurmukhi tippi in Noto Sans Gurmukhi). Mark positioning moves marks
262
+ // without changing advances, so measure again without it (FF-19).
263
+ if (!run) {
264
+ try { run=shape({abvm:false,blwm:false,mark:false,mkmk:false}); }
265
+ catch { throw new OPFFontError("font-shaping-failed", `Font '${face.family}' cannot shape this text.`, {path:style.path,fontFamily:face.family,cause:error?.message}); }
266
+ }
267
+ }
268
+ value={width:run.positions.reduce((total,position)=>total+position.xAdvance,0)/face.font.unitsPerEm,...value};
269
+ if(includeOutline) {
270
+ const unit=face.font.unitsPerEm;
271
+ let bounds=null;
272
+ // FF-45: fontkit reads COLR v0 layers only; a COLRv1 colour face (Noto Color Emoji) has no outlines it can bound (its
273
+ // glyf outlines are empty), so its ink box is the face's em box over the run: the colour image fills the em square.
274
+ try { bounds=run.bbox; } catch { bounds=null; }
275
+ if(bounds&&[bounds.minX,bounds.minY,bounds.maxX,bounds.maxY].every(Number.isFinite)) value.outline={x:bounds.minX/unit,y:-bounds.maxY/unit,width:bounds.width/unit,height:bounds.height/unit};
276
+ else if(face.font.directory?.tables?.COLR&&value.width>0) { const ascent=face.font.ascent/unit,descent=face.font.descent/unit; value.outline={x:0,y:-ascent,width:value.width,height:ascent-descent}; }
277
+ else value.outline=null;
278
+ }
279
+ if (text.length<=2048) {
280
+ if (face.cache.size>=512&&!face.cache.has(key)) face.cache.delete(face.cache.keys().next().value);
281
+ face.cache.set(key,value);
282
+ }
283
+ }
284
+ // RR-38: the replacement is measured at its adjusted size, the one the SVG draws it at.
285
+ return {value,scale:resolution.sizeAdjust??1};
286
+ };
287
+ const measure=(text,size,style)=>{const {value,scale}=metrics(text,size,style);return value.width*adjustedFontSize(size,scale===1?undefined:scale);};
288
+ const outlineBounds=(text,size,style)=>{
289
+ const {value,scale}=metrics(text,size,style,true),bounds=value.outline,drawn=adjustedFontSize(size,scale===1?undefined:scale);
290
+ return bounds===null?null:{x:bounds.x*drawn,y:bounds.y*drawn,width:bounds.width*drawn,height:bounds.height*drawn};
291
+ };
292
+ return {
293
+ // resolveFont lets exporters tell a substitute from the chosen family (FF-31).
294
+ textMeasurement: {measure,resolveStyle,outlineBounds,resolveFont:style=>resolve(style).resolution},
295
+ resolveFont(style) { return resolve(style).resolution; },
296
+ clearSubstitutions() { substitutions.clear(); },
297
+ get substitutions() { return [...substitutions.values()]; },
298
+ // Faces flagged embed:"used" (the open pack, FF-31) are not in this eager list: supplied with selectEmbeddedFonts (or
299
+ // the `loadFonts` handle's embeddedFonts) they are embedded only in SVGs whose text names their family.
300
+ get embeddedFonts() { return embedded(face=>face.embed!=="used"); },
301
+ /** Parsed face metadata in entry order, without encoding font bytes. */
302
+ describeFaces: ()=>faces.map(face=>({family:face.family,weight:face.weight,italic:face.italic,...(face.scripts?{scripts:[...face.scripts]}:{}),...(face.fallbackOnly?{fallbackOnly:true}:{})})),
303
+ /** Embedded faces for which `predicate({family,weight,italic,scripts})` holds; large script faces can be left to raster fontFiles. */
304
+ selectEmbeddedFonts: predicate=>embedded(predicate),
305
+ /** Every face as `{family, data}` (the bytes the registry measures with), for the browser PDF export to embed. */
306
+ exportFaces: ()=>faces.map(face=>({family:face.family,data:face.data})),
307
+ /** True when a loaded script-pack face has a glyph for the character (FF-19 glyph fallback planning). */
308
+ scriptFacesCover: character => faces.some(face => face.scripts && face.font.hasGlyphForCodePoint(character.codePointAt(0))),
309
+ /**
310
+ * Register more faces in this registry (FF-19: script faces are added once a document needs
311
+ * them). Atomic: on an error no face is added. Returns the added faces' metadata. Text
312
+ * measurements planned before the call may not know the new faces; plan again afterwards.
313
+ */
314
+ addFaces(added) {
315
+ if (!Array.isArray(added)) throw new OPFFontError("invalid-font-data", "addFaces requires an array of font entries.");
316
+ const made = added.map(makeFace);
317
+ register(made);
318
+ buildAliases();
319
+ substitutions.clear();
320
+ return made.map(face=>({family:face.family,weight:face.weight,italic:face.italic,...(face.scripts?{scripts:[...face.scripts]}:{})}));
321
+ },
322
+ };
323
+ function embedded(predicate) { return faces.filter(face=>predicate({family:face.family,weight:face.weight,italic:face.italic,scripts:face.scripts,embed:face.embed,fallbackOnly:face.fallbackOnly})).map(face=>({family:face.family,weight:face.weight,italic:face.italic,...(face.license ? {license:face.license} : {}),...(face.embed==="used" ? {embed:"used"} : {}),dataUrl:`data:font/${face.format};base64,${base64(face.data)}`})); }
324
+ }