@drawmotive/textgraph 0.2.1 → 0.2.2-alpha.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (40) hide show
  1. package/README.md +175 -80
  2. package/docs/api-v1.md +44 -4
  3. package/docs/images/README.md +28 -0
  4. package/docs/images/service-flow.png +0 -0
  5. package/docs/react.md +1 -1
  6. package/docs/releases.md +34 -16
  7. package/examples/service-flow.textgraph +6 -0
  8. package/generated/wasm/DrawMotive.TextGraph.Bridge.wasm +0 -0
  9. package/generated/wasm/ExCSS.wasm +0 -0
  10. package/generated/wasm/Graphics.Core.wasm +0 -0
  11. package/generated/wasm/Grpc.Core.Api.wasm +0 -0
  12. package/generated/wasm/HarfBuzzSharp.wasm +0 -0
  13. package/generated/wasm/Math.Core.wasm +0 -0
  14. package/generated/wasm/Microsoft.Extensions.Logging.Abstractions.wasm +0 -0
  15. package/generated/wasm/NotoSans-Regular.ttf +0 -0
  16. package/generated/wasm/Polly.Core.wasm +0 -0
  17. package/generated/wasm/RBush.wasm +0 -0
  18. package/generated/wasm/SkiaSharp.wasm +0 -0
  19. package/generated/wasm/System.Linq.wasm +0 -0
  20. package/generated/wasm/System.Memory.wasm +0 -0
  21. package/generated/wasm/System.Private.CoreLib.wasm +0 -0
  22. package/generated/wasm/System.Runtime.wasm +0 -0
  23. package/generated/wasm/System.Text.Json.wasm +0 -0
  24. package/generated/wasm/System.Text.RegularExpressions.wasm +0 -0
  25. package/generated/wasm/dotnet.boot.js +22 -17
  26. package/generated/wasm/dotnet.native.js +2 -2
  27. package/generated/wasm/dotnet.native.wasm +0 -0
  28. package/generated/wasm/themes.css +25 -12
  29. package/generated/wasm-manifest.js +44 -37
  30. package/generated/wasm-manifest.json +44 -37
  31. package/package.json +15 -3
  32. package/src/index.d.ts +17 -1
  33. package/src/index.js +2 -2
  34. package/src/platform/node.js +17 -1
  35. package/src/runtime/browser.js +4 -5
  36. package/src/runtime/dotnet.js +7 -5
  37. package/src/runtime/font-resources.js +148 -0
  38. package/src/runtime/language-packs.js +32 -1
  39. package/src/runtime/render-resources.js +13 -12
  40. package/src/runtime/rendering.js +3 -2
package/package.json CHANGED
@@ -1,7 +1,19 @@
1
1
  {
2
2
  "name": "@drawmotive/textgraph",
3
- "version": "0.2.1",
4
- "description": "TextGraph DSL and headless DrawMotive file toolkit",
3
+ "version": "0.2.2-alpha.1",
4
+ "description": "Render TextGraph flowcharts and directed graphs as PNG images in Node.js, browsers, React, and Web Workers.",
5
+ "keywords": [
6
+ "textgraph",
7
+ "diagram",
8
+ "flowchart",
9
+ "directed-graph",
10
+ "text-to-diagram",
11
+ "automatic-layout",
12
+ "png",
13
+ "react",
14
+ "webassembly",
15
+ "wasm"
16
+ ],
5
17
  "type": "module",
6
18
  "types": "./src/index.d.ts",
7
19
  "exports": {
@@ -82,7 +94,7 @@
82
94
  "publishConfig": {
83
95
  "access": "public",
84
96
  "registry": "https://registry.npmjs.org/",
85
- "tag": "latest"
97
+ "tag": "alpha"
86
98
  },
87
99
  "peerDependencies": {
88
100
  "react": "^18.2.0 || ^19.0.0"
package/src/index.d.ts CHANGED
@@ -93,14 +93,28 @@ export interface TextGraphInitializeOptions {
93
93
  adapters?: TextGraphAdapters;
94
94
  resolveAsset?(asset: RuntimeAsset, defaultUrl: URL): string | URL;
95
95
  fetch?: typeof globalThis.fetch;
96
- /** Font data is copied during initialization and loaded only before the first render. */
96
+ /** Legacy descriptors load on first render; descriptors with coverage load on demand. */
97
97
  languagePacks?: readonly TextGraphLanguagePack[];
98
+ /** Optional deployed catalog; without a package, online rendering uses the staging catalog. */
99
+ fontAssets?: TextGraphFontAssets;
100
+ }
101
+
102
+ export interface TextGraphFontAssets {
103
+ /** Catalog URL; files resolve relative to this URL. A configured catalog is authoritative. */
104
+ readonly catalog?: URL;
105
+ /** Disable external fallback for offline hosts, or select another remote catalog. */
106
+ readonly fallback?: false | URL;
98
107
  }
99
108
 
100
109
  export interface TextGraphFont {
101
110
  readonly family: string;
102
111
  /** Use a URL (including file: in Node) or bytes; bytes are copied during initialization. */
103
112
  readonly source: URL | Uint8Array;
113
+ /** Sorted, disjoint inclusive Unicode ranges. Supply all metadata fields together for lazy loading. */
114
+ readonly coverage?: readonly (readonly [number, number])[];
115
+ readonly languages?: readonly string[];
116
+ readonly bytes?: number;
117
+ readonly sha256?: string;
104
118
  }
105
119
 
106
120
  export interface TextGraphLanguagePack {
@@ -112,6 +126,8 @@ export interface TextGraphLanguagePack {
112
126
  export type TextGraphPngEncoding = 'bytes' | 'base64';
113
127
 
114
128
  export interface TextGraphRenderPngOptions<Encoding extends TextGraphPngEncoding = TextGraphPngEncoding> {
129
+ /** Optional language hint for ambiguous Han text, e.g. ja or zh-CN. */
130
+ language?: string;
115
131
  /** Defaults to bytes. Base64 contains no data-URL prefix. */
116
132
  encoding?: Encoding;
117
133
  /** Positive raster density relative to logical diagram size. Defaults to 1. */
package/src/index.js CHANGED
@@ -2,7 +2,7 @@ import { validateTextGraphAdapters } from './adapters.js';
2
2
  import { createManagedInstance, throwIfAborted, validateAbi, waitForInitialResource } from './runtime/lifecycle.js';
3
3
  import { decodeValidation, validateSignal } from './runtime/validation.js';
4
4
  import { DrawMotiveError } from './runtime/errors.js';
5
- import { normalizeLanguagePacks } from './runtime/language-packs.js';
5
+ import { normalizeFontAssets, normalizeLanguagePacks } from './runtime/language-packs.js';
6
6
  import { decodeRender, normalizeRenderOptions } from './runtime/rendering.js';
7
7
  import manifest from '../generated/wasm-manifest.js';
8
8
 
@@ -26,7 +26,7 @@ export async function initializeTextGraph(options = {}) {
26
26
  if (typeof options.loadRuntime !== 'function') {
27
27
  throw new TypeError('initializeTextGraph requires a loadRuntime function');
28
28
  }
29
- const normalized = Object.freeze({ ...options, adapters: validateTextGraphAdapters(options.adapters), languagePacks: normalizeLanguagePacks(options.languagePacks) });
29
+ const normalized = Object.freeze({ ...options, adapters: validateTextGraphAdapters(options.adapters), languagePacks: normalizeLanguagePacks(options.languagePacks), fontAssets: normalizeFontAssets(options.fontAssets) });
30
30
  const runtime = await waitForInitialResource(() => options.loadRuntime(normalized), options.signal);
31
31
  await validateAbi(runtime, abiManifest.abiVersion);
32
32
  const instance = createManagedInstance([runtime]);
@@ -3,13 +3,29 @@ import { initializeTextGraph as initialize } from '../index.js';
3
3
  import { loadNodeRuntime } from '../runtime/node.js';
4
4
  import manifest from '../../generated/wasm-manifest.js';
5
5
  import { DrawMotiveError } from '../runtime/errors.js';
6
+ import { normalizeFontAssets, normalizeLanguagePacks } from '../runtime/language-packs.js';
6
7
 
7
8
  export async function initializeTextGraph(options = {}) {
8
9
  if (!options || typeof options !== 'object') throw new DrawMotiveError('INVALID_ARGUMENT', 'Initialization options must be an object');
10
+ options = { ...options };
9
11
  // The workspace development command opts in explicitly; imports and browser
10
12
  // loaders never consult a local build directory or replace packaged assets.
11
13
  const directory = process.env.DRAWMOTIVE_TEXTGRAPH_RUNTIME;
12
- return initialize({ ...options, loadRuntime: options.loadRuntime ?? (context => loadNodeRuntime(context, manifest, directory)) });
14
+ const languagePacks = normalizeLanguagePacks(options.languagePacks);
15
+ let fontAssets = normalizeFontAssets(options.fontAssets);
16
+ if (!options.loadRuntime && fontAssets?.catalog === undefined && options.languagePacks === undefined) {
17
+ let entry;
18
+ try { entry = import.meta.resolve('@drawmotive/textgraph-fonts'); }
19
+ catch (error) { if (error.code !== 'ERR_MODULE_NOT_FOUND') throw error; }
20
+ // Resolve the optional package from the installed SDK tree. A broken installed
21
+ // package must surface its error; only absence enables the remote source.
22
+ if (entry) {
23
+ const pack = await import(entry);
24
+ if (!(pack.fontCatalogUrl instanceof URL)) throw new DrawMotiveError('INVALID_MANIFEST', 'Installed font package has no catalog URL');
25
+ fontAssets = { ...fontAssets, catalog: pack.fontCatalogUrl };
26
+ }
27
+ }
28
+ return initialize({ ...options, languagePacks, fontAssets, loadRuntime: options.loadRuntime ?? (context => loadNodeRuntime(context, manifest, directory)) });
13
29
  }
14
30
 
15
31
  export const platform = 'node';
@@ -25,17 +25,16 @@ export function createTextGraphRuntimeLoader(options) {
25
25
  return {
26
26
  plan,
27
27
  async loadAssets() {
28
- const loaded = new Map();
29
- for (const item of plan) {
28
+ // Fetch independent assets together; retain manifest order in the result.
29
+ return new Map(await Promise.all(plan.map(async item => {
30
30
  const response = await fetchResource(item.url);
31
31
  if (!response.ok) {
32
32
  throw new DrawMotiveError('RESOURCE_NOT_FOUND', `Failed to load ${item.asset.path}: HTTP ${response.status}`, {
33
33
  details: { asset: item.asset.path, status: response.status, url: item.url.href },
34
34
  });
35
35
  }
36
- loaded.set(item.asset.path, { ...item.asset, url: item.url, bytes: new Uint8Array(await response.arrayBuffer()) });
37
- }
38
- return loaded;
36
+ return [item.asset.path, { ...item.asset, url: item.url, bytes: new Uint8Array(await response.arrayBuffer()) }];
37
+ })));
39
38
  },
40
39
  };
41
40
  }
@@ -5,6 +5,7 @@ import { throwIfAborted } from './lifecycle.js';
5
5
  import { readVerifiedAsset } from './integrity.js';
6
6
  import { createRenderingExecutor } from './render-resources.js';
7
7
  import { decodeConfiguration } from './rendering.js';
8
+ import { fontCapability } from './font-resources.js';
8
9
 
9
10
  /** Starts one isolated .NET module graph; platform loaders own all data I/O. */
10
11
  export async function startTextGraphRuntime(options, manifest, readAsset) {
@@ -26,12 +27,12 @@ export async function startTextGraphRuntime(options, manifest, readAsset) {
26
27
  try {
27
28
  // .NET treats download failures as fatal and may terminate a Node host. Own
28
29
  // loading/cancellation here, before entering its non-cancellable startup.
29
- const data = new Map();
30
- for (const item of plan) {
30
+ // Data assets are independent: start them together, but verify every response
31
+ // before importing modules or giving .NET ownership of the bytes.
32
+ const data = new Map(await Promise.all(plan.filter(item => !/[.](mjs|js)$/.test(item.asset.path)).map(async item => {
31
33
  throwIfAborted(options.signal);
32
- if (/[.](mjs|js)$/.test(item.asset.path)) continue;
33
- data.set(item.asset.path, await readVerifiedAsset(item, options, readAsset));
34
- }
34
+ return [item.asset.path, await readVerifiedAsset(item, options, readAsset)];
35
+ })));
35
36
  throwIfAborted(options.signal);
36
37
  // Detect unsupported WASM features before the runtime takes ownership of failure handling.
37
38
  await WebAssembly.compile(data.get(manifest.runtimeWasm));
@@ -70,6 +71,7 @@ export async function startTextGraphRuntime(options, manifest, readAsset) {
70
71
  if (!Array.isArray(info.capabilities) || !info.capabilities.includes(validationCapability)) throw new DrawMotiveError('UNSUPPORTED_CAPABILITY', 'Bridge does not implement validation');
71
72
  const renders = manifest.capabilities.includes(renderingCapability);
72
73
  if (renders && !info.capabilities.includes(renderingCapability)) throw new DrawMotiveError('UNSUPPORTED_CAPABILITY', 'Bridge does not implement rendering');
74
+ if (manifest.capabilities.includes(fontCapability) && !info.capabilities.includes(fontCapability)) throw new DrawMotiveError('UNSUPPORTED_CAPABILITY', 'Bridge does not implement lazy fonts');
73
75
  if (renders && typeof bridge[manifest.bridge.execute] !== 'function') throw new DrawMotiveError('ABI_MISMATCH', 'Rendering bridge export is missing');
74
76
  return createBridgeRuntime({ manifest, options, readAsset, bridge, info });
75
77
  } catch (cause) {
@@ -0,0 +1,148 @@
1
+ import { DrawMotiveError } from './errors.js';
2
+ import { normalizeFontMetadata } from './language-packs.js';
3
+ import { decodeDiagnostics } from './validation.js';
4
+ import { decodeConfiguration } from './rendering.js';
5
+ import { readVerifiedAsset } from './integrity.js';
6
+ import { throwIfAborted } from './lifecycle.js';
7
+
8
+ export const fontCapability = 'textgraph-fonts-v1';
9
+ const stagingCatalog = 'https://staging.drawmotive.com/static/font-catalog.json';
10
+
11
+ export function fontBase64(bytes) {
12
+ const chunks = [];
13
+ for (let offset = 0; offset < bytes.length; offset += 32768) chunks.push(String.fromCharCode(...bytes.subarray(offset, offset + 32768)));
14
+ return btoa(chunks.join(''));
15
+ }
16
+
17
+ /** Catalog paths stay within their deployment directory, independent of the hosting origin. */
18
+ function decodeCatalog(value, url) {
19
+ try {
20
+ if (value?.schemaVersion !== 1 || !Array.isArray(value.fonts) || !value.fonts.length) throw new Error('Invalid font catalog envelope');
21
+ const ids = new Set(), families = new Set(), paths = new Set();
22
+ return value.fonts.map(font => {
23
+ if (!font || typeof font.id !== 'string' || !/^[a-z0-9][a-z0-9-]*$/.test(font.id) || ids.has(font.id)
24
+ || typeof font.family !== 'string' || !font.family.trim() || font.family !== font.family.trim() || families.has(font.family)
25
+ || typeof font.path !== 'string' || !/^[A-Za-z0-9_./-]+[.](ttf|otf)$/.test(font.path) || font.path.startsWith('/')
26
+ || font.path.split('/').some(segment => !segment || segment === '.' || segment === '..') || paths.has(font.path) || font.coverage === undefined) throw new Error('Invalid or duplicate font catalog entry');
27
+ ids.add(font.id); families.add(font.family); paths.add(font.path);
28
+ const metadata = normalizeFontMetadata(font);
29
+ const source = new URL(font.path, url);
30
+ // A stable content identity lets HTTP caches distinguish font revisions.
31
+ // File URLs remain plain paths for local/offline hosts.
32
+ if (['http:', 'https:'].includes(source.protocol)) source.searchParams.set('v', metadata.sha256);
33
+ return Object.freeze({ family: font.family, source, ...metadata });
34
+ });
35
+ } catch (cause) {
36
+ throw new DrawMotiveError('INVALID_MANIFEST', 'Invalid font catalog', { cause, details: { url: url.href } });
37
+ }
38
+ }
39
+
40
+ function decodePreparation(json) {
41
+ try {
42
+ const value = JSON.parse(json);
43
+ if (value?.protocolVersion !== 1 || typeof value.success !== 'boolean') throw new Error('Invalid envelope');
44
+ const diagnostics = decodeDiagnostics(value.diagnostics, ['parse', 'semantic', 'layout', 'render', 'font']);
45
+ if (value.success === diagnostics.some(d => d.severity === 'error')) throw new Error('Inconsistent success');
46
+ if (!Array.isArray(value.fontRuns) || value.fontRuns.some(run => !run || typeof run.text !== 'string' || !run.text
47
+ || typeof run.missing !== 'boolean' || (run.language !== undefined && run.language !== null && typeof run.language !== 'string'))) throw new Error('Invalid font runs');
48
+ return { ...value, diagnostics };
49
+ } catch (cause) {
50
+ throw new DrawMotiveError('INVALID_RESPONSE', 'Invalid native font preparation response', { cause });
51
+ }
52
+ }
53
+
54
+ function contains(ranges, point) {
55
+ let low = 0, high = ranges.length - 1;
56
+ while (low <= high) {
57
+ const middle = (low + high) >>> 1, range = ranges[middle];
58
+ if (point < range[0]) high = middle - 1;
59
+ else if (point > range[1]) low = middle + 1;
60
+ else return true;
61
+ }
62
+ return false;
63
+ }
64
+
65
+ function matches(font, run) {
66
+ // Coverage overlap does not grant a language-specific pack authority for another
67
+ // script: loading history must not turn a JP font into implicit Korean fallback.
68
+ if (!run.language || !font.languages.some(language => language.split('-')[0] === run.language.split('-')[0])) return false;
69
+ // Variation selectors and joiners describe shaping; they need no standalone cmap glyph.
70
+ const points = [...run.text].filter(character => !/[\s\p{Default_Ignorable_Code_Point}]/u.test(character)).map(character => character.codePointAt(0));
71
+ return points.length > 0 && points.every(point => contains(font.coverage, point));
72
+ }
73
+
74
+ /** Owns optional font I/O inside the instance queue; native owns text, shaping and final coverage. */
75
+ export function createFontPreparation({ options, readAsset, execute }) {
76
+ const packs = options.languagePacks ?? [];
77
+ const fallbackOrder = [...new Set(packs.flatMap(pack => pack.fallbackFamilies))];
78
+ const descriptors = packs.flatMap(pack => pack.fonts);
79
+ const explicit = fallbackOrder.map(family => descriptors.find(font => font.family === family)).filter(font => font?.coverage);
80
+ const installed = new Set(packs.flatMap(pack => pack.fonts).filter(font => !font.coverage).map(font => font.family));
81
+ const settings = options.fontAssets ?? {};
82
+ // A configured package is authoritative, including its failures. Never probe another source silently.
83
+ const catalogUrl = settings.catalog ?? (packs.length ? undefined : settings.fallback === false ? undefined : settings.fallback ?? new URL(stagingCatalog));
84
+ let catalog;
85
+ async function candidates(context) {
86
+ if (!catalogUrl) return explicit;
87
+ if (!catalog) {
88
+ const response = await readAsset({ asset: { path: 'font-catalog.json', mediaType: 'application/json' }, url: catalogUrl }, context);
89
+ throwIfAborted(context.signal);
90
+ let value;
91
+ try { value = await response.json(); }
92
+ catch (cause) { throw new DrawMotiveError('INVALID_MANIFEST', 'Could not decode font catalog', { cause }); }
93
+ const decoded = decodeCatalog(value, catalogUrl);
94
+ throwIfAborted(context.signal);
95
+ const overridden = new Set(packs.flatMap(pack => pack.fonts).map(font => font.family));
96
+ catalog = decoded.filter(font => !overridden.has(font.family));
97
+ }
98
+ return [...explicit, ...catalog];
99
+ }
100
+ return async (request, context) => {
101
+ const render = JSON.parse(request);
102
+ const loading = { ...options, signal: context.signal };
103
+ const attempted = new Set();
104
+ let consumed = false;
105
+ try {
106
+ while (true) {
107
+ throwIfAborted(context.signal);
108
+ const prepared = decodePreparation(await execute(JSON.stringify({ protocolVersion: 1, operation: 'prepare-fonts', source: render.source, ...(render.language === undefined ? {} : { language: render.language }) })));
109
+ throwIfAborted(context.signal);
110
+ if (!prepared.success) return JSON.stringify({ protocolVersion: 1, success: false, diagnostics: prepared.diagnostics });
111
+ const missing = prepared.fontRuns.filter(run => run.missing && run.language);
112
+ if (!missing.length) break;
113
+ const selected = new Set();
114
+ let available = explicit;
115
+ for (const run of missing) {
116
+ const usable = font => !installed.has(font.family) && !attempted.has(font.family) && matches(font, run);
117
+ let candidate = available.find(usable);
118
+ if (!candidate && catalogUrl) {
119
+ available = await candidates(loading);
120
+ candidate = available.find(usable);
121
+ }
122
+ if (candidate) selected.add(candidate);
123
+ }
124
+ if (!selected.size) break;
125
+ const fonts = [];
126
+ for (const font of selected) {
127
+ attempted.add(font.family);
128
+ throwIfAborted(context.signal);
129
+ const bytes = await readVerifiedAsset({ asset: { path: `font:${font.family}`, bytes: font.bytes, sha256: font.sha256, mediaType: 'font/ttf' }, url: font.source }, loading,
130
+ font.source instanceof Uint8Array ? async () => new Response(font.source) : readAsset);
131
+ throwIfAborted(context.signal);
132
+ fonts.push({ family: font.family, data: fontBase64(bytes), languages: font.languages });
133
+ }
134
+ const installedAfter = new Set([...installed, ...fonts.map(font => font.family)]);
135
+ const order = [...fallbackOrder, ...(catalog ?? []).map(font => font.family)].filter(family => installedAfter.has(family));
136
+ decodeConfiguration(await execute(JSON.stringify({ protocolVersion: 1, operation: 'install-fonts', fonts, fallbackFamilies: order })), 'font installation');
137
+ // Native has committed before cancellation can be observed: preserve that installation identity.
138
+ for (const font of fonts) installed.add(font.family);
139
+ }
140
+ throwIfAborted(context.signal);
141
+ const result = await execute(request);
142
+ consumed = true;
143
+ return result;
144
+ } finally {
145
+ if (!consumed) decodeConfiguration(await execute(JSON.stringify({ protocolVersion: 1, operation: 'cancel-prepare' })), 'font preparation cleanup');
146
+ }
147
+ };
148
+ }
@@ -1,6 +1,37 @@
1
1
  import { DrawMotiveError } from './errors.js';
2
2
 
3
3
  const defaultFamilies = ['NotoSans-Regular', 'FuzzyBubbles-Regular'];
4
+ const fail = message => { throw new DrawMotiveError('INVALID_ARGUMENT', message); };
5
+
6
+ function copyUrl(value, name) {
7
+ if (!(value instanceof URL) || !['https:', 'http:', 'file:', 'data:', 'blob:'].includes(value.protocol)) fail(`${name} must be a supported URL`);
8
+ return new URL(value.href);
9
+ }
10
+
11
+ /** Host configuration selects a source, never probes the network during initialization. */
12
+ export function normalizeFontAssets(value = {}) {
13
+ if (!value || typeof value !== 'object' || Array.isArray(value)) fail('fontAssets must be an object');
14
+ if (value.fallback !== undefined && value.fallback !== false && !(value.fallback instanceof URL)) fail('fontAssets.fallback must be false or a catalog URL');
15
+ return Object.freeze({
16
+ ...(value.catalog === undefined ? {} : { catalog: copyUrl(value.catalog, 'fontAssets.catalog') }),
17
+ ...(value.fallback === undefined ? {} : { fallback: value.fallback === false ? false : copyUrl(value.fallback, 'fontAssets.fallback') }),
18
+ });
19
+ }
20
+
21
+ /** Official lazy descriptors carry complete immutable coverage and content identity. */
22
+ export function normalizeFontMetadata(font) {
23
+ if (font.coverage === undefined) return {};
24
+ if (!Array.isArray(font.coverage) || !font.coverage.length || !Array.isArray(font.languages) || !font.languages.length
25
+ || !font.languages.every(language => typeof language === 'string' && /^[a-z]{2,8}(?:-[A-Za-z0-9]+)*$/.test(language))
26
+ || !Number.isSafeInteger(font.bytes) || font.bytes <= 0 || !/^[a-f0-9]{64}$/.test(font.sha256)) fail('Lazy fonts require coverage, languages, bytes and sha256');
27
+ let end = -1;
28
+ const coverage = font.coverage.map(range => {
29
+ if (!Array.isArray(range) || range.length !== 2 || !range.every(Number.isSafeInteger) || range[0] <= end || range[0] < 0 || range[1] < range[0] || range[1] > 0x10ffff) fail('Font coverage must contain sorted non-overlapping Unicode ranges');
30
+ end = range[1];
31
+ return Object.freeze([...range]);
32
+ });
33
+ return { coverage: Object.freeze(coverage), languages: Object.freeze([...font.languages]), bytes: font.bytes, sha256: font.sha256 };
34
+ }
4
35
 
5
36
  /** Copy caller-owned font descriptors before asynchronous startup without performing font I/O. */
6
37
  export function normalizeLanguagePacks(packs = []) {
@@ -19,7 +50,7 @@ export function normalizeLanguagePacks(packs = []) {
19
50
  source = new URL(font.source.href);
20
51
  } else if (font.source instanceof Uint8Array && font.source.byteLength > 0) source = new Uint8Array(font.source);
21
52
  else invalid(`Font ${font.family} source must be a URL or non-empty Uint8Array`);
22
- return Object.freeze({ family: font.family, source });
53
+ return Object.freeze({ family: font.family, source, ...normalizeFontMetadata(font) });
23
54
  });
24
55
  const fallbackFamilies = pack.fallbackFamilies ?? [];
25
56
  if (!Array.isArray(fallbackFamilies) || !fallbackFamilies.every(family => typeof family === 'string' && family.trim() && family === family.trim())) invalid('fallbackFamilies must be an array of font family names');
@@ -2,19 +2,18 @@ import { createRuntimeAssetPlan } from './shared.js';
2
2
  import { readVerifiedAsset } from './integrity.js';
3
3
  import { decodeConfiguration } from './rendering.js';
4
4
  import { throwIfAborted } from './lifecycle.js';
5
-
6
- function base64(bytes) {
7
- const chunks = [];
8
- for (let offset = 0; offset < bytes.length; offset += 32768) chunks.push(String.fromCharCode(...bytes.subarray(offset, offset + 32768)));
9
- return btoa(chunks.join(''));
10
- }
5
+ import { createFontPreparation, fontBase64 as base64, fontCapability } from './font-resources.js';
6
+ import { DrawMotiveError } from './errors.js';
11
7
 
12
8
  /** Preparation lives inside the instance operation queue; validation never triggers font I/O. */
13
9
  export function createRenderingExecutor({ manifest, options, readAsset, execute }) {
14
10
  let configured = false;
11
+ const lazy = manifest.capabilities?.includes(fontCapability);
12
+ const prepareFonts = lazy ? createFontPreparation({ options, readAsset, execute }) : undefined;
15
13
  return async (request, context = {}) => {
16
14
  const loadingOptions = { ...options, signal: context.signal };
17
15
  throwIfAborted(context.signal);
16
+ if (!lazy && options.fontAssets?.catalog) throw new DrawMotiveError('UNSUPPORTED_CAPABILITY', 'This runtime does not support lazy font assets');
18
17
  if (!configured) {
19
18
  const selected = new Set([manifest.rendering.theme, ...manifest.rendering.fonts.map(font => font.asset)]);
20
19
  const plan = createRuntimeAssetPlan({ manifest: { assets: manifest.assets.filter(asset => selected.has(asset.path)) }, moduleUrl: import.meta.url, resolveAsset: options.resolveAsset });
@@ -31,14 +30,16 @@ export function createRenderingExecutor({ manifest, options, readAsset, execute
31
30
  const fallbackFamilies = [];
32
31
  for (const pack of options.languagePacks ?? []) {
33
32
  for (const font of pack.fonts) {
33
+ if (lazy && font.coverage) continue;
34
34
  throwIfAborted(context.signal);
35
- const bytes = font.source instanceof Uint8Array ? font.source : new Uint8Array(await (await readAsset({
36
- asset: { path: `font:${font.family}`, mediaType: 'font/ttf' }, url: font.source,
37
- }, loadingOptions)).arrayBuffer());
35
+ const item = { asset: { path: `font:${font.family}`, mediaType: 'font/ttf', bytes: font.bytes, sha256: font.sha256 }, url: font.source };
36
+ const bytes = font.coverage
37
+ ? await readVerifiedAsset(item, loadingOptions, font.source instanceof Uint8Array ? async () => new Response(font.source) : readAsset)
38
+ : font.source instanceof Uint8Array ? font.source : new Uint8Array(await (await readAsset(item, loadingOptions)).arrayBuffer());
38
39
  throwIfAborted(context.signal);
39
- fonts.push({ family: font.family, data: base64(bytes) });
40
+ fonts.push({ family: font.family, data: base64(bytes), ...(font.languages ? { languages: font.languages } : {}) });
40
41
  }
41
- for (const family of pack.fallbackFamilies) if (!fallbackFamilies.includes(family)) fallbackFamilies.push(family);
42
+ for (const family of pack.fallbackFamilies) if (fonts.some(font => font.family === family) && !fallbackFamilies.includes(family)) fallbackFamilies.push(family);
42
43
  }
43
44
  throwIfAborted(context.signal);
44
45
  decodeConfiguration(await execute(JSON.stringify({ protocolVersion: 1, operation: 'configure', theme, fonts, fallbackFamilies })));
@@ -46,6 +47,6 @@ export function createRenderingExecutor({ manifest, options, readAsset, execute
46
47
  configured = true;
47
48
  throwIfAborted(context.signal);
48
49
  }
49
- return execute(request);
50
+ return prepareFonts ? prepareFonts(request, context) : execute(request);
50
51
  };
51
52
  }
@@ -7,14 +7,15 @@ const stages = ['parse', 'semantic', 'layout', 'render', 'font'];
7
7
  export function normalizeRenderOptions(source, options) {
8
8
  const invalid = message => { throw new DrawMotiveError('INVALID_ARGUMENT', message); };
9
9
  if (typeof source !== 'string' || !options || typeof options !== 'object' || Array.isArray(options)) invalid('Rendering requires a source string and options object');
10
- const { encoding = 'bytes', scale = 1, padding = 10, maxWidth, signal } = options;
10
+ const { encoding = 'bytes', scale = 1, padding = 10, maxWidth, signal, language } = options;
11
+ if (language !== undefined && (typeof language !== 'string' || !/^[a-z]{2,8}(?:-[A-Za-z0-9]+)*$/.test(language))) invalid('language must be a language tag');
11
12
  if (!['bytes', 'base64'].includes(encoding)) invalid('encoding must be bytes or base64');
12
13
  // The native geometry contract uses float32; reject values lost at that boundary.
13
14
  if (!Number.isFinite(scale) || !Number.isFinite(Math.fround(scale)) || Math.fround(scale) <= 0) invalid('scale must be positive and representable as a finite float32');
14
15
  if (!Number.isFinite(padding) || !Number.isFinite(Math.fround(padding)) || padding < 0) invalid('padding must be non-negative and representable as a finite float32');
15
16
  if (maxWidth !== undefined && (!Number.isSafeInteger(maxWidth) || maxWidth <= 0)) invalid('maxWidth must be a positive integer');
16
17
  validateSignal(signal);
17
- return { encoding, signal, request: { protocolVersion: 1, operation: 'render', source, export: { format: 'png', scale, padding, ...(maxWidth === undefined ? {} : { maxWidth }) } } };
18
+ return { encoding, signal, request: { protocolVersion: 1, operation: 'render', source, ...(language === undefined ? {} : { language }), export: { format: 'png', scale, padding, ...(maxWidth === undefined ? {} : { maxWidth }) } } };
18
19
  }
19
20
 
20
21
  /** Accept canonical base64 only; platform atob implementations otherwise tolerate corruption. */