@drawmotive/textgraph 0.2.0 → 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 (44) hide show
  1. package/README.md +176 -74
  2. package/docs/api-v1.md +53 -7
  3. package/docs/images/README.md +28 -0
  4. package/docs/images/service-flow.png +0 -0
  5. package/docs/react.md +7 -4
  6. package/docs/releases.md +34 -15
  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.wasm +0 -0
  27. package/generated/wasm/themes.css +25 -12
  28. package/generated/wasm-manifest.js +42 -35
  29. package/generated/wasm-manifest.json +42 -35
  30. package/package.json +15 -3
  31. package/schemas/render.schema.json +33 -1
  32. package/src/index.d.ts +21 -3
  33. package/src/index.js +2 -2
  34. package/src/platform/node.d.ts +2 -0
  35. package/src/platform/node.js +20 -1
  36. package/src/react/index.d.ts +1 -0
  37. package/src/react/index.js +6 -3
  38. package/src/runtime/browser.js +4 -5
  39. package/src/runtime/dotnet.js +10 -6
  40. package/src/runtime/font-resources.js +148 -0
  41. package/src/runtime/language-packs.js +32 -1
  42. package/src/runtime/node.js +22 -1
  43. package/src/runtime/render-resources.js +13 -12
  44. package/src/runtime/rendering.js +13 -4
package/package.json CHANGED
@@ -1,7 +1,19 @@
1
1
  {
2
2
  "name": "@drawmotive/textgraph",
3
- "version": "0.2.0",
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"
@@ -76,6 +76,14 @@
76
76
  "type": "integer",
77
77
  "minimum": 1,
78
78
  "maximum": 2147483647
79
+ },
80
+ "displayWidth": {
81
+ "type": "number",
82
+ "exclusiveMinimum": 0
83
+ },
84
+ "displayHeight": {
85
+ "type": "number",
86
+ "exclusiveMinimum": 0
79
87
  }
80
88
  },
81
89
  "required": [
@@ -145,6 +153,22 @@
145
153
  "properties": {
146
154
  "height": {}
147
155
  }
156
+ },
157
+ {
158
+ "required": [
159
+ "displayWidth"
160
+ ],
161
+ "properties": {
162
+ "displayWidth": {}
163
+ }
164
+ },
165
+ {
166
+ "required": [
167
+ "displayHeight"
168
+ ],
169
+ "properties": {
170
+ "displayHeight": {}
171
+ }
148
172
  }
149
173
  ]
150
174
  },
@@ -163,5 +187,13 @@
163
187
  }
164
188
  }
165
189
  }
166
- ]
190
+ ],
191
+ "dependentRequired": {
192
+ "displayWidth": [
193
+ "displayHeight"
194
+ ],
195
+ "displayHeight": [
196
+ "displayWidth"
197
+ ]
198
+ }
167
199
  }
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,9 +126,11 @@ 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
- /** Positive render scale. Defaults to 2. */
133
+ /** Positive raster density relative to logical diagram size. Defaults to 1. */
118
134
  scale?: number;
119
135
  /** Non-negative padding in diagram units. Defaults to 10. */
120
136
  padding?: number;
@@ -125,7 +141,9 @@ export interface TextGraphRenderPngOptions<Encoding extends TextGraphPngEncoding
125
141
  }
126
142
 
127
143
  export type TextGraphRenderPngResult<Png extends Uint8Array | string = Uint8Array | string> =
128
- | { readonly success: true; readonly png: Png; readonly width: number; readonly height: number; readonly diagnostics: readonly TextGraphRenderDiagnostic[] }
144
+ | { readonly success: true; readonly png: Png; readonly width: number; readonly height: number;
145
+ /** Logical display dimensions before density scaling; omitted by older runtimes. */
146
+ readonly displayWidth?: number; readonly displayHeight?: number; readonly diagnostics: readonly TextGraphRenderDiagnostic[] }
129
147
  | { readonly success: false; readonly diagnostics: readonly TextGraphRenderDiagnostic[] };
130
148
 
131
149
  export interface TextGraphRuntimeInfo extends TextGraphRuntimeManifest {
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]);
@@ -1,3 +1,5 @@
1
1
  export * from '../index.js';
2
2
 
3
+ /** The workspace development command may select local native assets through
4
+ * DRAWMOTIVE_TEXTGRAPH_RUNTIME; ordinary Node loading uses packaged assets. */
3
5
  export declare const platform: 'node';
@@ -3,10 +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');
9
- return initialize({ ...options, loadRuntime: options.loadRuntime ?? (context => loadNodeRuntime(context, manifest)) });
10
+ options = { ...options };
11
+ // The workspace development command opts in explicitly; imports and browser
12
+ // loaders never consult a local build directory or replace packaged assets.
13
+ const directory = process.env.DRAWMOTIVE_TEXTGRAPH_RUNTIME;
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)) });
10
29
  }
11
30
 
12
31
  export const platform = 'node';
@@ -11,6 +11,7 @@ export interface TextGraphProviderProps {
11
11
  export interface TextGraphProps extends Omit<ImgHTMLAttributes<HTMLImageElement>, 'src' | 'srcSet' | 'children' | 'loading'> {
12
12
  source: string;
13
13
  alt?: string;
14
+ /** Defaults to scale 1; the image displays at logical size. */
14
15
  renderOptions?: Omit<TextGraphRenderPngOptions, 'encoding' | 'signal'>;
15
16
  loading?: ReactNode;
16
17
  }
@@ -15,10 +15,10 @@ export function TextGraphProvider({ options = defaultOptions, children }) {
15
15
 
16
16
  /** Renders DSL as an image; only the latest committed input may publish a result. */
17
17
  export function TextGraph({ source, alt = 'TextGraph diagram', renderOptions = defaultOptions,
18
- loading = 'Rendering diagram…', ...imageProps }) {
18
+ loading = 'Rendering diagram…', style, ...imageProps }) {
19
19
  const shared = useContext(RuntimeContext);
20
20
  const resource = useMemo(() => shared ?? createRuntimeResource(defaultOptions), [shared]);
21
- const { scale, padding, maxWidth } = renderOptions;
21
+ const { scale = 1, padding, maxWidth } = renderOptions;
22
22
  const request = useMemo(() => ({ source, scale, padding, maxWidth, resource }),
23
23
  [source, scale, padding, maxWidth, resource]);
24
24
  const [state, setState] = useState(null);
@@ -51,6 +51,9 @@ export function TextGraph({ source, alt = 'TextGraph diagram', renderOptions = d
51
51
  return createElement('span', { role: 'alert' }, message || 'Could not render diagram');
52
52
  }
53
53
  const result = state.result;
54
- return createElement('img', { width: result.width, height: result.height, ...imageProps,
54
+ // Density improves sharpness without enlarging the diagram. Native dimensions
55
+ // also preserve logical size when maxWidth lowers the actual render density.
56
+ return createElement('img', { width: result.displayWidth ?? result.width / scale, height: result.displayHeight ?? result.height / scale, ...imageProps,
57
+ style: { maxWidth: '100%', ...(imageProps.height == null ? { height: 'auto' } : {}), ...style },
55
58
  src: `data:image/png;base64,${result.png}`, alt });
56
59
  }
@@ -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) {
@@ -83,7 +85,9 @@ export async function startTextGraphRuntime(options, manifest, readAsset) {
83
85
  export function createBridgeRuntime({ manifest, options, readAsset, bridge, info }) {
84
86
  const renders = manifest.capabilities.includes(renderingCapability);
85
87
  return {
86
- abiVersion: info.abiVersion, capabilities: info.capabilities,
88
+ // A bridge may support operations intentionally omitted by the selected
89
+ // manifest; advertise only the intersection that this loader exposes.
90
+ abiVersion: info.abiVersion, capabilities: info.capabilities.filter(capability => manifest.capabilities.includes(capability)),
87
91
  validate: source => bridge[manifest.bridge.validate](source),
88
92
  ...(renders ? { execute: createRenderingExecutor({ manifest, options, readAsset, execute: request => bridge[manifest.bridge.execute](request) }) } : {}),
89
93
  async dispose() {
@@ -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');
@@ -1,9 +1,30 @@
1
1
  import { readFile } from 'node:fs/promises';
2
+ import path from 'node:path';
3
+ import { pathToFileURL } from 'node:url';
2
4
  import { startTextGraphRuntime } from './dotnet.js';
3
5
  import { DrawMotiveError } from './errors.js';
4
6
 
5
7
  /** Node file loading is isolated from the browser module graph. */
6
- export function loadNodeRuntime(options, manifest) {
8
+ export async function loadNodeRuntime(options, manifest, directory) {
9
+ if (directory !== undefined) {
10
+ if (!path.isAbsolute(directory)) throw new DrawMotiveError('INVALID_ARGUMENT', 'DRAWMOTIVE_TEXTGRAPH_RUNTIME must be an absolute generated directory');
11
+ const manifestPath = path.join(directory, 'wasm-manifest.json');
12
+ let json;
13
+ try { json = await readFile(manifestPath, { encoding: 'utf8', signal: options.signal }); }
14
+ catch (cause) {
15
+ if (cause?.name === 'AbortError') throw cause;
16
+ throw new DrawMotiveError('RESOURCE_NOT_FOUND', 'Could not read the selected local runtime manifest', { cause, details: { manifest: manifestPath } });
17
+ }
18
+ try { manifest = JSON.parse(json); }
19
+ catch (cause) { throw new DrawMotiveError('INVALID_MANIFEST', 'The selected local runtime manifest is not valid JSON', { cause }); }
20
+ const resolveAsset = options.resolveAsset;
21
+ // Use the selected manifest and directory together for every native module,
22
+ // theme and font. A missing local artifact must never load packaged bytes.
23
+ options = { ...options, resolveAsset: asset => {
24
+ const defaultUrl = pathToFileURL(path.join(directory, asset.path));
25
+ return resolveAsset ? resolveAsset(asset, defaultUrl) : defaultUrl;
26
+ } };
27
+ }
7
28
  return startTextGraphRuntime(options, manifest, async (item, context) => {
8
29
  try {
9
30
  if (item.url.protocol === 'file:') {
@@ -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 = 2, 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. */
@@ -34,17 +35,25 @@ export function decodeRender(json, encoding) {
34
35
  const diagnostics = decodeDiagnostics(value.diagnostics, stages);
35
36
  if (value.success === diagnostics.some(item => item.severity === 'error')) throw new Error('Inconsistent success');
36
37
  if (!value.success) {
37
- if (['png', 'width', 'height'].some(key => Object.hasOwn(value, key))) throw new Error('Failure contains image data');
38
+ if (['png', 'width', 'height', 'displayWidth', 'displayHeight'].some(key => Object.hasOwn(value, key))) throw new Error('Failure contains image data');
38
39
  return Object.freeze({ success: false, diagnostics });
39
40
  }
40
41
  if (![value.width, value.height].every(n => Number.isSafeInteger(n) && n > 0 && n <= 0x7fffffff)) throw new Error('Invalid dimensions');
42
+ // Only the native exporter knows the effective density after maxWidth clamps
43
+ // the raster. Older runtimes may omit this additive pair entirely.
44
+ const displayDimensions = {};
45
+ if (Object.hasOwn(value, 'displayWidth') || Object.hasOwn(value, 'displayHeight')) {
46
+ if (![value.displayWidth, value.displayHeight].every(n => Number.isFinite(n) && n > 0)) throw new Error('Invalid display dimensions');
47
+ displayDimensions.displayWidth = value.displayWidth;
48
+ displayDimensions.displayHeight = value.displayHeight;
49
+ }
41
50
  const bytes = decodeBase64(value.png);
42
51
  const signature = [137, 80, 78, 71, 13, 10, 26, 10];
43
52
  if (bytes.length < 33 || !signature.every((byte, index) => bytes[index] === byte)) throw new Error('Invalid PNG signature');
44
53
  const header = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
45
54
  if (header.getUint32(8) !== 13 || header.getUint32(12) !== 0x49484452
46
55
  || header.getUint32(16) !== value.width || header.getUint32(20) !== value.height) throw new Error('PNG dimensions disagree');
47
- return Object.freeze({ success: true, png: encoding === 'base64' ? value.png : bytes, width: value.width, height: value.height, diagnostics });
56
+ return Object.freeze({ success: true, png: encoding === 'base64' ? value.png : bytes, width: value.width, height: value.height, ...displayDimensions, diagnostics });
48
57
  } catch (cause) {
49
58
  throw new DrawMotiveError('INVALID_RESPONSE', 'The rendering response violates protocol 1', { cause });
50
59
  }