@openpresentation/opf-render 0.15.0 → 0.16.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.
@@ -25,10 +25,24 @@ async function verifiedFile(file, expected, details) {
25
25
  // The default Latin, Cyrillic and Greek glyph-fallback face (the office pack always loads it, fallback-only).
26
26
  const FALLBACK_PACKAGE = "@expo-google-fonts/noto-sans";
27
27
 
28
+ // RR-63: every `@expo-google-fonts/*` package is an optional peer dependency, so a host installs only the faces it uses. A request that
29
+ // needs packages that are not installed fails once, naming all of them and the install command, instead of one package at a time.
30
+ function requireInstalled(packages, what) {
31
+ const missing = packages.filter(pkg => {
32
+ if (pkg.vendored) return false;
33
+ try { require.resolve(`${pkg.name}/package.json`); return false; } catch { return true; }
34
+ });
35
+ if (!missing.length) return;
36
+ const install = `npm install ${missing.map(pkg => `${pkg.name}@${pkg.version}`).join(" ")}`;
37
+ throw new OPFFontError("font-resource-unavailable", `${what} needs font packages that are not installed (optional peer dependencies of @openpresentation/opf-render): run \`${install}\`.`, {packages: missing.map(pkg => pkg.name), install});
38
+ }
39
+
28
40
  // `skipped`: an optional npm package that is not installed is recorded there instead of failing (scripts: 'auto').
29
41
  // `fallbackOnly`: the faces serve glyph fallback and requests by their own name, never a replacement for another family.
30
- async function loadPackages(packages, skipped, {fallbackOnly = false} = {}) {
42
+ // `what`: how the request is named in the error for a package that is not installed, for example "The 'office' font pack".
43
+ async function loadPackages(packages, skipped, {fallbackOnly = false, what = "This font request"} = {}) {
31
44
  const entries = [], fontFiles = [];
45
+ if (!skipped) requireInstalled(packages, what);
32
46
  for (const pkg of packages) {
33
47
  if (pkg.vendored) {
34
48
  // FF-31: vendored faces ship inside this package (pkg.vendored, for example fonts/carlito), hash-pinned like the npm packs. The open pack is
@@ -53,7 +67,7 @@ async function loadPackages(packages, skipped, {fallbackOnly = false} = {}) {
53
67
  installed = JSON.parse(await readFile(manifestPath, "utf8"));
54
68
  } catch (error) {
55
69
  if (skipped) { skipped.push({package: pkg.name, version: pkg.version, scripts: [...pkg.scripts]}); continue; }
56
- throw new OPFFontError("font-resource-unavailable", `Install ${pkg.name}@${pkg.version} to use this offline font pack.`, {package:pkg.name, cause:error.code});
70
+ throw new OPFFontError("font-resource-unavailable", `Install ${pkg.name}@${pkg.version} to use ${what}.`, {package:pkg.name, cause:error.code});
57
71
  }
58
72
  if (installed.version !== pkg.version) throw new OPFFontError("font-version-mismatch", `Expected ${pkg.name}@${pkg.version}; reinstall the pinned package.`, {package:pkg.name, expected:pkg.version, actual:installed.version});
59
73
  const directory = path.dirname(manifestPath);
@@ -73,7 +87,7 @@ async function loadPackages(packages, skipped, {fallbackOnly = false} = {}) {
73
87
  const renamedAliases = () => Object.fromEntries(BUNDLED_FONT_MANIFEST.packages.filter(item => item.pack === "open" && item.renamedFrom).map(item => [item.renamedFrom, item.faces[0].family]));
74
88
  // The vendored faces this registry holds that are embed "used": a host that serves fonts itself lists them to copy their files.
75
89
  const lazyOf = registry => lazyFontList().filter(face => registry.describeFaces().some(held => held.family === face.family && held.weight === face.weight && held.italic === face.italic));
76
- const loadPack = pack => loadPackages(BUNDLED_FONT_MANIFEST.packages.filter(item => item.pack === pack));
90
+ const loadPack = pack => loadPackages(BUNDLED_FONT_MANIFEST.packages.filter(item => item.pack === pack), undefined, {what: `The '${pack}' font pack`});
77
91
 
78
92
  /**
79
93
  * Adds the script pack. `scripts` is 'all', ISO 15924 codes, or 'auto' (FF-19): detect the scripts the
@@ -86,7 +100,7 @@ async function withScripts(loaded, scripts, {presentation, onDiagnostic, renderO
86
100
  // A package the registry already loaded (the default Noto Sans fallback) is never loaded twice.
87
101
  const notLoaded = list => list.filter(pkg => !loaded.packages?.includes(pkg.name));
88
102
  if (scripts !== "auto") {
89
- const extra = await loadPackages(notLoaded(scriptFontPackages(scripts)));
103
+ const extra = await loadPackages(notLoaded(scriptFontPackages(scripts)), undefined, {what: `The requested script fonts (${Array.isArray(scripts) ? scripts.join(", ") : scripts})`});
90
104
  return {...loaded, entries:[...loaded.entries, ...embedUsed(extra.entries)], fontFiles:[...loaded.fontFiles, ...extra.fontFiles], packages:[...(loaded.packages ?? []), ...extra.packages]};
91
105
  }
92
106
  if (presentation === null || typeof presentation !== "object") throw new OPFFontError("invalid-font-scripts", "scripts: 'auto' needs the presentation whose text decides the scripts.", {scripts});
@@ -160,6 +174,11 @@ async function withFaces(loaded, faces) {
160
174
  */
161
175
  async function buildRegistry(pack, {scripts, faces, presentation, onDiagnostic, renderOptions, ...options}) {
162
176
  let loaded, registryOptions = options;
177
+ // RR-63: name every npm font package the pack needs at once, before loading any of them.
178
+ if (pack === "base" || pack === "office") {
179
+ const packs = pack === "base" ? ["base"] : ["office", ...(options.includeBaseFonts === false ? [] : ["base"])];
180
+ requireInstalled([...BUNDLED_FONT_MANIFEST.packages.filter(item => packs.includes(item.pack)), ...(pack === "office" ? scriptFontPackages(["Latn"]) : [])], `The '${pack}' font pack`);
181
+ }
163
182
  if (pack === "office") {
164
183
  const {entries, fontFiles, packages} = await loadPack("office");
165
184
  for (const [include, name] of [[options.includeOpenFonts, "open"], [options.includeBaseFonts, "base"]]) {
@@ -177,7 +196,7 @@ async function buildRegistry(pack, {scripts, faces, presentation, onDiagnostic,
177
196
  // an explicit list does; a presentation that does not select Latn keeps it fallback-only.
178
197
  const requested = scripts === "auto" && presentation !== null && typeof presentation === "object" ? scriptFontPackages(autoScriptSelection(presentation, renderOptions).scripts).map(pkg => pkg.name)
179
198
  : scripts === undefined || scripts === "auto" || (Array.isArray(scripts) && !scripts.length) ? [] : scriptFontPackages(scripts).map(pkg => pkg.name);
180
- const fallback = await loadPackages(scriptFontPackages(["Latn"]).filter(pkg => !requested.includes(pkg.name)), undefined, {fallbackOnly:true});
199
+ const fallback = await loadPackages(scriptFontPackages(["Latn"]).filter(pkg => !requested.includes(pkg.name)), undefined, {fallbackOnly:true, what: "The 'office' font pack (its default glyph-fallback face)"});
181
200
  fontFiles.push(...fallback.fontFiles);
182
201
  entries.push(...fallback.entries);
183
202
  packages.push(...fallback.packages);
package/dist/fonts.d.ts CHANGED
@@ -8,8 +8,8 @@ export type { FontPolicyEntry } from "@openpresentation/opf/font-policy";
8
8
  export { isSymbolEncodedFamily, mapSymbolText, symbolCodeOf } from "@openpresentation/opf/symbol-font-encodings";
9
9
  export { scriptFontRole } from "@openpresentation/opf/composition";
10
10
  export type { ScriptRole } from "@openpresentation/opf/composition";
11
- export interface FontFaceInput { data: Uint8Array; family?: string; weight?: number; italic?: boolean; postscriptName?: string; license?: string; /** Serves glyph fallback and requests by its own family only; never a replacement for another family. */ fallbackOnly?: boolean; /** "used" embeds the face in an SVG only when the slide's text names its family (the open pack, FF-31). Default "always". */ embed?: "always" | "used"; /** ISO 15924 scripts a designated script replacement face serves (FF-19). */ scripts?: string[] }
12
- export interface EmbeddedFont { family: string; weight: number; italic?: boolean; dataUrl: string; license?: string; embed?: "used" }
11
+ export interface FontFaceInput { data: Uint8Array; family?: string; weight?: number; italic?: boolean; postscriptName?: string; license?: string; /** Serves glyph fallback and requests by its own family only; never a replacement for another family. */ fallbackOnly?: boolean; /** Every face is embedded in an SVG only when the slide's text draws it (RR-61). "used" also leaves the face out of the eager `registry.embeddedFonts` list (the open pack, FF-31); "always" embeds it in every SVG. */ embed?: "always" | "used"; /** ISO 15924 scripts a designated script replacement face serves (FF-19). */ scripts?: string[] }
12
+ export interface EmbeddedFont { family: string; weight: number; italic?: boolean; dataUrl: string; license?: string; /** "always": written into every SVG; otherwise only into an SVG whose text draws the face (RR-61). */ embed?: "always" | "used" }
13
13
  export type FontCompatibility = "exact" | "metric" | "visual" | "generic";
14
14
  export interface FontReplacementMeasurement { replacement:string; meanAbsWidthDelta:number; meanWidthDelta:number; maxAbsWidthDelta:number; styles:number; reference:string }
15
15
  export interface FontResolution {
@@ -41,7 +41,7 @@ export declare const EXPERIMENTAL_FONT_CANDIDATES: readonly Readonly<{requestedF
41
41
  export interface RenderFonts {
42
42
  /** Measures text with the real faces. Without it layout uses core's portable estimate. */
43
43
  textMeasurement?: TextMeasurement;
44
- /** Faces `renderSvg` writes into each SVG as @font-face data; a face flagged `embed: "used"` only when a slide draws its family. */
44
+ /** Faces `renderSvg` may write into an SVG as @font-face data: each only into the SVGs whose text draws it (its family, weight and style), unless flagged `embed: "always"` (RR-61). */
45
45
  embeddedFonts?: readonly EmbeddedFont[];
46
46
  /** Font files the Node raster and PDF conversions draw with. */
47
47
  fontFiles?: readonly string[];
package/dist/index.d.ts CHANGED
@@ -54,10 +54,12 @@ export interface RenderSvgOptions {
54
54
  skipHidden?: boolean;
55
55
  /**
56
56
  * The fonts the deck is laid out and drawn with: the handle `loadFonts()` returns (from `@openpresentation/opf-render/fonts-node` or
57
- * `/fonts-browser`), or any object with a `textMeasurement` and, to embed faces in each SVG, an `embeddedFonts` list. Without it layout uses
57
+ * `/fonts-browser`), or any object with a `textMeasurement` and, to embed faces, an `embeddedFonts` list (each SVG carries only the faces its text draws). Without it layout uses
58
58
  * core's portable width estimate and the SVG names the design fonts without embedding them.
59
59
  */
60
60
  fonts?: RenderFonts;
61
+ /** `false` writes no `@font-face` data into the SVG (RR-61): the fonts handle still measures and the SVG still names the families, for a host whose page already has the faces (a browser `loadFonts` handle adds them to the document). Default true: each SVG embeds the faces of `fonts.embeddedFonts` its own text draws. */
62
+ embedFonts?: boolean;
61
63
  /** `"chain"` (default): a character the resolved face lacks is drawn with the first bundled face that has it, and reported as `font-glyph-fallback`. `"none"`: exact faces, a missing glyph raises `missing-glyph`. */
62
64
  glyphFallback?: "chain" | "none";
63
65
  /** Unscaled reference-pixel clearance around supplied vector text outlines; default 1. */
@@ -724,6 +724,8 @@ class Converter {
724
724
  result = { resource, object, width: info.width, height: info.height };
725
725
  }
726
726
  } catch (error) {
727
+ // A missing converter is the host's install, not a bad picture: say so instead of writing a PDF without its pictures.
728
+ if (error?.code === "converter-missing") throw error;
727
729
  this.unsupported("pdf-image-skipped", `An embedded image could not be decoded and is not drawn: ${error instanceof Error ? error.message : String(error)}`, { path: tracePath });
728
730
  }
729
731
  this.images.set(digest, result);
package/dist/pdf.d.ts ADDED
@@ -0,0 +1,2 @@
1
+ export { svgToPdf } from "./index.js";
2
+ export type { SvgToPdfOptions, PdfDiagnostic, PdfMetadata } from "./index.js";
package/dist/pdf.js ADDED
@@ -0,0 +1,4 @@
1
+ // RR-63: the PDF format entry (Node). The default vector `svgToPdf` needs no converter for text and shapes; it loads the optional peer
2
+ // `sharp` for pictures and `@resvg/resvg-js` for the rare element it rasterizes, and `mode: "raster"` also loads `pdf-lib`. A converter
3
+ // that is not installed rejects with `OPFRenderError` code `converter-missing`. For a page use `@openpresentation/opf-render/export-browser`.
4
+ export { svgToPdf } from "./raster.js";
package/dist/player.d.ts CHANGED
@@ -7,7 +7,8 @@ import type { RenderSvgOptions } from "./svg.js";
7
7
  */
8
8
  export declare class DeckError extends Error {
9
9
  readonly code: string;
10
- constructor(code: string, message: string, options?: { cause?: unknown });
10
+ readonly details?: {source: string; status?: number; contentType?: string};
11
+ constructor(code: string, message: string, options?: { cause?: unknown; source?: string; status?: number; contentType?: string });
11
12
  }
12
13
 
13
14
  /** The slide that is showing, in the sequence that plays (hidden slides are not counted). `notes` is plain text. */
package/dist/png.d.ts ADDED
@@ -0,0 +1,2 @@
1
+ export { svgToPng } from "./index.js";
2
+ export type { SvgToPngOptions } from "./index.js";
package/dist/png.js ADDED
@@ -0,0 +1,4 @@
1
+ // RR-63: the PNG format entry (Node). `svgToPng` loads the optional peers `@resvg/resvg-js` (and `sharp` for WebP and rotated JPEG
2
+ // pictures) on first use and rejects with `OPFRenderError` code `converter-missing` when they are not installed. For a page use
3
+ // `@openpresentation/opf-render/export-browser`.
4
+ export { svgToPng } from "./raster.js";
@@ -1,7 +1,7 @@
1
1
  // RR-28: fill a self-hosted font root (the layout of `preview-fonts.js`) from the installed packages, for hosts that serve
2
2
  // `<opf-deck fonts="/opf-fonts/">`. Node only. Every file is copied unmodified and checked against its pinned SHA-256 and
3
3
  // every license notice travels with it (LICENSES.txt). Nothing is downloaded: the faces come from this package's `fonts`
4
- // directory and the installed `@expo-google-fonts/*` packages.
4
+ // directory and the installed `@expo-google-fonts/*` packages (optional peer dependencies of this package: install the ones you copy).
5
5
  import crypto from "node:crypto";
6
6
  import fs from "node:fs";
7
7
  import path from "node:path";
@@ -50,7 +50,7 @@ export function copyPreviewFonts({ outDir, scripts = false, cwd, manifest = BUND
50
50
  const sourceDirectory = pkg.vendored ? within(rendererRoot, pkg.vendored) : resolvePackageDirectory(pkg.name, cwd);
51
51
  if (!sourceDirectory || !fs.existsSync(sourceDirectory)) {
52
52
  if (pkg.pack === "scripts") { missing.push(pkg.name); continue; }
53
- throw new Error(`${pkg.name} is not installed: reinstall @openpresentation/opf-render.`);
53
+ throw new Error(`${pkg.name} is not installed: it is an optional peer dependency of @openpresentation/opf-render, so run "npm install ${pkg.name}@${pkg.version}".`);
54
54
  }
55
55
  if (!pkg.vendored) {
56
56
  const installed = JSON.parse(fs.readFileSync(path.join(sourceDirectory, "package.json"), "utf8"));
@@ -1,4 +1,5 @@
1
1
  import {OPFRenderError} from './svg.js';
2
+ import {isConverterMissing,loadConverter} from './converters.js';
2
3
 
3
4
  // Resvg does not decode WebP or apply JPEG EXIF orientation, and draws no text of an SVG used as an image (its nested
4
5
  // document has no fonts). Replace only affected embedded image hrefs in the private
@@ -27,6 +28,7 @@ export async function prepareRasterImages(svg, {nestedSvg} = {}) {
27
28
  const replacement = href[0].slice(0, href[0].indexOf(href[1]) + 1) + png + href[1];
28
29
  tag = tag.slice(0, href.index) + replacement + tag.slice(href.index + href[0].length);
29
30
  } catch (error) {
31
+ if (isConverterMissing(error)) throw error;
30
32
  throw new OPFRenderError('image-conversion-failed', 'Embedded raster image could not be prepared for PNG/PDF output.', {path, cause:error instanceof Error ? error.message : String(error)});
31
33
  }
32
34
  output += svg.slice(end, match.index) + tag;
@@ -74,7 +76,7 @@ async function prepareRasterImage(uri) {
74
76
  } else if (bytes.length < 4 || bytes[0] !== 0xff || bytes[1] !== 0xd8 || bytes[2] !== 0xff) {
75
77
  throw new Error('Embedded data is not a JPEG image.');
76
78
  }
77
- const {default:sharp}=await import('sharp');
79
+ const {default:sharp}=await loadConverter('sharp');
78
80
  const decoder=sharp(bytes,{limitInputPixels:40_000_000,animated:false,failOn:'warning'});
79
81
  if (!webp) {
80
82
  const {orientation}=await decoder.metadata();
package/dist/raster.js CHANGED
@@ -3,6 +3,7 @@ import {OPFRenderError,packageName} from './svg.js';
3
3
  import {separateLigatures} from './font-compatibility.js';
4
4
  import {monochromeColorFonts,rasterFontFiles} from './color-fonts.js';
5
5
  import {pinScriptClusters} from './raster-text.js';
6
+ import {loadConverter} from './converters.js';
6
7
  const DEFAULT_DIMENSIONS = { width: 1280, height: 720 };
7
8
  const DEFAULT_RASTER_SCALE = 1;
8
9
  const DEFAULT_RASTER_BACKGROUND = "#FFFFFF";
@@ -100,7 +101,7 @@ async function vectorPdf(inputs, options) {
100
101
  // The picture decoder of the Node vector export (the browser entry passes a canvas one). sharp loads on first use.
101
102
  function sharpImageCodec() {
102
103
  let loaded;
103
- const sharp = () => (loaded ??= import("sharp").then((module) => module.default));
104
+ const sharp = () => (loaded ??= loadConverter("sharp").then((module) => module.default));
104
105
  return {
105
106
  metadata: async (bytes) => (await sharp())(bytes, { limitInputPixels: 40_000_000, animated: false }).metadata(),
106
107
  rgba: async (bytes, { orient }) => {
@@ -162,25 +163,8 @@ async function rasterizeSvg(svgInput, options) {
162
163
  }
163
164
  }
164
165
 
165
- async function loadResvg() {
166
- try {
167
- return await import("@resvg/resvg-js");
168
- } catch (error) {
169
- throw new OPFRenderError("png-renderer-unavailable", "PNG/PDF conversion requires the Node runtime dependency @resvg/resvg-js.", {
170
- cause: error instanceof Error ? error.message : String(error)
171
- });
172
- }
173
- }
174
-
175
- async function loadPdfLib() {
176
- try {
177
- return await import("pdf-lib");
178
- } catch (error) {
179
- throw new OPFRenderError("pdf-writer-unavailable", "PDF conversion requires the runtime dependency pdf-lib.", {
180
- cause: error instanceof Error ? error.message : String(error)
181
- });
182
- }
183
- }
166
+ const loadResvg = () => loadConverter("@resvg/resvg-js");
167
+ const loadPdfLib = () => loadConverter("pdf-lib");
184
168
 
185
169
  async function bundledFontFiles() {
186
170
  if (!bundledFontFilesCache) {
package/dist/svg.d.ts CHANGED
@@ -54,10 +54,12 @@ export interface RenderSvgOptions {
54
54
  skipHidden?: boolean;
55
55
  /**
56
56
  * The fonts the deck is laid out and drawn with: the handle `loadFonts()` returns (from `@openpresentation/opf-render/fonts-node` or
57
- * `/fonts-browser`), or any object with a `textMeasurement` and, to embed faces in each SVG, an `embeddedFonts` list. Without it layout uses
57
+ * `/fonts-browser`), or any object with a `textMeasurement` and, to embed faces, an `embeddedFonts` list (each SVG carries only the faces its text draws). Without it layout uses
58
58
  * core's portable width estimate and the SVG names the design fonts without embedding them.
59
59
  */
60
60
  fonts?: RenderFonts;
61
+ /** `false` writes no `@font-face` data into the SVG (RR-61): the fonts handle still measures and the SVG still names the families, for a host whose page already has the faces (a browser `loadFonts` handle adds them to the document). Default true: each SVG embeds the faces of `fonts.embeddedFonts` its own text draws. */
62
+ embedFonts?: boolean;
61
63
  /** `"chain"` (default): a character the resolved face lacks is drawn with the first bundled face that has it, and reported as `font-glyph-fallback`. `"none"`: exact faces, a missing glyph raises `missing-glyph`. */
62
64
  glyphFallback?: "chain" | "none";
63
65
  /** Unscaled reference-pixel clearance around supplied vector text outlines; default 1. */
package/dist/svg.js CHANGED
@@ -635,10 +635,11 @@ function bindSlide(presentation, slide, index, context) {
635
635
  }
636
636
 
637
637
  // `options.fonts` is the handle `loadFonts()` returns (or any object with a `textMeasurement` and, to embed faces in the SVG, an
638
- // `embeddedFonts` list). The renderer reads the two as plain options from here on.
638
+ // `embeddedFonts` list). The renderer reads the two as plain options from here on. `embedFonts: false` (RR-61) keeps the
639
+ // measurement and drops the faces, for a host whose page already holds them.
639
640
  function normalizeOptions(options) {
640
- const { fonts, ...rest } = options ?? {};
641
- return { ...rest, textMeasurement: fonts?.textMeasurement, embeddedFonts: fonts?.embeddedFonts };
641
+ const { fonts, embedFonts, ...rest } = options ?? {};
642
+ return { ...rest, textMeasurement: fonts?.textMeasurement, embeddedFonts: embedFonts === false ? undefined : fonts?.embeddedFonts };
642
643
  }
643
644
 
644
645
  /**
@@ -720,7 +721,9 @@ function renderResolvedSlide(resolved, slideIndex, options) {
720
721
  "svg",
721
722
  {
722
723
  xmlns: "http://www.w3.org/2000/svg",
723
- role: "img",
724
+ // FA-30: a labelled container, not an image: role="img" makes every text node inside presentational.
725
+ role: "group",
726
+ "aria-roledescription": "slide",
724
727
  "aria-label": title,
725
728
  viewBox: `0 0 ${width} ${height}`,
726
729
  width,
@@ -756,6 +759,7 @@ function renderBackground(bound, width, height, options) {
756
759
  y2: `${50+dy}%`
757
760
  }, stopTags.join(""))),
758
761
  tag("rect", {
762
+ ...HIDDEN,
759
763
  x: 0,
760
764
  y: 0,
761
765
  width,
@@ -773,9 +777,10 @@ function renderBackground(bound, width, height, options) {
773
777
  const runs=patternRuns(preset);
774
778
  const mark=runs?tag('path',{d:runs.map(run=>`M${run.x} ${run.y}h${run.width}v1h-${run.width}z`).join(''),fill:color,'shape-rendering':'crispEdges'}):'';
775
779
  if(!mark)reportDiagnostic({code:'unsupported-pattern',path:`${bound.path}.design.background.pattern.preset`,message:`Pattern ${preset} is not implemented by the SVG preview.`},options);
776
- return tag('g',{opacity:background.opacity??1},tag('rect',{width,height,fill:bound.design.backgroundColor??'#FFFFFF'})+tag('defs',{},tag('pattern',{id,width:8,height:8,patternUnits:'userSpaceOnUse'},mark))+tag('rect',{width,height,fill:`url(#${id})`}));
780
+ return tag('g',{...HIDDEN,opacity:background.opacity??1},tag('rect',{width,height,fill:bound.design.backgroundColor??'#FFFFFF'})+tag('defs',{},tag('pattern',{id,width:8,height:8,patternUnits:'userSpaceOnUse'},mark))+tag('rect',{width,height,fill:`url(#${id})`}));
777
781
  }
778
782
  return tag("rect", {
783
+ ...HIDDEN,
779
784
  x: 0,
780
785
  y: 0,
781
786
  width,
@@ -792,9 +797,9 @@ function renderBackground(bound, width, height, options) {
792
797
  // and stretch use the shared fit math with the focus point; tile repeats the picture at its intrinsic size (1 picture px = 1
793
798
  // reference px) from the canvas top-left. alt is the picture's accessible name; without alt the picture is decorative.
794
799
  function renderBackgroundImage(background, bound, width, height, options) {
795
- const canvas = tag("rect", { x: 0, y: 0, width, height, fill: bound.design.backgroundColor ?? bound.design.colors.background, ...traceAttrs(options, `${bound.path}.design.background`) });
800
+ const canvas = tag("rect", { ...HIDDEN, x: 0, y: 0, width, height, fill: bound.design.backgroundColor ?? bound.design.colors.background, ...traceAttrs(options, `${bound.path}.design.background`) });
796
801
  const item = { value: background.alt === undefined ? background.src : { src: background.src, alt: background.alt }, path: background.path };
797
- const drawOptions = { ...options, imageDecorative: background.alt === undefined };
802
+ const drawOptions = { ...options, imageDecorative: background.alt === undefined || background.alt === "" };
798
803
  const picture = background.fit === "tile"
799
804
  ? renderTiledImage(item, background.box, bound, drawOptions)
800
805
  : renderImage(item, background.box, bound, { ...drawOptions, imageFit: background.fit, imageFocus: background.focus, imagePicture: background.picture });
@@ -838,7 +843,7 @@ function renderImageBlock(item, bound, options) {
838
843
  const children = [defs.length ? tag("defs", {}, defs.join("")) : "", pixels];
839
844
  if (image.border) {
840
845
  const paint = imagePaint(image.border.color, bound, bound.design.colors.border);
841
- children.push(tag("path", { d: shape?.path ?? rectanglePath(image.box), fill: "none", stroke: paint.color, "stroke-opacity": paint.alpha < 1 ? preciseNumber(paint.alpha) : undefined, "stroke-width": stableNumber(image.border.width), "stroke-linejoin": "miter", "stroke-miterlimit": 8 }));
846
+ children.push(tag("path", { ...HIDDEN, d: shape?.path ?? rectanglePath(image.box), fill: "none", stroke: paint.color, "stroke-opacity": paint.alpha < 1 ? preciseNumber(paint.alpha) : undefined, "stroke-width": stableNumber(image.border.width), "stroke-linejoin": "miter", "stroke-miterlimit": 8 }));
842
847
  }
843
848
  if (image.overlay) children.push(renderImageOverlay(image.overlay, bound, options));
844
849
  if (children.length === 2 && !children[0] && !Object.keys(trace).length) return pixels;
@@ -849,7 +854,7 @@ function renderImageBlock(item, bound, options) {
849
854
  // overlay opacity.
850
855
  function renderImageOverlay(overlay, bound, options) {
851
856
  const paint = imagePaint(overlay.color, bound, bound.design.colors.text);
852
- return tag("path", { d: overlay.shape?.path ?? rectanglePath(overlay.box), fill: paint.color, "fill-opacity": preciseNumber(paint.alpha * overlay.opacity), ...(options.trace ? { "data-opf-image-overlay": overlay.path } : {}) });
857
+ return tag("path", { ...HIDDEN, d: overlay.shape?.path ?? rectanglePath(overlay.box), fill: paint.color, "fill-opacity": preciseNumber(paint.alpha * overlay.opacity), ...(options.trace ? { "data-opf-image-overlay": overlay.path } : {}) });
853
858
  }
854
859
 
855
860
  // The placeholder renderImage draws for a source it cannot resolve.
@@ -885,7 +890,7 @@ function renderSlideContent(bound, width, height, options) {
885
890
  for (const diagnostic of [...bound.design.diagnostics, ...bound.geometry.diagnostics]) reportDiagnostic(diagnostic, options);
886
891
  return bound.geometry.items.map(item => {
887
892
  const frame=item.frameBox;
888
- const surface=frame ? tag('rect',{x:frame.x,y:frame.y,width:frame.width,height:frame.height,rx:8*Math.min(width,height)/720,fill:bound.design.colors.surface,stroke:bound.design.colors.border,...traceAttrs(options,item.path)}) : '';
893
+ const surface=frame ? tag('rect',{...HIDDEN,x:frame.x,y:frame.y,width:frame.width,height:frame.height,rx:8*Math.min(width,height)/720,fill:bound.design.colors.surface,stroke:bound.design.colors.border,...traceAttrs(options,item.path)}) : '';
889
894
  // RR-34: a captioned item draws its media in item.box and its caption band after it (src/annotations.js).
890
895
  return surface+renderPayload(item, item.box, { ...bound, composition: item.composition }, options)+(item.caption?renderCaption(item,{ ...bound, composition: item.composition },options,drawHelpers):'');
891
896
  });
@@ -900,11 +905,13 @@ function reportDiagnostic(diagnostic, options) {
900
905
  options.onDiagnostic?.(diagnostic);
901
906
  }
902
907
 
903
- // FA-09: Chart.alt is the chart's accessible name: a role="img" group with aria-label, which also makes the drawn marks and labels
904
- // inside presentational. An empty alt marks the chart decorative (aria-hidden). Without alt the SVG is unchanged.
905
- function chartText(chart, alt) {
906
- if (typeof alt !== "string" || !chart) return chart;
907
- return alt === "" ? tag("g", { "aria-hidden": "true" }, chart) : tag("g", { role: "img", "aria-label": alt }, chart);
908
+ // FA-09, FA-27: Chart.alt and Table.alt name the drawn payload with an aria-label on a wrapping group. A chart is a picture: role="img",
909
+ // which also makes its marks and labels presentational. A table's alt is a summary (the cells are real text and stay readable, as in
910
+ // PowerPoint, where descr on a table frame hides nothing): role="group" names it without hiding its children. An empty alt marks the payload
911
+ // decorative (aria-hidden) for both. Without alt the SVG is unchanged.
912
+ function altText(drawn, alt, role) {
913
+ if (typeof alt !== "string" || !drawn) return drawn;
914
+ return alt === "" ? tag("g", { "aria-hidden": "true" }, drawn) : tag("g", { role, "aria-label": alt }, drawn);
908
915
  }
909
916
 
910
917
  function renderPayload(item, box, bound, options) {
@@ -912,9 +919,9 @@ function renderPayload(item, box, bound, options) {
912
919
  if (item.field === "items" || item.field === "bullets") return renderList(item, box, bound, options);
913
920
  switch (item.type) {
914
921
  case "chart":
915
- return chartText(renderChart(item, box, bound, options), item.value?.alt);
922
+ return altText(renderChart(item, box, bound, options), item.value?.alt, "img");
916
923
  case "table":
917
- return renderTable(item, box, bound, options);
924
+ return altText(renderTable(item, box, bound, options), item.value?.alt, "group");
918
925
  case "image":
919
926
  return renderImageBlock(item, bound, options);
920
927
  case "video":
@@ -999,10 +1006,17 @@ function resolveImageSource(item, bound, options) {
999
1006
  return { asset, source: svg ?? source, drawable, missingReference };
1000
1007
  }
1001
1008
 
1009
+ // FA-30: purely decorative drawing is hidden from assistive technology so the slide's text and real pictures are what a reader meets
1010
+ // (background, overlays, card frames, dividers, table cell fills and borders, accent shapes). SVG-AAM already leaves an unnamed shape
1011
+ // out of the tree; the attribute states the intent for the readers that do not.
1012
+ const HIDDEN = Object.freeze({ "aria-hidden": "true" });
1013
+
1002
1014
  // The accessible name of a drawn picture: its alt text, else the caller's label; a decorative picture (an image background
1003
1015
  // without alt) is hidden from assistive technology.
1004
1016
  function imageAccessibility(asset, options) {
1005
- if (options.imageDecorative) return { "aria-hidden": "true" };
1017
+ // An empty alt marks the picture decorative (FA-30), as it does a chart; a picture that is only furniture (a header or footer
1018
+ // logo repeated on every slide) is decorative unless the author named it.
1019
+ if (options.imageDecorative || asset.alt === "" || (options.imageUnlabelled && asset.alt === undefined)) return HIDDEN;
1006
1020
  return { role: "img", "aria-label": asset.alt ?? options.imageLabel ?? "Image" };
1007
1021
  }
1008
1022
 
@@ -1227,6 +1241,7 @@ function renderMedia(item, box, bound, options) {
1227
1241
  const iconBox = centeredBox(box, 72, 72);
1228
1242
  const children = [
1229
1243
  tag("rect", {
1244
+ ...HIDDEN,
1230
1245
  x: box.x,
1231
1246
  y: box.y,
1232
1247
  width: box.width,
@@ -1237,6 +1252,7 @@ function renderMedia(item, box, bound, options) {
1237
1252
  ...traceAttrs(options, item.path)
1238
1253
  }),
1239
1254
  tag("circle", {
1255
+ ...HIDDEN,
1240
1256
  cx: iconBox.x + iconBox.width / 2,
1241
1257
  cy: iconBox.y + iconBox.height / 2,
1242
1258
  r: 36,
@@ -1244,6 +1260,7 @@ function renderMedia(item, box, bound, options) {
1244
1260
  ...traceAttrs(options, item.path)
1245
1261
  }),
1246
1262
  tag("path", {
1263
+ ...HIDDEN,
1247
1264
  d: trianglePath(iconBox.x + 28, iconBox.y + 22, 28, 28),
1248
1265
  fill: "#FFFFFF",
1249
1266
  ...traceAttrs(options, item.path)
@@ -1274,6 +1291,7 @@ function renderCode(item, box, bound, options) {
1274
1291
  const highlight = codeHighlight(item, layout, bound);
1275
1292
  const children = [
1276
1293
  tag("rect", {
1294
+ ...HIDDEN,
1277
1295
  x: box.x,
1278
1296
  y: box.y,
1279
1297
  width: box.width,
@@ -1290,6 +1308,7 @@ function renderCode(item, box, bound, options) {
1290
1308
  const body = layout.parts.find(part => part.role === "body");
1291
1309
  for (const band of highlight.bands) {
1292
1310
  children.push(tag("rect", {
1311
+ ...HIDDEN,
1293
1312
  x: stableNumber(box.x + 1), y: stableNumber(body.box.y + band.first * body.fit.lineHeight), width: stableNumber(box.width - 2),
1294
1313
  height: stableNumber((band.last - band.first + 1) * body.fit.lineHeight), fill: highlight.colors.band,
1295
1314
  ...(options.trace ? { "data-opf-code-highlight": "true" } : {})
@@ -1405,16 +1424,16 @@ function renderTimeline(item, box, bound, options) {
1405
1424
  const layout=item.timelineLayout;
1406
1425
  if(!layout)throw new OPFRenderError('missing-timeline-layout','Timeline rendering requires a coordinated core build with shared timeline geometry.',{path:item.path});
1407
1426
  const scale=Math.min(bound.design.dimensions.width,bound.design.dimensions.height)/720;
1408
- const children=[tag('line',{...layout.connector,stroke:bound.design.colors.border,'stroke-width':3*scale,...traceAttrs(options,item.path)})];
1427
+ const children=[tag('line',{...HIDDEN,...layout.connector,stroke:bound.design.colors.border,'stroke-width':3*scale,...traceAttrs(options,item.path)})];
1409
1428
  // FA-11: status colors come from the deck (core's timelineMarkerShapes / timelineTextColor); a marker with no status draws the plain primary circle.
1410
1429
  const background=bound.design.backgroundColor??bound.design.colors.background;
1411
1430
  const statusColors={background,primary:bound.design.colors.primary,text:bound.design.colors.text,mutedText:bound.design.colors.mutedText};
1412
1431
  for(const marker of layout.markers){
1413
1432
  if(!marker.status){
1414
- children.push(tag('circle',{cx:marker.x,cy:marker.y,r:marker.radius,fill:bound.design.colors.primary,...traceAttrs(options,marker.path)}));
1433
+ children.push(tag('circle',{...HIDDEN,cx:marker.x,cy:marker.y,r:marker.radius,fill:bound.design.colors.primary,...traceAttrs(options,marker.path)}));
1415
1434
  continue;
1416
1435
  }
1417
- for(const shape of timelineMarkerShapes(marker,statusColors))children.push(tag('circle',{cx:shape.cx,cy:shape.cy,r:shape.radius,fill:shape.fill??'none',...(shape.stroke?{stroke:shape.stroke.color,'stroke-width':shape.stroke.width}:{}),...traceAttrs(options,marker.path),...(options.trace?{'data-opf-timeline-status':marker.status,'data-opf-timeline-shape':shape.role}:{})}));
1436
+ for(const shape of timelineMarkerShapes(marker,statusColors))children.push(tag('circle',{...HIDDEN,cx:shape.cx,cy:shape.cy,r:shape.radius,fill:shape.fill??'none',...(shape.stroke?{stroke:shape.stroke.color,'stroke-width':shape.stroke.width}:{}),...traceAttrs(options,marker.path),...(options.trace?{'data-opf-timeline-status':marker.status,'data-opf-timeline-shape':shape.role}:{})}));
1418
1437
  }
1419
1438
  for(const part of layout.parts){
1420
1439
  if(!part.fit)throw new OPFRenderError('layout-overflow','Timeline field has no usable space; change the arrangement or paginate events.',{path:part.path,diagnostics:layout.diagnostics});
@@ -1445,6 +1464,7 @@ function renderTable(item, box, bound, options) {
1445
1464
  ? defaultText
1446
1465
  : resolveColorRef(style.color, bound, defaultText);
1447
1466
  children.push(tag("rect", {
1467
+ ...HIDDEN,
1448
1468
  x: stableNumber(cell.box.x), y: stableNumber(cell.box.y),
1449
1469
  width: stableNumber(cell.box.width), height: stableNumber(cell.box.height),
1450
1470
  fill,
@@ -1500,6 +1520,7 @@ function renderTableBorders(defaultEdges, explicitEdges, scale, bound, options)
1500
1520
  ? defaultColor
1501
1521
  : resolveColorRef(border.color, bound, defaultColor);
1502
1522
  return [tag('line', {
1523
+ ...HIDDEN,
1503
1524
  x1:stableNumber(x1), y1:stableNumber(y1), x2:stableNumber(x2), y2:stableNumber(y2),
1504
1525
  stroke, 'stroke-width':stableNumber(width),
1505
1526
  'stroke-dasharray':border?.dash === 'dash' ? `${width*4} ${width*3}` : border?.dash === 'dot' ? `${width} ${width*2}` : undefined,
@@ -1637,7 +1658,7 @@ function renderFurniture(bound, presentation, width, height, options, kind) {
1637
1658
  }
1638
1659
  return tag('g',{},layout.parts.filter(part=>part.kind===kind).map(part=>{
1639
1660
  const trace=options.trace?{'data-opf-furniture-kind':kind,'data-opf-furniture-field':part.field,'data-opf-furniture-generated':String(part.generated),'data-opf-furniture-editable':!part.generated?'true':undefined,'data-opf-furniture-source':part.sourcePath}:{};
1640
- if(part.type==='image')return tag('g',trace,renderImage({value:part.image,path:part.path},part.box,bound,{...options,imageFit:'contain'}));
1661
+ if(part.type==='image')return tag('g',trace,renderImage({value:part.image,path:part.path},part.box,bound,{...options,imageFit:'contain',imageUnlabelled:true}));
1641
1662
  return tag('g',trace,renderTextBox(part.text,part.box,bound,{path:part.path,fit:part.fit,textStyle:part.style,fontFamily:part.style.fontFamily,align:part.alignment,fill:bound.design.colors.mutedText,diagnosticsHandled:true,options}));
1642
1663
  }).join(''));
1643
1664
  }
@@ -1650,12 +1671,13 @@ function renderBranding(bound,presentation,width,height,options) {
1650
1671
  if(mark){
1651
1672
  const opacity=typeof design.watermark.opacity==='number'&&Number.isFinite(design.watermark.opacity)?Math.min(1,Math.max(0,design.watermark.opacity)):.08;
1652
1673
  const cx=width/2,cy=height/2;
1653
- pieces.push(tag('g',{opacity:stableNumber(opacity),transform:`rotate(${stableNumber(mark.rotation)} ${stableNumber(cx)} ${stableNumber(cy)})`,...traceAttrs(options,rootFor('watermark'))},
1674
+ pieces.push(tag('g',{...HIDDEN,opacity:stableNumber(opacity),transform:`rotate(${stableNumber(mark.rotation)} ${stableNumber(cx)} ${stableNumber(cy)})`,...traceAttrs(options,rootFor('watermark'))},
1654
1675
  tag('text',{x:stableNumber(cx),y:stableNumber(cy+mark.fontSize*.35),'text-anchor':'middle','font-family':fontStack(mark.style.fontFamily,bound.design.fontScheme.type),'font-size':stableNumber(mark.fontSize),'font-weight':mark.fontWeight,
1655
1676
  fill:bound.design.colors.text,'xml:space':'preserve','text-rendering':'geometricPrecision'},escapeText(mark.text))));
1656
1677
  }
1657
1678
  } else if(design.watermark){
1658
- pieces.push(tag('g',{opacity:typeof design.watermark==='object'?design.watermark.opacity??.08:.08},renderImage({value:design.watermark,path:rootFor('watermark')},{x:width*.3,y:height*.3,width:width*.4,height:height*.4},bound,{...options,imageFit:'contain'})));
1679
+ // The group keeps its bare opacity attribute (opf-pptx's watermark parity test reads it); the picture itself is hidden.
1680
+ pieces.push(tag('g',{opacity:typeof design.watermark==='object'?design.watermark.opacity??.08:.08},renderImage({value:design.watermark,path:rootFor('watermark')},{x:width*.3,y:height*.3,width:width*.4,height:height*.4},bound,{...options,imageFit:'contain',imageDecorative:true})));
1659
1681
  }
1660
1682
  // Cover and section slides: the deck logo core composed at the top-left of the free area, anchored left.
1661
1683
  const logo=bound.geometry.logo;
@@ -1798,21 +1820,26 @@ function codeSyntax(item, layout, bound, options) {
1798
1820
  return { tokens, palette: codeSyntaxPaletteForScheme(bound.design.colorScheme) };
1799
1821
  }
1800
1822
 
1801
- // Faces flagged embed:"used" (the vendored open, Intos and script-pack faces) are embedded only when the slide's own markup draws
1802
- // them: the family in a font-family list, at a font-weight and font-style some text of the slide takes (attributes are
1803
- // inherited down the element tree, as in SVG). A used family none of whose faces matches a drawn weight and style keeps all
1804
- // its faces, so the browser can always choose. Every other face (the npm packs) is embedded as before.
1823
+ // RR-61: a face is embedded only when the slide's own markup draws it: the family in a font-family list, at a font-weight and
1824
+ // font-style some text of the slide takes (attributes are inherited down the element tree, as in SVG). A drawn family none of
1825
+ // whose faces matches a drawn weight and style keeps all its faces, so the browser can always choose. A face no font-family of
1826
+ // the SVG names is one the browser can never select, so leaving it out changes no pixel. Only a face flagged embed:"always" is
1827
+ // embedded in every SVG. RR-59: each drawn style is checked on its own, so a family keeps an exactly matched face and, for
1828
+ // a drawn style none of its faces matches (a weight the family lacks), every face the browser could choose from.
1805
1829
  function embeddedFontsFor(fonts = [], content) {
1806
- if (!fonts.some(font => font?.embed === "used")) return fonts;
1830
+ if (!fonts.length) return fonts;
1831
+ fonts.forEach(assertEmbeddableFont);
1807
1832
  const drawn = drawnFaces(content.join("\n"));
1808
1833
  const wanted = font => {
1809
1834
  const triples = drawn.get(String(font.family).toLowerCase());
1810
1835
  if (!triples) return false;
1811
- const family = fonts.filter(other => other?.embed === "used" && String(other.family).toLowerCase() === String(font.family).toLowerCase());
1812
- const matching = family.filter(other => triples.has(`${other.weight}|${other.italic ? "italic" : "normal"}`));
1813
- return matching.length ? matching.includes(font) : true;
1836
+ const family = fonts.filter(other => String(other?.family).toLowerCase() === String(font.family).toLowerCase());
1837
+ return [...triples].some(triple => {
1838
+ const matching = family.filter(other => triple === `${other.weight}|${other.italic ? "italic" : "normal"}`);
1839
+ return matching.length ? matching.includes(font) : true;
1840
+ });
1814
1841
  };
1815
- return fonts.filter(font => font?.embed !== "used" || wanted(font));
1842
+ return fonts.filter(font => font?.embed === "always" || wanted(font));
1816
1843
  }
1817
1844
 
1818
1845
  const FONT_WEIGHT_KEYWORDS = { normal: "400", bold: "700" };
@@ -1841,10 +1868,21 @@ function drawnFaces(markup) {
1841
1868
  return drawn;
1842
1869
  }
1843
1870
 
1871
+ // Every supplied face is checked, drawn or not, so a bad entry fails on any slide. The data URI check is remembered per face
1872
+ // object while its dataUrl is unchanged: the handle's list holds the same objects on every slide, and its URIs run to tens of
1873
+ // megabytes.
1874
+ const checkedDataUrls = new WeakMap();
1875
+ function assertEmbeddableFont(font) {
1876
+ const valid = typeof font?.family === "string" && !/[\u0000-\u001f"'\\<>;]/.test(font.family) && Number.isInteger(font.weight) && font.weight >= 1 && font.weight <= 1000
1877
+ && (checkedDataUrls.get(font) === font.dataUrl || /^data:font\/(ttf|otf|woff|woff2);base64,[A-Za-z0-9+/=]+$/.test(font.dataUrl));
1878
+ if (!valid) throw new OPFRenderError("invalid-embedded-font", "Embedded fonts require a plain family name, valid weight, and a font data URI.");
1879
+ checkedDataUrls.set(font, font.dataUrl);
1880
+ }
1881
+
1844
1882
  function renderEmbeddedFonts(fonts = []) {
1845
1883
  if (!fonts.length) return "";
1846
1884
  const css = fonts.map(font => {
1847
- if (typeof font.family !== "string" || /[\u0000-\u001f"'\\<>;]/.test(font.family) || !/^data:font\/(ttf|otf|woff|woff2);base64,[A-Za-z0-9+/=]+$/.test(font.dataUrl) || !Number.isInteger(font.weight) || font.weight < 1 || font.weight > 1000) throw new OPFRenderError("invalid-embedded-font", "Embedded fonts require a plain family name, valid weight, and a font data URI.");
1885
+ assertEmbeddableFont(font);
1848
1886
  return `@font-face{font-family:"${font.family}";font-weight:${font.weight};font-style:${font.italic ? "italic" : "normal"};src:url("${font.dataUrl}")}`;
1849
1887
  }).join("\n");
1850
1888
  const licenses = [...new Set(fonts.map(font=>font.license).filter(Boolean))];