@lofcz/tikz 0.2.3 → 0.2.4
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 +9 -0
- package/package.json +1 -1
- package/src/browser-worker.js +2 -6
- package/src/deploy.js +1 -1
- package/src/index.js +3 -6
- package/src/source.js +97 -0
- package/src/worker.js +2 -1
package/README.md
CHANGED
|
@@ -56,6 +56,15 @@ loops; queue capacity is not released until the process closes. The Node heap
|
|
|
56
56
|
limit is 512 MB, not a total RSS/WASM memory limit. Apply container/OS resource
|
|
57
57
|
limits when serving untrusted workloads. Browser apps use the worker entry below; backend generation uses the Node entry.
|
|
58
58
|
|
|
59
|
+
## Source normalization
|
|
60
|
+
|
|
61
|
+
Bare pictures are wrapped in a `standalone` document with the common TikZ
|
|
62
|
+
libraries. A `\color{…}` switch inside a `font=` key (`font=\small\color{red!70!black}`)
|
|
63
|
+
is moved to the node's `text=` key before compilation: in this DVI→SVG pipeline
|
|
64
|
+
the colour special opened by the font switch is never popped inside the
|
|
65
|
+
picture and dvisvgm rejects the page (`missing closing tag(s): </g>`). The
|
|
66
|
+
rewrite keeps the same colour and leaves every other source untouched.
|
|
67
|
+
|
|
59
68
|
## Browser rendering
|
|
60
69
|
|
|
61
70
|
Compile locally in a dedicated Web Worker:
|
package/package.json
CHANGED
package/src/browser-worker.js
CHANGED
|
@@ -1,15 +1,11 @@
|
|
|
1
1
|
import { MetaPost } from '../runtime/dist/index.js';
|
|
2
|
+
import { prepareTikzSource } from './source.js';
|
|
2
3
|
|
|
3
4
|
try {
|
|
4
5
|
const engine = await MetaPost.create({ worker: false, lazy: true, snapshot: 'auto', logLevel: 'silent' });
|
|
5
6
|
self.onmessage = async ({ data: { source: code, maxOutputBytes } }) => {
|
|
6
7
|
try {
|
|
7
|
-
const
|
|
8
|
-
\usetikzlibrary{calc,intersections,angles,quotes,arrows.meta,positioning}
|
|
9
|
-
\begin{document}
|
|
10
|
-
${code}
|
|
11
|
-
\end{document}`;
|
|
12
|
-
const result = await engine.latex(source, { engine: 'auto', fonts: 'paths' });
|
|
8
|
+
const result = await engine.latex(prepareTikzSource(code), { engine: 'auto', fonts: 'paths' });
|
|
13
9
|
if (result.status === 'error' || result.status === 'fatal' || result.pages?.length !== 1)
|
|
14
10
|
throw new Error('TikZ requires one valid page. ' + JSON.stringify(result.diagnostics).slice(0, 2000));
|
|
15
11
|
const svg = result.pages[0], bytes = new TextEncoder().encode(svg);
|
package/src/deploy.js
CHANGED
|
@@ -28,7 +28,7 @@ export async function copyTikzBrowserAssets(directory) {
|
|
|
28
28
|
throw new Error('TikZ browser assets must be outside the installed package');
|
|
29
29
|
await mkdir(join(destination, 'src'), { recursive: true });
|
|
30
30
|
await cp(join(source, 'runtime'), join(destination, 'runtime'), { recursive: true });
|
|
31
|
-
for (const name of ['browser.js', 'browser-worker.js'])
|
|
31
|
+
for (const name of ['browser.js', 'browser-worker.js', 'source.js'])
|
|
32
32
|
await cp(join(source, 'src', name), join(destination, 'src', name));
|
|
33
33
|
for (const name of ['LICENSE', 'NOTICE.md', 'upstream.json'])
|
|
34
34
|
await cp(join(source, name), join(destination, name));
|
package/src/index.js
CHANGED
|
@@ -89,13 +89,10 @@ export function createTikzRenderer(options = {}) {
|
|
|
89
89
|
async render(code, { signal } = {}) {
|
|
90
90
|
if (typeof code !== 'string' || !code.trim() || code.length > settings.maxSourceLength)
|
|
91
91
|
throw new Error(`TikZ: use 1–${settings.maxSourceLength.toLocaleString('en-US')} characters.`);
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
\begin{document}
|
|
95
|
-
${code}
|
|
96
|
-
\end{document}`;
|
|
92
|
+
// The worker normalizes and wraps the picture (src/source.js), so this
|
|
93
|
+
// entry stays relocatable: it only needs the package directory.
|
|
97
94
|
await acquire(signal);
|
|
98
|
-
try { return await compile(
|
|
95
|
+
try { return await compile(code, signal); }
|
|
99
96
|
finally { release(); }
|
|
100
97
|
},
|
|
101
98
|
};
|
package/src/source.js
ADDED
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
// Source normalization shared by the Node worker and the browser worker.
|
|
2
|
+
//
|
|
3
|
+
// A bare picture is wrapped in a standalone document with the common
|
|
4
|
+
// libraries. `\color{…}` inside a `font=` key is rewritten into the node's
|
|
5
|
+
// `text=` key: in this DVI→SVG pipeline the colour special opened by a font
|
|
6
|
+
// switch is never popped inside the picture, and dvisvgm then rejects the page
|
|
7
|
+
// ("missing closing tag(s): </g>"). `text=` paints the same colour the TikZ way.
|
|
8
|
+
|
|
9
|
+
const PREAMBLE = String.raw`\documentclass[tikz,border=6pt]{standalone}
|
|
10
|
+
\usetikzlibrary{calc,intersections,angles,quotes,arrows.meta,positioning}
|
|
11
|
+
\begin{document}
|
|
12
|
+
`;
|
|
13
|
+
|
|
14
|
+
/** Wrap a bare picture into a complete standalone document. */
|
|
15
|
+
export function wrapStandalone(code) {
|
|
16
|
+
return /^\s*\\documentclass\b/m.test(code) ? code : `${PREAMBLE}${code}\n\\end{document}`;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** Index just past the balanced group that opens at `start` (`{`/`[`), or -1. */
|
|
20
|
+
function groupEnd(text, start) {
|
|
21
|
+
const open = text[start], close = open === '{' ? '}' : ']';
|
|
22
|
+
let depth = 0;
|
|
23
|
+
for (let i = start; i < text.length; i++) {
|
|
24
|
+
const char = text[i];
|
|
25
|
+
if (char === '\\') { i++; continue; }
|
|
26
|
+
if (char === open) depth++;
|
|
27
|
+
else if (char === close && --depth === 0) return i + 1;
|
|
28
|
+
}
|
|
29
|
+
return -1;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** End of a pgfkeys value starting at `start`: the next top-level `,`, `]` or `}`. */
|
|
33
|
+
function valueEnd(text, start) {
|
|
34
|
+
for (let i = start; i < text.length; i++) {
|
|
35
|
+
const char = text[i];
|
|
36
|
+
if (char === '\\') { i++; continue; }
|
|
37
|
+
if (char === '{' || char === '[') { const end = groupEnd(text, i); if (end < 0) return -1; i = end - 1; continue; }
|
|
38
|
+
if (char === ',' || char === ']' || char === '}') return i;
|
|
39
|
+
}
|
|
40
|
+
return text.length;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** Position of the `]`/`}` that closes the option list containing `from`, or -1. */
|
|
44
|
+
function listEnd(text, from) {
|
|
45
|
+
let depth = 0;
|
|
46
|
+
for (let i = from; i < text.length; i++) {
|
|
47
|
+
const char = text[i];
|
|
48
|
+
if (char === '\\') { i++; continue; }
|
|
49
|
+
if (char === '{' || char === '[') depth++;
|
|
50
|
+
else if (char === '}' || char === ']') { if (depth === 0) return i; depth--; }
|
|
51
|
+
}
|
|
52
|
+
return -1;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const COLOR = /\\color\s*\{([^{}]*)\}/g;
|
|
56
|
+
|
|
57
|
+
/** Move every `\color{name}` out of `font=` values into a trailing `text=name` key. */
|
|
58
|
+
export function hoistFontColors(source) {
|
|
59
|
+
let text = source;
|
|
60
|
+
const keyPattern = /(^|[\[{,])(\s*font\s*=\s*)/g;
|
|
61
|
+
for (let match = keyPattern.exec(text); match; match = keyPattern.exec(text)) {
|
|
62
|
+
const keyStart = match.index + match[1].length;
|
|
63
|
+
const valueStart = match.index + match[0].length;
|
|
64
|
+
const end = valueEnd(text, valueStart);
|
|
65
|
+
if (end < 0) break;
|
|
66
|
+
const value = text.slice(valueStart, end);
|
|
67
|
+
const colors = [...value.matchAll(COLOR)].map(m => m[1].trim()).filter(Boolean);
|
|
68
|
+
if (colors.length === 0) continue;
|
|
69
|
+
const braced = /^\{[\s\S]*\}$/.test(value.trim());
|
|
70
|
+
let cleaned = value.replace(COLOR, '').replace(/[ \t]+$/, '');
|
|
71
|
+
if (braced && !cleaned.replace(/^\{|\}$/g, '').trim()) cleaned = '';
|
|
72
|
+
else if (!braced && !cleaned.trim()) cleaned = '';
|
|
73
|
+
const close = listEnd(text, end);
|
|
74
|
+
if (close < 0) break;
|
|
75
|
+
const closer = text.slice(close);
|
|
76
|
+
let replaced;
|
|
77
|
+
if (cleaned) {
|
|
78
|
+
replaced = text.slice(0, valueStart) + cleaned + text.slice(end, close);
|
|
79
|
+
} else {
|
|
80
|
+
// Drop the whole `font=` key with the comma that separated it.
|
|
81
|
+
let keyRemoveStart = keyStart, keyRemoveEnd = end;
|
|
82
|
+
if (text[end] === ',') keyRemoveEnd = end + 1;
|
|
83
|
+
else if (match[1] === ',') keyRemoveStart = match.index;
|
|
84
|
+
replaced = text.slice(0, keyRemoveStart) + text.slice(keyRemoveEnd, close);
|
|
85
|
+
}
|
|
86
|
+
const replacedHead = replaced.replace(/[\s,]+$/, '');
|
|
87
|
+
const separator = /[[{]$/.test(replacedHead) ? '' : ', ';
|
|
88
|
+
text = `${replacedHead}${separator}text=${colors[colors.length - 1]}${closer}`;
|
|
89
|
+
keyPattern.lastIndex = Math.max(0, replacedHead.length - value.length);
|
|
90
|
+
}
|
|
91
|
+
return text;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** Everything the TeX engine receives: colour-safe keys inside a complete document. */
|
|
95
|
+
export function prepareTikzSource(code) {
|
|
96
|
+
return wrapStandalone(hoistFontColors(code));
|
|
97
|
+
}
|
package/src/worker.js
CHANGED
|
@@ -2,10 +2,11 @@
|
|
|
2
2
|
// filesystem is in-memory; no host texmf tree, shell or caller environment.
|
|
3
3
|
import { readFileSync } from 'node:fs';
|
|
4
4
|
import { MetaPost } from '../runtime/dist/index.js';
|
|
5
|
+
import { prepareTikzSource } from './source.js';
|
|
5
6
|
const { source } = JSON.parse(readFileSync(0, 'utf8'));
|
|
6
7
|
const engine = await MetaPost.create({ worker: false, lazy: true, logLevel: 'silent' });
|
|
7
8
|
try {
|
|
8
|
-
const result = await engine.latex(source, { engine: 'auto', fonts: 'paths' });
|
|
9
|
+
const result = await engine.latex(prepareTikzSource(source), { engine: 'auto', fonts: 'paths' });
|
|
9
10
|
if (result.status === 'error' || result.status === 'fatal' || result.pages?.length !== 1)
|
|
10
11
|
throw new Error('TikZ requires one valid page. ' + JSON.stringify(result.diagnostics).slice(0, 2000));
|
|
11
12
|
process.stdout.write(result.pages[0]);
|