@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.
- package/README.md +125 -9
- package/dist/annotations.js +1 -1
- package/dist/converters.js +41 -0
- package/dist/deck-runtime.js +6 -3
- package/dist/element.d.ts +6 -0
- package/dist/element.js +31 -8
- package/dist/export-browser.d.ts +6 -0
- package/dist/export-browser.js +16 -2
- package/dist/font-registry.js +3 -3
- package/dist/fonts-node.js +24 -5
- package/dist/fonts.d.ts +3 -3
- package/dist/index.d.ts +3 -1
- package/dist/pdf-vector.js +2 -0
- package/dist/pdf.d.ts +2 -0
- package/dist/pdf.js +4 -0
- package/dist/player.d.ts +2 -1
- package/dist/png.d.ts +2 -0
- package/dist/png.js +4 -0
- package/dist/preview-fonts-node.js +2 -2
- package/dist/raster-images.js +3 -1
- package/dist/raster.js +4 -20
- package/dist/svg.d.ts +3 -1
- package/dist/svg.js +72 -34
- package/package.json +70 -17
package/dist/fonts-node.js
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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; /**
|
|
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`
|
|
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
|
|
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/pdf-vector.js
CHANGED
|
@@ -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
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
|
-
|
|
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
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:
|
|
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"));
|
package/dist/raster-images.js
CHANGED
|
@@ -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
|
|
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 ??=
|
|
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
|
-
|
|
166
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
904
|
-
//
|
|
905
|
-
|
|
906
|
-
|
|
907
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
1802
|
-
//
|
|
1803
|
-
//
|
|
1804
|
-
//
|
|
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.
|
|
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 =>
|
|
1812
|
-
|
|
1813
|
-
|
|
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
|
|
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
|
-
|
|
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))];
|