@nowline/export-png 0.5.0 → 0.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.d.ts +8 -4
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +98 -50
- package/dist/index.js.map +1 -1
- package/package.json +7 -7
- package/src/index.ts +114 -58
package/dist/index.d.ts
CHANGED
|
@@ -9,10 +9,14 @@ export interface PngOptions {
|
|
|
9
9
|
background?: string;
|
|
10
10
|
/** Pre-resolved font pair. If absent, the exporter calls `resolveFonts()`. */
|
|
11
11
|
fonts?: ResolvedFontPair;
|
|
12
|
-
/** Resvg-js options bag for advanced overrides. Use sparingly. */
|
|
13
|
-
resvgOptions?: Record<string, unknown>;
|
|
14
12
|
}
|
|
15
|
-
/**
|
|
16
|
-
|
|
13
|
+
/**
|
|
14
|
+
* Initialize the resvg WASM module with the provided bytes. The kernel calls
|
|
15
|
+
* this with `await host.loadWasm()` before the first PNG export. Idempotent:
|
|
16
|
+
* subsequent calls with the same wasm bytes are no-ops.
|
|
17
|
+
*/
|
|
18
|
+
export declare function initPngWasm(wasm: ArrayBuffer | Uint8Array): Promise<void>;
|
|
19
|
+
/** Test seam: reset WASM initialization state between isolated tests. */
|
|
20
|
+
export declare function _resetPngWasm(): void;
|
|
17
21
|
export declare function exportPng(inputs: ExportInputs, svg: string, options?: PngOptions): Promise<Uint8Array>;
|
|
18
22
|
//# sourceMappingURL=index.d.ts.map
|
package/dist/index.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAgBA,OAAO,KAAK,EAAE,YAAY,EAAE,gBAAgB,EAAE,MAAM,sBAAsB,CAAC;AAE3E,MAAM,WAAW,UAAU;IACvB,6EAA6E;IAC7E,KAAK,CAAC,EAAE,MAAM,CAAC;IACf;;;OAGG;IACH,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,8EAA8E;IAC9E,KAAK,CAAC,EAAE,gBAAgB,CAAC;CAC5B;AAOD;;;;GAIG;AACH,wBAAsB,WAAW,CAAC,IAAI,EAAE,WAAW,GAAG,UAAU,GAAG,OAAO,CAAC,IAAI,CAAC,CAW/E;AA4BD,yEAAyE;AACzE,wBAAgB,aAAa,IAAI,IAAI,CAGpC;AA4BD,wBAAsB,SAAS,CAC3B,MAAM,EAAE,YAAY,EACpB,GAAG,EAAE,MAAM,EACX,OAAO,GAAE,UAAe,GACzB,OAAO,CAAC,UAAU,CAAC,CAqCrB"}
|
package/dist/index.js
CHANGED
|
@@ -1,57 +1,75 @@
|
|
|
1
|
-
// PNG exporter — rasterizes the renderer SVG via resvg-
|
|
1
|
+
// PNG exporter — rasterizes the renderer SVG via @resvg/resvg-wasm.
|
|
2
2
|
//
|
|
3
|
-
//
|
|
3
|
+
// Full replace of the old native @resvg/resvg-js dependency. The WASM build
|
|
4
|
+
// is browser-capable, loads system fonts optionally (we always use
|
|
5
|
+
// custom fontBuffers for determinism), and avoids native .node addons.
|
|
4
6
|
//
|
|
5
7
|
// Determinism contract:
|
|
6
|
-
// -
|
|
7
|
-
//
|
|
8
|
-
//
|
|
9
|
-
// -
|
|
10
|
-
//
|
|
11
|
-
//
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
8
|
+
// - fontBuffers: only the resolved fonts the caller hands in are visible to
|
|
9
|
+
// resvg, so identical inputs → identical bytes regardless of host machine.
|
|
10
|
+
// - Fonts come from the @nowline/export-core resolver; passed as Uint8Array[].
|
|
11
|
+
// - WASM module is initialized lazily on first call; initPngWasm() lets the
|
|
12
|
+
// caller supply the bytes (HostEnv.loadWasm() path); auto-init falls back
|
|
13
|
+
// to loading index_bg.wasm from the package's node_modules (Node only).
|
|
14
|
+
//
|
|
15
|
+
// Spec: specs/export-determinism.md — full replace (plan s4).
|
|
16
|
+
// ---- WASM initialization ----------------------------------------------------
|
|
17
|
+
let wasmReady = false;
|
|
18
|
+
let wasmInitializing;
|
|
19
|
+
/**
|
|
20
|
+
* Initialize the resvg WASM module with the provided bytes. The kernel calls
|
|
21
|
+
* this with `await host.loadWasm()` before the first PNG export. Idempotent:
|
|
22
|
+
* subsequent calls with the same wasm bytes are no-ops.
|
|
23
|
+
*/
|
|
24
|
+
export async function initPngWasm(wasm) {
|
|
25
|
+
if (wasmReady)
|
|
26
|
+
return;
|
|
27
|
+
const bytes = wasm instanceof Uint8Array ? wasm.buffer : wasm;
|
|
28
|
+
if (!wasmInitializing) {
|
|
29
|
+
wasmInitializing = (async () => {
|
|
30
|
+
const { initWasm } = await import('@resvg/resvg-wasm');
|
|
31
|
+
await initWasm(bytes);
|
|
32
|
+
wasmReady = true;
|
|
33
|
+
})();
|
|
17
34
|
}
|
|
18
|
-
|
|
19
|
-
}
|
|
20
|
-
/** Test seam: drop the cached WASM module so isolated tests start fresh. */
|
|
21
|
-
export function _resetResvgModule() {
|
|
22
|
-
resvgModule = undefined;
|
|
35
|
+
await wasmInitializing;
|
|
23
36
|
}
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
37
|
+
/**
|
|
38
|
+
* Ensure the WASM module is initialized. If initPngWasm() was not called
|
|
39
|
+
* explicitly, auto-loads index_bg.wasm from @resvg/resvg-wasm's Node package
|
|
40
|
+
* (Node.js / non-compiled use — tests and development). Under bun compile the
|
|
41
|
+
* kernel always calls initPngWasm() via HostEnv.loadWasm() so this path is not
|
|
42
|
+
* reached in production binaries.
|
|
43
|
+
*/
|
|
44
|
+
async function ensureWasmInitialized() {
|
|
45
|
+
if (wasmReady)
|
|
46
|
+
return;
|
|
47
|
+
if (!wasmInitializing) {
|
|
48
|
+
wasmInitializing = (async () => {
|
|
49
|
+
const { createRequire } = await import('node:module');
|
|
50
|
+
const { readFile } = await import('node:fs/promises');
|
|
51
|
+
const { dirname } = await import('node:path');
|
|
52
|
+
const req = createRequire(import.meta.url);
|
|
53
|
+
const entry = req.resolve('@resvg/resvg-wasm');
|
|
54
|
+
const wasmPath = `${dirname(entry)}/index_bg.wasm`;
|
|
55
|
+
const wasmBytes = await readFile(wasmPath);
|
|
56
|
+
const { initWasm } = await import('@resvg/resvg-wasm');
|
|
57
|
+
await initWasm(wasmBytes.buffer);
|
|
58
|
+
wasmReady = true;
|
|
59
|
+
})();
|
|
28
60
|
}
|
|
29
|
-
|
|
30
|
-
const fontPair = options.fonts ?? (await resolveFontsFor(inputs));
|
|
31
|
-
const fontBuffers = [bufferOf(fontPair.sans.bytes), bufferOf(fontPair.mono.bytes)];
|
|
32
|
-
// Workaround: in resvg-js 2.6.2, supplying `font.fontBuffers` silently
|
|
33
|
-
// disables `fitTo` (zoom / width / height / dpi). To honour `--scale` we
|
|
34
|
-
// multiply the root <svg width=…> / <svg height=…> attributes ourselves
|
|
35
|
-
// before handing the SVG to resvg. The internal viewBox stays the same,
|
|
36
|
-
// so vector geometry is preserved — only the rasterized pixel grid grows.
|
|
37
|
-
const scaledSvg = scale === 1 ? svg : scaleRootSvgDimensions(svg, scale);
|
|
38
|
-
const resvgOpts = {
|
|
39
|
-
background,
|
|
40
|
-
font: {
|
|
41
|
-
loadSystemFonts: false,
|
|
42
|
-
fontBuffers,
|
|
43
|
-
defaultFontFamily: fontPair.sans.name,
|
|
44
|
-
sansSerifFamily: fontPair.sans.name,
|
|
45
|
-
monospaceFamily: fontPair.mono.name,
|
|
46
|
-
},
|
|
47
|
-
...(options.resvgOptions ?? {}),
|
|
48
|
-
};
|
|
49
|
-
const { Resvg } = await getResvgModule();
|
|
50
|
-
const resvg = new Resvg(scaledSvg, resvgOpts);
|
|
51
|
-
const png = resvg.render();
|
|
52
|
-
const bytes = png.asPng();
|
|
53
|
-
return new Uint8Array(bytes.buffer, bytes.byteOffset, bytes.byteLength);
|
|
61
|
+
await wasmInitializing;
|
|
54
62
|
}
|
|
63
|
+
/** Test seam: reset WASM initialization state between isolated tests. */
|
|
64
|
+
export function _resetPngWasm() {
|
|
65
|
+
wasmReady = false;
|
|
66
|
+
wasmInitializing = undefined;
|
|
67
|
+
}
|
|
68
|
+
// ---- Scale workaround -------------------------------------------------------
|
|
69
|
+
//
|
|
70
|
+
// @resvg/resvg-wasm (like resvg-js ≤ 2.6.x) silently disables fitTo when
|
|
71
|
+
// fontBuffers are supplied. Pre-multiply root <svg width/height> by scale so
|
|
72
|
+
// the rasterized grid grows while preserving vector geometry.
|
|
55
73
|
const ROOT_SVG_RE = /<svg\b([^>]*)>/i;
|
|
56
74
|
const WIDTH_RE = /\bwidth="([\d.]+)(px)?"/i;
|
|
57
75
|
const HEIGHT_RE = /\bheight="([\d.]+)(px)?"/i;
|
|
@@ -70,11 +88,41 @@ function scaleRootSvgDimensions(svg, scale) {
|
|
|
70
88
|
nextAttrs = nextAttrs.replace(HEIGHT_RE, `height="${newHeight}"`);
|
|
71
89
|
return svg.replace(ROOT_SVG_RE, `<svg${nextAttrs}>`);
|
|
72
90
|
}
|
|
91
|
+
// ---- Public API -------------------------------------------------------------
|
|
92
|
+
export async function exportPng(inputs, svg, options = {}) {
|
|
93
|
+
const scale = options.scale ?? 2;
|
|
94
|
+
if (!Number.isFinite(scale) || scale <= 0) {
|
|
95
|
+
throw new Error(`exportPng: invalid scale ${scale}; expected a positive number`);
|
|
96
|
+
}
|
|
97
|
+
await ensureWasmInitialized();
|
|
98
|
+
const background = options.background ?? inputs.model.backgroundColor;
|
|
99
|
+
const fontPair = options.fonts ?? (await resolveFontsFor(inputs));
|
|
100
|
+
const sansBytes = new Uint8Array(fontPair.sans.bytes.buffer, fontPair.sans.bytes.byteOffset, fontPair.sans.bytes.byteLength);
|
|
101
|
+
const monoBytes = new Uint8Array(fontPair.mono.bytes.buffer, fontPair.mono.bytes.byteOffset, fontPair.mono.bytes.byteLength);
|
|
102
|
+
const scaledSvg = scale === 1 ? svg : scaleRootSvgDimensions(svg, scale);
|
|
103
|
+
const { Resvg } = await import('@resvg/resvg-wasm');
|
|
104
|
+
const resvg = new Resvg(scaledSvg, {
|
|
105
|
+
background,
|
|
106
|
+
font: {
|
|
107
|
+
fontBuffers: [sansBytes, monoBytes],
|
|
108
|
+
defaultFontFamily: fontPair.sans.name,
|
|
109
|
+
sansSerifFamily: fontPair.sans.name,
|
|
110
|
+
monospaceFamily: fontPair.mono.name,
|
|
111
|
+
},
|
|
112
|
+
});
|
|
113
|
+
const rendered = resvg.render();
|
|
114
|
+
const bytes = rendered.asPng();
|
|
115
|
+
return new Uint8Array(bytes.buffer, bytes.byteOffset, bytes.byteLength);
|
|
116
|
+
}
|
|
73
117
|
async function resolveFontsFor(_inputs) {
|
|
118
|
+
// Imported lazily (not at module top) so the static module graph of
|
|
119
|
+
// @nowline/export-png stays free of `node:fs` — the font resolver pulls it
|
|
120
|
+
// in. Canonical callers (the kernel, the CLI) always pass `options.fonts`,
|
|
121
|
+
// so this fallback never runs there; keeping the import dynamic lets the
|
|
122
|
+
// package bundle for the browser (the determinism gate's headless leg and
|
|
123
|
+
// the Free/Pro web apps) without a Node-builtin polyfill.
|
|
124
|
+
const { resolveFonts } = await import('@nowline/export-core');
|
|
74
125
|
const result = await resolveFonts();
|
|
75
126
|
return { sans: result.sans, mono: result.mono };
|
|
76
127
|
}
|
|
77
|
-
function bufferOf(bytes) {
|
|
78
|
-
return Buffer.from(bytes.buffer, bytes.byteOffset, bytes.byteLength);
|
|
79
|
-
}
|
|
80
128
|
//# sourceMappingURL=index.js.map
|
package/dist/index.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,
|
|
1
|
+
{"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"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"}
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@nowline/export-png",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "Nowline PNG exporter — rasterizes the renderer SVG via resvg-
|
|
3
|
+
"version": "0.6.0",
|
|
4
|
+
"description": "Nowline PNG exporter — rasterizes the renderer SVG via @resvg/resvg-wasm",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"engines": {
|
|
7
7
|
"node": ">=22",
|
|
@@ -21,17 +21,17 @@
|
|
|
21
21
|
"src/"
|
|
22
22
|
],
|
|
23
23
|
"dependencies": {
|
|
24
|
-
"@resvg/resvg-
|
|
25
|
-
"@nowline/export-core": "0.
|
|
24
|
+
"@resvg/resvg-wasm": "^2.6.2",
|
|
25
|
+
"@nowline/export-core": "0.6.0"
|
|
26
26
|
},
|
|
27
27
|
"devDependencies": {
|
|
28
28
|
"@types/node": "^25.9.1",
|
|
29
29
|
"langium": "~4.2.4",
|
|
30
30
|
"typescript": "^6.0.3",
|
|
31
31
|
"vitest": "^4.1.7",
|
|
32
|
-
"@nowline/core": "0.
|
|
33
|
-
"@nowline/
|
|
34
|
-
"@nowline/
|
|
32
|
+
"@nowline/core": "0.6.0",
|
|
33
|
+
"@nowline/layout": "0.6.0",
|
|
34
|
+
"@nowline/renderer": "0.6.0"
|
|
35
35
|
},
|
|
36
36
|
"scripts": {
|
|
37
37
|
"build": "tsc -b tsconfig.json",
|
package/src/index.ts
CHANGED
|
@@ -1,17 +1,20 @@
|
|
|
1
|
-
// PNG exporter — rasterizes the renderer SVG via resvg-
|
|
1
|
+
// PNG exporter — rasterizes the renderer SVG via @resvg/resvg-wasm.
|
|
2
2
|
//
|
|
3
|
-
//
|
|
3
|
+
// Full replace of the old native @resvg/resvg-js dependency. The WASM build
|
|
4
|
+
// is browser-capable, loads system fonts optionally (we always use
|
|
5
|
+
// custom fontBuffers for determinism), and avoids native .node addons.
|
|
4
6
|
//
|
|
5
7
|
// Determinism contract:
|
|
6
|
-
// -
|
|
7
|
-
//
|
|
8
|
-
//
|
|
9
|
-
// -
|
|
10
|
-
//
|
|
11
|
-
//
|
|
8
|
+
// - fontBuffers: only the resolved fonts the caller hands in are visible to
|
|
9
|
+
// resvg, so identical inputs → identical bytes regardless of host machine.
|
|
10
|
+
// - Fonts come from the @nowline/export-core resolver; passed as Uint8Array[].
|
|
11
|
+
// - WASM module is initialized lazily on first call; initPngWasm() lets the
|
|
12
|
+
// caller supply the bytes (HostEnv.loadWasm() path); auto-init falls back
|
|
13
|
+
// to loading index_bg.wasm from the package's node_modules (Node only).
|
|
14
|
+
//
|
|
15
|
+
// Spec: specs/export-determinism.md — full replace (plan s4).
|
|
12
16
|
|
|
13
17
|
import type { ExportInputs, ResolvedFontPair } from '@nowline/export-core';
|
|
14
|
-
import { resolveFonts } from '@nowline/export-core';
|
|
15
18
|
|
|
16
19
|
export interface PngOptions {
|
|
17
20
|
/** Pixel-density multiplier. 1 (native), 1.5, 2, or 3 typical. Default 2. */
|
|
@@ -23,24 +26,89 @@ export interface PngOptions {
|
|
|
23
26
|
background?: string;
|
|
24
27
|
/** Pre-resolved font pair. If absent, the exporter calls `resolveFonts()`. */
|
|
25
28
|
fonts?: ResolvedFontPair;
|
|
26
|
-
/** Resvg-js options bag for advanced overrides. Use sparingly. */
|
|
27
|
-
resvgOptions?: Record<string, unknown>;
|
|
28
29
|
}
|
|
29
30
|
|
|
30
|
-
|
|
31
|
+
// ---- WASM initialization ----------------------------------------------------
|
|
32
|
+
|
|
33
|
+
let wasmReady = false;
|
|
34
|
+
let wasmInitializing: Promise<void> | undefined;
|
|
31
35
|
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
36
|
+
/**
|
|
37
|
+
* Initialize the resvg WASM module with the provided bytes. The kernel calls
|
|
38
|
+
* this with `await host.loadWasm()` before the first PNG export. Idempotent:
|
|
39
|
+
* subsequent calls with the same wasm bytes are no-ops.
|
|
40
|
+
*/
|
|
41
|
+
export async function initPngWasm(wasm: ArrayBuffer | Uint8Array): Promise<void> {
|
|
42
|
+
if (wasmReady) return;
|
|
43
|
+
const bytes = wasm instanceof Uint8Array ? wasm.buffer : wasm;
|
|
44
|
+
if (!wasmInitializing) {
|
|
45
|
+
wasmInitializing = (async () => {
|
|
46
|
+
const { initWasm } = await import('@resvg/resvg-wasm');
|
|
47
|
+
await initWasm(bytes as ArrayBuffer);
|
|
48
|
+
wasmReady = true;
|
|
49
|
+
})();
|
|
35
50
|
}
|
|
36
|
-
|
|
51
|
+
await wasmInitializing;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Ensure the WASM module is initialized. If initPngWasm() was not called
|
|
56
|
+
* explicitly, auto-loads index_bg.wasm from @resvg/resvg-wasm's Node package
|
|
57
|
+
* (Node.js / non-compiled use — tests and development). Under bun compile the
|
|
58
|
+
* kernel always calls initPngWasm() via HostEnv.loadWasm() so this path is not
|
|
59
|
+
* reached in production binaries.
|
|
60
|
+
*/
|
|
61
|
+
async function ensureWasmInitialized(): Promise<void> {
|
|
62
|
+
if (wasmReady) return;
|
|
63
|
+
if (!wasmInitializing) {
|
|
64
|
+
wasmInitializing = (async () => {
|
|
65
|
+
const { createRequire } = await import('node:module');
|
|
66
|
+
const { readFile } = await import('node:fs/promises');
|
|
67
|
+
const { dirname } = await import('node:path');
|
|
68
|
+
const req = createRequire(import.meta.url);
|
|
69
|
+
const entry = req.resolve('@resvg/resvg-wasm');
|
|
70
|
+
const wasmPath = `${dirname(entry)}/index_bg.wasm`;
|
|
71
|
+
const wasmBytes = await readFile(wasmPath);
|
|
72
|
+
const { initWasm } = await import('@resvg/resvg-wasm');
|
|
73
|
+
await initWasm(wasmBytes.buffer as ArrayBuffer);
|
|
74
|
+
wasmReady = true;
|
|
75
|
+
})();
|
|
76
|
+
}
|
|
77
|
+
await wasmInitializing;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** Test seam: reset WASM initialization state between isolated tests. */
|
|
81
|
+
export function _resetPngWasm(): void {
|
|
82
|
+
wasmReady = false;
|
|
83
|
+
wasmInitializing = undefined;
|
|
37
84
|
}
|
|
38
85
|
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
86
|
+
// ---- Scale workaround -------------------------------------------------------
|
|
87
|
+
//
|
|
88
|
+
// @resvg/resvg-wasm (like resvg-js ≤ 2.6.x) silently disables fitTo when
|
|
89
|
+
// fontBuffers are supplied. Pre-multiply root <svg width/height> by scale so
|
|
90
|
+
// the rasterized grid grows while preserving vector geometry.
|
|
91
|
+
|
|
92
|
+
const ROOT_SVG_RE = /<svg\b([^>]*)>/i;
|
|
93
|
+
const WIDTH_RE = /\bwidth="([\d.]+)(px)?"/i;
|
|
94
|
+
const HEIGHT_RE = /\bheight="([\d.]+)(px)?"/i;
|
|
95
|
+
|
|
96
|
+
function scaleRootSvgDimensions(svg: string, scale: number): string {
|
|
97
|
+
const match = ROOT_SVG_RE.exec(svg);
|
|
98
|
+
if (!match) return svg;
|
|
99
|
+
const attrs = match[1];
|
|
100
|
+
const widthMatch = WIDTH_RE.exec(attrs);
|
|
101
|
+
const heightMatch = HEIGHT_RE.exec(attrs);
|
|
102
|
+
if (!widthMatch || !heightMatch) return svg;
|
|
103
|
+
const newWidth = Number(widthMatch[1]) * scale;
|
|
104
|
+
const newHeight = Number(heightMatch[1]) * scale;
|
|
105
|
+
let nextAttrs = attrs.replace(WIDTH_RE, `width="${newWidth}"`);
|
|
106
|
+
nextAttrs = nextAttrs.replace(HEIGHT_RE, `height="${newHeight}"`);
|
|
107
|
+
return svg.replace(ROOT_SVG_RE, `<svg${nextAttrs}>`);
|
|
42
108
|
}
|
|
43
109
|
|
|
110
|
+
// ---- Public API -------------------------------------------------------------
|
|
111
|
+
|
|
44
112
|
export async function exportPng(
|
|
45
113
|
inputs: ExportInputs,
|
|
46
114
|
svg: string,
|
|
@@ -50,60 +118,48 @@ export async function exportPng(
|
|
|
50
118
|
if (!Number.isFinite(scale) || scale <= 0) {
|
|
51
119
|
throw new Error(`exportPng: invalid scale ${scale}; expected a positive number`);
|
|
52
120
|
}
|
|
53
|
-
const background = options.background ?? inputs.model.backgroundColor;
|
|
54
121
|
|
|
122
|
+
await ensureWasmInitialized();
|
|
123
|
+
|
|
124
|
+
const background = options.background ?? inputs.model.backgroundColor;
|
|
55
125
|
const fontPair = options.fonts ?? (await resolveFontsFor(inputs));
|
|
56
|
-
const fontBuffers = [bufferOf(fontPair.sans.bytes), bufferOf(fontPair.mono.bytes)];
|
|
57
126
|
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
127
|
+
const sansBytes = new Uint8Array(
|
|
128
|
+
fontPair.sans.bytes.buffer,
|
|
129
|
+
fontPair.sans.bytes.byteOffset,
|
|
130
|
+
fontPair.sans.bytes.byteLength,
|
|
131
|
+
);
|
|
132
|
+
const monoBytes = new Uint8Array(
|
|
133
|
+
fontPair.mono.bytes.buffer,
|
|
134
|
+
fontPair.mono.bytes.byteOffset,
|
|
135
|
+
fontPair.mono.bytes.byteLength,
|
|
136
|
+
);
|
|
137
|
+
|
|
63
138
|
const scaledSvg = scale === 1 ? svg : scaleRootSvgDimensions(svg, scale);
|
|
64
139
|
|
|
65
|
-
const
|
|
140
|
+
const { Resvg } = await import('@resvg/resvg-wasm');
|
|
141
|
+
const resvg = new Resvg(scaledSvg, {
|
|
66
142
|
background,
|
|
67
143
|
font: {
|
|
68
|
-
|
|
69
|
-
fontBuffers,
|
|
144
|
+
fontBuffers: [sansBytes, monoBytes],
|
|
70
145
|
defaultFontFamily: fontPair.sans.name,
|
|
71
146
|
sansSerifFamily: fontPair.sans.name,
|
|
72
147
|
monospaceFamily: fontPair.mono.name,
|
|
73
148
|
},
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
const { Resvg } = await getResvgModule();
|
|
78
|
-
const resvg = new Resvg(scaledSvg, resvgOpts);
|
|
79
|
-
const png = resvg.render();
|
|
80
|
-
const bytes = png.asPng();
|
|
149
|
+
});
|
|
150
|
+
const rendered = resvg.render();
|
|
151
|
+
const bytes = rendered.asPng();
|
|
81
152
|
return new Uint8Array(bytes.buffer, bytes.byteOffset, bytes.byteLength);
|
|
82
153
|
}
|
|
83
154
|
|
|
84
|
-
const ROOT_SVG_RE = /<svg\b([^>]*)>/i;
|
|
85
|
-
const WIDTH_RE = /\bwidth="([\d.]+)(px)?"/i;
|
|
86
|
-
const HEIGHT_RE = /\bheight="([\d.]+)(px)?"/i;
|
|
87
|
-
|
|
88
|
-
function scaleRootSvgDimensions(svg: string, scale: number): string {
|
|
89
|
-
const match = ROOT_SVG_RE.exec(svg);
|
|
90
|
-
if (!match) return svg;
|
|
91
|
-
const attrs = match[1];
|
|
92
|
-
const widthMatch = WIDTH_RE.exec(attrs);
|
|
93
|
-
const heightMatch = HEIGHT_RE.exec(attrs);
|
|
94
|
-
if (!widthMatch || !heightMatch) return svg;
|
|
95
|
-
const newWidth = Number(widthMatch[1]) * scale;
|
|
96
|
-
const newHeight = Number(heightMatch[1]) * scale;
|
|
97
|
-
let nextAttrs = attrs.replace(WIDTH_RE, `width="${newWidth}"`);
|
|
98
|
-
nextAttrs = nextAttrs.replace(HEIGHT_RE, `height="${newHeight}"`);
|
|
99
|
-
return svg.replace(ROOT_SVG_RE, `<svg${nextAttrs}>`);
|
|
100
|
-
}
|
|
101
|
-
|
|
102
155
|
async function resolveFontsFor(_inputs: ExportInputs): Promise<ResolvedFontPair> {
|
|
156
|
+
// Imported lazily (not at module top) so the static module graph of
|
|
157
|
+
// @nowline/export-png stays free of `node:fs` — the font resolver pulls it
|
|
158
|
+
// in. Canonical callers (the kernel, the CLI) always pass `options.fonts`,
|
|
159
|
+
// so this fallback never runs there; keeping the import dynamic lets the
|
|
160
|
+
// package bundle for the browser (the determinism gate's headless leg and
|
|
161
|
+
// the Free/Pro web apps) without a Node-builtin polyfill.
|
|
162
|
+
const { resolveFonts } = await import('@nowline/export-core');
|
|
103
163
|
const result = await resolveFonts();
|
|
104
164
|
return { sans: result.sans, mono: result.mono };
|
|
105
165
|
}
|
|
106
|
-
|
|
107
|
-
function bufferOf(bytes: Uint8Array): Buffer {
|
|
108
|
-
return Buffer.from(bytes.buffer, bytes.byteOffset, bytes.byteLength);
|
|
109
|
-
}
|