@openpresentation/opf-render 0.0.1 → 0.1.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 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.
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
 
@@ -8,9 +8,48 @@ Deterministic local renderer for Open Presentation Format documents. This repo i
8
8
  - Repository: `OpenPresentation/opf-render`
9
9
  - License: MIT
10
10
  - Compatibility target: `@openpresentation/opf`
11
- - Planned public API: `renderSvg(opf, opts)`, `svgToPng(svg, opts)`, and `svgToPdf(svgs, opts)`
11
+ - Public API: `renderSvg(opf, opts)`, `renderSvgDeck(opf, opts)`, `resolvePresentation(opf, opts)`, `svgToPng(svg, opts)`, and `svgToPdf(svgs, opts)`
12
12
 
13
- Feature implementation is intentionally not in this provisioning slice. The first implementation task owns validation, catalog resolution, placeholder binding, SVG emission, and deterministic golden tests.
13
+ `renderSvg` renders a single slide selected by `opts.slideIndex` (default `0`). `renderSvgDeck` returns one SVG string per slide. Both validate OPF at the boundary via `@openpresentation/opf`, resolve inline and bundled catalogs locally, and emit byte-stable SVG for the same input.
14
+
15
+ ```js
16
+ import { renderSvgDeck } from "@openpresentation/opf-render";
17
+
18
+ const svgs = renderSvgDeck(opf, { trace: true });
19
+ ```
20
+
21
+ Set `trace: true` to stamp rendered SVG elements with `data-opf-path` values such as `slides.0.title`; omit it for smaller production SVG.
22
+
23
+ PNG and PDF conversion APIs are async because they load the local raster/PDF engines on demand:
24
+
25
+ ```js
26
+ import { renderSvgDeck, svgToPdf, svgToPng } from "@openpresentation/opf-render";
27
+
28
+ const svgs = renderSvgDeck(opf);
29
+ const png = await svgToPng(svgs[0], { scale: 1 });
30
+ const pdf = await svgToPdf(svgs, { scale: 1 });
31
+ ```
32
+
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
+
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.
14
53
 
15
54
  ## Runtime Policy
16
55
 
@@ -23,22 +62,50 @@ The package runtime must stay local and deterministic:
23
62
  - No clock, locale, random, or system-font fallback drift in render output
24
63
  - Host applications own auth, storage, queues, analytics, collaboration, and product workflow
25
64
 
65
+ Dependency policy:
66
+
67
+ - `@openpresentation/opf` is the compatibility source for schemas, validation, and bundled catalogs.
68
+ - `@resvg/resvg-js` is used only for local SVG rasterization in Node; it makes no network calls and does not require a browser.
69
+ - `pdf-lib` assembles PDF bytes locally. Metadata timestamps are disabled so repeated PDF output is byte-stable for the same SVG input and options.
70
+ - Bundled OFL Roboto and Roboto Mono TTF files provide the default deterministic font fallback.
71
+ - `svgToPng` and `svgToPdf` disable system-font loading by default. Hosts that require branded fonts should pass explicit `fontFiles` or `fontDirs`; `loadSystemFonts: true` is an opt-in escape hatch and can make output environment-dependent.
72
+
73
+ Browser support boundary: `renderSvg`, `renderSvgDeck`, and `resolvePresentation` are browser-importable pure JavaScript APIs. `svgToPng` and `svgToPdf` are Node APIs in this package version because they depend on the Node build of resvg.
74
+
26
75
  ## Development
27
76
 
28
77
  ```sh
29
78
  npm ci
30
79
  npm run build
80
+ npm run typecheck
31
81
  npm run validate
82
+ npm test
83
+ ```
84
+
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:
86
+
87
+ ```sh
88
+ npm run golden:update
32
89
  ```
33
90
 
34
91
  ## Release Lane
35
92
 
36
- Public npm package publication is handled by `.github/workflows/release.yml` with npm provenance.
93
+ Public npm package publication is handled by `.github/workflows/npm-publish.yml` with npm provenance.
37
94
 
38
95
  Required first-publish setup:
39
96
 
40
97
  1. An npm owner for the `@openpresentation` scope must run the first publish or reserve/grant the `@openpresentation/opf-render` package.
41
- 2. Configure npm Trusted Publishing for GitHub repository `OpenPresentation/opf-render` and workflow `.github/workflows/release.yml`.
42
- 3. Publish by creating a GitHub Release or manually running the Release workflow after CI passes.
98
+ 2. Configure npm Trusted Publishing for GitHub repository `OpenPresentation/opf-render` and workflow `.github/workflows/npm-publish.yml`.
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.
43
100
 
44
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<{
@@ -13,3 +15,100 @@ export declare const runtimePolicy: Readonly<{
13
15
  requiredNetworkCalls: false;
14
16
  deterministicLocalExecution: true;
15
17
  }>;
18
+
19
+ export declare const engineDefaults: Readonly<{
20
+ catalogs: Readonly<Record<string, Readonly<{ source: string }>>>;
21
+ theme: "minimal";
22
+ colorScheme: "cool-horizon";
23
+ language: "english";
24
+ narrative: "classic-story";
25
+ tone: "formal";
26
+ audience: "executives";
27
+ fontScheme: Readonly<{
28
+ pptx: Readonly<{ latin: "aptos"; ea: "microsoft-yahei"; cs: "nirmala-ui" }>;
29
+ google: Readonly<{ latin: "roboto"; ea: "noto-sans-sc"; cs: "noto-sans" }>;
30
+ }>;
31
+ chartTypes: readonly ["stacked-column-3x", "stacked-area-3x", "line-with-markers-3x"];
32
+ }>;
33
+
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;
40
+ slideIndex?: number;
41
+ trace?: boolean;
42
+ catalogs?: Record<string, { records?: unknown[] } | unknown[]>;
43
+ catalogSources?: Record<string, { records?: unknown[] } | unknown[]>;
44
+ }
45
+
46
+ export interface SvgToPngOptions {
47
+ scale?: number;
48
+ background?: string;
49
+ dpi?: number;
50
+ useBundledFonts?: boolean;
51
+ loadSystemFonts?: boolean;
52
+ fontFiles?: string[];
53
+ fontDirs?: string[];
54
+ defaultFontFamily?: string;
55
+ sansSerifFamily?: string;
56
+ monospaceFamily?: string;
57
+ }
58
+
59
+ export interface SvgToPdfOptions extends SvgToPngOptions {}
60
+
61
+ export interface ResolvedPresentation {
62
+ presentation: unknown;
63
+ engineDefaults: typeof engineDefaults;
64
+ slides: ResolvedSlide[];
65
+ }
66
+
67
+ export interface ResolvedSlide {
68
+ geometry: SlideComposition;
69
+ index: number;
70
+ path: string;
71
+ slide: unknown;
72
+ layout: unknown;
73
+ design: {
74
+ theme: unknown;
75
+ colorScheme: unknown;
76
+ fontScheme: unknown;
77
+ dimensions: { width: number; height: number };
78
+ background: unknown;
79
+ backgroundColor: string | null;
80
+ colors: Record<string, string>;
81
+ fonts: Record<"heading" | "body" | "code", string>;
82
+ };
83
+ titleBindings: RenderBinding[];
84
+ contentItems: RenderBinding[];
85
+ blocks: RenderBinding[];
86
+ }
87
+
88
+ export interface RenderBinding {
89
+ type: string;
90
+ field?: string;
91
+ slot?: string;
92
+ value: unknown;
93
+ path: string;
94
+ placeholderIndex?: number;
95
+ regionKey?: string;
96
+ }
97
+
98
+ export declare class OPFRenderError extends Error {
99
+ readonly code: string;
100
+ readonly details: Record<string, unknown>;
101
+ readonly issues?: unknown[];
102
+ readonly path?: string;
103
+ constructor(code: string, message: string, details?: Record<string, unknown>);
104
+ }
105
+
106
+ export declare function resolvePresentation(input: unknown, options?: RenderSvgOptions): ResolvedPresentation;
107
+
108
+ export declare function renderSvg(input: unknown, options?: RenderSvgOptions): string;
109
+
110
+ export declare function renderSvgDeck(input: unknown, options?: RenderSvgOptions): string[];
111
+
112
+ export declare function svgToPng(svg: string | Uint8Array, options?: SvgToPngOptions): Promise<Uint8Array>;
113
+
114
+ export declare function svgToPdf(svgs: string | Uint8Array | Array<string | Uint8Array>, options?: SvgToPdfOptions): Promise<Uint8Array>;
package/dist/index.js CHANGED
@@ -1,15 +1,2 @@
1
- export const packageName = "@openpresentation/opf-render";
2
-
3
- export const releaseLane = Object.freeze({
4
- githubRepository: "OpenPresentation/opf-render",
5
- npmPackage: "@openpresentation/opf-render",
6
- compatibilityPackage: "@openpresentation/opf"
7
- });
8
-
9
- export const runtimePolicy = Object.freeze({
10
- hostedServiceInCriticalPath: false,
11
- telemetry: false,
12
- commercialSdkInCriticalPath: false,
13
- requiredNetworkCalls: false,
14
- deterministicLocalExecution: true
15
- });
1
+ export * from './svg.js';
2
+ export {svgToPng,svgToPdf} from './raster.js';