@lofcz/tikz 0.0.0-stage → 0.2.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/LICENSE +674 -0
- package/NOTICE.md +30 -0
- package/README.md +127 -2
- package/package.json +56 -4
- package/runtime/LICENSE +165 -0
- package/runtime/NOTICE.md +32 -0
- package/runtime/dist/auto.d.ts +68 -0
- package/runtime/dist/auto.js +564 -0
- package/runtime/dist/bibtex.mjs +2 -0
- package/runtime/dist/bibtex.wasm +0 -0
- package/runtime/dist/bundles/cm-tfm/files.br +0 -0
- package/runtime/dist/bundles/cm-tfm/files.index.json +1 -0
- package/runtime/dist/bundles/cm-tfm/manifest.json +1 -0
- package/runtime/dist/bundles/cm-type1/files.br +0 -0
- package/runtime/dist/bundles/cm-type1/files.index.json +1 -0
- package/runtime/dist/bundles/cm-type1/manifest.json +1 -0
- package/runtime/dist/bundles/core/files.br +0 -0
- package/runtime/dist/bundles/core/files.index.json +1 -0
- package/runtime/dist/bundles/core/manifest.json +1 -0
- package/runtime/dist/bundles/hot.json +1 -0
- package/runtime/dist/bundles/index.json +80 -0
- package/runtime/dist/bundles/latex-core/files.br +0 -0
- package/runtime/dist/bundles/latex-core/files.index.json +1 -0
- package/runtime/dist/bundles/latex-core/manifest.json +1 -0
- package/runtime/dist/bundles/latex-extra/files.br +0 -0
- package/runtime/dist/bundles/latex-extra/files.index.json +1 -0
- package/runtime/dist/bundles/latex-extra/manifest.json +1 -0
- package/runtime/dist/bundles/lm-fonts/files.br +0 -0
- package/runtime/dist/bundles/lm-fonts/files.index.json +1 -0
- package/runtime/dist/bundles/lm-fonts/manifest.json +1 -0
- package/runtime/dist/bundles/luatex/files.br +0 -0
- package/runtime/dist/bundles/luatex/files.index.json +1 -0
- package/runtime/dist/bundles/luatex/manifest.json +1 -0
- package/runtime/dist/bundles/ps-fonts/files.br +0 -0
- package/runtime/dist/bundles/ps-fonts/files.index.json +1 -0
- package/runtime/dist/bundles/ps-fonts/manifest.json +1 -0
- package/runtime/dist/bundles/tex-plain/files.br +0 -0
- package/runtime/dist/bundles/tex-plain/files.index.json +1 -0
- package/runtime/dist/bundles/tex-plain/manifest.json +1 -0
- package/runtime/dist/bundles/tikz-snapshot/files.br +0 -0
- package/runtime/dist/bundles/tikz-snapshot/files.index.json +1 -0
- package/runtime/dist/bundles/tikz-snapshot/manifest.json +1 -0
- package/runtime/dist/bundles-config.d.ts +3 -0
- package/runtime/dist/bundles-config.js +16 -0
- package/runtime/dist/cli.d.ts +2 -0
- package/runtime/dist/cli.js +372 -0
- package/runtime/dist/core.d.ts +124 -0
- package/runtime/dist/core.js +927 -0
- package/runtime/dist/diagnostics.d.ts +53 -0
- package/runtime/dist/diagnostics.js +184 -0
- package/runtime/dist/dvisvgm.d.ts +20 -0
- package/runtime/dist/dvisvgm.js +46 -0
- package/runtime/dist/dvisvgm.mjs +2 -0
- package/runtime/dist/dvisvgm.wasm +0 -0
- package/runtime/dist/figures.d.ts +92 -0
- package/runtime/dist/figures.js +250 -0
- package/runtime/dist/ghostscript.d.ts +92 -0
- package/runtime/dist/ghostscript.js +218 -0
- package/runtime/dist/index.d.ts +87 -0
- package/runtime/dist/index.js +364 -0
- package/runtime/dist/logger.d.ts +48 -0
- package/runtime/dist/logger.js +55 -0
- package/runtime/dist/luatex.mjs +2 -0
- package/runtime/dist/luatex.wasm +0 -0
- package/runtime/dist/makeindex.mjs +2 -0
- package/runtime/dist/makeindex.wasm +0 -0
- package/runtime/dist/mplib.d.ts +106 -0
- package/runtime/dist/mplib.js +85 -0
- package/runtime/dist/mplib.mjs +2 -0
- package/runtime/dist/mplib.wasm +0 -0
- package/runtime/dist/node.d.ts +4 -0
- package/runtime/dist/node.js +49 -0
- package/runtime/dist/postscript.d.ts +21 -0
- package/runtime/dist/postscript.js +130 -0
- package/runtime/dist/prerender.d.ts +55 -0
- package/runtime/dist/prerender.js +106 -0
- package/runtime/dist/render/svg.d.ts +20 -0
- package/runtime/dist/render/svg.js +96 -0
- package/runtime/dist/tex/bridge.d.ts +93 -0
- package/runtime/dist/tex/bridge.js +223 -0
- package/runtime/dist/tex/cache-key.d.ts +33 -0
- package/runtime/dist/tex/cache-key.js +150 -0
- package/runtime/dist/tex/mpto.d.ts +60 -0
- package/runtime/dist/tex/mpto.js +112 -0
- package/runtime/dist/tex/mpx.d.ts +25 -0
- package/runtime/dist/tex/mpx.js +54 -0
- package/runtime/dist/tex/scanner.d.ts +78 -0
- package/runtime/dist/tex/scanner.js +326 -0
- package/runtime/dist/tex.mjs +2 -0
- package/runtime/dist/tex.wasm +0 -0
- package/runtime/dist/texengine.d.ts +35 -0
- package/runtime/dist/texengine.js +57 -0
- package/runtime/dist/texutils.d.ts +19 -0
- package/runtime/dist/texutils.js +60 -0
- package/runtime/dist/types.d.ts +376 -0
- package/runtime/dist/types.js +6 -0
- package/runtime/dist/vfs/bundle.d.ts +88 -0
- package/runtime/dist/vfs/bundle.js +315 -0
- package/runtime/dist/vfs/lazyfs.d.ts +62 -0
- package/runtime/dist/vfs/lazyfs.js +133 -0
- package/runtime/dist/worker.d.ts +15 -0
- package/runtime/dist/worker.js +143 -0
- package/runtime/licenses/COPYING.AFPL +230 -0
- package/runtime/licenses/COPYING.makeindex +42 -0
- package/runtime/licenses/GPL-3.0.txt +674 -0
- package/runtime/licenses/LGPL-3.0.txt +165 -0
- package/runtime/licenses/OFL-1.1.txt +93 -0
- package/runtime/package.json +1 -0
- package/runtime/upstream.json +27 -0
- package/src/browser-worker.js +26 -0
- package/src/browser.d.ts +18 -0
- package/src/browser.js +68 -0
- package/src/deploy.d.ts +5 -0
- package/src/deploy.js +48 -0
- package/src/index.d.ts +28 -0
- package/src/index.js +103 -0
- package/src/worker.js +15 -0
- package/upstream.json +27 -0
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* prerender.ts — `mpost-wasm --prerender`: typeset the diagram elements of
|
|
3
|
+
* HTML pages in Node and save each result as figure-HASH.svg, the file the
|
|
4
|
+
* drop-in tags look for when the loader carries data-figures (auto.ts,
|
|
5
|
+
* figures.ts). A page whose figures are all saved loads them as small static
|
|
6
|
+
* files and never starts the engines in the visitor's browser.
|
|
7
|
+
*
|
|
8
|
+
* For each page the figures go to the directory its loader names in
|
|
9
|
+
* data-figures (default `figures/`), resolved against the page, unless a
|
|
10
|
+
* directory is given for all of them (or a base directory to resolve each
|
|
11
|
+
* page's data-figures against, for a rendered copy saved elsewhere). Each page's
|
|
12
|
+
* figures are typeset with the bundles its loader names in data-bundles, read
|
|
13
|
+
* exactly as auto.js reads them, so an opt-in bundle (`+classico`) is there as
|
|
14
|
+
* in the browser; pages wanting different bundles get separate engines. A file
|
|
15
|
+
* that already exists is kept —
|
|
16
|
+
* the name is the content, so it is current — unless `force` is set. The
|
|
17
|
+
* engine is created with the browser's defaults (deterministic, seed 42) so
|
|
18
|
+
* the bytes are the ones the tags would have produced.
|
|
19
|
+
*/
|
|
20
|
+
import fs from 'node:fs';
|
|
21
|
+
import path from 'node:path';
|
|
22
|
+
import { MetaPost } from './index.js';
|
|
23
|
+
import { extractFigures, figureHash, figureName, loaderAttributes, renderFigure, bundleList } from './figures.js';
|
|
24
|
+
import { DEFAULT_BUNDLES } from './bundles-config.js';
|
|
25
|
+
/** The figures a page needs, with the directory they belong in. */
|
|
26
|
+
export function planPage(page, figuresDir, baseDir) {
|
|
27
|
+
const html = fs.readFileSync(page, 'utf8');
|
|
28
|
+
const loader = loaderAttributes(html);
|
|
29
|
+
const named = loader.figures;
|
|
30
|
+
const dir = figuresDir ?? path.resolve(baseDir ?? path.dirname(page), named && named !== 'off' ? named : 'figures');
|
|
31
|
+
const figures = extractFigures(html).map((f) => { const hash = figureHash(f); return { ...f, hash, name: figureName(hash) }; });
|
|
32
|
+
return { dir, bundles: bundleList(loader.bundles, DEFAULT_BUNDLES), figures };
|
|
33
|
+
}
|
|
34
|
+
export async function prerender(pages, opts = {}) {
|
|
35
|
+
const report = opts.report ?? (() => { });
|
|
36
|
+
const entries = [];
|
|
37
|
+
const seen = new Set();
|
|
38
|
+
const todo = [];
|
|
39
|
+
for (const page of pages) {
|
|
40
|
+
const { dir, figures, bundles: pageBundles } = planPage(page, opts.figuresDir, opts.baseDir);
|
|
41
|
+
const bundles = [...new Set([...(opts.createOptions?.bundles ?? pageBundles ?? DEFAULT_BUNDLES), ...(opts.addBundles ?? [])])];
|
|
42
|
+
for (const f of figures) {
|
|
43
|
+
const file = path.join(dir, f.name);
|
|
44
|
+
if (seen.has(file))
|
|
45
|
+
continue; // the same figure on two pages (or twice on one) is one file
|
|
46
|
+
seen.add(file);
|
|
47
|
+
const entry = { page, file, name: f.name, hash: f.hash, kind: f.kind, status: 'pending' };
|
|
48
|
+
entries.push(entry);
|
|
49
|
+
if (!opts.force && fs.existsSync(file)) {
|
|
50
|
+
entry.status = 'exists';
|
|
51
|
+
report(line(entry));
|
|
52
|
+
continue;
|
|
53
|
+
}
|
|
54
|
+
todo.push({ entry, req: { kind: f.kind, source: f.source, attrs: f.attrs }, bundles });
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
if (opts.dryRun) {
|
|
58
|
+
for (const { entry } of todo)
|
|
59
|
+
report(line(entry));
|
|
60
|
+
}
|
|
61
|
+
else if (todo.length) {
|
|
62
|
+
// one engine per distinct bundle list, in first-use order (or the caller's engine for all)
|
|
63
|
+
const groups = new Map();
|
|
64
|
+
for (const t of todo) {
|
|
65
|
+
const k = opts.mp ? '' : t.bundles.join(',');
|
|
66
|
+
groups.set(k, [...(groups.get(k) ?? []), t]);
|
|
67
|
+
}
|
|
68
|
+
for (const group of groups.values()) {
|
|
69
|
+
const own = !opts.mp;
|
|
70
|
+
const mp = opts.mp ?? await MetaPost.create({ logLevel: 'warn', ...opts.createOptions, bundles: group[0].bundles });
|
|
71
|
+
try {
|
|
72
|
+
for (const { entry, req } of group) {
|
|
73
|
+
const r = await renderFigure(mp, req, entry.hash);
|
|
74
|
+
entry.ms = r.ms;
|
|
75
|
+
if (r.ok) {
|
|
76
|
+
fs.mkdirSync(path.dirname(entry.file), { recursive: true });
|
|
77
|
+
fs.writeFileSync(entry.file, r.svg);
|
|
78
|
+
entry.status = 'rendered';
|
|
79
|
+
}
|
|
80
|
+
else {
|
|
81
|
+
entry.status = 'failed';
|
|
82
|
+
entry.diagnostics = r.diagnostics;
|
|
83
|
+
}
|
|
84
|
+
report(line(entry));
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
finally {
|
|
88
|
+
if (own)
|
|
89
|
+
mp.dispose();
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
const count = (s) => entries.filter((e) => e.status === s).length;
|
|
94
|
+
return { entries, rendered: count('rendered'), existing: count('exists'), failed: count('failed') };
|
|
95
|
+
}
|
|
96
|
+
/** `figures/figure-abc123.svg tikz rendered 312 ms (tags.html)`, diagnostics indented under a failure. */
|
|
97
|
+
export function line(e) {
|
|
98
|
+
const rel = path.relative(process.cwd(), e.file) || e.file;
|
|
99
|
+
const status = e.status === 'pending' ? 'to render' : e.status;
|
|
100
|
+
const ms = e.ms !== undefined ? ` ${Math.round(e.ms)} ms` : '';
|
|
101
|
+
let s = `${rel} ${e.kind.padEnd(8)} ${status.padEnd(9)}${ms} (${path.basename(e.page)})`;
|
|
102
|
+
for (const d of e.diagnostics ?? [])
|
|
103
|
+
s += `\n ${d.severity}: ${d.message}${d.line ? ` (line ${d.line})` : ''}`;
|
|
104
|
+
return s;
|
|
105
|
+
}
|
|
106
|
+
//# sourceMappingURL=prerender.js.map
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* svg.ts — post-processing of MetaPost's SVG output (docs/07 §3.1). The C
|
|
3
|
+
* output is left byte-identical to upstream; everything here is optional
|
|
4
|
+
* polish applied on the string.
|
|
5
|
+
*/
|
|
6
|
+
import type { SvgPostOptions } from '../types.js';
|
|
7
|
+
export declare function postProcessSvg(svg: string, opts?: SvgPostOptions, figureIndex?: number): string;
|
|
8
|
+
/**
|
|
9
|
+
* A regular-expression allow-list over the markup: keeps the SVG elements
|
|
10
|
+
* MetaPost emits and drops everything else (scripts, event handlers,
|
|
11
|
+
* foreignObject, external references).
|
|
12
|
+
*
|
|
13
|
+
* @deprecated Not a security boundary, and too narrow for what the engines
|
|
14
|
+
* produce: it drops gradients (every PGF shading), patterns, `<style>` (the
|
|
15
|
+
* faces of `fonts: 'woff2'`) and the geometry of circles, ellipses and lines.
|
|
16
|
+
* Sanitise untrusted output with DOMPurify instead — the README's
|
|
17
|
+
* "Untrusted sources" has the configuration. Kept, unchanged, so existing
|
|
18
|
+
* callers do not break; to be removed at 1.0.
|
|
19
|
+
*/
|
|
20
|
+
export declare function sanitizeSvg(svg: string): string;
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
const NUM = /-?\d+\.\d{4,}/g;
|
|
2
|
+
export function postProcessSvg(svg, opts = {}, figureIndex = 0) {
|
|
3
|
+
let s = svg;
|
|
4
|
+
if (opts.precision !== false) {
|
|
5
|
+
const p = opts.precision ?? 3;
|
|
6
|
+
s = s.replace(NUM, (m) => {
|
|
7
|
+
const v = Number(m);
|
|
8
|
+
let r = v.toFixed(p);
|
|
9
|
+
if (r.includes('.'))
|
|
10
|
+
r = r.replace(/\.?0+$/, '');
|
|
11
|
+
if (r === '-0')
|
|
12
|
+
r = '0';
|
|
13
|
+
return r;
|
|
14
|
+
});
|
|
15
|
+
}
|
|
16
|
+
if (opts.idPrefix !== false) {
|
|
17
|
+
// MetaPost's ids (GLYPHcmr10_77, CLIP1, ...) are global per figure and
|
|
18
|
+
// collide when several figures are inlined on one page: url(#CLIP1) then
|
|
19
|
+
// resolves to the first CLIP1 in the document. Namespace every id and
|
|
20
|
+
// every reference to one.
|
|
21
|
+
// MetaPost's backend writes double-quoted attributes, dvisvgm single-quoted ones
|
|
22
|
+
const prefix = opts.idPrefix ?? `mp${figureIndex}-`;
|
|
23
|
+
s = s.replace(/\bid=(["'])([^"']+)\1/g, (_m, q, id) => `id=${q}${prefix}${id}${q}`)
|
|
24
|
+
.replace(/href=(["'])#([^"']+)\1/g, (_m, q, id) => `href=${q}#${prefix}${id}${q}`)
|
|
25
|
+
.replace(/url\(#([^)]+)\)/g, (_m, id) => `url(#${prefix}${id})`);
|
|
26
|
+
// fonts: 'woff2' has the same problem one level up. dvisvgm names each embedded
|
|
27
|
+
// face nf0, nf1, ... and styles the runs with `text.f0 {font-family:nf0}`. Once the
|
|
28
|
+
// SVG is inlined both the @font-face family names and the class selectors are
|
|
29
|
+
// document-global, so a second figure's nf0 wins and the first renders from the
|
|
30
|
+
// wrong subset — with a fallback glyph wherever that subset has no such character,
|
|
31
|
+
// which is how it shows up: a word half in one weight and half in another.
|
|
32
|
+
s = s.replace(/font-family:nf(\d+)/g, (_m, n) => `font-family:${prefix}nf${n}`)
|
|
33
|
+
.replace(/\btext\.f(\d+)\b/g, (_m, n) => `text.${prefix}f${n}`)
|
|
34
|
+
.replace(/\bclass=(["'])f(\d+)\1/g, (_m, q, n) => `class=${q}${prefix}f${n}${q}`);
|
|
35
|
+
}
|
|
36
|
+
if (opts.modernHref)
|
|
37
|
+
s = s.replace(/xlink:href=/g, 'href=');
|
|
38
|
+
if (opts.units === 'px') {
|
|
39
|
+
s = s.replace(/<svg([^>]*)width="([\d.]+)"([^>]*)height="([\d.]+)"/, (_m, a, w, b, h) => `<svg${a}width="${trim(Number(w) * 96 / 72)}"${b}height="${trim(Number(h) * 96 / 72)}"`);
|
|
40
|
+
}
|
|
41
|
+
else if (opts.units === 'none') {
|
|
42
|
+
s = s.replace(/<svg([^>]*)\swidth="[\d.]+"/, '<svg$1').replace(/<svg([^>]*)\sheight="[\d.]+"/, '<svg$1');
|
|
43
|
+
}
|
|
44
|
+
if (opts.title) {
|
|
45
|
+
const t = escapeXml(opts.title);
|
|
46
|
+
s = s.replace(/<svg([^>]*)>/, `<svg$1 role="img" aria-label="${t}"><title>${t}</title>`);
|
|
47
|
+
}
|
|
48
|
+
return s;
|
|
49
|
+
}
|
|
50
|
+
function trim(v) { let r = v.toFixed(3); r = r.replace(/\.?0+$/, ''); return r; }
|
|
51
|
+
function escapeXml(s) { return s.replace(/[<>&"]/g, (c) => ({ '<': '<', '>': '>', '&': '&', '"': '"' }[c])); }
|
|
52
|
+
/**
|
|
53
|
+
* A regular-expression allow-list over the markup: keeps the SVG elements
|
|
54
|
+
* MetaPost emits and drops everything else (scripts, event handlers,
|
|
55
|
+
* foreignObject, external references).
|
|
56
|
+
*
|
|
57
|
+
* @deprecated Not a security boundary, and too narrow for what the engines
|
|
58
|
+
* produce: it drops gradients (every PGF shading), patterns, `<style>` (the
|
|
59
|
+
* faces of `fonts: 'woff2'`) and the geometry of circles, ellipses and lines.
|
|
60
|
+
* Sanitise untrusted output with DOMPurify instead — the README's
|
|
61
|
+
* "Untrusted sources" has the configuration. Kept, unchanged, so existing
|
|
62
|
+
* callers do not break; to be removed at 1.0.
|
|
63
|
+
*/
|
|
64
|
+
export function sanitizeSvg(svg) {
|
|
65
|
+
const allowedTags = new Set(['svg', 'g', 'path', 'defs', 'use', 'text', 'title', 'desc', 'clipPath', 'rect', 'circle', 'ellipse', 'line', 'polyline', 'polygon', 'tspan']);
|
|
66
|
+
const allowedAttr = /^(id|class|d|x|y|width|height|viewBox|version|xmlns|xmlns:xlink|xlink:href|href|transform|style|fill|fill-rule|stroke|stroke-width|stroke-linecap|stroke-linejoin|stroke-miterlimit|stroke-dasharray|stroke-dashoffset|clip-path|font-size|font-family|role|aria-label|opacity|fill-opacity|stroke-opacity)$/;
|
|
67
|
+
// comments are harmless but drop them to be safe; scripts and foreign
|
|
68
|
+
// content go with their contents, not just their tags
|
|
69
|
+
let out = svg.replace(/<!--[\s\S]*?-->/g, '');
|
|
70
|
+
out = out.replace(/<\?xml[^>]*\?>/, '');
|
|
71
|
+
out = out.replace(/<(script|style|foreignObject|iframe|object|embed)\b[^>]*>[\s\S]*?<\/\1\s*>/gi, '');
|
|
72
|
+
out = out.replace(/<\/?([a-zA-Z:]+)([^>]*)>/g, (m, tag, attrs) => {
|
|
73
|
+
if (!allowedTags.has(tag))
|
|
74
|
+
return '';
|
|
75
|
+
if (m.startsWith('</'))
|
|
76
|
+
return `</${tag}>`;
|
|
77
|
+
const kept = (attrs.match(/([a-zA-Z:-]+)\s*=\s*("[^"]*"|'[^']*')/g) || []).filter((a) => {
|
|
78
|
+
const name = a.split('=')[0].trim();
|
|
79
|
+
if (!allowedAttr.test(name))
|
|
80
|
+
return false;
|
|
81
|
+
const val = a.slice(a.indexOf('=') + 1).trim().slice(1, -1);
|
|
82
|
+
if (/href/.test(name) && !val.startsWith('#'))
|
|
83
|
+
return false;
|
|
84
|
+
// only same-document url(#id) references (clip-path, fill patterns); nothing external
|
|
85
|
+
if (/url\(/i.test(val) && !/^url\(#[^)]+\)$/.test(val.trim()))
|
|
86
|
+
return false;
|
|
87
|
+
if (/javascript:|expression\(|@import|data:/i.test(val))
|
|
88
|
+
return false;
|
|
89
|
+
return true;
|
|
90
|
+
});
|
|
91
|
+
const selfClose = attrs.trim().endsWith('/') ? '/' : '';
|
|
92
|
+
return `<${tag}${kept.length ? ' ' + kept.join(' ') : ''}${selfClose}>`;
|
|
93
|
+
});
|
|
94
|
+
return out.trim();
|
|
95
|
+
}
|
|
96
|
+
//# sourceMappingURL=svg.js.map
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* bridge.ts — the TeX Bridge (docs/05): lift TeX out of the MetaPost run.
|
|
3
|
+
*
|
|
4
|
+
* scan → keys → cache → batch TeX (tex.wasm) → dvitomp (mplib.wasm) → split
|
|
5
|
+
*
|
|
6
|
+
* The bridge never runs inside make_text; it fills the snippet cache before
|
|
7
|
+
* a MetaPost run and again between fixpoint iterations.
|
|
8
|
+
*/
|
|
9
|
+
import type { TexBlock } from './scanner.js';
|
|
10
|
+
import { type TexFactory, type TexModule } from '../texengine.js';
|
|
11
|
+
import { type MplibModule } from '../mplib.js';
|
|
12
|
+
import type { Diagnostic, TexEngine } from '../types.js';
|
|
13
|
+
export interface Snippet {
|
|
14
|
+
key: string;
|
|
15
|
+
chain: string[];
|
|
16
|
+
body: string;
|
|
17
|
+
line?: number;
|
|
18
|
+
file?: string;
|
|
19
|
+
}
|
|
20
|
+
export interface SnippetCache {
|
|
21
|
+
get(key: string): string | undefined;
|
|
22
|
+
set(key: string, chunk: string): void;
|
|
23
|
+
clear(): void;
|
|
24
|
+
readonly size: number;
|
|
25
|
+
}
|
|
26
|
+
export declare class MemorySnippetCache implements SnippetCache {
|
|
27
|
+
private m;
|
|
28
|
+
get(k: string): string | undefined;
|
|
29
|
+
set(k: string, v: string): void;
|
|
30
|
+
clear(): void;
|
|
31
|
+
get size(): number;
|
|
32
|
+
}
|
|
33
|
+
/** Canonical form of a snippet body for cache keys: whitespace runs collapsed,
|
|
34
|
+
* trimmed. mplib hands make_text a trimmed, newline-to-space converted body
|
|
35
|
+
* (texscriptmode=1) while the pre-scan sees the raw text; both map here. */
|
|
36
|
+
export declare function canonicalBody(s: string): string;
|
|
37
|
+
export type ResolvedEngine = 'plain' | 'etex' | 'latex';
|
|
38
|
+
export interface EngineInfo {
|
|
39
|
+
engine: ResolvedEngine;
|
|
40
|
+
format: string;
|
|
41
|
+
progname: string;
|
|
42
|
+
formatId: string;
|
|
43
|
+
engineId: string;
|
|
44
|
+
}
|
|
45
|
+
export declare function resolveEngine(engine: TexEngine, blocks: TexBlock[], source: string): ResolvedEngine | 'none';
|
|
46
|
+
export interface BridgeOptions {
|
|
47
|
+
texFactory: TexFactory;
|
|
48
|
+
mplib: MplibModule;
|
|
49
|
+
/** prepare a fresh tex.wasm FS: mount/install /texmf, create /work */
|
|
50
|
+
setupTexFS: (M: TexModule) => void;
|
|
51
|
+
cache: SnippetCache;
|
|
52
|
+
texPreamble?: string;
|
|
53
|
+
workDir?: string;
|
|
54
|
+
onLine?: (line: string) => void;
|
|
55
|
+
engineId: string;
|
|
56
|
+
formatIds: Record<ResolvedEngine, string>;
|
|
57
|
+
}
|
|
58
|
+
export interface TypesetResult {
|
|
59
|
+
texLog: string;
|
|
60
|
+
exitCode: number;
|
|
61
|
+
ms: number;
|
|
62
|
+
pages: number;
|
|
63
|
+
diagnostics: Diagnostic[];
|
|
64
|
+
resolved: number;
|
|
65
|
+
}
|
|
66
|
+
export declare class TexBridge {
|
|
67
|
+
private o;
|
|
68
|
+
constructor(o: BridgeOptions);
|
|
69
|
+
key(engine: ResolvedEngine, chain: string[], body: string): string;
|
|
70
|
+
/**
|
|
71
|
+
* Given the ordered blocks of a document, return the snippets whose chunk is
|
|
72
|
+
* not cached (with their verbatimtex chain), in source order.
|
|
73
|
+
*/
|
|
74
|
+
misses(engine: ResolvedEngine, blocks: TexBlock[]): {
|
|
75
|
+
hits: number;
|
|
76
|
+
misses: Snippet[];
|
|
77
|
+
chainFor: Map<number, string[]>;
|
|
78
|
+
};
|
|
79
|
+
/**
|
|
80
|
+
* Typeset a list of snippets in ONE TeX run and store the resulting chunks.
|
|
81
|
+
* Snippets must be in source order; chains are emitted incrementally.
|
|
82
|
+
*/
|
|
83
|
+
typeset(engine: ResolvedEngine, snippets: Snippet[]): Promise<TypesetResult>;
|
|
84
|
+
}
|
|
85
|
+
/**
|
|
86
|
+
* Map TeX errors back to btex blocks: the generated .tex has one
|
|
87
|
+
* `\mpxshipout% line N file` wrapper line per block; a TeX `l.<n>` line number
|
|
88
|
+
* inside [wrapper, \stopmpxshipout] belongs to that block.
|
|
89
|
+
*/
|
|
90
|
+
export declare function parseTexLog(log: string, tex: string, blocks: {
|
|
91
|
+
block: TexBlock;
|
|
92
|
+
snippet: Snippet;
|
|
93
|
+
}[]): Diagnostic[];
|
|
@@ -0,0 +1,223 @@
|
|
|
1
|
+
import { buildTexJob } from './mpto.js';
|
|
2
|
+
import { splitMpx } from './mpx.js';
|
|
3
|
+
import { snippetKey } from './cache-key.js';
|
|
4
|
+
import { runTex } from '../texengine.js';
|
|
5
|
+
import { dvitomp, mpxLastError } from '../mplib.js';
|
|
6
|
+
import { writeFileDeep } from '../vfs/lazyfs.js';
|
|
7
|
+
export class MemorySnippetCache {
|
|
8
|
+
m = new Map();
|
|
9
|
+
get(k) { return this.m.get(k); }
|
|
10
|
+
set(k, v) { this.m.set(k, v); }
|
|
11
|
+
clear() { this.m.clear(); }
|
|
12
|
+
get size() { return this.m.size; }
|
|
13
|
+
}
|
|
14
|
+
/** Canonical form of a snippet body for cache keys: whitespace runs collapsed,
|
|
15
|
+
* trimmed. mplib hands make_text a trimmed, newline-to-space converted body
|
|
16
|
+
* (texscriptmode=1) while the pre-scan sees the raw text; both map here. */
|
|
17
|
+
export function canonicalBody(s) { return s.replace(/\s+/g, ' ').trim(); }
|
|
18
|
+
export function resolveEngine(engine, blocks, source) {
|
|
19
|
+
if (engine === 'none' || engine === 'plain' || engine === 'etex' || engine === 'latex')
|
|
20
|
+
return engine;
|
|
21
|
+
// auto (docs/05 §8)
|
|
22
|
+
const first = source.split('\n', 1)[0] ?? '';
|
|
23
|
+
if (/^%&\s*latex\b/i.test(first))
|
|
24
|
+
return 'latex';
|
|
25
|
+
const firstVerb = blocks.find((b) => b.kind === 'verbatimtex');
|
|
26
|
+
if (firstVerb && /\\documentclass|\\documentstyle/.test(firstVerb.body))
|
|
27
|
+
return 'latex';
|
|
28
|
+
if (/%&\s*etex\b/i.test(first))
|
|
29
|
+
return 'etex';
|
|
30
|
+
return 'plain';
|
|
31
|
+
}
|
|
32
|
+
const PROG = {
|
|
33
|
+
plain: { fmt: 'plain', progname: 'tex' },
|
|
34
|
+
etex: { fmt: 'etex', progname: 'etex' },
|
|
35
|
+
latex: { fmt: 'latex', progname: 'latex' },
|
|
36
|
+
};
|
|
37
|
+
let jobCounter = 0;
|
|
38
|
+
export class TexBridge {
|
|
39
|
+
o;
|
|
40
|
+
constructor(o) {
|
|
41
|
+
this.o = o;
|
|
42
|
+
}
|
|
43
|
+
key(engine, chain, body) {
|
|
44
|
+
return snippetKey({ engineId: this.o.engineId, formatId: this.o.formatIds[engine], chain: chain.map(canonicalBody), body: canonicalBody(body) });
|
|
45
|
+
}
|
|
46
|
+
/**
|
|
47
|
+
* Given the ordered blocks of a document, return the snippets whose chunk is
|
|
48
|
+
* not cached (with their verbatimtex chain), in source order.
|
|
49
|
+
*/
|
|
50
|
+
misses(engine, blocks) {
|
|
51
|
+
const chain = [];
|
|
52
|
+
const misses = [];
|
|
53
|
+
let hits = 0;
|
|
54
|
+
const chainFor = new Map();
|
|
55
|
+
for (const b of blocks) {
|
|
56
|
+
if (b.kind === 'verbatimtex') {
|
|
57
|
+
chain.push(b.body);
|
|
58
|
+
continue;
|
|
59
|
+
}
|
|
60
|
+
const key = this.key(engine, chain, b.body);
|
|
61
|
+
chainFor.set(b.index, [...chain]);
|
|
62
|
+
if (this.o.cache.get(key) !== undefined) {
|
|
63
|
+
hits++;
|
|
64
|
+
continue;
|
|
65
|
+
}
|
|
66
|
+
misses.push({ key, chain: [...chain], body: b.body, line: b.line, file: b.file });
|
|
67
|
+
}
|
|
68
|
+
return { hits, misses, chainFor };
|
|
69
|
+
}
|
|
70
|
+
/**
|
|
71
|
+
* Typeset a list of snippets in ONE TeX run and store the resulting chunks.
|
|
72
|
+
* Snippets must be in source order; chains are emitted incrementally.
|
|
73
|
+
*/
|
|
74
|
+
async typeset(engine, snippets) {
|
|
75
|
+
const blocks = [];
|
|
76
|
+
let emitted = [];
|
|
77
|
+
let idx = 0;
|
|
78
|
+
const lineOf = [];
|
|
79
|
+
for (const s of snippets) {
|
|
80
|
+
// emit any verbatimtex entries not yet emitted (chains normally extend each other)
|
|
81
|
+
let common = 0;
|
|
82
|
+
while (common < emitted.length && common < s.chain.length && emitted[common] === s.chain[common])
|
|
83
|
+
common++;
|
|
84
|
+
for (let i = common; i < s.chain.length; i++) {
|
|
85
|
+
blocks.push({ kind: 'verbatimtex', body: s.chain[i], raw: s.chain[i], line: s.line ?? 0, file: s.file ?? 'job.mp', index: idx++ });
|
|
86
|
+
}
|
|
87
|
+
emitted = [...s.chain];
|
|
88
|
+
const b = { kind: 'btex', body: s.body, raw: s.body, line: s.line ?? 0, file: s.file ?? 'job.mp', index: idx++ };
|
|
89
|
+
blocks.push(b);
|
|
90
|
+
lineOf.push({ block: b, snippet: s });
|
|
91
|
+
}
|
|
92
|
+
const tex = buildTexJob(blocks, { mode: 'tex', mptexpre: this.o.texPreamble });
|
|
93
|
+
const name = `mpx${(++jobCounter).toString(36)}`;
|
|
94
|
+
const { fmt, progname } = PROG[engine];
|
|
95
|
+
let dvi = null;
|
|
96
|
+
let logFile = '';
|
|
97
|
+
const r = await runTex(this.o.texFactory, {
|
|
98
|
+
args: [`-fmt=${fmt}`, `-progname=${progname}`, '-interaction=nonstopmode', '-parse-first-line', `${name}.tex`],
|
|
99
|
+
setup: (M) => {
|
|
100
|
+
this.o.setupTexFS(M);
|
|
101
|
+
writeFileDeep(M.FS, `/work/${name}.tex`, tex);
|
|
102
|
+
M.FS.chdir('/work');
|
|
103
|
+
},
|
|
104
|
+
collect: (M) => {
|
|
105
|
+
try {
|
|
106
|
+
dvi = M.FS.readFile(`/work/${name}.dvi`, { encoding: 'binary' });
|
|
107
|
+
}
|
|
108
|
+
catch {
|
|
109
|
+
dvi = null;
|
|
110
|
+
}
|
|
111
|
+
try {
|
|
112
|
+
logFile = M.FS.readFile(`/work/${name}.log`, { encoding: 'utf8' });
|
|
113
|
+
}
|
|
114
|
+
catch {
|
|
115
|
+
logFile = r?.log ?? '';
|
|
116
|
+
}
|
|
117
|
+
},
|
|
118
|
+
onLine: this.o.onLine,
|
|
119
|
+
});
|
|
120
|
+
const texLog = logFile || r.log;
|
|
121
|
+
const diagnostics = parseTexLog(texLog, tex, lineOf);
|
|
122
|
+
let pages = 0, resolved = 0;
|
|
123
|
+
if (dvi) {
|
|
124
|
+
const M = this.o.mplib;
|
|
125
|
+
const work = this.o.workDir ?? '/work';
|
|
126
|
+
writeFileDeep(M.FS, `${work}/${name}.dvi`, dvi);
|
|
127
|
+
const rc = dvitomp(M, `${work}/${name}.dvi`, `${work}/${name}.mpx`, '% Written by mp-tikz-wasm');
|
|
128
|
+
if (rc !== 0) {
|
|
129
|
+
let err = mpxLastError(M);
|
|
130
|
+
try {
|
|
131
|
+
err += '\n' + M.FS.readFile(`${work}/mpxerr.log`, { encoding: 'utf8' });
|
|
132
|
+
}
|
|
133
|
+
catch { /* none */ }
|
|
134
|
+
diagnostics.push({ severity: 'error', source: 'tex', message: `dvitomp failed: ${err.trim()}` });
|
|
135
|
+
}
|
|
136
|
+
else {
|
|
137
|
+
const mpx = M.FS.readFile(`${work}/${name}.mpx`, { encoding: 'utf8' });
|
|
138
|
+
const chunks = splitMpx(mpx);
|
|
139
|
+
pages = chunks.length;
|
|
140
|
+
for (let i = 0; i < lineOf.length; i++) {
|
|
141
|
+
const chunk = chunks[i];
|
|
142
|
+
if (chunk !== undefined) {
|
|
143
|
+
this.o.cache.set(lineOf[i].snippet.key, chunk);
|
|
144
|
+
resolved++;
|
|
145
|
+
}
|
|
146
|
+
else {
|
|
147
|
+
this.o.cache.set(lineOf[i].snippet.key, 'nullpicture');
|
|
148
|
+
diagnostics.push({ severity: 'warning', source: 'tex', message: 'btex block produced no output; replaced by nullpicture', file: lineOf[i].snippet.file, line: lineOf[i].snippet.line, snippet: lineOf[i].snippet.body });
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
try {
|
|
152
|
+
M.FS.unlink(`${work}/${name}.mpx`);
|
|
153
|
+
}
|
|
154
|
+
catch { /* ignore */ }
|
|
155
|
+
}
|
|
156
|
+
try {
|
|
157
|
+
M.FS.unlink(`${work}/${name}.dvi`);
|
|
158
|
+
}
|
|
159
|
+
catch { /* ignore */ }
|
|
160
|
+
}
|
|
161
|
+
else {
|
|
162
|
+
// no DVI at all: every snippet becomes nullpicture so the run can proceed
|
|
163
|
+
for (const s of snippets)
|
|
164
|
+
this.o.cache.set(s.key, 'nullpicture');
|
|
165
|
+
if (!diagnostics.some((d) => d.severity === 'error')) {
|
|
166
|
+
diagnostics.push({ severity: 'error', source: 'tex', message: `TeX produced no DVI (exit ${r.exitCode})` });
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
return { texLog, exitCode: r.exitCode, ms: r.ms, pages, diagnostics, resolved };
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
/**
|
|
173
|
+
* Map TeX errors back to btex blocks: the generated .tex has one
|
|
174
|
+
* `\mpxshipout% line N file` wrapper line per block; a TeX `l.<n>` line number
|
|
175
|
+
* inside [wrapper, \stopmpxshipout] belongs to that block.
|
|
176
|
+
*/
|
|
177
|
+
export function parseTexLog(log, tex, blocks) {
|
|
178
|
+
const out = [];
|
|
179
|
+
const texLines = tex.split('\n');
|
|
180
|
+
// wrapper line index (0-based) for each btex block, in order of appearance
|
|
181
|
+
const starts = [];
|
|
182
|
+
texLines.forEach((l, i) => { if (l.startsWith('\\mpxshipout%'))
|
|
183
|
+
starts.push(i); });
|
|
184
|
+
const lines = log.split('\n');
|
|
185
|
+
for (let i = 0; i < lines.length; i++) {
|
|
186
|
+
const m = /^! (.*)$/.exec(lines[i]);
|
|
187
|
+
if (!m)
|
|
188
|
+
continue;
|
|
189
|
+
let texLine;
|
|
190
|
+
const help = [];
|
|
191
|
+
let j = i + 1;
|
|
192
|
+
for (; j < lines.length && j < i + 40; j++) {
|
|
193
|
+
const lm = /^l\.(\d+)/.exec(lines[j]);
|
|
194
|
+
if (lm) {
|
|
195
|
+
texLine = Number(lm[1]);
|
|
196
|
+
break;
|
|
197
|
+
}
|
|
198
|
+
if (/^! /.test(lines[j]))
|
|
199
|
+
break;
|
|
200
|
+
}
|
|
201
|
+
// help paragraph follows the context lines
|
|
202
|
+
for (let k = (texLine !== undefined ? j + 2 : i + 1); k < lines.length && k < i + 40; k++) {
|
|
203
|
+
if (lines[k].trim() === '' || /^! /.test(lines[k]) || /^l\.\d+/.test(lines[k]))
|
|
204
|
+
break;
|
|
205
|
+
help.push(lines[k]);
|
|
206
|
+
}
|
|
207
|
+
let blockIdx = -1;
|
|
208
|
+
if (texLine !== undefined) {
|
|
209
|
+
for (let b = 0; b < starts.length; b++)
|
|
210
|
+
if (starts[b] < texLine)
|
|
211
|
+
blockIdx = b;
|
|
212
|
+
}
|
|
213
|
+
const d = { severity: 'error', source: 'tex', message: m[1], help: help.length ? help : undefined };
|
|
214
|
+
if (blockIdx >= 0 && blocks[blockIdx]) {
|
|
215
|
+
d.file = blocks[blockIdx].snippet.file;
|
|
216
|
+
d.line = blocks[blockIdx].snippet.line;
|
|
217
|
+
d.snippet = blocks[blockIdx].snippet.body;
|
|
218
|
+
}
|
|
219
|
+
out.push(d);
|
|
220
|
+
}
|
|
221
|
+
return out;
|
|
222
|
+
}
|
|
223
|
+
//# sourceMappingURL=bridge.js.map
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* cache-key.ts — content-addressed keys for the two TeX-bridge caches
|
|
3
|
+
* (docs/05 §6): L2 snippet keys and L1 document keys.
|
|
4
|
+
*
|
|
5
|
+
* Both are SHA-256 over a canonical, length-prefixed encoding, so no two
|
|
6
|
+
* distinct inputs can collide by concatenation ("ab"+"c" vs "a"+"bc"), and a
|
|
7
|
+
* snippet key can never equal a document key (each kind carries its own tag).
|
|
8
|
+
*
|
|
9
|
+
* The digest is computed by a small pure-TypeScript SHA-256 rather than
|
|
10
|
+
* `crypto.subtle.digest`, because the `make_text` hook that consults the
|
|
11
|
+
* snippet cache must answer synchronously and Web Crypto is Promise-only.
|
|
12
|
+
* Dependency-free and browser-safe.
|
|
13
|
+
*/
|
|
14
|
+
export interface SnippetKeyParts {
|
|
15
|
+
/** Build id of tex.wasm. */
|
|
16
|
+
engineId: string;
|
|
17
|
+
/** Hash of the format file (plain.fmt / latex.fmt). */
|
|
18
|
+
formatId: string;
|
|
19
|
+
/** The verbatimtex blocks that precede the snippet, in source order. */
|
|
20
|
+
chain: readonly string[];
|
|
21
|
+
/** The btex text as handed to make_text. */
|
|
22
|
+
body: string;
|
|
23
|
+
/** MetaPost's `texscriptmode` internal; defaults to 1 like MetaPost. */
|
|
24
|
+
texscriptmode?: number;
|
|
25
|
+
}
|
|
26
|
+
/** L2 key: H(engineId, formatId, verbatimtex chain, body, texscriptmode). */
|
|
27
|
+
export declare function snippetKey(parts: SnippetKeyParts): string;
|
|
28
|
+
/** L1 key: H(full generated .tex, engineId, formatId). */
|
|
29
|
+
export declare function documentKey(texSource: string, engineId: string, formatId: string): string;
|
|
30
|
+
/** SHA-256 of `data` (a string is UTF-8 encoded first) as lowercase hex. */
|
|
31
|
+
export declare function sha256Hex(data: Uint8Array | string): string;
|
|
32
|
+
/** SHA-256 of `msg`, as 32 bytes. */
|
|
33
|
+
export declare function sha256(msg: Uint8Array): Uint8Array;
|