@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 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
- /** Test seam: drop the cached WASM module so isolated tests start fresh. */
16
- export declare function _resetResvgModule(): void;
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
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAYA,OAAO,KAAK,EAAE,YAAY,EAAE,gBAAgB,EAAE,MAAM,sBAAsB,CAAC;AAG3E,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;IACzB,kEAAkE;IAClE,YAAY,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;CAC1C;AAWD,4EAA4E;AAC5E,wBAAgB,iBAAiB,IAAI,IAAI,CAExC;AAED,wBAAsB,SAAS,CAC3B,MAAM,EAAE,YAAY,EACpB,GAAG,EAAE,MAAM,EACX,OAAO,GAAE,UAAe,GACzB,OAAO,CAAC,UAAU,CAAC,CAkCrB"}
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-js (WASM).
1
+ // PNG exporter — rasterizes the renderer SVG via @resvg/resvg-wasm.
2
2
  //
3
- // Spec: specs/handoffs/m2c.md § 3 "PNG rasterized SVG via resvg-js (WASM)".
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
- // - `loadSystemFonts: false` — only the resolved fonts the caller hands in
7
- // are visible to resvg, so identical input → identical bytes regardless
8
- // of host machine.
9
- // - Fonts come from the @nowline/export-core resolver; we pass them as
10
- // `fontBuffers` (Uint8Array, supported since resvg-js 2.5.0).
11
- // - The WASM module is loaded lazily on first call.
12
- import { resolveFonts } from '@nowline/export-core';
13
- let resvgModule;
14
- async function getResvgModule() {
15
- if (!resvgModule) {
16
- resvgModule = await import('@resvg/resvg-js');
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
- return resvgModule;
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
- export async function exportPng(inputs, svg, options = {}) {
25
- const scale = options.scale ?? 2;
26
- if (!Number.isFinite(scale) || scale <= 0) {
27
- throw new Error(`exportPng: invalid scale ${scale}; expected a positive number`);
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
- const background = options.background ?? inputs.model.backgroundColor;
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":"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"}
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.5.0",
4
- "description": "Nowline PNG exporter — rasterizes the renderer SVG via resvg-js (WASM)",
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-js": "^2.6.2",
25
- "@nowline/export-core": "0.5.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.5.0",
33
- "@nowline/renderer": "0.5.0",
34
- "@nowline/layout": "0.5.0"
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-js (WASM).
1
+ // PNG exporter — rasterizes the renderer SVG via @resvg/resvg-wasm.
2
2
  //
3
- // Spec: specs/handoffs/m2c.md § 3 "PNG rasterized SVG via resvg-js (WASM)".
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
- // - `loadSystemFonts: false` — only the resolved fonts the caller hands in
7
- // are visible to resvg, so identical input → identical bytes regardless
8
- // of host machine.
9
- // - Fonts come from the @nowline/export-core resolver; we pass them as
10
- // `fontBuffers` (Uint8Array, supported since resvg-js 2.5.0).
11
- // - The WASM module is loaded lazily on first call.
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
- let resvgModule: typeof import('@resvg/resvg-js') | undefined;
31
+ // ---- WASM initialization ----------------------------------------------------
32
+
33
+ let wasmReady = false;
34
+ let wasmInitializing: Promise<void> | undefined;
31
35
 
32
- async function getResvgModule(): Promise<typeof import('@resvg/resvg-js')> {
33
- if (!resvgModule) {
34
- resvgModule = await import('@resvg/resvg-js');
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
- return resvgModule;
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
- /** Test seam: drop the cached WASM module so isolated tests start fresh. */
40
- export function _resetResvgModule(): void {
41
- resvgModule = undefined;
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
- // Workaround: in resvg-js 2.6.2, supplying `font.fontBuffers` silently
59
- // disables `fitTo` (zoom / width / height / dpi). To honour `--scale` we
60
- // multiply the root <svg width=…> / <svg height=…> attributes ourselves
61
- // before handing the SVG to resvg. The internal viewBox stays the same,
62
- // so vector geometry is preserved — only the rasterized pixel grid grows.
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 resvgOpts = {
140
+ const { Resvg } = await import('@resvg/resvg-wasm');
141
+ const resvg = new Resvg(scaledSvg, {
66
142
  background,
67
143
  font: {
68
- loadSystemFonts: false,
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
- ...(options.resvgOptions ?? {}),
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
- }