@lofcz/tikz 0.2.0 → 0.2.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.
package/NOTICE.md CHANGED
@@ -16,7 +16,8 @@ Only the ten upstream default bundles are shipped. Optional Classico (AFPL),
16
16
  Ghostscript, latex-doc, OpenType and otf-fonts bundles are excluded. No Ghostscript
17
17
  engine is included. Native binaries and macro contents are unchanged. Bundle payloads are Brotli-compressed
18
18
  without changing their contents. Our Node I/O decompresses them on demand and
19
- uses portable file URL handling. Browser I/O still uses uncompressed assets.
19
+ uses portable file URL handling. Engine glue supports lazy optional module loading
20
+ and TeX-only execution without initializing MetaPost. Browser I/O still uses uncompressed assets.
20
21
 
21
22
  Corresponding upstream source, patches and reproducible build instructions:
22
23
  https://github.com/jmckalex/mp-tikz-wasm/tree/d44aeeba98b0e60d13c35dfd080076e6b05c68b5
package/README.md CHANGED
@@ -28,6 +28,12 @@ are accepted unchanged. Snippets receive a standalone wrapper and the standard
28
28
  calc, intersections, angles, quotes, arrows.meta and positioning libraries.
29
29
  pgfplots is available through `\usepackage{pgfplots}` in a complete document.
30
30
 
31
+ Both entries enable the fork's lazy engine loading: TikZ skips MetaPost WASM,
32
+ optional glue loads only when used, and graph-drawing documents automatically
33
+ select LuaTeX. Each document still gets a fresh TeX/WASM execution state. These
34
+ savings do not require cross-request caches, including on Vercel Fluid Node
35
+ functions. The server entry keeps CPU-bound TeX outside the shared request loop.
36
+
31
37
  ## Limits and cancellation
32
38
 
33
39
  One page; 12,000 source characters; 30 seconds per execution and queue wait;
@@ -96,6 +102,8 @@ the full package including `runtime/`; copying only JavaScript is insufficient.
96
102
  ```sh
97
103
  npm ci
98
104
  npm run check
105
+ npm run test:browser
106
+ npm run bench:cold
99
107
  ```
100
108
 
101
109
  The engine source lives in `packages/engine`, imported from upstream v0.5.0 as a
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lofcz/tikz",
3
- "version": "0.2.0",
3
+ "version": "0.2.1",
4
4
  "description": "Self-contained, process-isolated TikZ to SVG rendering powered by mp-tikz-wasm.",
5
5
  "type": "module",
6
6
  "license": "GPL-3.0-or-later",
@@ -47,7 +47,8 @@
47
47
  "check": "npm run build && npm test && npm run test:package",
48
48
  "prepack": "npm run build",
49
49
  "build:engine": "tsc -p packages/engine/tsconfig.build.json",
50
- "test:browser": "node test/browser.mjs"
50
+ "test:browser": "node test/browser.mjs",
51
+ "bench:cold": "node test/bench-cold.mjs"
51
52
  },
52
53
  "devDependencies": {
53
54
  "@types/node": "^22.20.5",
@@ -45,6 +45,7 @@ export declare class MetaPostCore {
45
45
  build: string;
46
46
  };
47
47
  private userFiles;
48
+ private mplibReady?;
48
49
  /**
49
50
  * luaotfload's font cache (TEXMF_VAR), carried from one LuaTeX run to the
50
51
  * next. Parsing an OpenType face costs about a second, and every TeX run
@@ -53,7 +54,8 @@ export declare class MetaPostCore {
53
54
  private texmfVar;
54
55
  private get logger();
55
56
  constructor(env: CoreEnv);
56
- init(): Promise<void>;
57
+ init(eager?: boolean): Promise<void>;
58
+ private initMetaPost;
57
59
  private progress;
58
60
  /** The errors and warnings of a finished run, one record each; a MetaPost help paragraph is indented under its error. */
59
61
  private report;
@@ -69,7 +69,8 @@ export class MetaPostCore {
69
69
  cache = new MemorySnippetCache();
70
70
  bridge;
71
71
  version = { metapost: '', tex: 'pdfTeX 1.40.27 (DVI)', build: '' };
72
- userFiles = new Set();
72
+ userFiles = new Map();
73
+ mplibReady;
73
74
  /**
74
75
  * luaotfload's font cache (TEXMF_VAR), carried from one LuaTeX run to the
75
76
  * next. Parsing an OpenType face costs about a second, and every TeX run
@@ -80,7 +81,13 @@ export class MetaPostCore {
80
81
  constructor(env) {
81
82
  this.env = env;
82
83
  }
83
- async init() {
84
+ async init(eager = !this.env.options.lazy) {
85
+ if (!eager)
86
+ return;
87
+ this.mplibReady ??= this.initMetaPost();
88
+ await this.mplibReady;
89
+ }
90
+ async initMetaPost() {
84
91
  const o = this.env.options;
85
92
  this.progress({ phase: 'loading', detail: 'mplib.wasm' });
86
93
  // mplib's own stdout/stderr (dvitomp, the runtime) and, through the termLine hook, MetaPost's terminal
@@ -96,6 +103,8 @@ export class MetaPostCore {
96
103
  mkdirp(FS, '/work');
97
104
  this.installTexmf(FS, this.M.NODEFS);
98
105
  FS.chdir('/work');
106
+ for (const [path, bytes] of this.userFiles)
107
+ writeFileDeep(FS, path, bytes);
99
108
  mpxAddPath(this.M, `${TEXMF_ROOT}/fonts/tfm`);
100
109
  mpxAddPath(this.M, `${TEXMF_ROOT}/fonts/vf`);
101
110
  this.M.mpwasmHooks = {
@@ -193,9 +202,9 @@ export class MetaPostCore {
193
202
  }
194
203
  copyUserFiles(T) {
195
204
  // TeX may \input files the user supplied next to the job
196
- for (const p of this.userFiles) {
205
+ for (const [p, bytes] of this.userFiles) {
197
206
  try {
198
- writeFileDeep(T.FS, p, this.M.FS.readFile(p, { encoding: 'binary' }));
207
+ writeFileDeep(T.FS, p, this.M ? this.M.FS.readFile(p, { encoding: 'binary' }) : bytes);
199
208
  }
200
209
  catch { /* ignore */ }
201
210
  }
@@ -288,8 +297,9 @@ export class MetaPostCore {
288
297
  addFiles(files) {
289
298
  for (const [name, data] of Object.entries(files)) {
290
299
  const p = name.startsWith('/') ? name : `/work/${name}`;
291
- writeFileDeep(this.M.FS, p, data);
292
- this.userFiles.add(p);
300
+ if (this.M)
301
+ writeFileDeep(this.M.FS, p, data);
302
+ this.userFiles.set(p, typeof data === 'string' ? data : data.slice());
293
303
  }
294
304
  }
295
305
  /**
@@ -298,7 +308,7 @@ export class MetaPostCore {
298
308
  * typesetting one document after another would hand the first one's .aux to the next.
299
309
  */
300
310
  removeFiles(names) {
301
- const paths = names ? names.map((n) => (n.startsWith('/') ? n : `/work/${n}`)) : [...this.userFiles];
311
+ const paths = names ? names.map((n) => (n.startsWith('/') ? n : `/work/${n}`)) : [...this.userFiles.keys()];
302
312
  for (const p of paths) {
303
313
  if (!this.userFiles.delete(p))
304
314
  continue;
@@ -310,6 +320,7 @@ export class MetaPostCore {
310
320
  }
311
321
  clearCache() { this.cache.clear(); }
312
322
  async run(source, ro = {}) {
323
+ await this.init(true);
313
324
  const o = this.env.options;
314
325
  const t0 = now();
315
326
  this.stats = { totalMs: 0, metapostMs: 0, texMs: 0, texRuns: 0, metapostRuns: 0, snippetCacheHits: 0, snippetCacheMisses: 0 };
@@ -34,16 +34,22 @@ class InProcessBackend {
34
34
  this.bundles.logger = this.logger;
35
35
  const base = o.bundleBaseUrl ?? new URL('./bundles/', here).href;
36
36
  await this.bundles.addAll(resolveBundleSpecs(o.bundles ?? DEFAULT_BUNDLES, base));
37
- await this.bundles.prefetchEager();
38
- await this.bundles.loadHot((base.endsWith('/') ? base : base + '/') + 'hot.json');
37
+ if (!o.lazy || !this.bundles.canFetchSync)
38
+ await this.bundles.prefetchEager();
39
+ if (!o.lazy)
40
+ await this.bundles.loadHot((base.endsWith('/') ? base : base + '/') + 'hot.json');
39
41
  if (!this.bundles.canFetchSync)
40
42
  await this.bundles.prefetchAll();
41
43
  }
42
- const mplibFactory = o.modules?.mplib ?? (await import(/* @vite-ignore */ new URL('./mplib.mjs', here).href)).default;
44
+ const lazyFactory = (name) => async (opts) => {
45
+ const module = await import(/* @vite-ignore */ new URL(`./${name}.mjs`, here).href);
46
+ return module.default(opts);
47
+ };
48
+ const mplibFactory = o.modules?.mplib ?? (o.lazy ? lazyFactory('mplib') : (await import(/* @vite-ignore */ new URL('./mplib.mjs', here).href)).default);
43
49
  let texFactory = o.modules?.tex;
44
50
  if (!texFactory && (o.tex ?? 'auto') !== 'none') {
45
51
  try {
46
- texFactory = (await import(/* @vite-ignore */ new URL('./tex.mjs', here).href)).default;
52
+ texFactory = o.lazy ? lazyFactory('tex') : (await import(/* @vite-ignore */ new URL('./tex.mjs', here).href)).default;
47
53
  }
48
54
  catch {
49
55
  texFactory = undefined;
@@ -52,7 +58,7 @@ class InProcessBackend {
52
58
  let dvisvgmFactory = o.modules?.dvisvgm;
53
59
  if (!dvisvgmFactory && texFactory) {
54
60
  try {
55
- dvisvgmFactory = (await import(/* @vite-ignore */ new URL('./dvisvgm.mjs', here).href)).default;
61
+ dvisvgmFactory = o.lazy ? lazyFactory('dvisvgm') : (await import(/* @vite-ignore */ new URL('./dvisvgm.mjs', here).href)).default;
56
62
  }
57
63
  catch {
58
64
  dvisvgmFactory = undefined;
@@ -62,7 +68,7 @@ class InProcessBackend {
62
68
  let luatexFactory = o.modules?.luatex;
63
69
  if (!luatexFactory && texFactory) {
64
70
  try {
65
- luatexFactory = (await import(/* @vite-ignore */ new URL('./luatex.mjs', here).href)).default;
71
+ luatexFactory = o.lazy ? lazyFactory('luatex') : (await import(/* @vite-ignore */ new URL('./luatex.mjs', here).href)).default;
66
72
  }
67
73
  catch {
68
74
  luatexFactory = undefined;
@@ -104,7 +110,15 @@ class InProcessBackend {
104
110
  await this.bundles.add(spec);
105
111
  await this.bundles.prefetchAll((f) => bundles.includes(f.bundle));
106
112
  }
107
- async prefetch(kinds) { return this.bundles ? this.bundles.prefetchHot(kinds) : 0; }
113
+ async prefetch(kinds) {
114
+ if (!this.bundles)
115
+ return 0;
116
+ if (!Object.keys(this.bundles.hot).length) {
117
+ const base = this.options.bundleBaseUrl ?? new URL('./bundles/', import.meta.url).href;
118
+ await this.bundles.loadHot((base.endsWith('/') ? base : base + '/') + 'hot.json');
119
+ }
120
+ return this.bundles.prefetchHot(kinds);
121
+ }
108
122
  setLogLevel(level) { this.logger.level = level; }
109
123
  dispose() { }
110
124
  }
@@ -98,6 +98,9 @@ export interface MetaPostOptions {
98
98
  };
99
99
  /** Run in-process instead of in a Web Worker (default: worker in browsers, in-process in Node). */
100
100
  worker?: boolean;
101
+ /** Defer engine glue and MetaPost WASM until used. MetaPost version fields in creation
102
+ * metadata remain empty in this mode. No cross-request cache is required. */
103
+ lazy?: boolean;
101
104
  /** Default for latex()'s snapshot option. Node: 'auto' (tikz.fmt from local files is free). Browser: 'none',
102
105
  * because the 5.8 MB format does not compress and costs more to download than the files it replaces
103
106
  * (2.5 MB gzipped); set 'auto' when many TikZ figures amortise it or the format is already cached. */
@@ -1,7 +1,7 @@
1
1
  import { MetaPost } from '../runtime/dist/index.js';
2
2
 
3
3
  try {
4
- const engine = await MetaPost.create({ worker: false, logLevel: 'silent' });
4
+ const engine = await MetaPost.create({ worker: false, lazy: true, logLevel: 'silent' });
5
5
  self.onmessage = async ({ data: { source: code, maxOutputBytes } }) => {
6
6
  try {
7
7
  const source = /^\s*\\documentclass\b/m.test(code) ? code : String.raw`\documentclass[tikz,border=6pt]{standalone}
@@ -9,7 +9,7 @@ try {
9
9
  \begin{document}
10
10
  ${code}
11
11
  \end{document}`;
12
- const result = await engine.latex(source, { fonts: 'paths' });
12
+ const result = await engine.latex(source, { engine: 'auto', fonts: 'paths' });
13
13
  if (result.status === 'error' || result.status === 'fatal' || result.pages?.length !== 1)
14
14
  throw new Error('TikZ requires one valid page. ' + JSON.stringify(result.diagnostics).slice(0, 2000));
15
15
  const svg = result.pages[0], bytes = new TextEncoder().encode(svg);
package/src/worker.js CHANGED
@@ -3,9 +3,9 @@
3
3
  import { readFileSync } from 'node:fs';
4
4
  import { MetaPost } from '../runtime/dist/index.js';
5
5
  const { source } = JSON.parse(readFileSync(0, 'utf8'));
6
- const engine = await MetaPost.create({ worker: false, logLevel: 'silent' });
6
+ const engine = await MetaPost.create({ worker: false, lazy: true, logLevel: 'silent' });
7
7
  try {
8
- const result = await engine.latex(source, { fonts: 'paths' });
8
+ const result = await engine.latex(source, { engine: 'auto', fonts: 'paths' });
9
9
  if (result.status === 'error' || result.status === 'fatal' || result.pages?.length !== 1)
10
10
  throw new Error('TikZ requires one valid page. ' + JSON.stringify(result.diagnostics).slice(0, 2000));
11
11
  process.stdout.write(JSON.stringify({ svg: result.pages[0] }));