@drawmotive/textgraph 0.2.1 → 0.2.2-alpha.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +175 -80
- package/docs/api-v1.md +44 -4
- package/docs/images/README.md +28 -0
- package/docs/images/service-flow.png +0 -0
- package/docs/react.md +1 -1
- package/docs/releases.md +34 -16
- package/examples/service-flow.textgraph +6 -0
- package/generated/wasm/DrawMotive.TextGraph.Bridge.wasm +0 -0
- package/generated/wasm/ExCSS.wasm +0 -0
- package/generated/wasm/Graphics.Core.wasm +0 -0
- package/generated/wasm/Grpc.Core.Api.wasm +0 -0
- package/generated/wasm/HarfBuzzSharp.wasm +0 -0
- package/generated/wasm/Math.Core.wasm +0 -0
- package/generated/wasm/Microsoft.Extensions.Logging.Abstractions.wasm +0 -0
- package/generated/wasm/NotoSans-Regular.ttf +0 -0
- package/generated/wasm/Polly.Core.wasm +0 -0
- package/generated/wasm/RBush.wasm +0 -0
- package/generated/wasm/SkiaSharp.wasm +0 -0
- package/generated/wasm/System.Linq.wasm +0 -0
- package/generated/wasm/System.Memory.wasm +0 -0
- package/generated/wasm/System.Private.CoreLib.wasm +0 -0
- package/generated/wasm/System.Runtime.wasm +0 -0
- package/generated/wasm/System.Text.Json.wasm +0 -0
- package/generated/wasm/System.Text.RegularExpressions.wasm +0 -0
- package/generated/wasm/dotnet.boot.js +22 -17
- package/generated/wasm/dotnet.native.js +2 -2
- package/generated/wasm/dotnet.native.wasm +0 -0
- package/generated/wasm/themes.css +25 -12
- package/generated/wasm-manifest.js +44 -37
- package/generated/wasm-manifest.json +44 -37
- package/package.json +15 -3
- package/src/index.d.ts +17 -1
- package/src/index.js +2 -2
- package/src/platform/node.js +17 -1
- package/src/runtime/browser.js +4 -5
- package/src/runtime/dotnet.js +7 -5
- package/src/runtime/font-resources.js +148 -0
- package/src/runtime/language-packs.js +32 -1
- package/src/runtime/render-resources.js +13 -12
- 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.
|
|
4
|
-
"description": "TextGraph
|
|
3
|
+
"version": "0.2.2-alpha.2",
|
|
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": "
|
|
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
|
-
/**
|
|
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]);
|
package/src/platform/node.js
CHANGED
|
@@ -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
|
-
|
|
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';
|
package/src/runtime/browser.js
CHANGED
|
@@ -25,17 +25,16 @@ export function createTextGraphRuntimeLoader(options) {
|
|
|
25
25
|
return {
|
|
26
26
|
plan,
|
|
27
27
|
async loadAssets() {
|
|
28
|
-
|
|
29
|
-
|
|
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
|
-
|
|
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
|
}
|
package/src/runtime/dotnet.js
CHANGED
|
@@ -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
|
-
|
|
30
|
-
|
|
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
|
-
|
|
33
|
-
|
|
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
|
-
|
|
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
|
|
36
|
-
|
|
37
|
-
|
|
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
|
}
|
package/src/runtime/rendering.js
CHANGED
|
@@ -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. */
|