@openpresentation/opf-render 0.9.0 → 0.10.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.
@@ -3,9 +3,12 @@ import type {
3
3
  FontRegistry,
4
4
  FontRegistryOptions,
5
5
  } from "./fonts.js";
6
+ import type { BundledFontPackage, ScriptSelection } from "./fonts-node.js";
6
7
  export interface BrowserFontInput extends Omit<FontFaceInput, "data"> {
7
8
  data?: Uint8Array;
8
9
  url?: string;
10
+ /** Reviewed SHA-256 (hex); verified with Web Crypto before the face is used. */
11
+ sha256?: string;
9
12
  }
10
13
  export declare function loadBrowserFontRegistry(
11
14
  entries: BrowserFontInput[],
@@ -13,5 +16,12 @@ export declare function loadBrowserFontRegistry(
13
16
  document?: Document;
14
17
  fetch?: typeof fetch;
15
18
  signal?: AbortSignal;
19
+ crypto?: { subtle: SubtleCrypto };
16
20
  },
17
21
  ): Promise<FontRegistry & { dispose(): void }>;
22
+ export declare function scriptFontPackages(scripts: ScriptSelection): BundledFontPackage[];
23
+ /** Hash-pinned browser entries for the script pack, served by the host from `baseUrl`. */
24
+ export declare function scriptFontEntries(
25
+ scripts: ScriptSelection,
26
+ options: { baseUrl: string },
27
+ ): (BrowserFontInput & { url: string; family: string; weight: number; italic: boolean; sha256: string; scripts: string[]; package: string; license: string })[];
@@ -1,4 +1,20 @@
1
1
  import { createFontRegistry, OPFFontError } from "./fonts.js";
2
+ export { scriptFontEntries, scriptFontPackages } from "./script-font-pack.js";
3
+
4
+ async function verifyDigest(entry, data, subtle) {
5
+ if (entry.sha256 === undefined) return;
6
+ if (!subtle?.digest)
7
+ throw new OPFFontError("font-api-unavailable", "Verifying a font SHA-256 requires Web Crypto.");
8
+ const digest = new Uint8Array(await subtle.digest("SHA-256", data));
9
+ const actual = [...digest].map((byte) => byte.toString(16).padStart(2, "0")).join("");
10
+ if (actual !== String(entry.sha256).toLowerCase())
11
+ throw new OPFFontError(
12
+ "font-integrity-mismatch",
13
+ "The font file differs from its reviewed SHA-256; serve the pinned package.",
14
+ { url: entry.url, expected: entry.sha256, actual },
15
+ );
16
+ }
17
+
2
18
  /** Fetch only host-selected font files, then use those exact bytes for shaping and CSS. */
3
19
  export async function loadBrowserFontRegistry(entries, options = {}) {
4
20
  const documentRef = options.document ?? globalThis.document;
@@ -9,32 +25,38 @@ export async function loadBrowserFontRegistry(entries, options = {}) {
9
25
  "The browser Font Loading API is required.",
10
26
  );
11
27
  const fetcher = options.fetch ?? globalThis.fetch;
28
+ const subtle = options.crypto?.subtle ?? globalThis.crypto?.subtle;
12
29
  const faces = await Promise.all(
13
30
  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()) };
31
+ let loaded = entry;
32
+ if (!entry.data) {
33
+ if (!entry.url)
34
+ throw new OPFFontError(
35
+ "invalid-font-source",
36
+ "Supply font bytes or a font-file URL.",
37
+ );
38
+ const response = await fetcher(entry.url, { signal: options.signal });
39
+ if (!response.ok)
40
+ throw new OPFFontError(
41
+ "font-fetch-failed",
42
+ `Could not load font (${response.status}).`,
43
+ { url: entry.url },
44
+ );
45
+ loaded = { ...entry, data: new Uint8Array(await response.arrayBuffer()) };
46
+ }
47
+ // Script-pack entries carry the reviewed hash (FF-19); verify before use.
48
+ await verifyDigest(entry, loaded.data, subtle);
49
+ return loaded;
28
50
  }),
29
51
  );
30
52
  const registry = createFontRegistry(faces, options),
31
53
  loaded = [];
32
54
  // Normalize family names using the registry's parsed font metadata.
33
- const embedded = registry.embeddedFonts;
55
+ const described = registry.describeFaces();
34
56
  try {
35
57
  await Promise.all(
36
58
  faces.map(async (entry, index) => {
37
- const descriptor = embedded[index];
59
+ const descriptor = described[index];
38
60
  const face = new FontFaceRef(
39
61
  descriptor.family,
40
62
  entry.data.slice().buffer,
@@ -1,17 +1,31 @@
1
- import type {FontRegistry,FontRegistryOptions,EmbeddedFont} from "./fonts.js";
2
- export declare function loadBundledFontRegistry(options?: FontRegistryOptions): Promise<FontRegistry & {fontFiles:string[]}>;
1
+ import type {FontRegistry,FontRegistryOptions,EmbeddedFont,FontFaceInput} from "./fonts.js";
2
+ /** A caller-supplied face (FF-31), for example a licensed copy of the real font. Node callers may pass a file path. */
3
+ export type CallerFontFace = FontFaceInput | (Omit<FontFaceInput, "data"> & {path: string});
4
+ /** "all" or ISO 15924 codes (`Jpan`, `Hans`, `Hant`, `Kore`, `Arab`, `Hebr`, `Deva`, `Thai`, ...). */
5
+ export type ScriptSelection = "all" | string[];
6
+ export interface ScriptPackOptions {
7
+ /** Caller-supplied faces, loaded first so a licensed real font resolves as an exact face (FF-31). */
8
+ faces?: readonly CallerFontFace[];
9
+ /** Also load the optional, hash-pinned OFL Noto script pack for these scripts (FF-19). */
10
+ scripts?: ScriptSelection;
11
+ }
12
+ export declare function loadBundledFontRegistry(options?: FontRegistryOptions & ScriptPackOptions): Promise<FontRegistry & {fontFiles:string[]}>;
3
13
 
4
- export declare function loadOfficeFontRegistry(options?: FontRegistryOptions & {includeBaseFonts?:boolean}): Promise<FontRegistry & {fontFiles:string[]}>;
14
+ export declare function loadOfficeFontRegistry(options?: FontRegistryOptions & ScriptPackOptions & {includeBaseFonts?:boolean}): Promise<FontRegistry & {fontFiles:string[]}>;
5
15
 
16
+ export type BundledFontPackage = Readonly<{
17
+ name:string; version:string; pack:"base"|"office"|"scripts"; source:string;
18
+ /** ISO 15924 scripts served by a script-pack package. */
19
+ scripts?: readonly string[];
20
+ license:string; licenseFile:string; licenseSha256:string;
21
+ faces:readonly Readonly<{file:string; family:string; weight:number; italic:boolean; sha256:string}>[];
22
+ }>;
6
23
  export interface BundledFontManifest {
7
24
  readonly version: number;
8
- readonly packages: readonly Readonly<{
9
- name:string; version:string; pack:"base"|"office"; source:string;
10
- license:string; licenseFile:string; licenseSha256:string;
11
- faces:readonly Readonly<{file:string; family:string; weight:number; italic:boolean; sha256:string}>[];
12
- }>[];
25
+ readonly packages: readonly BundledFontPackage[];
13
26
  }
14
27
  export declare const BUNDLED_FONT_MANIFEST: BundledFontManifest;
28
+ export declare function scriptFontPackages(scripts: ScriptSelection): BundledFontPackage[];
15
29
  export interface PreparedNodeFonts {
16
30
  registry: FontRegistry & {fontFiles:string[]};
17
31
  manifest: BundledFontManifest;
@@ -23,4 +37,4 @@ export interface PreparedNodeFonts {
23
37
  loadSystemFonts: false;
24
38
  };
25
39
  }
26
- export declare function prepareNodeFonts(options?: FontRegistryOptions & {pack?:"base"|"office"; includeBaseFonts?:boolean}): Promise<PreparedNodeFonts>;
40
+ export declare function prepareNodeFonts(options?: FontRegistryOptions & ScriptPackOptions & {pack?:"base"|"office"; includeBaseFonts?:boolean; /** Embed script faces in SVG (large); default false. */ embedScriptFonts?:boolean}): Promise<PreparedNodeFonts>;
@@ -5,6 +5,8 @@ import { createHash } from "node:crypto";
5
5
  import { createFontRegistry, OPFFontError } from "./fonts.js";
6
6
  import { BUNDLED_FONT_MANIFEST } from "./font-manifest.js";
7
7
  export { BUNDLED_FONT_MANIFEST } from "./font-manifest.js";
8
+ import { scriptFontPackages } from "./script-font-pack.js";
9
+ export { scriptFontPackages } from "./script-font-pack.js";
8
10
  const require = createRequire(import.meta.url);
9
11
 
10
12
  async function verifiedFile(file, expected, details) {
@@ -16,9 +18,9 @@ async function verifiedFile(file, expected, details) {
16
18
  return bytes;
17
19
  }
18
20
 
19
- async function loadPack(pack) {
21
+ async function loadPackages(packages) {
20
22
  const entries = [], fontFiles = [];
21
- for (const pkg of BUNDLED_FONT_MANIFEST.packages.filter(item => item.pack === pack)) {
23
+ for (const pkg of packages) {
22
24
  let manifestPath, installed;
23
25
  try {
24
26
  manifestPath = require.resolve(`${pkg.name}/package.json`);
@@ -33,39 +35,75 @@ async function loadPack(pack) {
33
35
  const file = path.join(directory, face.file);
34
36
  const data = await verifiedFile(file, face.sha256, {package:pkg.name, file:face.file});
35
37
  fontFiles.push(file);
36
- entries.push({data:new Uint8Array(data), family:face.family, weight:face.weight, italic:face.italic, license});
38
+ entries.push({data:new Uint8Array(data), family:face.family, weight:face.weight, italic:face.italic, license, ...(pkg.scripts ? {scripts:[...pkg.scripts]} : {})});
37
39
  }
38
40
  }
39
41
  return {entries, fontFiles};
40
42
  }
41
43
 
44
+ const loadPack = pack => loadPackages(BUNDLED_FONT_MANIFEST.packages.filter(item => item.pack === pack));
45
+
46
+ async function withScripts(loaded, scripts) {
47
+ if (scripts === undefined || (Array.isArray(scripts) && !scripts.length)) return loaded;
48
+ const extra = await loadPackages(scriptFontPackages(scripts));
49
+ return {entries:[...loaded.entries, ...extra.entries], fontFiles:[...loaded.fontFiles, ...extra.fontFiles]};
50
+ }
51
+
52
+ /** FF-31: caller-supplied faces, for example the caller's own licensed Aptos files. Plain
53
+ * FontFaceInput entries; Node callers may pass `path` instead of `data`. They come first, so the
54
+ * real family resolves as an exact face ahead of any replacement. */
55
+ async function callerFaces(faces = []) {
56
+ if (!Array.isArray(faces)) throw new OPFFontError("invalid-font-data", "faces must be an array of font entries.");
57
+ const entries = [], fontFiles = [];
58
+ for (const face of faces) {
59
+ if (face?.data instanceof Uint8Array) { entries.push(face); continue; }
60
+ if (typeof face?.path !== "string") throw new OPFFontError("invalid-font-data", "Each caller face needs data (Uint8Array) or a file path.");
61
+ let data;
62
+ try { data = await readFile(face.path); } catch (error) { throw new OPFFontError("font-resource-unavailable", `Cannot read caller font file ${face.path}.`, {file:face.path, cause:error.code}); }
63
+ const {path:file, ...rest} = face;
64
+ entries.push({...rest, data:new Uint8Array(data)});
65
+ fontFiles.push(path.resolve(file));
66
+ }
67
+ return {entries, fontFiles};
68
+ }
69
+
70
+ async function withFaces(loaded, faces) {
71
+ if (!faces?.length) return loaded;
72
+ const own = await callerFaces(faces);
73
+ return {entries:[...own.entries, ...loaded.entries], fontFiles:[...own.fontFiles, ...loaded.fontFiles]};
74
+ }
75
+
42
76
  /** Bundled, openly licensed faces. No system font discovery or network requests. */
43
- export async function loadBundledFontRegistry(options = {}) {
44
- const {entries, fontFiles} = await loadPack("base");
77
+ export async function loadBundledFontRegistry({scripts, faces, ...options} = {}) {
78
+ const {entries, fontFiles} = await withFaces(await withScripts(await loadPack("base"), scripts), faces);
45
79
  return Object.assign(createFontRegistry(entries,options),{fontFiles});
46
80
  }
47
81
 
48
82
  /** Six pinned open-source Office substitutes, optionally alongside the base Roboto pack. */
49
- export async function loadOfficeFontRegistry(options = {}) {
83
+ export async function loadOfficeFontRegistry({scripts, faces, ...options} = {}) {
50
84
  const {entries, fontFiles} = await loadPack("office");
51
85
  if (options.includeBaseFonts !== false) {
52
86
  const base = await loadPack("base");
53
87
  fontFiles.push(...base.fontFiles);
54
88
  entries.push(...base.entries);
55
89
  }
56
- return Object.assign(createFontRegistry(entries,{substitutionPolicy:"metric",...options}),{fontFiles});
90
+ const loaded = await withFaces(await withScripts({entries, fontFiles}, scripts), faces);
91
+ return Object.assign(createFontRegistry(loaded.entries,{substitutionPolicy:"metric",...options}),{fontFiles:loaded.fontFiles});
57
92
  }
58
93
 
59
94
  /** One set of verified font inputs for layout, SVG, editor, PPTX, and Node raster export. */
60
- export async function prepareNodeFonts({pack = "base", ...options} = {}) {
95
+ export async function prepareNodeFonts({pack = "base", embedScriptFonts = false, ...options} = {}) {
61
96
  if (pack !== "base" && pack !== "office") throw new OPFFontError("invalid-font-pack", "Choose the base or office font pack.", {pack});
62
97
  const registry = await (pack === "base" ? loadBundledFontRegistry(options) : loadOfficeFontRegistry(options));
98
+ // Script faces are large (CJK faces are 5-10 MB each). Raster output reads them
99
+ // from fontFiles; embed them in standalone SVG only on request.
100
+ const embeddedFonts = registry.selectEmbeddedFonts(face => embedScriptFonts || !face.scripts);
63
101
  return {
64
102
  registry,
65
103
  manifest:BUNDLED_FONT_MANIFEST,
66
104
  options:{
67
105
  textMeasurement:registry.textMeasurement,
68
- embeddedFonts:registry.embeddedFonts,
106
+ embeddedFonts,
69
107
  fontFiles:[...registry.fontFiles],
70
108
  useBundledFonts:false,
71
109
  loadSystemFonts:false,
package/dist/fonts.d.ts CHANGED
@@ -1,21 +1,88 @@
1
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 }
2
+ export interface FontFaceInput { data: Uint8Array; family?: string; weight?: number; italic?: boolean; postscriptName?: string; license?: string; /** ISO 15924 scripts a designated script replacement face serves (FF-19). */ scripts?: string[] }
3
3
  export interface EmbeddedFont { family: string; weight: number; italic?: boolean; dataUrl: string; license?: string }
4
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; fontFace?:TextStyle['fontFace']; path?:string; source?:string; note?:string }
5
+ export interface FontReplacementMeasurement { replacement:string; meanAbsWidthDelta:number; meanWidthDelta:number; maxAbsWidthDelta:number; styles:number; reference:string }
6
+ export interface FontResolution {
7
+ requestedFamily:string; sourceFamily:string; resolvedFamily:string; requestedWeight:number; resolvedWeight:number; italic:boolean; compatibility:FontCompatibility;
8
+ /** True when the face is not the chosen family itself (policy replacement, alias, script replacement or fallback).
9
+ * Exporters keep writing sourceFamily; the face only drives measurement and drawing (FF-31). */
10
+ substitute:boolean;
11
+ /** A visual replacement without the requested italic/upright style draws the other style. */
12
+ styleFallback?:true;
13
+ fontFace?:TextStyle['fontFace']; path?:string; source?:string; note?:string;
14
+ /** The provisional owner decision the replacement follows, if any. */
15
+ decision?:string;
16
+ /** Measured width difference of the declared replacement against the real font. */
17
+ measured?:FontReplacementMeasurement;
18
+ licenseClass?:FontPolicyEntry['licenseClass']; availability?:FontPolicyEntry['availability'];
19
+ }
20
+ /** One OPF font policy row (snapshot of opf spec/reference/font-policy.json, decisions applied). */
21
+ export interface FontPolicyEntry {
22
+ family:string; licenseClass:"open"|"proprietary-standard"|"proprietary-nonstandard"; license:string;
23
+ availability:("windows"|"windows-optional"|"macos"|"office"|"office-cloud")[]; embeddableByOpf:boolean;
24
+ replacement:{family:string; compatibility:"metric"|"visual"; decision?:string; metricModeFallback?:true; weight?:number; measured:FontReplacementMeasurement|null; source?:string}|null;
25
+ alternates?:string[];
26
+ }
27
+ export declare const FONT_POLICY: readonly Readonly<FontPolicyEntry>[];
28
+ export declare const FONT_POLICY_SOURCE: Readonly<{version:number; path:string; sha256:string}>;
29
+ /** Provisional owner decisions applied to the snapshot (owner may revise). */
30
+ export declare const FONT_POLICY_DECISIONS: Readonly<Record<string, unknown>>;
31
+ export declare function fontPolicyFor(family:string): Readonly<FontPolicyEntry>|undefined;
6
32
  export interface FontRegistryOptions {
7
33
  aliases?: Record<string,string>; fallbackFamily?: string; strictGlyphs?: boolean;
8
34
  substitutionPolicy?: "none" | "metric" | "visual";
9
35
  themeFonts?: Partial<Record<"majorLatin"|"minorLatin"|"majorEastAsia"|"minorEastAsia"|"majorComplexScript"|"minorComplexScript",string>>;
10
36
  }
11
- export declare const FONT_COMPATIBILITY: readonly Readonly<{requestedFamily:string;substitutes:readonly string[];compatibility:"metric"|"visual";weights?:readonly number[];source?:string;note:string}>[];
37
+ export declare const FONT_COMPATIBILITY: readonly Readonly<{requestedFamily:string;substitutes:readonly string[];compatibility:"metric"|"visual";weights?:readonly number[];weight?:number;source?:string;measured?:FontReplacementMeasurement;decision?:string;metricModeFallback?:true;licenseClass?:FontPolicyEntry['licenseClass'];note:string}>[];
12
38
  export declare const EXPERIMENTAL_FONT_CANDIDATES: readonly Readonly<{requestedFamily:string;substitute:string;source:string;note:string}>[];
39
+ export interface FaceDescription { family:string; weight:number; italic:boolean; scripts?:string[] }
13
40
  export interface FontRegistry {
14
- textMeasurement: TextMeasurement & {outlineBounds: NonNullable<TextMeasurement['outlineBounds']>};
41
+ textMeasurement: TextMeasurement & {outlineBounds: NonNullable<TextMeasurement['outlineBounds']>; resolveFont(style: TextStyle): FontResolution};
15
42
  clearSubstitutions(): void;
16
43
  resolveFont(style: TextStyle): FontResolution;
17
44
  readonly embeddedFonts: EmbeddedFont[];
45
+ /** Embedded faces matching `predicate`; large script faces can stay with raster fontFiles. */
46
+ selectEmbeddedFonts(predicate: (face: FaceDescription) => boolean): EmbeddedFont[];
47
+ /** Parsed face metadata in entry order, without encoding font bytes. */
48
+ describeFaces(): FaceDescription[];
18
49
  readonly substitutions: FontResolution[];
19
50
  }
20
51
  export declare class OPFFontError extends Error { code:string; details:Record<string,unknown>; constructor(code:string,message:string,details?:Record<string,unknown>) }
21
52
  export declare function createFontRegistry(entries: FontFaceInput[], options?: FontRegistryOptions): FontRegistry;
53
+
54
+ /** OOXML script slot. */
55
+ export type ScriptRole = "latin" | "eastAsian" | "complexScript";
56
+ export interface ScriptFontSlots { latin: string; eastAsian: string; complexScript: string }
57
+ /** Core `resolveScriptFonts` output, or the same shape. `serif` prefers serif replacements. */
58
+ export interface ScriptFontProfile {
59
+ heading?: ScriptFontSlots; body?: ScriptFontSlots;
60
+ supplement?: { script: string; heading: string; body: string };
61
+ script?: string; bcp47?: string; lang?: string; direction?: "ltr" | "rtl"; rtl?: boolean;
62
+ languageSource?: "document" | "option" | "default"; scriptRole?: ScriptRole; serif?: boolean;
63
+ }
64
+ export interface ScriptRun { text: string; script: string; role: ScriptRole }
65
+ export interface PlannedRun { text: string; family: string; own: boolean; stack?: string[] }
66
+ export interface ScriptFonts {
67
+ readonly profile: ScriptFontProfile;
68
+ readonly rtl: boolean;
69
+ /** Font runs for text drawn in a resolved latin style. */
70
+ plan(text: string, style: TextStyle): PlannedRun[];
71
+ runWidths(runs: PlannedRun[], size: number, style: TextStyle): number[] | undefined;
72
+ /** Script-aware measurement; undefined without a measurement provider. */
73
+ readonly textMeasurement?: TextMeasurement;
74
+ }
75
+ export declare const SCRIPT_FONT_FAMILIES: Readonly<Record<string, Readonly<{ sans?: string; serif?: string }>>>;
76
+ export declare const SCRIPT_FONT_REPLACEMENTS: readonly Readonly<{requestedFamily:string;script:string;substitutes:readonly string[];compatibility:"visual";note:string}>[];
77
+ export declare function scriptOfCharacter(character: string): string;
78
+ export declare function scriptFontRole(script: string): ScriptRole;
79
+ export declare function itemizeScripts(text: string, profile?: ScriptFontProfile): ScriptRun[];
80
+ export declare function detectScripts(value: unknown, profile?: ScriptFontProfile): string[];
81
+ export declare function designatedFamilies(script: string, serif?: boolean): string[];
82
+ export declare function scriptFontAliases(families: Iterable<string>): Record<string, string>;
83
+ export declare function createScriptFonts(profile?: ScriptFontProfile, measurement?: TextMeasurement): ScriptFonts;
84
+ /** Wrap a measurement so pagination, the renderer and the editor itemize script runs identically. */
85
+ export declare function createScriptTextMeasurement(measurement: TextMeasurement, profile: ScriptFontProfile): TextMeasurement;
86
+ export declare function openTypeLanguage(tag: string | undefined): string | undefined;
87
+ /** Heading or body role of a style from its OPF path; undefined without a slide path. */
88
+ export declare function textRole(style: { path?: string } | undefined): "heading" | "body" | undefined;
package/dist/fonts.js CHANGED
@@ -1,6 +1,11 @@
1
1
  import { create } from "fontkit";
2
2
  import { FONT_COMPATIBILITY } from "./font-compatibility.js";
3
+ import { openTypeLanguage, scriptFontAliases } from "./script-fonts.js";
4
+ import { fontPolicyFor } from "./font-policy.js";
5
+ import { BUNDLED_FONT_MANIFEST } from "./font-manifest.js";
3
6
  export { FONT_COMPATIBILITY, EXPERIMENTAL_FONT_CANDIDATES } from "./font-compatibility.js";
7
+ export { FONT_POLICY, FONT_POLICY_DECISIONS, FONT_POLICY_SOURCE, fontPolicyFor } from "./font-policy.js";
8
+ export { SCRIPT_FONT_FAMILIES, SCRIPT_FONT_REPLACEMENTS, createScriptFonts, createScriptTextMeasurement, designatedFamilies, detectScripts, itemizeScripts, openTypeLanguage, scriptFontAliases, scriptFontRole, scriptOfCharacter, textRole } from "./script-fonts.js";
4
9
 
5
10
  export class OPFFontError extends Error {
6
11
  constructor(code, message, details = {}) { super(message); this.name = "OPFFontError"; this.code = code; this.details = details; }
@@ -15,6 +20,29 @@ function validFamily(family) {
15
20
  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
21
  return family;
17
22
  }
23
+ const packFor = family => BUNDLED_FONT_MANIFEST.packages.find(pkg=>pkg.faces.some(face=>face.family.toLowerCase()===family.toLowerCase()))?.pack;
24
+ const percent = value => `${(value*100).toFixed(1)}%`;
25
+ /** FF-31: say why a family has no face and what the caller can do, from the OPF font policy. */
26
+ function unavailableFontError(family, style, policy) {
27
+ const row = fontPolicyFor(family), details = {path:style.path, fontFamily:family, substitutionPolicy:policy};
28
+ const hook = `supply licensed '${family}' font files (prepareNodeFonts({faces}) or createFontRegistry entries)`;
29
+ 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"});
30
+ Object.assign(details, {licenseClass:row.licenseClass});
31
+ if (!row.replacement) {
32
+ const pack = packFor(family);
33
+ 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}:{})});
34
+ }
35
+ const {family:replacement, compatibility, measured} = row.replacement;
36
+ const candidates = [replacement, ...(row.alternates ?? [])], packs = [...new Set(candidates.map(packFor).filter(Boolean))];
37
+ Object.assign(details, {replacement, replacementCompatibility:compatibility, candidates, ...(packs.length?{packs}:{}), ...(measured?{measured}:{}), ...(row.replacement.decision?{decision:row.replacement.decision}:{})});
38
+ const delta = measured ? ` (measured mean width difference ${percent(measured.meanAbsWidthDelta)}, max ${percent(measured.maxAbsWidthDelta)})` : " (not measured against the real font)";
39
+ const owner = `'${family}' is ${row.license} and OPF never bundles or embeds it.`;
40
+ if (compatibility === "visual" && policy !== "visual")
41
+ 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);
42
+ if (policy === "none")
43
+ 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);
44
+ 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}.`, details);
45
+ }
18
46
  /** Local font files only. The caller explicitly chooses aliases and fallback. */
19
47
  export function createFontRegistry(entries, options = {}) {
20
48
  if (!Array.isArray(entries) || !entries.length) throw new OPFFontError("empty-font-registry", "Supply at least one font file.");
@@ -38,7 +66,9 @@ export function createFontRegistry(entries, options = {}) {
38
66
  if (!Number.isInteger(weight) || weight < 1 || weight > 1000) throw new OPFFontError("invalid-font-weight", "Font weight must be between 1 and 1000.");
39
67
  const signature = String.fromCharCode(...data.subarray(0,4));
40
68
  const format = signature === "OTTO" ? "otf" : signature === "wOFF" ? "woff" : signature === "wOF2" ? "woff2" : "ttf";
41
- return {family,familyGroup,fontFace,weight,italic,font,data,format,license:entry.license,cache:new Map()};
69
+ // Script replacement faces (FF-19) declare the ISO 15924 scripts they serve.
70
+ const scripts = Array.isArray(entry.scripts) && entry.scripts.length ? Object.freeze(entry.scripts.map(String)) : undefined;
71
+ return {family,familyGroup,fontFace,weight,italic,font,data,format,license:entry.license,scripts,cache:new Map()};
42
72
  });
43
73
  const duplicates = new Set();
44
74
  for (const face of faces) {
@@ -53,7 +83,10 @@ export function createFontRegistry(entries, options = {}) {
53
83
  familySlots.set(id,face);
54
84
  }
55
85
  const substitutions = new Map();
56
- const aliases = new Map(Object.entries(options.aliases ?? {}).map(([from,to])=>[validFamily(from).toLowerCase(),validFamily(to)]));
86
+ // Loaded script replacement faces stand in for the proprietary script fonts
87
+ // they replace (Meiryo -> Noto Sans JP). Caller aliases take precedence.
88
+ const scriptAliases = scriptFontAliases(new Set(faces.filter(face=>face.scripts).map(face=>face.family)));
89
+ const aliases = new Map(Object.entries({...scriptAliases,...options.aliases}).map(([from,to])=>[validFamily(from).toLowerCase(),validFamily(to)]));
57
90
  const policy = options.substitutionPolicy ?? "none";
58
91
  if (!["none","metric","visual"].includes(policy)) throw new OPFFontError("invalid-font-policy", "substitutionPolicy must be none, metric, or visual.");
59
92
  if (options.fallbackFamily) validFamily(options.fallbackFamily);
@@ -68,33 +101,56 @@ export function createFontRegistry(entries, options = {}) {
68
101
  const family = themeKey ? options.themeFonts?.[themeKey] : requested;
69
102
  if (!family || family.startsWith("+")) throw new OPFFontError("unresolved-theme-font", `Supply a concrete theme family for '${requested}'.`, {path:style.path});
70
103
  validFamily(family);
71
- let matching = findFamily(family), compatibility = "exact", rule;
104
+ let matching = findFamily(family), compatibility = "exact", rule, targetWeight = weight, styleFallback = false, via = "family";
72
105
  const encodedSymbol = /^(wingdings(?: [23])?|webdings|symbol)$/i.test(family);
73
106
  if (!matching.length && encodedSymbol) throw new OPFFontError("font-encoding-required", `Font '${family}' requires character mapping before substitution.`, {path:style.path,fontFamily:family});
74
107
  if (!matching.length && aliases.has(family.toLowerCase())) {
75
- matching = findFamily(aliases.get(family.toLowerCase())); compatibility = "visual";
108
+ matching = findFamily(aliases.get(family.toLowerCase())); compatibility = "visual"; via = "alias";
76
109
  }
77
110
  if (!matching.length && policy!=="none") {
78
111
  const candidate = FONT_COMPATIBILITY.find(entry=>entry.requestedFamily.toLowerCase()===family.toLowerCase());
79
112
  const tier = candidate?.compatibility==="metric" && !candidate.weights.includes(weight) ? "visual" : candidate?.compatibility;
80
- if (candidate && (policy==="visual" || tier==="metric")) {
81
- for (const substitute of candidate.substitutes) {
82
- const available = findFamily(substitute).filter(face=>face.italic===!!style.italic && (tier!=="metric" || face.weight===weight));
83
- if (available.length) { matching=available; compatibility=tier; rule=candidate; break; }
113
+ // A declared visual replacement is used in visual mode, and in metric mode only when the
114
+ // policy keeps it as a metric-mode fallback (Cambria -> Caladea); it is still reported visual.
115
+ // In metric mode the fallback applies only where the pre-FF-31 metric rule did: the declared
116
+ // replacement at weights 400 and 700, normal or italic, with an exact face weight.
117
+ const metricFallback = policy!=="visual" && candidate?.metricModeFallback===true && [400,700].includes(weight);
118
+ const allowVisual = policy==="visual" || metricFallback;
119
+ if (candidate && (allowVisual || tier==="metric")) {
120
+ // A family that names its weight (Segoe UI Semibold, Arial Black) selects that weight in
121
+ // the replacement; its bold style link still selects bold (FF-31).
122
+ const wanted = candidate.weight ? (weight>=600 ? Math.max(candidate.weight,700) : candidate.weight) : weight;
123
+ for (const [index, substitute] of candidate.substitutes.entries()) {
124
+ // Only the declared replacement can carry the row's metric claim; an alternate is visual.
125
+ const substituteTier = index===0 ? tier : "visual";
126
+ if (substituteTier==="visual" && !(policy==="visual" || metricFallback && index===0)) continue;
127
+ const exactWeight = substituteTier==="metric" || policy!=="visual";
128
+ const faces = findFamily(substitute).filter(face=>!exactWeight || face.weight===weight);
129
+ let available = faces.filter(face=>face.italic===!!style.italic);
130
+ // Visual replacements without the requested style draw the other one and say so.
131
+ if (!available.length && substituteTier==="visual" && policy==="visual" && faces.length) { available = faces.filter(face=>!face.italic); styleFallback = available.length>0; }
132
+ if (available.length) { matching=available; compatibility=substituteTier; rule={...candidate, substituteIndex:index}; targetWeight=wanted; via="replacement"; break; }
84
133
  }
85
134
  }
86
135
  }
87
136
  // Equations require an explicit math-aware choice; never fall through to body text.
88
137
  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});
89
- if (!matching.length && options.fallbackFamily) { matching=findFamily(options.fallbackFamily); compatibility="generic"; }
90
- if (!matching.length) throw new OPFFontError("font-unavailable", `No local font face for '${family}'.`, {path:style.path,fontFamily:family});
91
- matching = matching.filter(face=>face.italic===!!style.italic);
138
+ if (!matching.length && options.fallbackFamily) { matching=findFamily(options.fallbackFamily); compatibility="generic"; via="fallback"; }
139
+ if (!matching.length) throw unavailableFontError(family, style, policy);
140
+ const styled = styleFallback ? matching : matching.filter(face=>face.italic===!!style.italic);
141
+ // Script replacement faces have no italics; use upright glyphs and advances.
142
+ if (!styled.length && style.italic && matching.length && matching.every(face=>face.scripts)) { if (compatibility!=="generic") compatibility="visual"; }
143
+ else matching = styled;
92
144
  if (!matching.length) throw new OPFFontError("font-style-unavailable", `No ${style.italic ? "italic" : "upright"} face for '${family}'.`, {path:style.path});
93
- matching.sort((a,b)=>Math.abs(a.weight-weight)-Math.abs(b.weight-weight) || a.weight-b.weight);
145
+ matching.sort((a,b)=>Math.abs(a.weight-targetWeight)-Math.abs(b.weight-targetWeight) || a.weight-b.weight);
94
146
  const face = matching[0];
95
- if (face.weight!==weight && compatibility!=="generic") compatibility="visual";
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}:{})};
97
- if (face.family.toLowerCase()!==family.toLowerCase() || face.weight!==weight) substitutions.set(JSON.stringify([requested,weight,!!style.italic,style.path]),resolution);
147
+ if ((face.weight!==targetWeight || styleFallback) && compatibility!=="generic") compatibility="visual";
148
+ // FF-31: `substitute` is true whenever the face is not the chosen family itself (policy
149
+ // replacement, alias, script replacement or fallback). Exporters keep writing sourceFamily.
150
+ const substitute = via!=="family", policyRow = substitute ? fontPolicyFor(family) : undefined;
151
+ const measured = rule?.measured && rule.substituteIndex===0 ? rule.measured : undefined;
152
+ 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]}:{})};
153
+ if (face.family.toLowerCase()!==family.toLowerCase() || face.weight!==weight || face.italic!==!!style.italic) substitutions.set(JSON.stringify([requested,weight,!!style.italic,style.path]),resolution);
98
154
  return {face,resolution};
99
155
  };
100
156
  const resolveFace = style => resolve(style).face;
@@ -108,7 +164,10 @@ export function createFontRegistry(entries, options = {}) {
108
164
  const metrics = (text,size,style,includeOutline=false) => {
109
165
  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.');
110
166
  const face=resolveFace(style);
111
- let value=face.cache.get(text);
167
+ // A BCP-47 `lang` selects the font's OpenType language system, as a browser
168
+ // does for an element's lang (FF-19). Without it, shaping is unchanged.
169
+ const language=openTypeLanguage(style.lang),key=language?`${language}\u0000${text}`:text;
170
+ let value=face.cache.get(key);
112
171
  if (value===undefined) {
113
172
  if (options.strictGlyphs!==false) for(const character of text) {
114
173
  if (/\p{Default_Ignorable_Code_Point}/u.test(character)) continue;
@@ -116,7 +175,16 @@ export function createFontRegistry(entries, options = {}) {
116
175
  }
117
176
  }
118
177
  if(value===undefined||includeOutline&&!Object.hasOwn(value,'outline')) {
119
- const run=face.font.layout(text);
178
+ const shape=features=>language?face.font.layout(text,features,undefined,language):features?face.font.layout(text,features):face.font.layout(text);
179
+ let run;
180
+ try { run=shape(); }
181
+ catch (error) {
182
+ // fontkit rejects some mark-attachment lookups (null anchors, for example
183
+ // Gurmukhi tippi in Noto Sans Gurmukhi). Mark positioning moves marks
184
+ // without changing advances, so measure again without it (FF-19).
185
+ try { run=shape({abvm:false,blwm:false,mark:false,mkmk:false}); }
186
+ catch { throw new OPFFontError("font-shaping-failed", `Font '${face.family}' cannot shape this text.`, {path:style.path,fontFamily:face.family,cause:error?.message}); }
187
+ }
120
188
  value={width:run.positions.reduce((total,position)=>total+position.xAdvance,0)/face.font.unitsPerEm,...value};
121
189
  if(includeOutline) {
122
190
  const bounds=run.bbox,unit=face.font.unitsPerEm;
@@ -124,8 +192,8 @@ export function createFontRegistry(entries, options = {}) {
124
192
  ?{x:bounds.minX/unit,y:-bounds.maxY/unit,width:bounds.width/unit,height:bounds.height/unit}:null;
125
193
  }
126
194
  if (text.length<=2048) {
127
- if (face.cache.size>=512&&!face.cache.has(text)) face.cache.delete(face.cache.keys().next().value);
128
- face.cache.set(text,value);
195
+ if (face.cache.size>=512&&!face.cache.has(key)) face.cache.delete(face.cache.keys().next().value);
196
+ face.cache.set(key,value);
129
197
  }
130
198
  }
131
199
  return value;
@@ -136,10 +204,16 @@ export function createFontRegistry(entries, options = {}) {
136
204
  return bounds===null?null:{x:bounds.x*size,y:bounds.y*size,width:bounds.width*size,height:bounds.height*size};
137
205
  };
138
206
  return {
139
- textMeasurement: {measure,resolveStyle,outlineBounds},
207
+ // resolveFont lets exporters tell a substitute from the chosen family (FF-31).
208
+ textMeasurement: {measure,resolveStyle,outlineBounds,resolveFont:style=>resolve(style).resolution},
140
209
  resolveFont(style) { return resolve(style).resolution; },
141
210
  clearSubstitutions() { substitutions.clear(); },
142
211
  get substitutions() { return [...substitutions.values()]; },
143
- 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)}`})); },
212
+ get embeddedFonts() { return embedded(()=>true); },
213
+ /** Parsed face metadata in entry order, without encoding font bytes. */
214
+ describeFaces: ()=>faces.map(face=>({family:face.family,weight:face.weight,italic:face.italic,...(face.scripts?{scripts:[...face.scripts]}:{})})),
215
+ /** Embedded faces for which `predicate({family,weight,italic,scripts})` holds; large script faces can be left to raster fontFiles. */
216
+ selectEmbeddedFonts: predicate=>embedded(predicate),
144
217
  };
218
+ function embedded(predicate) { return faces.filter(face=>predicate({family:face.family,weight:face.weight,italic:face.italic,scripts:face.scripts})).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)}`})); }
145
219
  }
package/dist/index.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- import type { EmbeddedFont } from "./fonts.js";
1
+ import type { EmbeddedFont, ScriptFonts } from "./fonts.js";
2
2
  import type { SlideComposition, LayoutDiagnostic, TextMeasurement } from "@openpresentation/opf/composition";
3
3
  export declare const packageName = "@openpresentation/opf-render";
4
4
 
@@ -32,9 +32,16 @@ export declare const engineDefaults: Readonly<{
32
32
  }>;
33
33
 
34
34
  export type RenderDiagnostic = LayoutDiagnostic | {
35
- code: "unsupported-pattern" | "date-needs-value";
35
+ code: "unsupported-pattern" | "date-needs-value" | "language-preview-unavailable" | "language-preview-unresolved" | "paragraph-direction-unavailable";
36
36
  path: string;
37
37
  message: string;
38
+ } | {
39
+ /** A font-scheme id matched no record; the default font scheme (`aptos`) was used as the base. */
40
+ code: "unresolved-font-scheme";
41
+ path: string;
42
+ message: string;
43
+ id: string;
44
+ fallback: string;
38
45
  } | {
39
46
  code: "unresolved-asset";
40
47
  path: string;
@@ -48,12 +55,19 @@ export type RenderDiagnostic = LayoutDiagnostic | {
48
55
 
49
56
  export interface RenderSvgOptions {
50
57
  textMeasurement?: TextMeasurement;
58
+ /** Unscaled reference-pixel clearance around supplied vector text outlines; default 1. */
59
+ textRasterPadding?: number;
51
60
  embeddedFonts?: EmbeddedFont[];
52
61
  strictAssets?: boolean;
53
62
  imageResolver?: (src: string | undefined, context: { asset: unknown; path: string }) => string | null | undefined;
54
63
  onDiagnostic?: (diagnostic: RenderDiagnostic) => void;
55
64
  validate?: boolean;
56
65
  slideIndex?: number;
66
+ /**
67
+ * Today's calendar date (ISO YYYY-MM-DD) for `date: true` header/footer furniture. The renderer
68
+ * never reads a clock; without it a current date is reported as unresolved content.
69
+ */
70
+ date?: string;
57
71
  trace?: boolean;
58
72
  catalogs?: Record<string, { records?: unknown[] } | unknown[]>;
59
73
  catalogSources?: Record<string, { records?: unknown[] } | unknown[]>;
@@ -82,6 +96,10 @@ export interface ResolvedPresentation {
82
96
 
83
97
  export interface ResolvedSlide {
84
98
  geometry: SlideComposition;
99
+ /** Script font plan for this slide (FF-19): slots, language tags and direction. */
100
+ scriptFonts: ScriptFonts;
101
+ /** The script-aware measurement composition used; undefined for estimated layout. */
102
+ textMeasurement?: TextMeasurement;
85
103
  index: number;
86
104
  path: string;
87
105
  slide: unknown;