@openpresentation/opf-render 0.13.1 → 0.15.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/fonts.js CHANGED
@@ -1,323 +1,10 @@
1
- import { create } from "fontkit";
2
- import { FONT_COMPATIBILITY, disabledFeaturesFor } from "./font-compatibility.js";
3
- import { adjustedFontSize, openTypeLanguage, scriptFontAliases, sizeAdjustFor } from "./script-fonts.js";
4
- import { fontPolicyFor } from "./font-policy.js";
5
- import { BUNDLED_FONT_MANIFEST } from "./font-manifest.js";
6
- import { SYMBOL_SCRIPT, isSymbolEncodedFamily, symbolEncodingFor, symbolPreviewFaces } from "./symbol-fonts.js";
7
- export { SYMBOL_SCRIPT, SYMBOL_PLACEHOLDER, SYMBOL_PREVIEW_FACES, SYMBOL_FACE_FAMILIES, SYMBOL_ENCODINGS, SYMBOL_ENCODINGS_SOURCE, isSymbolEncodedFamily, symbolEncodingFor, symbolPreviewFaces, symbolCodeOf, mapSymbolText } from "./symbol-fonts.js";
1
+ // `/fonts`: the font data and helpers a host may read, in an entry that needs neither Node nor a DOM. The pieces that core owns
2
+ // are core's own exports, not copies: the font policy table and its lookup (`FONT_POLICY`, `fontPolicyFor`), the symbol-font
3
+ // code rules (`isSymbolEncodedFamily`, `symbolCodeOf`, `mapSymbolText`) and the script slot of a script (`scriptFontRole`).
4
+ // Fonts are loaded with `loadFonts` from `/fonts-node` or `/fonts-browser`, which return the handle every deck-level function takes.
5
+ export { OPFFontError } from "./font-registry.js";
6
+ export { FONT_POLICY, fontPolicyFor } from "@openpresentation/opf/font-policy";
7
+ export { SYMBOL_SCRIPT, SYMBOL_PLACEHOLDER, SYMBOL_PREVIEW_FACES, SYMBOL_FACE_FAMILIES, isSymbolEncodedFamily, symbolPreviewFaces, symbolCodeOf, mapSymbolText } from "./symbol-fonts.js";
8
8
  export { FONT_COMPATIBILITY, EXPERIMENTAL_FONT_CANDIDATES, disabledFeaturesFor } from "./font-compatibility.js";
9
- export { FONT_POLICY, FONT_POLICY_DECISIONS, FONT_POLICY_SOURCE, fontPolicyFor } from "./font-policy.js";
10
9
  export { EMOJI_FONT_FAMILIES, SCRIPT_FONT_FAMILIES, SCRIPT_FONT_REPLACEMENTS, createScriptFonts, createScriptTextMeasurement, designatedFamilies, glyphFallbackFamilies, detectScripts, hasEmojiPresentation, hasMathNotation, itemizeScripts, openTypeLanguage, scriptFontAliases, scriptFontRole, sizeAdjustFor, adjustedFontSize, lineAscentFor, baselineShift, scriptOfCharacter, textRole } from "./script-fonts.js";
11
10
  export { COLOR_FONT_FACES } from "./color-fonts.js";
12
-
13
- export class OPFFontError extends Error {
14
- constructor(code, message, details = {}) { super(message); this.name = "OPFFontError"; this.code = code; this.details = details; }
15
- }
16
- const key = (family, weight, italic) => `${family.toLowerCase()}:${weight}:${!!italic}`;
17
- function base64(data) {
18
- let binary = "";
19
- for (let i = 0; i < data.length; i += 32768) binary += String.fromCharCode(...data.subarray(i, i + 32768));
20
- return btoa(binary);
21
- }
22
- function validFamily(family) {
23
- if (typeof family !== "string" || !family.trim() || /[\u0000-\u001f"'\\<>;]/.test(family)) throw new OPFFontError("invalid-font-family", "Font family must be a nonempty plain name.");
24
- return family;
25
- }
26
- // The open families load with the office pack (prepareNodeFonts({pack:"office"})).
27
- const packFor = family => { const pack = BUNDLED_FONT_MANIFEST.packages.find(pkg=>pkg.renamedFrom?.toLowerCase()===family.toLowerCase() || pkg.faces.some(face=>face.family.toLowerCase()===family.toLowerCase()))?.pack; return pack==="open" ? "office" : pack; };
28
- const percent = value => `${(value*100).toFixed(1)}%`;
29
- /** FF-31: say why a family has no face and what the caller can do, from the OPF font policy. */
30
- function unavailableFontError(family, style, policy) {
31
- const row = fontPolicyFor(family), details = {path:style.path, fontFamily:family, substitutionPolicy:policy};
32
- const hook = `supply licensed '${family}' font files (prepareNodeFonts({faces}) or createFontRegistry entries)`;
33
- if (!row) return new OPFFontError("font-unavailable", `No local font face for '${family}'. '${family}' is not in the OPF font policy table, so there is no declared replacement: ${hook}, add an alias, or set fallbackFamily.`, {...details, licenseClass:"unknown"});
34
- Object.assign(details, {licenseClass:row.licenseClass});
35
- if (!row.replacement) {
36
- const pack = packFor(family);
37
- return new OPFFontError("font-unavailable", `No local font face for '${family}'. '${family}' is ${row.licenseClass === "open" ? `openly licensed (${row.license})` : row.license}${pack ? `; load the '${pack}' font pack` : `; no pinned renderer pack ships it yet, so supply its font files (for example from @expo-google-fonts/${family.toLowerCase().replace(/ +/g,"-")})`}.`, {...details, ...(pack?{pack}:{})});
38
- }
39
- const {family:replacement, compatibility, measured} = row.replacement;
40
- const candidates = [replacement, ...(row.alternates ?? [])], packs = [...new Set(candidates.map(packFor).filter(Boolean))];
41
- Object.assign(details, {replacement, replacementCompatibility:compatibility, candidates, ...(packs.length?{packs}:{}), ...(measured?{measured}:{}), ...(row.replacement.decision?{decision:row.replacement.decision}:{})});
42
- const delta = measured ? ` (measured mean width difference ${percent(measured.meanAbsWidthDelta)}, max ${percent(measured.maxAbsWidthDelta)})` : " (not measured against the real font)";
43
- const owner = `'${family}' is ${row.license} and OPF never bundles or embeds it.`;
44
- if (compatibility === "visual" && policy !== "visual")
45
- return new OPFFontError("font-unavailable", `No local font face for '${family}'. ${owner} Its declared replacement ${replacement} is visual only${delta}, which substitutionPolicy '${policy}' does not allow. Pass substitutionPolicy: 'visual' to preview with ${candidates.join(" or ")}, or ${hook}. The PPTX names '${family}' either way.`, details);
46
- if (policy === "none")
47
- return new OPFFontError("font-unavailable", `No local font face for '${family}'. ${owner} Its declared ${compatibility} replacement is ${replacement}${delta}; substitutionPolicy 'none' does not allow it. Pass substitutionPolicy: '${compatibility}', or ${hook}.`, details);
48
- return new OPFFontError("font-unavailable", `No local font face for '${family}'. ${owner} None of its replacements (${candidates.join(", ")}) is loaded${packs.length ? `; load the ${packs.map(pack=>"'"+pack+"'").join(" or ")} font pack` : ""}, or ${hook}. The PPTX names '${family}' either way.`, details);
49
- }
50
- /** Local font files only. The caller explicitly chooses aliases and fallback. */
51
- // fontkit 2.0.4 cannot decode every OpenType lookup: its GSUB type 8 (reverse chaining contextual single substitution) struct lacks the
52
- // backtrackGlyphCount field, so the lookup fails with "Not a fixed size" when a feature that references it is applied, and it has no processor
53
- // for the type anyway. Noto Sans Mongolian 3.x has such a lookup under calt and rclt in every script, so every shaping of the face threw (FF-44).
54
- // A lookup that cannot be decoded is treated as one that applies nothing (no subtables), so the rest of the feature still runs: advances equal
55
- // a browser's for the face's Latin and Mongolian samples (test/mongolian-shaping.mjs). Applied only after shaping failed, once per font.
56
- const lookupsGuarded = new WeakSet();
57
- export function skipUndecodableLookups(font) {
58
- if (lookupsGuarded.has(font)) return false;
59
- lookupsGuarded.add(font);
60
- let guarded = false;
61
- for (const tag of ["GSUB", "GPOS"]) {
62
- let list;
63
- try { list = font[tag]?.lookupList; } catch { continue; }
64
- if (typeof list?.get !== "function") continue;
65
- const get = list.get.bind(list);
66
- list.get = index => { try { return get(index); } catch { return { lookupType: 1, flags: {}, subTables: [] }; } };
67
- guarded = true;
68
- }
69
- return guarded;
70
- }
71
-
72
- /**
73
- * The face a registry draws for `family` (a validated concrete family: theme tokens are already resolved) in `style`, chosen
74
- * from `faces` ({family, familyGroup, weight, italic, fallbackOnly?, scripts?}) under the substitution policy, aliases and
75
- * fallback family, or an OPFFontError when none applies. Shared by the registry's resolution and by the lazy loader's face
76
- * planning (lazy-fonts.js), so the browser loads exactly the faces Node would draw with everything loaded.
77
- */
78
- export function pickFace(faces, family, style, {policy = "none", aliases = new Map(), fallbackFamily} = {}) {
79
- const findFamily = name => faces.filter(face=>face.family.toLowerCase()===name.toLowerCase() || face.familyGroup.toLowerCase()===name.toLowerCase());
80
- const weight = style.fontWeight ?? 400;
81
- let matching = findFamily(family), compatibility = "exact", rule, targetWeight = weight, styleFallback = false, via = "family", encoding;
82
- // FF-45: a symbol-encoded family (Symbol, Wingdings, Wingdings 2, Wingdings 3, Webdings) whose own face is not loaded
83
- // draws the Unicode equivalent of each code with the first loaded open symbol face (symbol-fonts.js: Noto Sans Symbols 2,
84
- // Noto Sans Symbols, Noto Sans Math, or the office pack's Noto Sans). The resolved style carries the encoding so the
85
- // planner maps every character; the PPTX keeps the family and the original codes. Without any such face the caller's
86
- // fallback family applies, else the request fails as before (font-encoding-required) and names the pack to load.
87
- if (!matching.length && isSymbolEncodedFamily(family)) {
88
- for (const candidate of symbolPreviewFaces(family)) { const pool = findFamily(candidate); if (pool.length) { matching = pool; break; } }
89
- if (matching.length) { compatibility = "visual"; via = "encoding"; encoding = symbolEncodingFor(family).family; }
90
- else if (!fallbackFamily) throw new OPFFontError("font-encoding-required", `Font '${family}' is symbol-encoded and no open symbol face is loaded: load the symbol faces (prepareNodeFonts or loadBrowserFontRegistry with scripts: ['${SYMBOL_SCRIPT}'], or scripts: 'auto' with the presentation) or supply '${family}' itself.`, {path:style.path,fontFamily:family,scripts:[SYMBOL_SCRIPT],faces:[...symbolPreviewFaces(family)]});
91
- }
92
- if (!matching.length && aliases.has(family.toLowerCase())) {
93
- matching = findFamily(aliases.get(family.toLowerCase())); compatibility = "visual"; via = "alias";
94
- }
95
- if (!matching.length && policy!=="none") {
96
- const candidate = FONT_COMPATIBILITY.find(entry=>entry.requestedFamily.toLowerCase()===family.toLowerCase());
97
- const tier = candidate?.compatibility==="metric" && !candidate.weights.includes(weight) ? "visual" : candidate?.compatibility;
98
- // A declared visual replacement is used in visual mode, and in metric mode only when the
99
- // policy keeps it as a metric-mode fallback (Cambria -> Caladea); it is still reported visual.
100
- // In metric mode the fallback applies only where the pre-FF-31 metric rule did: the declared
101
- // replacement at weights 400 and 700, normal or italic, with an exact face weight.
102
- const metricFallback = policy!=="visual" && candidate?.metricModeFallback===true && [400,700].includes(weight);
103
- const allowVisual = policy==="visual" || metricFallback;
104
- if (candidate && (allowVisual || tier==="metric")) {
105
- // A family that names its weight (Segoe UI Semibold, Arial Black) selects that weight in
106
- // the replacement; its bold style link still selects bold (FF-31).
107
- const wanted = candidate.weight ? (weight>=600 ? Math.max(candidate.weight,700) : candidate.weight) : weight;
108
- for (const [index, substitute] of candidate.substitutes.entries()) {
109
- // Only the declared replacement can carry the row's metric claim; an alternate is visual.
110
- const substituteTier = index===0 ? tier : "visual";
111
- if (substituteTier==="visual" && !(policy==="visual" || metricFallback && index===0)) continue;
112
- const exactWeight = substituteTier==="metric" || policy!=="visual";
113
- const pool = findFamily(substitute).filter(face=>!face.fallbackOnly && (!exactWeight || face.weight===weight));
114
- let available = pool.filter(face=>face.italic===!!style.italic);
115
- // Visual replacements without the requested style draw the other one and say so.
116
- if (!available.length && substituteTier==="visual" && policy==="visual" && pool.length) { available = pool.filter(face=>!face.italic); styleFallback = available.length>0; }
117
- if (available.length) { matching=available; compatibility=substituteTier; rule={...candidate, substituteIndex:index}; targetWeight=wanted; via="replacement"; break; }
118
- }
119
- }
120
- }
121
- // FF-45: Cambria Math previews with STIX Two Math (the math pack, loaded as a script face: the alias above resolves it
122
- // under every policy). Without the pack the policy error below names the pack; the former `math-font-required` failure is gone.
123
- if (!matching.length && fallbackFamily) { matching=findFamily(fallbackFamily); compatibility="generic"; via="fallback"; }
124
- if (!matching.length) throw unavailableFontError(family, style, policy);
125
- const styled = styleFallback ? matching : matching.filter(face=>face.italic===!!style.italic);
126
- // Script replacement faces have no italics; use upright glyphs and advances. RR-17 (FF-41): so does a visual preview of a
127
- // single-style display face (Anton, Bebas Neue: the families Impact and the open font schemes name), reported visual; PowerPoint
128
- // synthesizes the slant of such a face too, so the advances agree and the preview no longer refuses the deck. A strict registry
129
- // (policy none) still reports font-style-unavailable.
130
- if (!styled.length && style.italic && matching.length && (matching.every(face=>face.scripts) || policy==="visual" && matching.every(face=>!face.italic))) { if (compatibility!=="generic") compatibility="visual"; }
131
- else matching = styled;
132
- if (!matching.length) throw new OPFFontError("font-style-unavailable", `No ${style.italic ? "italic" : "upright"} face for '${family}'.`, {path:style.path});
133
- matching.sort((a,b)=>Math.abs(a.weight-targetWeight)-Math.abs(b.weight-targetWeight) || a.weight-b.weight);
134
- const face = matching[0];
135
- if ((face.weight!==targetWeight || styleFallback) && compatibility!=="generic") compatibility="visual";
136
- return {face, compatibility, rule, styleFallback, via, ...(encoding ? {encoding} : {})};
137
- }
138
-
139
- export function createFontRegistry(entries, options = {}) {
140
- if (!Array.isArray(entries) || !entries.length) throw new OPFFontError("empty-font-registry", "Supply at least one font file.");
141
- const makeFace = entry => {
142
- if (!(entry.data instanceof Uint8Array)) throw new OPFFontError("invalid-font-data", "Font data must be a Uint8Array.");
143
- const data = entry.data.slice();
144
- let font;
145
- try { font = create(data, entry.postscriptName); } catch (error) { throw new OPFFontError("invalid-font-data", error.message); }
146
- if (!font?.layout || !font.unitsPerEm) throw new OPFFontError("font-collection", "Select one font from a collection with postscriptName.");
147
- const family = validFamily(entry.family ?? font.familyName);
148
- // OpenType groups Medium/SemiBold/ExtraBold under a preferred family, while
149
- // retaining separate legacy families for four-style native font selectors.
150
- // An explicit caller rename owns its namespace and must not add this alias.
151
- const familyGroup = entry.family && entry.family.toLowerCase() !== font.familyName?.toLowerCase()
152
- ? family : validFamily(font.getName?.('preferredFamily', 'en') ?? family);
153
- const linking = font['OS/2']?.fsSelection ?? font.head?.macStyle;
154
- const fontFace = linking && typeof linking.bold === 'boolean' && typeof linking.italic === 'boolean'
155
- ? {family:validFamily(font.getName?.('fontFamily', 'en') ?? font.familyName ?? family),bold:linking.bold,italic:linking.italic} : undefined;
156
- const weight = entry.weight ?? 400;
157
- const italic = entry.italic ?? !!font.italicAngle;
158
- if (!Number.isInteger(weight) || weight < 1 || weight > 1000) throw new OPFFontError("invalid-font-weight", "Font weight must be between 1 and 1000.");
159
- const signature = String.fromCharCode(...data.subarray(0,4));
160
- const format = signature === "OTTO" ? "otf" : signature === "wOFF" ? "woff" : signature === "wOF2" ? "woff2" : "ttf";
161
- // Script replacement faces (FF-19) declare the ISO 15924 scripts they serve.
162
- const scripts = Array.isArray(entry.scripts) && entry.scripts.length ? Object.freeze(entry.scripts.map(String)) : undefined;
163
- // "used": a bundled face is embedded in an SVG only when the slide's text names its family.
164
- if (entry.embed !== undefined && entry.embed !== "always" && entry.embed !== "used") throw new OPFFontError("invalid-font-embed", "Font embed must be 'always' or 'used'.");
165
- // A fallback-only face (the default Noto Sans) serves glyph fallback and explicit requests by its own name,
166
- // but never stands in for another family, so loading it does not change what any other font previews with.
167
- return {family,familyGroup,fontFace,weight,italic,font,data,format,license:entry.license,embed:entry.embed,scripts,fallbackOnly:entry.fallbackOnly===true,cache:new Map()};
168
- };
169
- const faces = [], duplicates = new Set(), familySlots = new Map();
170
- // Adds faces atomically: every check runs before any face is registered.
171
- const register = added => {
172
- const ids = new Set(duplicates), slots = new Map(familySlots);
173
- for (const face of added) {
174
- const id = key(face.family,face.weight,face.italic);
175
- if (ids.has(id)) throw new OPFFontError("duplicate-font-face", `Duplicate face: ${id}`);
176
- ids.add(id);
177
- }
178
- for (const face of added) for (const family of new Set([face.family,face.familyGroup])) {
179
- const id=key(family,face.weight,face.italic),previous=slots.get(id);
180
- if(previous && previous!==face) throw new OPFFontError('ambiguous-font-face', `Multiple physical faces match '${family}' at weight ${face.weight}.`, {fontFamily:family,fontWeight:face.weight,italic:face.italic});
181
- slots.set(id,face);
182
- }
183
- faces.push(...added);
184
- for (const id of ids) duplicates.add(id);
185
- for (const [id,face] of slots) familySlots.set(id,face);
186
- };
187
- register(entries.map(makeFace));
188
- const substitutions = new Map();
189
- // Loaded script replacement faces stand in for the proprietary script fonts
190
- // they replace (Meiryo -> Noto Sans JP). Caller aliases take precedence.
191
- let aliases = new Map();
192
- const buildAliases = () => {
193
- const scriptAliases = scriptFontAliases(new Set(faces.filter(face=>face.scripts&&!face.fallbackOnly).map(face=>face.family)));
194
- aliases = new Map(Object.entries({...scriptAliases,...options.aliases}).map(([from,to])=>[validFamily(from).toLowerCase(),validFamily(to)]));
195
- };
196
- buildAliases();
197
- const policy = options.substitutionPolicy ?? "none";
198
- if (!["none","metric","visual"].includes(policy)) throw new OPFFontError("invalid-font-policy", "substitutionPolicy must be none, metric, or visual.");
199
- if (options.fallbackFamily) validFamily(options.fallbackFamily);
200
- const resolve = style => {
201
- const requested = validFamily(style?.fontFamily);
202
- const weight = style.fontWeight ?? 400;
203
- if (!Number.isFinite(weight) || weight < 1 || weight > 1000) throw new OPFFontError("invalid-font-weight", "Font weight must be between 1 and 1000.");
204
- // DrawingML theme tokens are resolved before family lookup, never as literal fonts.
205
- const themeKey = {"+mj-lt":"majorLatin","+mn-lt":"minorLatin","+mj-ea":"majorEastAsia","+mn-ea":"minorEastAsia","+mj-cs":"majorComplexScript","+mn-cs":"minorComplexScript"}[requested.toLowerCase()];
206
- if (requested.startsWith("+") && !themeKey) throw new OPFFontError("unresolved-theme-font", `Unknown theme font '${requested}'.`, {path:style.path});
207
- const family = themeKey ? options.themeFonts?.[themeKey] : requested;
208
- if (!family || family.startsWith("+")) throw new OPFFontError("unresolved-theme-font", `Supply a concrete theme family for '${requested}'.`, {path:style.path});
209
- validFamily(family);
210
- const {face, compatibility, rule, styleFallback, via, encoding} = pickFace(faces, family, style, {policy, aliases, fallbackFamily:options.fallbackFamily});
211
- // FF-31: `substitute` is true whenever the face is not the chosen family itself (policy
212
- // replacement, alias, script replacement or fallback). Exporters keep writing sourceFamily.
213
- const substitute = via!=="family", policyRow = substitute ? fontPolicyFor(family) : undefined;
214
- // RR-38: a policy row can scale its replacement's size (Arabic Typesetting -> Noto Naskh Arabic: 0.64) so measured and drawn
215
- // advances approximate the real font's. It holds only for the face the multiplier was measured on.
216
- const sizeAdjust = sizeAdjustFor(policyRow, face.family);
217
- const measured = rule?.measured && rule.substituteIndex===0 ? rule.measured : undefined;
218
- const resolution={requestedFamily:requested,sourceFamily:family,resolvedFamily:face.family,requestedWeight:weight,resolvedWeight:face.weight,italic:face.italic,compatibility,substitute,...(styleFallback?{styleFallback:true}:{}),...(face.fontFace?{fontFace:{...face.fontFace}}:{}),...(style.path?{path:style.path}:{}),...(rule?{source:rule.source,note:rule.note,...(rule.decision?{decision:rule.decision}:{})}:{}),...(measured?{measured:{...measured}}:{}),...(policyRow?{licenseClass:policyRow.licenseClass,availability:[...policyRow.availability]}:{}),...(sizeAdjust?{sizeAdjust}:{}),...(encoding?{symbolEncoding:encoding}:{})};
219
- if (face.family.toLowerCase()!==family.toLowerCase() || face.weight!==weight || face.italic!==!!style.italic) substitutions.set(JSON.stringify([requested,weight,!!style.italic,style.path]),resolution);
220
- return {face,resolution};
221
- };
222
- const resolveFace = style => resolve(style).face;
223
- const resolveStyle = style => {
224
- const {face,resolution}=resolve(style),resolved={...style,fontFamily:face.family,fontWeight:face.weight,italic:face.italic};
225
- // Never carry a stale selection from a previously resolved style.
226
- delete resolved.fontFace;
227
- delete resolved.symbolEncoding;
228
- if(face.fontFace) resolved.fontFace={...face.fontFace};
229
- // FF-45: the planner maps a symbol-encoded family's codes (symbol-fonts.js); the face only lends its glyphs.
230
- if(resolution.symbolEncoding) resolved.symbolEncoding=resolution.symbolEncoding;
231
- return resolved;
232
- };
233
- const metrics = (text,size,style,includeOutline=false) => {
234
- if (typeof text!=='string'||!Number.isFinite(size)||size<=0) throw new OPFFontError('invalid-text-measurement','Text measurement requires a string and a positive finite font size.');
235
- const {face,resolution}=resolve(style);
236
- // A BCP-47 `lang` selects the font's OpenType language system, as a browser
237
- // does for an element's lang (FF-19). Without it, shaping is unchanged.
238
- const language=openTypeLanguage(style.lang),key=language?`${language}\u0000${text}`:text;
239
- let value=face.cache.get(key);
240
- if (value===undefined) {
241
- if (options.strictGlyphs!==false) for(const character of text) {
242
- if (/\p{Default_Ignorable_Code_Point}/u.test(character)) continue;
243
- const code=character.codePointAt(0);
244
- if (!face.font.hasGlyphForCodePoint(code)) {
245
- // No loaded face has it: the script faces for this text are not loaded (glyph fallback needs a face to fall back to).
246
- const loadedElsewhere=faces.some(other=>other!==face&&other.font.hasGlyphForCodePoint(code));
247
- throw new OPFFontError("missing-glyph", `Font '${face.family}' cannot display U+${code.toString(16).toUpperCase()}.${loadedElsewhere?"":" No loaded font has it, so glyph fallback has no face to fall back to; if it is a script character, load that script's font (prepareNodeFonts scripts: 'auto' with the presentation, or registry.ensureScripts(presentation) in a browser)."}`, {path:style.path,fontFamily:face.family,character,...(loadedElsewhere?{}:{loadedFaceHasGlyph:false})});
248
- }
249
- }
250
- }
251
- if(value===undefined||includeOutline&&!Object.hasOwn(value,'outline')) {
252
- // FF-31: features a policy row turns off in its replacement (Gelasio for Georgia: liga, clig), the
253
- // same ones the SVG output turns off, so measured and drawn advances agree.
254
- const off=disabledFeaturesFor(face.family),policyFeatures=off&&Object.fromEntries(off.map(tag=>[tag,false]));
255
- const shape=extra=>{const features=policyFeatures||extra?{...policyFeatures,...extra}:undefined;return language?face.font.layout(text,features,undefined,language):features?face.font.layout(text,features):face.font.layout(text);};
256
- let run;
257
- try { run=shape(); }
258
- catch (error) {
259
- // A lookup fontkit cannot decode (GSUB type 8 in Noto Sans Mongolian) applies nothing; shape again with the rest (FF-44).
260
- if (skipUndecodableLookups(face.font)) { try { run=shape(); } catch { /* the mark retry below */ } }
261
- // fontkit rejects some mark-attachment lookups (null anchors, for example
262
- // Gurmukhi tippi in Noto Sans Gurmukhi). Mark positioning moves marks
263
- // without changing advances, so measure again without it (FF-19).
264
- if (!run) {
265
- try { run=shape({abvm:false,blwm:false,mark:false,mkmk:false}); }
266
- catch { throw new OPFFontError("font-shaping-failed", `Font '${face.family}' cannot shape this text.`, {path:style.path,fontFamily:face.family,cause:error?.message}); }
267
- }
268
- }
269
- value={width:run.positions.reduce((total,position)=>total+position.xAdvance,0)/face.font.unitsPerEm,...value};
270
- if(includeOutline) {
271
- const unit=face.font.unitsPerEm;
272
- let bounds=null;
273
- // FF-45: fontkit reads COLR v0 layers only; a COLRv1 colour face (Noto Color Emoji) has no outlines it can bound (its
274
- // glyf outlines are empty), so its ink box is the face's em box over the run: the colour image fills the em square.
275
- try { bounds=run.bbox; } catch { bounds=null; }
276
- if(bounds&&[bounds.minX,bounds.minY,bounds.maxX,bounds.maxY].every(Number.isFinite)) value.outline={x:bounds.minX/unit,y:-bounds.maxY/unit,width:bounds.width/unit,height:bounds.height/unit};
277
- else if(face.font.directory?.tables?.COLR&&value.width>0) { const ascent=face.font.ascent/unit,descent=face.font.descent/unit; value.outline={x:0,y:-ascent,width:value.width,height:ascent-descent}; }
278
- else value.outline=null;
279
- }
280
- if (text.length<=2048) {
281
- if (face.cache.size>=512&&!face.cache.has(key)) face.cache.delete(face.cache.keys().next().value);
282
- face.cache.set(key,value);
283
- }
284
- }
285
- // RR-38: the replacement is measured at its adjusted size, the one the SVG draws it at.
286
- return {value,scale:resolution.sizeAdjust??1};
287
- };
288
- const measure=(text,size,style)=>{const {value,scale}=metrics(text,size,style);return value.width*adjustedFontSize(size,scale===1?undefined:scale);};
289
- const outlineBounds=(text,size,style)=>{
290
- const {value,scale}=metrics(text,size,style,true),bounds=value.outline,drawn=adjustedFontSize(size,scale===1?undefined:scale);
291
- return bounds===null?null:{x:bounds.x*drawn,y:bounds.y*drawn,width:bounds.width*drawn,height:bounds.height*drawn};
292
- };
293
- return {
294
- // resolveFont lets exporters tell a substitute from the chosen family (FF-31).
295
- textMeasurement: {measure,resolveStyle,outlineBounds,resolveFont:style=>resolve(style).resolution},
296
- resolveFont(style) { return resolve(style).resolution; },
297
- clearSubstitutions() { substitutions.clear(); },
298
- get substitutions() { return [...substitutions.values()]; },
299
- // Faces flagged embed:"used" (the open pack, FF-31) are not in this eager list: supplied with selectEmbeddedFonts (or
300
- // prepareNodeFonts().options.embeddedFonts) they are embedded only in SVGs whose text names their family.
301
- get embeddedFonts() { return embedded(face=>face.embed!=="used"); },
302
- /** Parsed face metadata in entry order, without encoding font bytes. */
303
- describeFaces: ()=>faces.map(face=>({family:face.family,weight:face.weight,italic:face.italic,...(face.scripts?{scripts:[...face.scripts]}:{}),...(face.fallbackOnly?{fallbackOnly:true}:{})})),
304
- /** Embedded faces for which `predicate({family,weight,italic,scripts})` holds; large script faces can be left to raster fontFiles. */
305
- selectEmbeddedFonts: predicate=>embedded(predicate),
306
- /** True when a loaded script-pack face has a glyph for the character (FF-19 glyph fallback planning). */
307
- scriptFacesCover: character => faces.some(face => face.scripts && face.font.hasGlyphForCodePoint(character.codePointAt(0))),
308
- /**
309
- * Register more faces in this registry (FF-19: script faces are added once a document needs
310
- * them). Atomic: on an error no face is added. Returns the added faces' metadata. Text
311
- * measurements planned before the call may not know the new faces; plan again afterwards.
312
- */
313
- addFaces(added) {
314
- if (!Array.isArray(added)) throw new OPFFontError("invalid-font-data", "addFaces requires an array of font entries.");
315
- const made = added.map(makeFace);
316
- register(made);
317
- buildAliases();
318
- substitutions.clear();
319
- return made.map(face=>({family:face.family,weight:face.weight,italic:face.italic,...(face.scripts?{scripts:[...face.scripts]}:{})}));
320
- },
321
- };
322
- function embedded(predicate) { return faces.filter(face=>predicate({family:face.family,weight:face.weight,italic:face.italic,scripts:face.scripts,embed:face.embed,fallbackOnly:face.fallbackOnly})).map(face=>({family:face.family,weight:face.weight,italic:face.italic,...(face.license ? {license:face.license} : {}),...(face.embed==="used" ? {embed:"used"} : {}),dataUrl:`data:font/${face.format};base64,${base64(face.data)}`})); }
323
- }
package/dist/index.d.ts CHANGED
@@ -1,4 +1,5 @@
1
- import type { EmbeddedFont, ScriptFonts } from "./fonts.js";
1
+ import type { RenderFonts, ScriptFonts } from "./fonts.js";
2
+ import type { Catalog, Finding, UnresolvedReferenceDiagnostic } from "@openpresentation/opf";
2
3
  import type { SlideComposition, LayoutDiagnostic, TextMeasurement } from "@openpresentation/opf/composition";
3
4
  export declare const packageName = "@openpresentation/opf-render";
4
5
 
@@ -16,19 +17,12 @@ export declare const runtimePolicy: Readonly<{
16
17
  deterministicLocalExecution: true;
17
18
  }>;
18
19
 
20
+ /** What draws when nothing resolves: core's engine defaults (OPF 0.15 ships no catalog records), and the chart type a chart without `type` previews as. */
19
21
  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"];
22
+ theme: typeof import("@openpresentation/opf/composition").ENGINE_DEFAULT_THEME;
23
+ colorScheme: typeof import("@openpresentation/opf/composition").ENGINE_DEFAULT_COLOR_SCHEME;
24
+ fontScheme: typeof import("@openpresentation/opf/composition").ENGINE_DEFAULT_FONT_SCHEME;
25
+ chartType: string;
32
26
  }>;
33
27
 
34
28
  export type RenderDiagnostic = LayoutDiagnostic | {
@@ -41,17 +35,10 @@ export type RenderDiagnostic = LayoutDiagnostic | {
41
35
  scripts: string[];
42
36
  characters: string[];
43
37
  } | {
44
- code: "unsupported-pattern" | "variable-example-used" | "date-needs-value" | "language-preview-unavailable" | "language-preview-unresolved" | "paragraph-direction-unavailable";
38
+ code: "unsupported-pattern" | "variable-example-used" | "variable-builtin-missing" | "date-needs-value" | "language-preview-unresolved";
45
39
  path: string;
46
40
  message: string;
47
- } | {
48
- /** A font-scheme id matched no record; the default font scheme (`aptos`) was used as the base. */
49
- code: "unresolved-font-scheme";
50
- path: string;
51
- message: string;
52
- id: string;
53
- fallback: string;
54
- } | {
41
+ } | UnresolvedReferenceDiagnostic | {
55
42
  code: "unresolved-asset";
56
43
  path: string;
57
44
  message: string;
@@ -63,12 +50,18 @@ export type RenderDiagnostic = LayoutDiagnostic | {
63
50
  };
64
51
 
65
52
  export interface RenderSvgOptions {
66
- textMeasurement?: TextMeasurement;
53
+ /** Leave out slides marked `hidden: true`, as the player does. Default false: one SVG per slide. With true the result holds only the visible slides, in order, so an index no longer names the slide at that index. Read by the deck-level `renderSvg`; `renderSlideSvg` draws the slide it is given. */
54
+ skipHidden?: boolean;
55
+ /**
56
+ * The fonts the deck is laid out and drawn with: the handle `loadFonts()` returns (from `@openpresentation/opf-render/fonts-node` or
57
+ * `/fonts-browser`), or any object with a `textMeasurement` and, to embed faces in each SVG, an `embeddedFonts` list. Without it layout uses
58
+ * core's portable width estimate and the SVG names the design fonts without embedding them.
59
+ */
60
+ fonts?: RenderFonts;
67
61
  /** `"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`. */
68
62
  glyphFallback?: "chain" | "none";
69
63
  /** Unscaled reference-pixel clearance around supplied vector text outlines; default 1. */
70
64
  textRasterPadding?: number;
71
- embeddedFonts?: EmbeddedFont[];
72
65
  strictAssets?: boolean;
73
66
  imageResolver?: (src: string | undefined, context: { asset: unknown; path: string }) => string | null | undefined;
74
67
  onDiagnostic?: (diagnostic: RenderDiagnostic) => void;
@@ -76,34 +69,42 @@ export interface RenderSvgOptions {
76
69
  /**
77
70
  * Values for the deck's template variables, keyed by variable id (core `resolveVariables`). A deck that uses
78
71
  * content variables, or is marked `template: true`, is resolved to a concrete deck first. A template previews with
79
- * each unfilled variable's example (reported as `variable-example-used`); a normal deck with an unfilled required
72
+ * each unfilled variable's example (reported as `variable-example-used`; a built-in such as `{{speaker.name}}` with no source value is reported as `variable-builtin-missing`); a normal deck with an unfilled required
80
73
  * variable throws `unfilled-variables`. `false` draws the document as authored, with `{{id}}` tokens and
81
74
  * `var:id` references visible and nothing resolved: the view an editor uses while the template itself is edited.
82
75
  */
83
76
  variables?: Record<string, unknown> | false;
84
- slideIndex?: number;
85
77
  /**
86
78
  * Today's calendar date (ISO YYYY-MM-DD) for `date: true` header/footer furniture. The renderer
87
79
  * never reads a clock; without it a current date is reported as unresolved content.
88
80
  */
89
81
  date?: string;
90
82
  trace?: boolean;
91
- catalogs?: Record<string, { records?: unknown[] } | unknown[]>;
92
83
  /**
93
- * Records for catalog sources the host has already fetched, keyed by the source string a document's
94
- * `catalogs.<kind>.source` names (a single source or an ordered array; first match wins, the bundled
95
- * default catalog is appended). The renderer never fetches a source itself.
84
+ * Catalogs the host registered (core `Catalog[]`), passed unchanged to core resolution: a reference the document does not
85
+ * embed resolves against the catalog whose `source` its group names, and the first entry is the default catalog for bare ids
86
+ * when the document omits `catalogs.default`. The renderer bundles and fetches no catalog; register the gallery snapshot with
87
+ * `import { defaultCatalog } from "@openpresentation/opf/catalog"` and `catalogs: [defaultCatalog]`. Omitted: only embedded
88
+ * records resolve, and anything else draws with core's engine defaults (reported as `unresolved-reference`).
89
+ * A malformed value throws core's `OPFCatalogsOptionError` (`code: "invalid-catalogs"`); a reference with an undeclared prefix
90
+ * (`foo:id` without `catalogs.foo`) fails the boundary check (`invalid-opf`, finding `opf/undeclared-catalog`).
96
91
  */
97
- catalogSources?: Record<string, { records?: unknown[] } | unknown[]>;
92
+ catalogs?: readonly Catalog[];
93
+ /** Fail with `unresolved-reference` (an OPFRenderError whose `details.diagnostics` list the references) instead of falling back when a reference resolves nowhere. Default false. */
94
+ strictReferences?: boolean;
98
95
  }
99
96
 
100
97
  export interface SvgToPngOptions {
98
+ /**
99
+ * The fonts the conversion draws with: the handle `loadFonts()` returns from `/fonts-node` (its `fontFiles`; `useBundledFonts` and
100
+ * `loadSystemFonts` say whether the bundled base faces are added, default true, and whether system fonts load, default false). Without
101
+ * it the bundled base faces draw. The same handle goes to `renderSvg`, so the preview and the PNG use the same faces.
102
+ */
103
+ fonts?: RenderFonts;
101
104
  scale?: number;
102
105
  background?: string;
103
106
  dpi?: number;
104
- useBundledFonts?: boolean;
105
- loadSystemFonts?: boolean;
106
- fontFiles?: string[];
107
+ /** Extra directories of font files to draw with. */
107
108
  fontDirs?: string[];
108
109
  defaultFontFamily?: string;
109
110
  sansSerifFamily?: string;
@@ -176,8 +177,8 @@ export interface PdfMetadata {
176
177
  export interface SvgToPdfOptions extends SvgToPngOptions {
177
178
  /**
178
179
  * `"vector"` (default): shapes, gradients and patterns as PDF vector graphics, pictures as images, text as real text
179
- * with embedded font subsets (selectable, searchable, correct copy and paste); the fonts are the bundled files and
180
- * `fontFiles` / `fontDirs` you supply, never system fonts. `"raster"`: each slide an image, as before.
180
+ * with embedded font subsets (selectable, searchable, correct copy and paste); the fonts are the bundled files, the `fonts` handle's
181
+ * `fontFiles` and the `fontDirs` you supply, never system fonts. `"raster"`: each slide an image, as before.
181
182
  */
182
183
  mode?: "vector" | "raster";
183
184
  /** Vector mode paints the page this colour first (default white, as raster mode composites on white); `"none"` leaves it unpainted. */
@@ -217,7 +218,8 @@ export interface ResolvedSlide {
217
218
  index: number;
218
219
  path: string;
219
220
  slide: unknown;
220
- layout: unknown;
221
+ /** The layout record the slide names; absent when it names none or a reference that resolves nowhere (the slide then composes automatically). */
222
+ layout?: unknown;
221
223
  design: {
222
224
  theme: unknown;
223
225
  colorScheme: unknown;
@@ -246,16 +248,19 @@ export interface RenderBinding {
246
248
  export declare class OPFRenderError extends Error {
247
249
  readonly code: string;
248
250
  readonly details: Record<string, unknown>;
249
- readonly issues?: unknown[];
251
+ /** `invalid-opf`: the error findings of core `validate(presentation, { only: ["format"] })` (also `details.findings`; `details.report` is the whole report). */
252
+ readonly findings?: Finding[];
250
253
  readonly path?: string;
251
254
  constructor(code: string, message: string, details?: Record<string, unknown>);
252
255
  }
253
256
 
254
257
  export declare function resolvePresentation(input: unknown, options?: RenderSvgOptions): ResolvedPresentation;
255
258
 
256
- export declare function renderSvg(input: unknown, options?: RenderSvgOptions): string;
259
+ /** The SVG of every slide of the deck, in order. */
260
+ export declare function renderSvg(input: unknown, options?: RenderSvgOptions): string[];
257
261
 
258
- export declare function renderSvgDeck(input: unknown, options?: RenderSvgOptions): string[];
262
+ /** The SVG of one slide of the deck (`index` is zero-based; out of range throws `slide-index-out-of-range`). */
263
+ export declare function renderSlideSvg(input: unknown, index: number, options?: RenderSvgOptions): string;
259
264
 
260
265
  export declare function svgToPng(svg: string | Uint8Array, options?: SvgToPngOptions): Promise<Uint8Array>;
261
266
 
@@ -1,5 +1,5 @@
1
1
  import { BUNDLED_FONT_MANIFEST } from "./font-manifest.js";
2
- import { OPFFontError } from "./fonts.js";
2
+ import { OPFFontError } from "./font-registry.js";
3
3
 
4
4
  // FF-31: the vendored faces (the open families and Intos) are not in a registry's eager list. Browser hosts load them
5
5
  // on demand from separate hash-pinned files; Node hosts and build scripts list them to copy the files.
@@ -1,6 +1,6 @@
1
- import { fontPolicyFor } from "./font-policy.js";
2
- import { pickFace } from "./fonts.js";
3
- import { renderSvgDeck, resolvePresentation } from "./svg.js";
1
+ import { fontPolicyFor } from "@openpresentation/opf/font-policy";
2
+ import { pickFace } from "./font-registry.js";
3
+ import { renderSvg, resolvePresentation } from "./svg.js";
4
4
  export { lazyFontEntries, lazyFontList } from "./lazy-font-list.js";
5
5
 
6
6
  /**
@@ -17,10 +17,10 @@ const lc = value => String(value).trim().toLowerCase();
17
17
 
18
18
  /**
19
19
  * Render options that decide how a document resolves (catalogs, validate, date, ...) are forwarded from the host's
20
- * `renderSvg` options. The analysis supplies its own measurement and reports nothing: callbacks, the host's registry
21
- * measurement, embedded fonts, asset resolution and tracing are dropped.
20
+ * `renderSvg` options. The analysis supplies its own measurement and reports nothing: callbacks, the host's fonts handle
21
+ * (measurement and embedded fonts), asset resolution and tracing are dropped.
22
22
  */
23
- const NOT_RESOLVE_OPTIONS = new Set(["textMeasurement", "embeddedFonts", "fontFiles", "useBundledFonts", "loadSystemFonts", "onDiagnostic", "trace", "glyphFallback", "imageResolver", "strictAssets", "slideIndex", "signal"]);
23
+ const NOT_RESOLVE_OPTIONS = new Set(["fonts", "onDiagnostic", "trace", "glyphFallback", "imageResolver", "strictAssets", "signal"]);
24
24
  function resolveOptionsOf(options) {
25
25
  return Object.fromEntries(Object.entries(options ?? {}).filter(([key]) => !NOT_RESOLVE_OPTIONS.has(key)));
26
26
  }
@@ -98,7 +98,7 @@ export function presentationFaces(presentation, options = {}, registry) {
98
98
  measure(text, size, style) { if (text !== "") record(settle(style)); return String(text).length * size * RECORDED_EM; },
99
99
  resolveStyle: settle,
100
100
  };
101
- renderSvgDeck(presentation, { ...resolveOptionsOf(options), textMeasurement });
101
+ renderSvg(presentation, { ...resolveOptionsOf(options), fonts: { textMeasurement } });
102
102
  return [...seen.values()].sort((a, b) => a.family < b.family ? -1 : a.family > b.family ? 1 : Number(a.italic) - Number(b.italic) || a.weight - b.weight);
103
103
  }
104
104
 
package/dist/pdf-fonts.js CHANGED
@@ -1,5 +1,5 @@
1
1
  import { create } from "fontkit";
2
- import { skipUndecodableLookups } from "./fonts.js";
2
+ import { skipUndecodableLookups } from "./font-registry.js";
3
3
  import { isTrueTypeOutlines, subsetTrueType } from "./pdf-subset.js";
4
4
  import { hex, name, num, sha256, textString, utf16Hex } from "./pdf-writer.js";
5
5
 
package/dist/player.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- import type { BrowserFontRegistry } from "./fonts-browser.js";
1
+ import type { BrowserFontsHandle } from "./fonts-browser.js";
2
2
  import type { RenderSvgOptions } from "./svg.js";
3
3
 
4
4
  /**
@@ -16,9 +16,9 @@ export interface PlayerSlideChange { slide: number; total: number; index: number
16
16
  export type BlankMode = "none" | "black" | "white";
17
17
 
18
18
  export interface PresentOptions {
19
- /** The self-hosted font root URL (see `copyPreviewFonts`), or a browser font registry the page already has. Without it layout uses estimated widths and system fonts. */
20
- fonts?: string | BrowserFontRegistry;
21
- /** Extra `renderSvg` options (catalogs, imageResolver, date, ...). */
19
+ /** The self-hosted font root URL (see `copyPreviewFonts`), or a browser fonts handle the page already has (`loadFonts` from `/fonts-browser`). Without it layout uses estimated widths and system fonts. */
20
+ fonts?: string | BrowserFontsHandle;
21
+ /** Extra `renderSlideSvg` options (catalogs, imageResolver, date, ...). */
22
22
  renderOptions?: Partial<RenderSvgOptions>;
23
23
  /** 1-based slide to start on, in the sequence that plays. Default 1. */
24
24
  startSlide?: number;
package/dist/player.js CHANGED
@@ -632,7 +632,7 @@ async function readSource(source, signal) {
632
632
  * player covers the page at once (and asks for full screen), then resolves with the running session when the first slide is
633
633
  * drawn. Call it from a click or key handler: full screen and the speaker view both need a user gesture.
634
634
  *
635
- * Options: `fonts` (the self-hosted font root URL, or a browser font registry), `renderOptions` (extra `renderSvg` options),
635
+ * Options: `fonts` (the self-hosted font root URL, or a browser fonts handle from `loadFonts`), `renderOptions` (extra `renderSlideSvg` options),
636
636
  * `startSlide` (1-based), `includeHidden`, `fullscreen` (default true), `presenterView` (open the speaker view too),
637
637
  * `role: "presenter"` (this window is the speaker view: for a second tab or window of your page), `channel` (name, or `false`
638
638
  * for no sync), `container`, `signal` and `now` (an injectable clock).
@@ -666,12 +666,12 @@ export async function present(source, options = {}) {
666
666
  deck = await readSource(source, abort.signal);
667
667
  const sequence = presentableIndexes(deck, { includeHidden: options.includeHidden });
668
668
  if (!sequence.length) throw new DeckError("no-slides", "The deck has no slides to present: every slide is hidden.");
669
- let registry = options.fonts;
670
- if (typeof registry === "string") { try { registry = await loadPreviewFonts(registry, { signal: abort.signal }); } catch { registry = undefined; } }
669
+ let fonts = options.fonts;
670
+ if (typeof fonts === "string") { try { fonts = await loadPreviewFonts(fonts, { signal: abort.signal }); } catch { fonts = undefined; } }
671
671
  const date = options.renderOptions?.date ?? localIsoDate();
672
- store = options.store ?? createDeckStore({ document: deck, fonts: registry, renderOptions: options.renderOptions ?? {}, date });
672
+ store = options.store ?? createDeckStore({ document: deck, fonts, renderOptions: options.renderOptions ?? {}, date });
673
673
  try { await store.ready(abort.signal); } catch (error) {
674
- if (!registry || options.store) throw error;
674
+ if (!fonts || options.store) throw error;
675
675
  store = createDeckStore({ document: deck, renderOptions: options.renderOptions ?? {}, date });
676
676
  }
677
677
  abort.signal.throwIfAborted?.();