@openpresentation/opf-render 0.7.0 → 0.8.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
@@ -25,12 +25,20 @@ export function createFontRegistry(entries, options = {}) {
25
25
  try { font = create(data, entry.postscriptName); } catch (error) { throw new OPFFontError("invalid-font-data", error.message); }
26
26
  if (!font?.layout || !font.unitsPerEm) throw new OPFFontError("font-collection", "Select one font from a collection with postscriptName.");
27
27
  const family = validFamily(entry.family ?? font.familyName);
28
+ // OpenType groups Medium/SemiBold/ExtraBold under a preferred family, while
29
+ // retaining separate legacy families for four-style native font selectors.
30
+ // An explicit caller rename owns its namespace and must not add this alias.
31
+ const familyGroup = entry.family && entry.family.toLowerCase() !== font.familyName?.toLowerCase()
32
+ ? family : validFamily(font.getName?.('preferredFamily', 'en') ?? family);
33
+ const linking = font['OS/2']?.fsSelection ?? font.head?.macStyle;
34
+ const fontFace = linking && typeof linking.bold === 'boolean' && typeof linking.italic === 'boolean'
35
+ ? {family:validFamily(font.getName?.('fontFamily', 'en') ?? font.familyName ?? family),bold:linking.bold,italic:linking.italic} : undefined;
28
36
  const weight = entry.weight ?? 400;
29
37
  const italic = entry.italic ?? !!font.italicAngle;
30
38
  if (!Number.isInteger(weight) || weight < 1 || weight > 1000) throw new OPFFontError("invalid-font-weight", "Font weight must be between 1 and 1000.");
31
39
  const signature = String.fromCharCode(...data.subarray(0,4));
32
40
  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()};
41
+ return {family,familyGroup,fontFace,weight,italic,font,data,format,license:entry.license,cache:new Map()};
34
42
  });
35
43
  const duplicates = new Set();
36
44
  for (const face of faces) {
@@ -38,12 +46,18 @@ export function createFontRegistry(entries, options = {}) {
38
46
  if (duplicates.has(id)) throw new OPFFontError("duplicate-font-face", `Duplicate face: ${id}`);
39
47
  duplicates.add(id);
40
48
  }
49
+ const familySlots = new Map();
50
+ for (const face of faces) for (const family of new Set([face.family,face.familyGroup])) {
51
+ const id=key(family,face.weight,face.italic),previous=familySlots.get(id);
52
+ 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});
53
+ familySlots.set(id,face);
54
+ }
41
55
  const substitutions = new Map();
42
56
  const aliases = new Map(Object.entries(options.aliases ?? {}).map(([from,to])=>[validFamily(from).toLowerCase(),validFamily(to)]));
43
57
  const policy = options.substitutionPolicy ?? "none";
44
58
  if (!["none","metric","visual"].includes(policy)) throw new OPFFontError("invalid-font-policy", "substitutionPolicy must be none, metric, or visual.");
45
59
  if (options.fallbackFamily) validFamily(options.fallbackFamily);
46
- const findFamily = family => faces.filter(face=>face.family.toLowerCase()===family.toLowerCase());
60
+ const findFamily = family => faces.filter(face=>face.family.toLowerCase()===family.toLowerCase() || face.familyGroup.toLowerCase()===family.toLowerCase());
47
61
  const resolve = style => {
48
62
  const requested = validFamily(style?.fontFamily);
49
63
  const weight = style.fontWeight ?? 400;
@@ -79,30 +93,50 @@ export function createFontRegistry(entries, options = {}) {
79
93
  matching.sort((a,b)=>Math.abs(a.weight-weight)-Math.abs(b.weight-weight) || a.weight-b.weight);
80
94
  const face = matching[0];
81
95
  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}:{})};
96
+ const resolution={requestedFamily:requested,sourceFamily:family,resolvedFamily:face.family,requestedWeight:weight,resolvedWeight:face.weight,italic:face.italic,compatibility,...(face.fontFace?{fontFace:{...face.fontFace}}:{}),...(style.path?{path:style.path}:{}),...(rule?{source:rule.source,note:rule.note}:{})};
83
97
  if (face.family.toLowerCase()!==family.toLowerCase() || face.weight!==weight) substitutions.set(JSON.stringify([requested,weight,!!style.italic,style.path]),resolution);
84
98
  return {face,resolution};
85
99
  };
86
100
  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) => {
101
+ const resolveStyle = style => {
102
+ const face=resolveFace(style),resolved={...style,fontFamily:face.family,fontWeight:face.weight,italic:face.italic};
103
+ // Never carry a stale selection from a previously resolved style.
104
+ delete resolved.fontFace;
105
+ if(face.fontFace) resolved.fontFace={...face.fontFace};
106
+ return resolved;
107
+ };
108
+ const metrics = (text,size,style,includeOutline=false) => {
109
+ 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.');
89
110
  const face=resolveFace(style);
90
- let width=face.cache.get(text);
91
- if (width===undefined) {
111
+ let value=face.cache.get(text);
112
+ if (value===undefined) {
92
113
  if (options.strictGlyphs!==false) for(const character of text) {
93
114
  if (/\p{Default_Ignorable_Code_Point}/u.test(character)) continue;
94
115
  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
116
  }
96
- width=face.font.layout(text).positions.reduce((total,position)=>total+position.xAdvance,0)/face.font.unitsPerEm;
117
+ }
118
+ if(value===undefined||includeOutline&&!Object.hasOwn(value,'outline')) {
119
+ const run=face.font.layout(text);
120
+ value={width:run.positions.reduce((total,position)=>total+position.xAdvance,0)/face.font.unitsPerEm,...value};
121
+ if(includeOutline) {
122
+ const bounds=run.bbox,unit=face.font.unitsPerEm;
123
+ value.outline=[bounds.minX,bounds.minY,bounds.maxX,bounds.maxY].every(Number.isFinite)
124
+ ?{x:bounds.minX/unit,y:-bounds.maxY/unit,width:bounds.width/unit,height:bounds.height/unit}:null;
125
+ }
97
126
  if (text.length<=2048) {
98
- if (face.cache.size>=512) face.cache.delete(face.cache.keys().next().value);
99
- face.cache.set(text,width);
127
+ if (face.cache.size>=512&&!face.cache.has(text)) face.cache.delete(face.cache.keys().next().value);
128
+ face.cache.set(text,value);
100
129
  }
101
130
  }
102
- return width*size;
131
+ return value;
132
+ };
133
+ const measure=(text,size,style)=>metrics(text,size,style).width*size;
134
+ const outlineBounds=(text,size,style)=>{
135
+ const bounds=metrics(text,size,style,true).outline;
136
+ return bounds===null?null:{x:bounds.x*size,y:bounds.y*size,width:bounds.width*size,height:bounds.height*size};
103
137
  };
104
138
  return {
105
- textMeasurement: {measure,resolveStyle},
139
+ textMeasurement: {measure,resolveStyle,outlineBounds},
106
140
  resolveFont(style) { return resolve(style).resolution; },
107
141
  clearSubstitutions() { substitutions.clear(); },
108
142
  get substitutions() { return [...substitutions.values()]; },
package/dist/index.d.ts CHANGED
@@ -31,12 +31,27 @@ export declare const engineDefaults: Readonly<{
31
31
  chartTypes: readonly ["stacked-column-3x", "stacked-area-3x", "line-with-markers-3x"];
32
32
  }>;
33
33
 
34
+ export type RenderDiagnostic = LayoutDiagnostic | {
35
+ code: "unsupported-pattern" | "date-needs-value";
36
+ path: string;
37
+ message: string;
38
+ } | {
39
+ code: "unresolved-asset";
40
+ path: string;
41
+ message: string;
42
+ reason: "missing-reference" | "missing-source" | "unsupported-source";
43
+ source?: string;
44
+ assetId?: string;
45
+ description: string;
46
+ placeholder: "label" | "icon";
47
+ };
48
+
34
49
  export interface RenderSvgOptions {
35
50
  textMeasurement?: TextMeasurement;
36
51
  embeddedFonts?: EmbeddedFont[];
37
52
  strictAssets?: boolean;
38
53
  imageResolver?: (src: string | undefined, context: { asset: unknown; path: string }) => string | null | undefined;
39
- onDiagnostic?: (diagnostic: LayoutDiagnostic) => void;
54
+ onDiagnostic?: (diagnostic: RenderDiagnostic) => void;
40
55
  slideIndex?: number;
41
56
  trace?: boolean;
42
57
  catalogs?: Record<string, { records?: unknown[] } | unknown[]>;
package/dist/raster.js CHANGED
@@ -98,29 +98,17 @@ async function loadPdfLib() {
98
98
 
99
99
  async function bundledFontFiles() {
100
100
  if (!bundledFontFilesCache) {
101
- bundledFontFilesCache = resolveBundledFontFiles();
101
+ bundledFontFilesCache = resolveBundledFontFiles().catch(error => {
102
+ bundledFontFilesCache = null;
103
+ throw error;
104
+ });
102
105
  }
103
106
  return bundledFontFilesCache;
104
107
  }
105
108
 
106
109
  async function resolveBundledFontFiles() {
107
- try {
108
- const { createRequire } = await import("node:module");
109
- const path = await import("node:path");
110
- const require = createRequire(import.meta.url);
111
- const roboto = path.dirname(require.resolve("@expo-google-fonts/roboto/package.json"));
112
- const robotoMono = path.dirname(require.resolve("@expo-google-fonts/roboto-mono/package.json"));
113
- return [
114
- path.join(roboto, "400Regular/Roboto_400Regular.ttf"),
115
- path.join(roboto, "500Medium/Roboto_500Medium.ttf"),
116
- path.join(roboto, "700Bold/Roboto_700Bold.ttf"),
117
- path.join(roboto, "800ExtraBold/Roboto_800ExtraBold.ttf"),
118
- path.join(robotoMono, "400Regular/RobotoMono_400Regular.ttf"),
119
- path.join(robotoMono, "700Bold/RobotoMono_700Bold.ttf")
120
- ];
121
- } catch {
122
- return [];
123
- }
110
+ const {loadBundledFontRegistry} = await import('./fonts-node.js');
111
+ return (await loadBundledFontRegistry()).fontFiles;
124
112
  }
125
113
 
126
114
  function normalizeSvgList(value) {
package/dist/svg.d.ts CHANGED
@@ -31,12 +31,29 @@ export declare const engineDefaults: Readonly<{
31
31
  chartTypes: readonly ["stacked-column-3x", "stacked-area-3x", "line-with-markers-3x"];
32
32
  }>;
33
33
 
34
+ export type RenderDiagnostic = LayoutDiagnostic | {
35
+ code: "unsupported-pattern" | "date-needs-value";
36
+ path: string;
37
+ message: string;
38
+ } | {
39
+ code: "unresolved-asset";
40
+ path: string;
41
+ message: string;
42
+ reason: "missing-reference" | "missing-source" | "unsupported-source";
43
+ source?: string;
44
+ assetId?: string;
45
+ description: string;
46
+ placeholder: "label" | "icon";
47
+ };
48
+
34
49
  export interface RenderSvgOptions {
35
50
  textMeasurement?: TextMeasurement;
51
+ /** Unscaled reference-pixel clearance around supplied vector text outlines; default 1. */
52
+ textRasterPadding?: number;
36
53
  embeddedFonts?: EmbeddedFont[];
37
54
  strictAssets?: boolean;
38
55
  imageResolver?: (src: string | undefined, context: { asset: unknown; path: string }) => string | null | undefined;
39
- onDiagnostic?: (diagnostic: LayoutDiagnostic) => void;
56
+ onDiagnostic?: (diagnostic: RenderDiagnostic) => void;
40
57
  slideIndex?: number;
41
58
  trace?: boolean;
42
59
  catalogs?: Record<string, { records?: unknown[] } | unknown[]>;