@openpresentation/opf-render 0.0.2 → 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # OPF Render
2
2
 
3
- Deterministic local renderer for Open Presentation Format documents. This repo is the Phase 1 toolkit lane for SVG, PNG, and PDF output. The current package implements the validation, catalog resolution, placeholder binding, deterministic text layout, and SVG core that PNG/PDF conversion will build on.
3
+ Deterministic local renderer for Open Presentation Format documents. The shared SVG core implements validation, catalog resolution, placeholder binding and text layout. Node APIs additionally convert SVG to PNG and raster-backed PDF.
4
4
 
5
5
  ## Scope
6
6
 
@@ -32,6 +32,25 @@ const pdf = await svgToPdf(svgs, { scale: 1 });
32
32
 
33
33
  `svgToPng` returns PNG bytes for one SVG. `svgToPdf` accepts one SVG or an array of SVGs and returns PDF bytes with one slide per page. The SVG page `width`/`height` or `viewBox` determines the PDF page size; `scale` controls raster density only.
34
34
 
35
+ ## Browser preview and fonts (preview APIs)
36
+
37
+ Use `@openpresentation/opf-render/svg` for browser rendering without Node dependencies. Browser-aware bundlers also select this shared SVG implementation for the root import; Node's root import retains PNG/PDF conversion.
38
+
39
+ ```js
40
+ import { renderSvg } from '@openpresentation/opf-render/svg';
41
+ import { loadBrowserFontRegistry } from '@openpresentation/opf-render/fonts-browser';
42
+
43
+ const fonts = await loadBrowserFontRegistry(fontFileEntries);
44
+ container.innerHTML = renderSvg(presentation, {
45
+ textMeasurement: fonts.textMeasurement,
46
+ });
47
+ // fonts.dispose() when its canvases are unmounted.
48
+ ```
49
+
50
+ Each entry contains `url` or `data: Uint8Array`, with optional `family`, `weight`, `italic` and `license`. The loader registers browser FontFaces using the same bytes used for measurement. It fetches only URLs supplied by the host, supports an AbortSignal and custom fetch, and awaits font loading. Use pinned static faces and retain their licenses. For standalone SVG export also pass `embeddedFonts: fonts.embeddedFonts`; embedding is unnecessary for each live draft after browser fonts are loaded.
51
+
52
+ The new entries require coordinated preview builds of OPF and the toolkit. The sibling OPF repository's `pnpm pack:ecosystem` prepares local npm tarballs; older registry releases do not include these APIs. See `opf/docs/live-editor.md` for installation and the fidelity contract. Identical SVG geometry does not guarantee identical raster pixels across browser engines or PowerPoint.
53
+
35
54
  ## Runtime Policy
36
55
 
37
56
  The package runtime must stay local and deterministic:
@@ -63,10 +82,10 @@ npm run validate
63
82
  npm test
64
83
  ```
65
84
 
66
- When this repo is checked out beside `openpresentation/opf`, `npm test` also renders every `examples/**/*.opf.json` deck twice and asserts byte-identical SVG output. Golden PNG drift is checked against `test/golden/opf-examples-png.sha256.json` when the sibling corpus is at the approved OPF commit. To regenerate the approved PNG hash manifest after an intentional visual change:
85
+ When this repo is checked out beside `openpresentation/opf`, `npm test` also renders every `examples/**/*.opf.json` deck twice and asserts byte-identical SVG output. Golden PNG drift is checked on every run against all 805 slides from the installed core package, identified by a content digest. Changed or missing corpora fail; Git history cannot skip the gate. To generate a review candidate after an intentional visual change:
67
86
 
68
87
  ```sh
69
- OPF_EXAMPLES_DIR=/path/to/opf/examples npm run golden:update
88
+ npm run golden:update
70
89
  ```
71
90
 
72
91
  ## Release Lane
@@ -80,3 +99,13 @@ Required first-publish setup:
80
99
  3. Publish by pushing a git tag matching `opf-render-v<version>` or `@openpresentation/opf-render@v<version>` (the tag must match the `package.json` version), or by manually running the workflow after CI passes.
81
100
 
82
101
  This repo does not require an npm automation token when Trusted Publishing is configured.
102
+
103
+ ## Shared dynamic composition (local development)
104
+
105
+ The current checkout uses `@openpresentation/opf/composition` for portable geometry. Slides can select `auto`, `row`, `column`, or `grid`, set weighted tracks, and request path-specific overflow diagnostics. See the sibling OPF repo's `docs/dynamic-composition.md` for the complete contract.
106
+
107
+ Version 0.1.0 requires the published `@openpresentation/opf@^0.4.0` for composition, pagination and measured text. A clean `npm ci && npm test` uses registry packages and runs the full raster corpus without sibling checkouts. For coordinated source development, build OPF and run `node scripts/link-ecosystem.mjs` there; `pnpm test:ecosystem` checks editing, rendering, editable PowerPoint geometry and import together.
108
+
109
+ For actual font measurement, load `loadBundledFontRegistry()` from `@openpresentation/opf-render/fonts-node`, then pass its `textMeasurement` to rendering and its `embeddedFonts` to SVG export. Pass its `fontFiles` to PNG/PDF conversion. The browser-safe `@openpresentation/opf-render/fonts` entry accepts local font bytes. Missing fonts/glyphs fail explicitly; aliases and fallback are opt-in and recorded in `registry.substitutions`. Bundled font license notices travel with embedded SVG fonts.
110
+
111
+ Raster updates produce an HTML gallery and `artifacts/golden/candidate.json`; they never overwrite the approved baseline automatically. See [baseline review and known limitations](test/golden/README.md).
@@ -0,0 +1,48 @@
1
+ /** Curated substitution policy. Metric means upstream intent, not universal pixel identity. */
2
+ 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});
3
+ const visual = (requestedFamily, substitutes, note = "Approximate appearance; measure again and expect reflow.") => ({requestedFamily, substitutes, compatibility:"visual", note});
4
+ export const FONT_COMPATIBILITY = Object.freeze([
5
+ metric("Calibri",["Carlito"],"https://github.com/googlefonts/carlito"),
6
+ metric("Cambria",["Caladea"],"https://chromium.googlesource.com/external/fontconfig/+/refs/heads/main/conf.d/30-metric-aliases.conf","Fontconfig compatibility mapping; font-version and repertoire differences require verification."),
7
+ metric("Arial",["Arimo","Liberation Sans"],"https://github.com/google/fonts/blob/main/ofl/arimo/DESCRIPTION.en_us.html"),
8
+ metric("Times New Roman",["Tinos","Liberation Serif"],"https://github.com/google/fonts/blob/main/ofl/tinos/DESCRIPTION.en_us.html"),
9
+ metric("Courier New",["Cousine","Liberation Mono"],"https://github.com/google/fonts/blob/main/apache/cousine/DESCRIPTION.en_us.html"),
10
+ {...visual("Georgia",["Gelasio"],"Individual advances match the tested Georgia, but optional ligatures change shaped widths by up to 2.02% in our corpus. Treated as approximate until feature parity is supported."),source:"https://github.com/SorkinType/Gelasio"},
11
+ metric("Arial Narrow",["Liberation Sans Narrow"],"https://github.com/liberationfonts/liberation-sans-narrow","Separate legacy distribution and license; not included in Liberation 2 or this bundle."),
12
+ visual("Calibri Light",["Carlito"],"The bundled Carlito family has no Light face. This replacement is approximate."),
13
+ visual("Aptos",["Source Sans 3","Carlito"]),
14
+ visual("Aptos Display",["Source Sans 3","Carlito"]),
15
+ visual("Aptos Narrow",["Liberation Sans Narrow"]),
16
+ visual("Aptos Mono",["Cousine","Liberation Mono"]),
17
+ visual("Segoe UI",["Open Sans","Noto Sans"]),
18
+ visual("Tahoma",["Arimo","Noto Sans"]),
19
+ visual("Verdana",["DejaVu Sans"]),
20
+ visual("Trebuchet MS",["Ubuntu","Open Sans"]),
21
+ visual("Century Gothic",["Montserrat","URW Gothic"]),
22
+ visual("Franklin Gothic",["Libre Franklin"]),
23
+ visual("Gill Sans",["Cabin","Lato"]),
24
+ visual("Garamond",["EB Garamond"]),
25
+ visual("Palatino Linotype",["TeX Gyre Pagella"]),
26
+ visual("Book Antiqua",["TeX Gyre Pagella"]),
27
+ visual("Bookman Old Style",["TeX Gyre Bonum"]),
28
+ visual("Century Schoolbook",["TeX Gyre Schola"]),
29
+ visual("Consolas",["Inconsolata","Cousine"]),
30
+ visual("Lucida Console",["Liberation Mono","Cousine"]),
31
+ visual("Lucida Sans Unicode",["Noto Sans","DejaVu Sans"]),
32
+ visual("Impact",["Anton","Oswald"]),
33
+ visual("Rockwell",["Arvo","Roboto Slab"]),
34
+ visual("Baskerville",["Libre Baskerville"]),
35
+ visual("Bodoni MT",["Libre Bodoni"]),
36
+ visual("Didot",["GFS Didot"]),
37
+ visual("Candara",["Lato"]),
38
+ visual("Corbel",["Source Sans 3","Lato"]),
39
+ visual("Constantia",["Merriweather"]),
40
+ visual("Ebrima",["Noto Sans"]),
41
+ visual("Microsoft Sans Serif",["Liberation Sans","Arimo"]),
42
+ ...["MS Gothic","Meiryo","Yu Gothic"].map(name=>visual(name,["Noto Sans CJK JP"])),
43
+ visual("SimSun",["Noto Serif CJK SC"]),
44
+ ...["SimHei","Microsoft YaHei"].map(name=>visual(name,["Noto Sans CJK SC"])),
45
+ visual("Malgun Gothic",["Noto Sans CJK KR"]),
46
+ visual("Microsoft JhengHei",["Noto Sans CJK TC"]),
47
+ ].map(rule=>Object.freeze({...rule,substitutes:Object.freeze(rule.substitutes),...(rule.weights?{weights:Object.freeze(rule.weights)}:{})})));
48
+ export const EXPERIMENTAL_FONT_CANDIDATES = Object.freeze([Object.freeze({requestedFamily:"Aptos",substitute:"Akasia",source:"https://codeberg.org/bloudraad/akasia",note:"Upstream claims metric compatibility in twelve styles. Not bundled or automatically selected; OPF conformance testing is pending. No Narrow or Display compatibility is implied."})]);
@@ -0,0 +1,17 @@
1
+ import type {
2
+ FontFaceInput,
3
+ FontRegistry,
4
+ FontRegistryOptions,
5
+ } from "./fonts.js";
6
+ export interface BrowserFontInput extends Omit<FontFaceInput, "data"> {
7
+ data?: Uint8Array;
8
+ url?: string;
9
+ }
10
+ export declare function loadBrowserFontRegistry(
11
+ entries: BrowserFontInput[],
12
+ options?: FontRegistryOptions & {
13
+ document?: Document;
14
+ fetch?: typeof fetch;
15
+ signal?: AbortSignal;
16
+ },
17
+ ): Promise<FontRegistry & { dispose(): void }>;
@@ -0,0 +1,61 @@
1
+ import { createFontRegistry, OPFFontError } from "./fonts.js";
2
+ /** Fetch only host-selected font files, then use those exact bytes for shaping and CSS. */
3
+ export async function loadBrowserFontRegistry(entries, options = {}) {
4
+ const documentRef = options.document ?? globalThis.document;
5
+ const FontFaceRef = documentRef?.defaultView?.FontFace ?? globalThis.FontFace;
6
+ if (!documentRef?.fonts || !FontFaceRef)
7
+ throw new OPFFontError(
8
+ "font-api-unavailable",
9
+ "The browser Font Loading API is required.",
10
+ );
11
+ const fetcher = options.fetch ?? globalThis.fetch;
12
+ const faces = await Promise.all(
13
+ entries.map(async (entry) => {
14
+ if (entry.data) return entry;
15
+ if (!entry.url)
16
+ throw new OPFFontError(
17
+ "invalid-font-source",
18
+ "Supply font bytes or a font-file URL.",
19
+ );
20
+ const response = await fetcher(entry.url, { signal: options.signal });
21
+ if (!response.ok)
22
+ throw new OPFFontError(
23
+ "font-fetch-failed",
24
+ `Could not load font (${response.status}).`,
25
+ { url: entry.url },
26
+ );
27
+ return { ...entry, data: new Uint8Array(await response.arrayBuffer()) };
28
+ }),
29
+ );
30
+ const registry = createFontRegistry(faces, options),
31
+ loaded = [];
32
+ // Normalize family names using the registry's parsed font metadata.
33
+ const embedded = registry.embeddedFonts;
34
+ try {
35
+ await Promise.all(
36
+ faces.map(async (entry, index) => {
37
+ const descriptor = embedded[index];
38
+ const face = new FontFaceRef(
39
+ descriptor.family,
40
+ entry.data.slice().buffer,
41
+ {
42
+ weight: String(descriptor.weight),
43
+ style: descriptor.italic ? "italic" : "normal",
44
+ },
45
+ );
46
+ await face.load();
47
+ loaded.push(face);
48
+ }),
49
+ );
50
+ for (const face of loaded) documentRef.fonts.add(face);
51
+ await documentRef.fonts.ready;
52
+ } catch (error) {
53
+ for (const face of loaded) documentRef.fonts.delete(face);
54
+ throw new OPFFontError("font-load-failed", error.message);
55
+ }
56
+ return Object.assign(registry, {
57
+ dispose() {
58
+ for (const face of loaded) documentRef.fonts.delete(face);
59
+ },
60
+ });
61
+ }
@@ -0,0 +1,4 @@
1
+ import type {FontRegistry,FontRegistryOptions} from "./fonts.js";
2
+ export declare function loadBundledFontRegistry(options?: FontRegistryOptions): Promise<FontRegistry & {fontFiles:string[]}>;
3
+
4
+ export declare function loadOfficeFontRegistry(options?: FontRegistryOptions & {includeBaseFonts?:boolean}): Promise<FontRegistry & {fontFiles:string[]}>;
@@ -0,0 +1,44 @@
1
+ import { readFile } from "node:fs/promises";
2
+ import { createRequire } from "node:module";
3
+ import { createFontRegistry } from "./fonts.js";
4
+ const require = createRequire(import.meta.url);
5
+ /** Bundled, openly licensed faces. No system font discovery or network requests. */
6
+ export async function loadBundledFontRegistry(options = {}) {
7
+ const definitions = [
8
+ ["roboto","400Regular","Roboto_400Regular",400,false],
9
+ ["roboto","500Medium","Roboto_500Medium",500,false],
10
+ ["roboto","600SemiBold","Roboto_600SemiBold",600,false],
11
+ ["roboto","700Bold","Roboto_700Bold",700,false],
12
+ ["roboto","800ExtraBold","Roboto_800ExtraBold",800,false],
13
+ ["roboto","400Regular_Italic","Roboto_400Regular_Italic",400,true],
14
+ ["roboto","700Bold_Italic","Roboto_700Bold_Italic",700,true],
15
+ ["roboto-mono","400Regular","RobotoMono_400Regular",400,false],
16
+ ["roboto-mono","700Bold","RobotoMono_700Bold",700,false],
17
+ ];
18
+ const licenses = Object.fromEntries(await Promise.all(["roboto","roboto-mono"].map(async pkg=>[pkg,await readFile(require.resolve(`@expo-google-fonts/${pkg}/LICENSE_FONT`),"utf8")])));
19
+ const fontFiles = definitions.map(([pkg,directory,name])=>require.resolve(`@expo-google-fonts/${pkg}/${directory}/${name}.ttf`));
20
+ const entries = await Promise.all(definitions.map(async ([pkg,,,weight,italic],index)=>({license:licenses[pkg],data:new Uint8Array(await readFile(fontFiles[index])),weight,italic})));
21
+ return Object.assign(createFontRegistry(entries,options),{fontFiles});
22
+ }
23
+
24
+ /** Six pinned open-source Office substitutes, optionally alongside the base Roboto pack. */
25
+ export async function loadOfficeFontRegistry(options = {}) {
26
+ const packages = ["carlito","caladea","arimo","tinos","cousine","gelasio"];
27
+ const styles = [["400Regular",400,false],["400Regular_Italic",400,true],["700Bold",700,false],["700Bold_Italic",700,true]];
28
+ const entries = [], fontFiles = [];
29
+ for (const pkg of packages) {
30
+ const name = pkg[0].toUpperCase()+pkg.slice(1);
31
+ const license = await readFile(require.resolve(`@expo-google-fonts/${pkg}/LICENSE_FONT`),"utf8");
32
+ for (const [directory,weight,italic] of styles) {
33
+ const path = require.resolve(`@expo-google-fonts/${pkg}/${directory}/${name}_${directory}.ttf`);
34
+ fontFiles.push(path);
35
+ entries.push({data:new Uint8Array(await readFile(path)),weight,italic,license});
36
+ }
37
+ }
38
+ if (options.includeBaseFonts !== false) {
39
+ const base = await loadBundledFontRegistry();
40
+ fontFiles.push(...base.fontFiles);
41
+ for (const face of base.embeddedFonts) entries.push({...face,data:Uint8Array.from(Buffer.from(face.dataUrl.split(",")[1],"base64"))});
42
+ }
43
+ return Object.assign(createFontRegistry(entries,{substitutionPolicy:"metric",...options}),{fontFiles});
44
+ }
@@ -0,0 +1,21 @@
1
+ import type { TextMeasurement, TextStyle } from "@openpresentation/opf/composition";
2
+ export interface FontFaceInput { data: Uint8Array; family?: string; weight?: number; italic?: boolean; postscriptName?: string; license?: string }
3
+ export interface EmbeddedFont { family: string; weight: number; italic?: boolean; dataUrl: string; license?: string }
4
+ export type FontCompatibility = "exact" | "metric" | "visual" | "generic";
5
+ export interface FontResolution { requestedFamily:string; sourceFamily:string; resolvedFamily:string; requestedWeight:number; resolvedWeight:number; italic:boolean; compatibility:FontCompatibility; path?:string; source?:string; note?:string }
6
+ export interface FontRegistryOptions {
7
+ aliases?: Record<string,string>; fallbackFamily?: string; strictGlyphs?: boolean;
8
+ substitutionPolicy?: "none" | "metric" | "visual";
9
+ themeFonts?: Partial<Record<"majorLatin"|"minorLatin"|"majorEastAsia"|"minorEastAsia"|"majorComplexScript"|"minorComplexScript",string>>;
10
+ }
11
+ export declare const FONT_COMPATIBILITY: readonly Readonly<{requestedFamily:string;substitutes:readonly string[];compatibility:"metric"|"visual";weights?:readonly number[];source?:string;note:string}>[];
12
+ export declare const EXPERIMENTAL_FONT_CANDIDATES: readonly Readonly<{requestedFamily:string;substitute:string;source:string;note:string}>[];
13
+ export interface FontRegistry {
14
+ textMeasurement: TextMeasurement;
15
+ clearSubstitutions(): void;
16
+ resolveFont(style: TextStyle): FontResolution;
17
+ readonly embeddedFonts: EmbeddedFont[];
18
+ readonly substitutions: FontResolution[];
19
+ }
20
+ export declare class OPFFontError extends Error { code:string; details:Record<string,unknown>; constructor(code:string,message:string,details?:Record<string,unknown>) }
21
+ export declare function createFontRegistry(entries: FontFaceInput[], options?: FontRegistryOptions): FontRegistry;
package/dist/fonts.js ADDED
@@ -0,0 +1,111 @@
1
+ import { create } from "fontkit";
2
+ import { FONT_COMPATIBILITY } from "./font-compatibility.js";
3
+ export { FONT_COMPATIBILITY, EXPERIMENTAL_FONT_CANDIDATES } from "./font-compatibility.js";
4
+
5
+ export class OPFFontError extends Error {
6
+ constructor(code, message, details = {}) { super(message); this.name = "OPFFontError"; this.code = code; this.details = details; }
7
+ }
8
+ const key = (family, weight, italic) => `${family.toLowerCase()}:${weight}:${!!italic}`;
9
+ function base64(data) {
10
+ let binary = "";
11
+ for (let i = 0; i < data.length; i += 32768) binary += String.fromCharCode(...data.subarray(i, i + 32768));
12
+ return btoa(binary);
13
+ }
14
+ function validFamily(family) {
15
+ if (typeof family !== "string" || !family.trim() || /[\u0000-\u001f"'\\<>;]/.test(family)) throw new OPFFontError("invalid-font-family", "Font family must be a nonempty plain name.");
16
+ return family;
17
+ }
18
+ /** Local font files only. The caller explicitly chooses aliases and fallback. */
19
+ export function createFontRegistry(entries, options = {}) {
20
+ if (!Array.isArray(entries) || !entries.length) throw new OPFFontError("empty-font-registry", "Supply at least one font file.");
21
+ const faces = entries.map(entry => {
22
+ if (!(entry.data instanceof Uint8Array)) throw new OPFFontError("invalid-font-data", "Font data must be a Uint8Array.");
23
+ const data = entry.data.slice();
24
+ let font;
25
+ try { font = create(data, entry.postscriptName); } catch (error) { throw new OPFFontError("invalid-font-data", error.message); }
26
+ if (!font?.layout || !font.unitsPerEm) throw new OPFFontError("font-collection", "Select one font from a collection with postscriptName.");
27
+ const family = validFamily(entry.family ?? font.familyName);
28
+ const weight = entry.weight ?? 400;
29
+ const italic = entry.italic ?? !!font.italicAngle;
30
+ if (!Number.isInteger(weight) || weight < 1 || weight > 1000) throw new OPFFontError("invalid-font-weight", "Font weight must be between 1 and 1000.");
31
+ const signature = String.fromCharCode(...data.subarray(0,4));
32
+ const format = signature === "OTTO" ? "otf" : signature === "wOFF" ? "woff" : signature === "wOF2" ? "woff2" : "ttf";
33
+ return {family,weight,italic,font,data,format,license:entry.license,cache:new Map()};
34
+ });
35
+ const duplicates = new Set();
36
+ for (const face of faces) {
37
+ const id = key(face.family,face.weight,face.italic);
38
+ if (duplicates.has(id)) throw new OPFFontError("duplicate-font-face", `Duplicate face: ${id}`);
39
+ duplicates.add(id);
40
+ }
41
+ const substitutions = new Map();
42
+ const aliases = new Map(Object.entries(options.aliases ?? {}).map(([from,to])=>[validFamily(from).toLowerCase(),validFamily(to)]));
43
+ const policy = options.substitutionPolicy ?? "none";
44
+ if (!["none","metric","visual"].includes(policy)) throw new OPFFontError("invalid-font-policy", "substitutionPolicy must be none, metric, or visual.");
45
+ if (options.fallbackFamily) validFamily(options.fallbackFamily);
46
+ const findFamily = family => faces.filter(face=>face.family.toLowerCase()===family.toLowerCase());
47
+ const resolve = style => {
48
+ const requested = validFamily(style?.fontFamily);
49
+ const weight = style.fontWeight ?? 400;
50
+ if (!Number.isFinite(weight) || weight < 1 || weight > 1000) throw new OPFFontError("invalid-font-weight", "Font weight must be between 1 and 1000.");
51
+ // DrawingML theme tokens are resolved before family lookup, never as literal fonts.
52
+ const themeKey = {"+mj-lt":"majorLatin","+mn-lt":"minorLatin","+mj-ea":"majorEastAsia","+mn-ea":"minorEastAsia","+mj-cs":"majorComplexScript","+mn-cs":"minorComplexScript"}[requested.toLowerCase()];
53
+ if (requested.startsWith("+") && !themeKey) throw new OPFFontError("unresolved-theme-font", `Unknown theme font '${requested}'.`, {path:style.path});
54
+ const family = themeKey ? options.themeFonts?.[themeKey] : requested;
55
+ if (!family || family.startsWith("+")) throw new OPFFontError("unresolved-theme-font", `Supply a concrete theme family for '${requested}'.`, {path:style.path});
56
+ validFamily(family);
57
+ let matching = findFamily(family), compatibility = "exact", rule;
58
+ const encodedSymbol = /^(wingdings(?: [23])?|webdings|symbol)$/i.test(family);
59
+ if (!matching.length && encodedSymbol) throw new OPFFontError("font-encoding-required", `Font '${family}' requires character mapping before substitution.`, {path:style.path,fontFamily:family});
60
+ if (!matching.length && aliases.has(family.toLowerCase())) {
61
+ matching = findFamily(aliases.get(family.toLowerCase())); compatibility = "visual";
62
+ }
63
+ if (!matching.length && policy!=="none") {
64
+ const candidate = FONT_COMPATIBILITY.find(entry=>entry.requestedFamily.toLowerCase()===family.toLowerCase());
65
+ const tier = candidate?.compatibility==="metric" && !candidate.weights.includes(weight) ? "visual" : candidate?.compatibility;
66
+ if (candidate && (policy==="visual" || tier==="metric")) {
67
+ for (const substitute of candidate.substitutes) {
68
+ const available = findFamily(substitute).filter(face=>face.italic===!!style.italic && (tier!=="metric" || face.weight===weight));
69
+ if (available.length) { matching=available; compatibility=tier; rule=candidate; break; }
70
+ }
71
+ }
72
+ }
73
+ // Equations require an explicit math-aware choice; never fall through to body text.
74
+ if (!matching.length && /^(cambria math|stix two math|noto sans math)$/i.test(family)) throw new OPFFontError("math-font-required", `Supply '${family}' or an explicit math-font alias.`, {path:style.path});
75
+ if (!matching.length && options.fallbackFamily) { matching=findFamily(options.fallbackFamily); compatibility="generic"; }
76
+ if (!matching.length) throw new OPFFontError("font-unavailable", `No local font face for '${family}'.`, {path:style.path,fontFamily:family});
77
+ matching = matching.filter(face=>face.italic===!!style.italic);
78
+ if (!matching.length) throw new OPFFontError("font-style-unavailable", `No ${style.italic ? "italic" : "upright"} face for '${family}'.`, {path:style.path});
79
+ matching.sort((a,b)=>Math.abs(a.weight-weight)-Math.abs(b.weight-weight) || a.weight-b.weight);
80
+ const face = matching[0];
81
+ if (face.weight!==weight && compatibility!=="generic") compatibility="visual";
82
+ const resolution={requestedFamily:requested,sourceFamily:family,resolvedFamily:face.family,requestedWeight:weight,resolvedWeight:face.weight,italic:face.italic,compatibility,...(style.path?{path:style.path}:{}),...(rule?{source:rule.source,note:rule.note}:{})};
83
+ if (face.family.toLowerCase()!==family.toLowerCase() || face.weight!==weight) substitutions.set(JSON.stringify([requested,weight,!!style.italic,style.path]),resolution);
84
+ return {face,resolution};
85
+ };
86
+ const resolveFace = style => resolve(style).face;
87
+ const resolveStyle = style => { const face=resolveFace(style); return {...style,fontFamily:face.family,fontWeight:face.weight,italic:face.italic}; };
88
+ const measure = (text,size,style) => {
89
+ const face=resolveFace(style);
90
+ let width=face.cache.get(text);
91
+ if (width===undefined) {
92
+ if (options.strictGlyphs!==false) for(const character of text) {
93
+ if (/\p{Default_Ignorable_Code_Point}/u.test(character)) continue;
94
+ if (!face.font.hasGlyphForCodePoint(character.codePointAt(0))) throw new OPFFontError("missing-glyph", `Font '${face.family}' cannot display U+${character.codePointAt(0).toString(16).toUpperCase()}.`, {path:style.path,fontFamily:face.family,character});
95
+ }
96
+ width=face.font.layout(text).positions.reduce((total,position)=>total+position.xAdvance,0)/face.font.unitsPerEm;
97
+ if (text.length<=2048) {
98
+ if (face.cache.size>=512) face.cache.delete(face.cache.keys().next().value);
99
+ face.cache.set(text,width);
100
+ }
101
+ }
102
+ return width*size;
103
+ };
104
+ return {
105
+ textMeasurement: {measure,resolveStyle},
106
+ resolveFont(style) { return resolve(style).resolution; },
107
+ clearSubstitutions() { substitutions.clear(); },
108
+ get substitutions() { return [...substitutions.values()]; },
109
+ get embeddedFonts() { return faces.map(face=>({family:face.family,weight:face.weight,italic:face.italic,...(face.license ? {license:face.license} : {}),dataUrl:`data:font/${face.format};base64,${base64(face.data)}`})); },
110
+ };
111
+ }
package/dist/index.d.ts CHANGED
@@ -1,3 +1,5 @@
1
+ import type { EmbeddedFont } from "./fonts.js";
2
+ import type { SlideComposition, LayoutDiagnostic, TextMeasurement } from "@openpresentation/opf/composition";
1
3
  export declare const packageName = "@openpresentation/opf-render";
2
4
 
3
5
  export declare const releaseLane: Readonly<{
@@ -30,6 +32,11 @@ export declare const engineDefaults: Readonly<{
30
32
  }>;
31
33
 
32
34
  export interface RenderSvgOptions {
35
+ textMeasurement?: TextMeasurement;
36
+ embeddedFonts?: EmbeddedFont[];
37
+ strictAssets?: boolean;
38
+ imageResolver?: (src: string | undefined, context: { asset: unknown; path: string }) => string | null | undefined;
39
+ onDiagnostic?: (diagnostic: LayoutDiagnostic) => void;
33
40
  slideIndex?: number;
34
41
  trace?: boolean;
35
42
  catalogs?: Record<string, { records?: unknown[] } | unknown[]>;
@@ -58,6 +65,7 @@ export interface ResolvedPresentation {
58
65
  }
59
66
 
60
67
  export interface ResolvedSlide {
68
+ geometry: SlideComposition;
61
69
  index: number;
62
70
  path: string;
63
71
  slide: unknown;