@lofcz/tikz 0.2.4 → 0.3.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/src/source.js CHANGED
@@ -1,97 +1,1249 @@
1
1
  // Source normalization shared by the Node worker and the browser worker.
2
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.
3
+ // Real-world TikZ arrives as Markdown fences, JSON-escaped strings, bare
4
+ // pictures with `\usepackage` lines in front, article/beamer documents, Czech
5
+ // labels with Unicode symbols, `_`/`&`/`%` in node text, colours from CSS and
6
+ // packages the bundled TeX tree does not ship. `prepareTikzDocument` turns all
7
+ // of that into one standalone document the DVI→SVG pipeline accepts, and
8
+ // `describeTikzFailure` turns a failed run into one short, actionable line.
9
+ //
10
+ // The TeX is parsed with unified-latex (PEG LaTeX parser; comments, groups,
11
+ // math, environments and macro arguments become an AST) and printed back with
12
+ // its raw printer. Regular expressions only touch what is not TeX: Markdown
13
+ // fences, JSON escapes, the TeX log, and keyword lookups for library detection.
14
+
15
+ import { getParser, parseMinimal } from '@unified-latex/unified-latex-util-parse';
16
+ import { printRaw } from '@unified-latex/unified-latex-util-print-raw';
17
+ import { parsePgfkeys } from '@unified-latex/unified-latex-util-pgfkeys';
18
+ import { BUNDLED_PACKAGES, BUNDLED_PGFPLOTS_LIBRARIES, BUNDLED_TIKZ_LIBRARIES } from './bundled.js';
19
+ import * as T from './tables.js';
20
+ import { recoverGeneratedSyntax } from './recover.js';
21
+
22
+ export const DEFAULT_LIBRARIES = ['calc', 'intersections', 'angles', 'quotes', 'arrows.meta', 'positioning'];
8
23
 
9
- const PREAMBLE = String.raw`\documentclass[tikz,border=6pt]{standalone}
10
- \usetikzlibrary{calc,intersections,angles,quotes,arrows.meta,positioning}
11
- \begin{document}
12
- `;
24
+ /** Thrown before compilation when no picture can be made from the input. */
25
+ export class TikzSourceError extends Error {
26
+ constructor(message) { super(message); this.name = 'TikzSourceError'; }
27
+ }
13
28
 
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}`;
29
+ const parsers = new Map();
30
+ /**
31
+ * A parser whose macro table omits `excluded`: names the document defines
32
+ * itself (`\def\ang{60}`, `\foreach \num in …`) must not get siunitx's or
33
+ * tkz-euclide's argument signature, or `(\ang:2)` re-prints as `(\ang{:}2)`.
34
+ */
35
+ function parserFor(excluded) {
36
+ const key = [...excluded].sort().join(' ');
37
+ let parser = parsers.get(key);
38
+ if (!parser) {
39
+ const macros = key ? Object.fromEntries(Object.entries(T.MACRO_SIGNATURES).filter(([name]) => !excluded.has(name))) : T.MACRO_SIGNATURES;
40
+ parser = getParser({ macros, environments: T.ENVIRONMENT_SIGNATURES });
41
+ if (parsers.size > 64) parsers.delete(parsers.keys().next().value);
42
+ parsers.set(key, parser);
43
+ }
44
+ return parser;
45
+ }
46
+ const DEFINING = new Set(['def', 'edef', 'gdef', 'xdef', 'let', 'newcommand', 'renewcommand', 'providecommand', 'DeclareRobustCommand', 'NewDocumentCommand', 'RenewDocumentCommand', 'ProvideDocumentCommand', 'DeclareDocumentCommand', 'newcommandx', 'pgfmathsetmacro', 'pgfmathtruncatemacro', 'pgfmathsetlengthmacro', 'pgfmathsetlength', 'newlength', 'newdimen', 'newcount', 'newcounter', 'newsavebox', 'newif', 'setlength', 'DeclareMathOperator']);
47
+ /** Discover bindings from the PEG parser's unprocessed tokens, never comments. */
48
+ export function definedMacroNames(text) {
49
+ const names = new Set();
50
+ const root = parseMinimal(text).content;
51
+ walkArrays(root, array => {
52
+ for (let i = 0; i < array.length; i++) {
53
+ const node = array[i];
54
+ if (!isMacro(node)) continue;
55
+ if (DEFINING.has(node.content)) {
56
+ let next = skipSpace(array, i + 1);
57
+ if (isString(array[next], '*')) next = skipSpace(array, next + 1);
58
+ const target = isGroup(array[next]) ? array[next].content.find(child => !isSpace(child)) : array[next];
59
+ if (isMacro(target)) names.add(target.content);
60
+ } else if (node.content === 'foreach') {
61
+ for (let j = i + 1; j < array.length; j++) {
62
+ if (isString(array[j], 'in') || isGroup(array[j])) break;
63
+ if (isMacro(array[j])) names.add(array[j].content);
64
+ }
65
+ } else if (node.content === 'tikzmath') {
66
+ const group = array[skipSpace(array, i + 1)];
67
+ if (isGroup(group)) walkArrays(group.content, tokens => {
68
+ for (let j = 0; j < tokens.length; j++) if (isMacro(tokens[j]) && isString(tokens[skipSpace(tokens, j + 1)], '=')) names.add(tokens[j].content);
69
+ });
70
+ } else if (node.content === 'pgfkeysgetvalue') {
71
+ const value = array[skipSpace(array, skipSpace(array, i + 1) + 1)];
72
+ const target = isGroup(value) ? value.content[0] : value;
73
+ if (isMacro(target)) names.add(target.content);
74
+ }
75
+ }
76
+ });
77
+ return names;
17
78
  }
79
+ /** Parse LaTeX into unified-latex's AST (`root.content` is the node list). */
80
+ export function parseLatex(text, excluded = definedMacroNames(text)) { return parserFor(excluded).parse(text); }
81
+
82
+ // ------------------------------------------------------------ node helpers
83
+
84
+ const str = content => ({ type: 'string', content });
85
+ const macro = (content, args) => (args ? { type: 'macro', content, args } : { type: 'macro', content });
86
+ const inlineMath = content => ({ type: 'inlinemath', content });
87
+ const isMacro = (node, name) => node?.type === 'macro' && (name == null || node.content === name);
88
+ const isString = (node, content) => node?.type === 'string' && (content == null || node.content === content);
89
+ const isSpace = node => node?.type === 'whitespace' || node?.type === 'parbreak';
90
+ const isGroup = node => node?.type === 'group';
91
+ const groupText = node => (isGroup(node) ? printRaw(node.content).trim() : '');
92
+ const argText = (node, index) => { const argument = node?.args?.[index]; return argument ? printRaw(argument.content).trim() : ''; };
93
+ const hasArgs = node => Array.isArray(node?.args) && node.args.length > 0;
94
+ const splitList = value => value.split(',').map(part => part.trim()).filter(Boolean);
18
95
 
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 === '{' ? '}' : ']';
96
+ /** Index of the string token closing the `open`…`close` pair that starts at `from` (same array level), or -1. */
97
+ function matchingToken(nodes, from, open, close) {
22
98
  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;
99
+ for (let i = from; i < nodes.length; i++) {
100
+ if (isString(nodes[i], open)) depth++;
101
+ else if (isString(nodes[i], close) && --depth === 0) return i;
28
102
  }
29
103
  return -1;
30
104
  }
31
105
 
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;
106
+ function skipSpace(nodes, i) { while (i < nodes.length && isSpace(nodes[i])) i++; return i; }
107
+
108
+ /** End index of the generic trailing arguments (`[…]`, `{…}`, `(…)`, `*`) a macro without a known signature takes. */
109
+ function takeArgs(nodes, i) {
110
+ let end = i;
111
+ for (;;) {
112
+ const j = skipSpace(nodes, end);
113
+ const node = nodes[j];
114
+ if (isGroup(node)) end = j + 1;
115
+ else if (isString(node, '[')) { const close = matchingToken(nodes, j, '[', ']'); if (close < 0) return end; end = close + 1; }
116
+ else if (isString(node, '(')) { const close = matchingToken(nodes, j, '(', ')'); if (close < 0) return end; end = close + 1; }
117
+ else if (isString(node, '*') && j === end) end = j + 1;
118
+ else return end;
39
119
  }
40
- return text.length;
41
120
  }
42
121
 
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--; }
122
+ /** End index just past the string token containing `;` (a path statement), or the array end. */
123
+ function pathEnd(nodes, i) {
124
+ for (let j = i; j < nodes.length; j++) if (isString(nodes[j]) && nodes[j].content.includes(';')) return j + 1;
125
+ return nodes.length;
126
+ }
127
+
128
+ /** Walk every node array in the tree (environment/group/argument/math content), parents first. */
129
+ function walkArrays(nodes, visit, context = {}) {
130
+ visit(nodes, context);
131
+ for (const node of nodes) {
132
+ if (!node || typeof node !== 'object') continue;
133
+ const next = node._inDefinition ? { ...context, inDefinition: true } : context;
134
+ if (Array.isArray(node.args)) for (const argument of node.args) if (Array.isArray(argument.content)) walkArrays(argument.content, visit, { ...next, inArgOf: node.content });
135
+ if (Array.isArray(node.content)) walkArrays(node.content, visit, { ...next, parent: node });
51
136
  }
52
- return -1;
53
137
  }
54
138
 
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);
139
+ /**
140
+ * The parser brackets every sub/superscript argument (`C_A` → `C_{A}`), which
141
+ * renames TikZ coordinates and nodes. Print single-token scripts without braces.
142
+ */
143
+ function tightScripts(root) {
144
+ walkArrays(root, array => {
145
+ for (const node of array) {
146
+ if (!isMacro(node) || (node.content !== '_' && node.content !== '^') || !hasArgs(node)) continue;
147
+ const argument = node.args[node.args.length - 1];
148
+ const content = argument.content.filter(child => !isSpace(child));
149
+ if (content.length === 1 && ((isString(content[0]) && content[0].content.length === 1) || isMacro(content[0]))) { argument.openMark = ''; argument.closeMark = ''; argument.content = content; }
150
+ }
151
+ });
152
+ }
153
+
154
+ /** Every string node in the tree. */
155
+ function visitStrings(nodes, fn) {
156
+ walkArrays(nodes, array => { for (const node of array) if (node?.type === 'string') fn(node); });
157
+ }
158
+
159
+ // ------------------------------------------------------------ input cleanup
160
+
161
+ const ZERO_WIDTH = /[\u200B-\u200D\u2060\uFEFF\uFE0E\uFE0F]/g;
162
+ const ODD_SPACES = /[\u00A0\u2000-\u200A\u202F\u205F\u3000]/g;
163
+ const ENTITY = /&(?:gt|lt|amp|quot|apos|nbsp|#\d+|#x[0-9a-fA-F]+);/g;
164
+ const HAS_ENTITY = /&(?:gt|lt|amp|quot|apos|nbsp|#\d+|#x[0-9a-fA-F]+);/;
165
+ const FENCE = /```[ \t]*([\w+-]*)[^\n]*\n([\s\S]*?)(?:\n[ \t]*```|$)/g;
166
+
167
+ function decodeEntities(text) {
168
+ return text.replace(ENTITY, entity => {
169
+ switch (entity.toLowerCase()) {
170
+ case '&gt;': return '>';
171
+ case '&lt;': return '<';
172
+ case '&quot;': return '"';
173
+ case '&apos;': return "'";
174
+ case '&nbsp;': return ' ';
175
+ case '&amp;': return '&';
176
+ default: {
177
+ const code = entity[2] === 'x' || entity[2] === 'X' ? parseInt(entity.slice(3, -1), 16) : parseInt(entity.slice(2, -1), 10);
178
+ return Number.isFinite(code) && code > 0 && code < 0x110000 ? String.fromCodePoint(code) : entity;
179
+ }
180
+ }
181
+ });
182
+ }
183
+
184
+ /** `\\begin{tikzpicture}\n\\draw …` (a JSON string pasted verbatim) → real TeX. */
185
+ function unescapeDoubled(text) {
186
+ const doubled = /\\\\(?:begin|draw|node|fill|path|documentclass|coordinate|usetikzlibrary|tikz)\b/;
187
+ const single = /(?:^|[^\\])\\(?:begin|draw|node|fill|path|documentclass|coordinate|usetikzlibrary|tikz)\b/;
188
+ if (!doubled.test(text) || single.test(text)) return text;
189
+ return text.replace(/\\\\/g, '\u0000').replace(/\\n/g, '\n').replace(/\\t/g, '\t').replace(/\\"/g, '"').replace(/\u0000/g, '\\');
190
+ }
191
+
192
+ /** Keep the TeX inside Markdown fences, dropping the prose around them. */
193
+ function unfence(text) {
194
+ if (!text.includes('```')) return text.replace(/^[ \t]*(?:tikz|latex|tex)[ \t]*\n/i, '');
195
+ const blocks = [];
196
+ for (const match of text.matchAll(FENCE)) {
197
+ const language = match[1].toLowerCase();
198
+ if (!language || /^(?:tikz|latex|tex|pgf|tikzpicture)$/.test(language)) blocks.push(match[2]);
199
+ }
200
+ if (blocks.length) return blocks.join('\n\n');
201
+ return text.replace(/^[ \t]*```[^\n]*\n?/gm, '');
202
+ }
203
+
204
+ /**
205
+ * `50%` in a label is a percent sign, not a comment: a `%` right after a digit
206
+ * becomes `\%` unless the line is already inside a comment.
207
+ */
208
+ function literalPercents(text) {
209
+ return text.split('\n').map(line => {
210
+ let out = '';
211
+ let comment = false;
212
+ for (let i = 0; i < line.length; i++) {
213
+ const char = line[i];
214
+ if (comment) { out += char; continue; }
215
+ if (char === '\\') { out += char + (line[i + 1] ?? ''); i++; continue; }
216
+ if (char === '%') {
217
+ if (/\d/.test(line[i - 1] ?? '')) { out += '\\%'; continue; }
218
+ comment = true;
219
+ }
220
+ out += char;
221
+ }
222
+ return out;
223
+ }).join('\n');
224
+ }
225
+
226
+ /** Normalize encoding noise before any TeX-level work. */
227
+ export function cleanInput(code) {
228
+ let text = String(code ?? '').replace(/\r\n?/g, '\n').replace(ZERO_WIDTH, '').replace(ODD_SPACES, ' ').replace(/\u2011/g, '-');
229
+ if (HAS_ENTITY.test(text)) text = decodeEntities(text);
230
+ text = unescapeDoubled(text);
231
+ text = unfence(text);
232
+ return literalPercents(text).trim();
233
+ }
234
+
235
+ // --------------------------------------------------------- statement reader
236
+
237
+ /**
238
+ * Read one top-level statement starting at `nodes[i]`.
239
+ * Returns { kind, name, env, end } where kind is one of
240
+ * picture | inner | path | command | begin | end | env | prose.
241
+ */
242
+ function readStatement(nodes, i) {
243
+ const node = nodes[i];
244
+ if (node.type === 'environment') {
245
+ if (T.PICTURE_ENVS.has(node.env)) return { kind: 'picture', env: node.env, end: i + 1 };
246
+ if (T.INNER_ENVS.has(node.env)) return { kind: 'inner', env: node.env, end: i + 1 };
247
+ return { kind: 'env', env: node.env, end: i + 1 };
248
+ }
249
+ if (node.type !== 'macro') return { kind: 'prose', end: i + 1 };
250
+ const name = node.content;
251
+ if (name === 'begin' || name === 'end') {
252
+ // An environment the parser could not close (no `\end{…}`): `\begin` + group.
253
+ const env = groupText(nodes[i + 1]);
254
+ const end = isGroup(nodes[i + 1]) ? i + 2 : i + 1;
255
+ if (name === 'begin' && (T.PICTURE_ENVS.has(env) || T.INNER_ENVS.has(env))) {
256
+ const synthesized = { type: 'environment', env, args: [], content: nodes.slice(end) };
257
+ return { kind: T.PICTURE_ENVS.has(env) ? 'picture' : 'inner', env, end: nodes.length, replacement: synthesized };
258
+ }
259
+ return { kind: name, env, end };
260
+ }
261
+ if (name === 'schemestart') {
262
+ let end = i + 1;
263
+ while (end < nodes.length && !isMacro(nodes[end], 'schemestop')) end++;
264
+ return { kind: 'picture', name, end: Math.min(nodes.length, end + 1) };
265
+ }
266
+ if (name === 'tikz' || T.PICTURE_MACROS.has(name)) return { kind: 'picture', name, end: hasArgs(node) ? i + 1 : takeArgs(nodes, i + 1) };
267
+ if (name.startsWith('tkz')) return { kind: 'path', name, end: takeArgs(nodes, i + 1) };
268
+ if (T.PATH_COMMANDS.has(name)) {
269
+ if (hasArgs(node) && node.args.some(argument => argument.closeMark === ';')) return { kind: 'path', name, end: i + 1 };
270
+ if (name === 'foreach') {
271
+ // `\foreach \x in {list} {body}` or `\foreach … \draw …;`
272
+ let j = i + 1;
273
+ while (j < nodes.length && !isGroup(nodes[j])) j++;
274
+ const afterList = skipSpace(nodes, j + 1);
275
+ if (isGroup(nodes[afterList])) return { kind: 'path', name, end: afterList + 1 };
276
+ return { kind: 'path', name, end: pathEnd(nodes, j + 1) };
277
+ }
278
+ return { kind: 'path', name, end: pathEnd(nodes, i + 1) };
279
+ }
280
+ if (name === 'def' || name === 'gdef' || name === 'edef' || name === 'xdef') {
281
+ let j = i + 1;
282
+ while (j < nodes.length && !isGroup(nodes[j])) j++;
283
+ return { kind: 'command', name, end: Math.min(nodes.length, j + 1) };
284
+ }
285
+ if (name === 'let' || name === 'futurelet' || name === 'newif') {
286
+ let j = skipSpace(nodes, i + 1);
287
+ let macros = 0;
288
+ const wanted = name === 'newif' ? 1 : 2;
289
+ while (j < nodes.length && macros < wanted) { if (isMacro(nodes[j])) macros++; else if (!isSpace(nodes[j]) && !isString(nodes[j], '=')) break; j++; }
290
+ return { kind: 'command', name, end: j };
291
+ }
292
+ if (name === 'tikzstyle') {
293
+ let end = hasArgs(node) ? i + 1 : takeArgs(nodes, i + 1);
294
+ let j = skipSpace(nodes, end);
295
+ if (isString(nodes[j], '+')) j++;
296
+ if (isString(nodes[j], '=')) {
297
+ j = skipSpace(nodes, j + 1);
298
+ if (isString(nodes[j], '[')) { const close = matchingToken(nodes, j, '[', ']'); if (close > 0) end = close + 1; }
299
+ }
300
+ return { kind: 'command', name, end };
301
+ }
302
+ return { kind: 'command', name, end: hasArgs(node) ? i + 1 : takeArgs(nodes, i + 1) };
303
+ }
304
+
305
+ /** Which environments wrap pictures without meaning here. */
306
+ function unwrapEnv(env, bare) {
307
+ return T.UNWRAP_ALWAYS.has(env) || (bare && T.UNWRAP_BARE.has(env));
308
+ }
309
+
310
+ /** A statement's nodes for printing (the synthesized environment when the source never closed it). */
311
+ function statementNodes(nodes, i, statement) {
312
+ return statement.replacement ? [statement.replacement] : nodes.slice(i, statement.end);
313
+ }
314
+
315
+ /**
316
+ * Split a node list into preamble statements and body parts.
317
+ * bare: prose, wrappers and typesetting commands around pictures are dropped.
318
+ * full: body prose is kept; only preamble-only commands move up.
319
+ */
320
+ function classify(nodes, { bare, preamble, body }) {
321
+ for (let i = 0; i < nodes.length;) {
322
+ const node = nodes[i];
323
+ if (isSpace(node) || node.type === 'comment') {
324
+ if (!bare) body.push({ kind: 'text', nodes: [node] });
325
+ i++;
326
+ continue;
327
+ }
328
+ const statement = readStatement(nodes, i);
329
+ const slice = statementNodes(nodes, i, statement);
330
+ i = Math.max(statement.end, i + 1);
331
+ switch (statement.kind) {
332
+ case 'picture':
333
+ case 'inner':
334
+ case 'path':
335
+ body.push({ kind: statement.kind, nodes: slice });
336
+ break;
337
+ case 'env':
338
+ if (unwrapEnv(statement.env, bare)) classify(node.content, { bare, preamble, body });
339
+ else if (!bare) body.push({ kind: 'text', nodes: slice });
340
+ break;
341
+ case 'begin':
342
+ case 'end':
343
+ if (!bare && !unwrapEnv(statement.env, bare)) body.push({ kind: 'text', nodes: slice });
344
+ break;
345
+ case 'prose':
346
+ if (isGroup(node)) classify(node.content, { bare, preamble, body });
347
+ else if (!bare) body.push({ kind: 'text', nodes: slice });
348
+ break;
349
+ case 'command':
350
+ if (T.HOISTED_COMMANDS.has(statement.name)) preamble.push(slice);
351
+ else if (bare) {
352
+ if (!T.DROP_AROUND_PICTURE.has(statement.name)) preamble.push(slice);
353
+ } else if (T.DROP_IN_BODY.has(statement.name)) { /* slide chrome */ }
354
+ else body.push({ kind: 'text', nodes: slice });
355
+ break;
356
+ default:
357
+ break;
358
+ }
359
+ }
360
+ }
361
+
362
+ /** Move preamble-only macros found inside pictures (e.g. `\usepackage` in a tikzpicture) to the preamble. */
363
+ function hoistNested(parts, preamble) {
364
+ for (const part of parts) {
365
+ walkArrays(part.nodes, array => {
366
+ for (let i = 0; i < array.length;) {
367
+ const node = array[i];
368
+ if (isMacro(node) && T.HOISTED_COMMANDS.has(node.content)) {
369
+ const end = hasArgs(node) ? i + 1 : takeArgs(array, i + 1);
370
+ preamble.push(array.slice(i, end));
371
+ array.splice(i, end - i);
372
+ } else i++;
373
+ }
374
+ });
375
+ }
376
+ }
377
+
378
+ /** Box and slide environments nested anywhere (tcolorbox inside minipage, frame inside …) keep only their content. */
379
+ function unwrapNested(parts, bare) {
380
+ for (const part of parts) {
381
+ walkArrays(part.nodes, array => {
382
+ for (let i = 0; i < array.length;) {
383
+ const node = array[i];
384
+ if (node?.type === 'environment' && unwrapEnv(node.env, bare)) array.splice(i, 1, ...node.content);
385
+ else i++;
386
+ }
387
+ });
388
+ }
389
+ }
390
+
391
+ /** Reject unbalanced braces up front: the parser leaves stray `{`/`}` as string tokens. */
392
+ function checkBraces(parts, source) {
393
+ for (const part of parts) {
394
+ walkArrays(part.nodes, array => {
395
+ for (const node of array) {
396
+ if (isString(node, '{') || isString(node, '}')) {
397
+ const line = node.position?.start?.line;
398
+ const context = line ? source.split('\n')[line - 1]?.trim().slice(0, 80) : printRaw(array).slice(0, 80);
399
+ throw new TikzSourceError(`Unbalanced braces: a '${node.content}' has no match near "${context}".`);
400
+ }
401
+ }
402
+ });
403
+ }
404
+ }
405
+
406
+ /** Bare pictures: loose paths and inner environments get a tikzpicture of their own. */
407
+ function assembleBareBody(parts) {
408
+ const blocks = [];
409
+ let loose = [];
410
+ const flush = () => {
411
+ if (!loose.length) return;
412
+ blocks.push({ type: 'environment', env: 'tikzpicture', args: [], content: [str('\n'), ...loose.flatMap(nodes => [...nodes, str('\n')])] });
413
+ blocks.push(str('\n\n'));
414
+ loose = [];
415
+ };
416
+ for (const part of parts) {
417
+ if (part.kind === 'picture') { flush(); blocks.push(...part.nodes, str('\n\n')); }
418
+ else loose.push(part.nodes);
419
+ }
420
+ flush();
421
+ return blocks;
422
+ }
423
+
424
+ /** Split the input into { full, preamble: Node[][], bodyText }. */
425
+ function structure(text, excluded) {
426
+ const root = parseLatex(text, excluded);
427
+ const nodes = root.content;
428
+ const classIndex = nodes.findIndex(node => isMacro(node, 'documentclass'));
429
+ const docIndex = nodes.findIndex(node => node.type === 'environment' && node.env === 'document');
430
+ const beginDoc = nodes.findIndex((node, index) => isMacro(node, 'begin') && groupText(nodes[index + 1]) === 'document');
431
+ const full = classIndex >= 0 || docIndex >= 0 || beginDoc >= 0;
432
+ const preamble = [];
433
+ const body = [];
434
+ if (!full) {
435
+ classify(nodes, { bare: true, preamble, body });
436
+ } else {
437
+ const start = classIndex >= 0 ? classIndex + 1 : 0;
438
+ const before = classIndex > 0 ? nodes.slice(0, classIndex) : [];
439
+ let preambleNodes, bodyNodes;
440
+ if (docIndex >= 0) { preambleNodes = nodes.slice(start, docIndex); bodyNodes = nodes[docIndex].content; }
441
+ else if (beginDoc >= 0) { preambleNodes = nodes.slice(start, beginDoc); bodyNodes = nodes.slice(beginDoc + 2); }
442
+ else {
443
+ let at = -1;
444
+ for (let i = start; i < nodes.length; i++) {
445
+ if (isSpace(nodes[i]) || nodes[i].type === 'comment') continue;
446
+ const kind = readStatement(nodes, i).kind;
447
+ if (kind !== 'command') { at = i; break; }
448
+ }
449
+ preambleNodes = at < 0 ? nodes.slice(start) : nodes.slice(start, at);
450
+ bodyNodes = at < 0 ? [] : nodes.slice(at);
451
+ }
452
+ classify([...before, ...preambleNodes], { bare: true, preamble, body: [] });
453
+ classify(bodyNodes, { bare: false, preamble, body });
454
+ }
455
+ unwrapNested(body, !full);
456
+ hoistNested(body, preamble);
457
+ checkBraces(body, text);
458
+ const bodyNodes = full ? body.flatMap(part => part.nodes) : assembleBareBody(body);
459
+ return { full, preamble, body: bodyNodes };
460
+ }
461
+
462
+ // ------------------------------------------------------------ pgfkeys passes
463
+
464
+ /** Colour macros inside a `font=` value, removed in place; returns the last colour name. */
465
+ function pullColors(nodes) {
466
+ let color = null;
467
+ for (let i = 0; i < nodes.length;) {
468
+ const node = nodes[i];
469
+ if (isMacro(node, 'color') && hasArgs(node)) {
470
+ color = argText(node, node.args.length - 1) || color;
471
+ nodes.splice(i, 1);
472
+ continue;
473
+ }
474
+ if (isGroup(node)) color = pullColors(node.content) ?? color;
475
+ i++;
476
+ }
477
+ return color;
478
+ }
479
+
480
+ function significant(nodes) { return nodes.filter(node => !isSpace(node) && !(isGroup(node) && !significant(node.content).length)); }
481
+
482
+ /**
483
+ * Rewrite one pgfkeys list (the nodes between `[` and `]`, or a `\tikzset`
484
+ * argument) in place: `font=…\color{c}` becomes `font=…, text=c`, nested
485
+ * `.style={…}` lists recurse, and label/pin texts get the node-text repairs.
486
+ */
487
+ function processKeyList(nodes, context) {
488
+ let items;
489
+ try { items = parsePgfkeys(nodes); } catch { return; }
490
+ items = mergeParenthesisedValues(items);
491
+ const rebuilt = [];
492
+ const trailing = [];
493
+ for (const item of items) {
494
+ const parts = item.itemParts ?? [];
495
+ if (!parts.length) continue;
496
+ let key = printRaw(parts[0]).trim();
497
+ const value = parts[1];
498
+ // In TikZ, a named collection of keys needs /.style. PGFPlots has real
499
+ // setters named "tick style" etc., so leave its options alone.
500
+ if (context.tikz && !key.includes('/') && key.endsWith(' style') && value?.some(isGroup)) {
501
+ key += '/.style';
502
+ parts[0] = [str(key)];
503
+ }
504
+ if (value && TEXT_KEYS.has(key)) repairTextNodes(value, context);
505
+ if (value && /^(?:xtick|ytick|ztick)labels$|^legend entries$/.test(key)) for (const node of value) if (isGroup(node)) repairTextNodes(node.content, context);
506
+ const styleName = /^(.+?)\/\.(?:style|append style)$/.exec(key)?.[1];
507
+ // A user style called name/label must not replace TikZ's node naming or
508
+ // labeling machinery. Keep explicit property assignments on the native key.
509
+ if (styleName && ['name', 'label'].includes(styleName)) {
510
+ const alias = `tikz recovered ${styleName} style`;
511
+ context.styleAliases?.set(styleName, alias);
512
+ parts[0] = [str(key.replace(styleName, alias))];
513
+ } else if (!value && context.styleAliases?.has(key)) parts[0] = [str(context.styleAliases.get(key))];
514
+ if (value && /^(above|below|left|right)( left| right)?$/.test(key)) {
515
+ const target = value.findIndex(node => isString(node, 'of'));
516
+ const point = target >= 0 ? skipSpace(value, target + 1) : -1;
517
+ if (point >= 0 && value[point]?.type === 'inlinemath') {
518
+ // positioning adds the outer parentheses itself; only protect commas
519
+ // from the PGF key parser, which does not recognize math delimiters.
520
+ parts[1] = [{ type: 'group', content: value }];
521
+ }
522
+ }
523
+ if (styleName && value) for (const node of value) if (isGroup(node)) unloopStyle(node.content, styleName);
524
+ if (key === 'font' && value) {
525
+ const color = pullColors(value);
526
+ if (color) {
527
+ trailing.push([str('text'), str('='), str(color)]);
528
+ if (!significant(value).length) continue; // the key only carried the colour
529
+ }
530
+ }
531
+ if (value && /\/\.(?:style|append style|style 2 args|style n args|prefix style|append)\b/.test(key)) {
532
+ for (const node of value) if (isGroup(node)) processKeyList(node.content, context);
533
+ }
534
+ if (value && (key === 'label' || key === 'pin')) {
535
+ const inner = value.length === 1 && isGroup(value[0]) ? value[0].content : value;
536
+ let at = 0;
537
+ if (isString(inner[at], '[')) { const close = matchingToken(inner, at, '[', ']'); if (close > 0) { const options = inner.slice(at + 1, close); processKeyList(options, context); inner.splice(at + 1, close - at - 1, ...options); at = at + 1 + options.length + 1; } }
538
+ const colon = inner.findIndex((node, index) => index >= at && isString(node, ':'));
539
+ const textStart = colon >= 0 ? colon + 1 : at;
540
+ const textNodes = inner.slice(textStart);
541
+ repairTextNodes(textNodes, context);
542
+ inner.splice(textStart, inner.length - textStart, ...textNodes);
543
+ }
544
+ rebuilt.push(keyItemNodes(parts));
545
+ }
546
+ nodes.splice(0, nodes.length, ...joinKeyItems([...rebuilt, ...trailing]));
547
+ }
548
+
549
+ const TEXT_KEYS = new Set(['xlabel', 'ylabel', 'zlabel', 'title', 'nodes near coords', 'text', 'node contents']);
550
+
551
+ /**
552
+ * `shift=(1,2)` is split at the comma by pgfkeys; TikZ wants `shift={(1,2)}`.
553
+ * Join the pieces of an item whose value has an unmatched `(` and brace it.
554
+ */
555
+ function mergeParenthesisedValues(items) {
556
+ const balance = nodes => { let depth = 0; for (const node of nodes) if (isString(node)) for (const char of node.content) { if (char === '(') depth++; else if (char === ')') depth--; } return depth; };
557
+ const out = [];
558
+ for (let i = 0; i < items.length; i++) {
559
+ const item = items[i];
560
+ const parts = item.itemParts ?? [];
561
+ if (parts.length === 2 && /^(above|below|left|right)( left| right)?$/.test(printRaw(parts[0]).trim())) {
562
+ const of = parts[1].findIndex(node => isString(node, 'of'));
563
+ const number = tokens => /^-?\d+(?:\.\d+)?(?:cm|mm|pt)?$/.test(printRaw(tokens).trim());
564
+ const next = items[i + 1]?.itemParts;
565
+ if (of >= 0 && number(parts[1].slice(of + 1)) && next?.length === 1 && number(next[0])) {
566
+ out.push({ ...item, itemParts: [parts[0], [{ type: 'group', content: [...parts[1], str(','), ...next[0]] }]] });
567
+ i++;
568
+ continue;
569
+ }
570
+ }
571
+ if (parts.length !== 2 || balance(parts[1]) <= 0) { out.push(item); continue; }
572
+ const value = [...parts[1]];
573
+ let depth = balance(value);
574
+ while (depth > 0 && i + 1 < items.length && (items[i + 1].itemParts ?? []).length === 1) {
575
+ const piece = items[++i].itemParts[0];
576
+ value.push(str(','), { type: 'whitespace' }, ...piece);
577
+ depth += balance(piece);
578
+ }
579
+ if (depth !== 0) { out.push(item); continue; }
580
+ while (value.length && isSpace(value[0])) value.shift();
581
+ while (value.length && isSpace(value[value.length - 1])) value.pop();
582
+ out.push({ ...item, itemParts: [parts[0], [{ type: 'group', content: value }]] });
583
+ }
584
+ return out;
585
+ }
586
+
587
+ /**
588
+ * `cloud/.style={cloud, draw}` recurses forever in TikZ (input stack
589
+ * overflow): inside its own body the name means the shape, so say so.
590
+ */
591
+ function unloopStyle(nodes, name) {
592
+ let items;
593
+ try { items = parsePgfkeys(nodes); } catch { return; }
594
+ let changed = false;
595
+ const kept = [];
596
+ for (const item of items) {
597
+ const parts = item.itemParts ?? [];
598
+ if (parts.length === 1 && printRaw(parts[0]).trim() === name) {
599
+ changed = true;
600
+ if (T.SHAPE_NAMES.has(name)) kept.push([str('shape='), str(name)]);
601
+ continue;
602
+ }
603
+ kept.push(keyItemNodes(parts));
604
+ }
605
+ if (changed) nodes.splice(0, nodes.length, ...joinKeyItems(kept));
606
+ }
607
+
608
+ /**
609
+ * The nodes of one key list item, comments removed: a comment re-printed
610
+ * inside a one-line option list would swallow every key after it.
611
+ */
612
+ function keyItemNodes(parts) {
613
+ const out = [];
614
+ parts.forEach((part, index) => { if (index) out.push(str('=')); out.push(...stripComments(part)); });
615
+ return out;
616
+ }
617
+ function stripComments(nodes) {
618
+ const kept = [];
619
+ for (const node of nodes) {
620
+ if (node.type === 'comment') { if (kept.length && !isSpace(kept[kept.length - 1])) kept.push({ type: 'whitespace' }); continue; }
621
+ if (isGroup(node)) kept.push({ ...node, content: stripComments(node.content) });
622
+ else kept.push(node);
623
+ }
624
+ while (kept.length && isSpace(kept[kept.length - 1])) kept.pop();
625
+ return kept;
626
+ }
627
+ function joinKeyItems(items) {
628
+ const out = [];
629
+ items.forEach((item, index) => { if (index) out.push(str(','), { type: 'whitespace' }); out.push(...item); });
630
+ return out;
631
+ }
632
+
633
+ const KEY_LIST_MACROS = new Set(['tikzset', 'pgfkeys', 'pgfplotsset', 'ctikzset', 'tikzoption', 'pgfqkeys']);
634
+
635
+ /** Find every option list in the tree and run `processKeyList` on it. */
636
+ function processKeyLists(root, context) {
637
+ walkArrays(root, (array, location) => {
638
+ if (location.parent?.env === "forest") return;
639
+ for (let i = 0; i < array.length; i++) {
640
+ const node = array[i];
641
+ if (isMacro(node, 'Tree')) {
642
+ const start = skipSpace(array, i + 1);
643
+ const end = isString(array[start], '[') ? matchingToken(array, start, '[', ']') : -1;
644
+ if (end >= 0) { i = end; continue; }
645
+ }
646
+ if (isString(node, '[')) {
647
+ const close = matchingToken(array, i, '[', ']');
648
+ if (close < 0) continue;
649
+ const inner = array.slice(i + 1, close);
650
+ processKeyList(inner, context);
651
+ array.splice(i + 1, close - i - 1, ...inner);
652
+ i += inner.length + 1;
653
+ continue;
654
+ }
655
+ if (node?.type === 'environment' && Array.isArray(node.args)) {
656
+ for (const argument of node.args) if (argument.openMark === '[') processKeyList(argument.content, { ...context, tikz: ['tikzpicture', 'scope', 'circuitikz'].includes(node.env) });
657
+ }
658
+ if (isMacro(node) && KEY_LIST_MACROS.has(node.content) && hasArgs(node)) {
659
+ for (const argument of node.args) if (argument.openMark === '{') processKeyList(argument.content, context);
660
+ }
661
+ }
662
+ });
663
+ }
664
+
665
+ // ---------------------------------------------------------- node text repair
666
+
667
+ const DEFINITION_MACROS = new Set(['newcommand', 'renewcommand', 'providecommand', 'def', 'gdef', 'edef', 'xdef', 'tikzset', 'pgfkeys', 'pgfplotsset', 'tikzstyle', 'NewDocumentCommand', 'RenewDocumentCommand', 'ProvideDocumentCommand', 'DeclareDocumentCommand', 'newenvironment', 'renewenvironment', 'pgfmathdeclarefunction', 'pgfkeysdef', 'DeclareRobustCommand']);
668
+
669
+ /** Does a run of nodes need math mode to typeset? */
670
+ function needsMath(run) {
671
+ for (const node of run) {
672
+ if (isString(node) && /[_^]/.test(node.content)) return true;
673
+ if (isMacro(node) && T.MATH_MACROS.has(node.content)) return true;
674
+ }
675
+ return false;
676
+ }
677
+
678
+ /**
679
+ * Repair text-mode node content in place: display math becomes inline,
680
+ * `&`/`#` are escaped, and words TeX only accepts in math mode (`x_1`,
681
+ * `\frac{1}{2}`, `\alpha`) are wrapped in `$…$`.
682
+ */
683
+ function repairTextNodes(nodes, context = {}) {
684
+ const hash = !context.inDefinition;
685
+ for (let i = 0; i < nodes.length; i++) {
686
+ const node = nodes[i];
687
+ if (node.type === 'displaymath') { nodes[i] = inlineMath([macro('displaystyle'), { type: 'whitespace' }, ...node.content]); continue; }
688
+ if (node.type === 'parbreak') { nodes[i] = { type: 'whitespace' }; continue; }
689
+ if (isString(node, '&')) { nodes[i] = macro('&'); continue; }
690
+ if (isString(node, '#') && hash) { nodes[i] = macro('#'); continue; }
691
+ if (isGroup(node)) repairTextNodes(node.content, context);
692
+ if (isMacro(node) && T.TEXT_ARG_MACROS.has(node.content) && hasArgs(node)) for (const argument of node.args) repairTextNodes(argument.content, context);
693
+ }
694
+ // Wrap math-only runs. A run is a maximal sequence of non-whitespace nodes.
695
+ for (let i = 0; i < nodes.length;) {
696
+ if (isSpace(nodes[i])) { i++; continue; }
697
+ let j = i;
698
+ while (j < nodes.length && !isSpace(nodes[j])) j++;
699
+ const run = nodes.slice(i, j);
700
+ if (!run.every(node => node.type === 'inlinemath') && needsMath(run)) {
701
+ nodes.splice(i, j - i, inlineMath(run.flatMap(node => (node.type === 'inlinemath' ? node.content : [node]))));
702
+ i += 1;
703
+ } else i = j;
704
+ }
705
+ }
706
+
707
+ const TEXT_PATH_MACROS = new Set(['node', 'draw', 'fill', 'filldraw', 'path', 'shade', 'shadedraw', 'clip', 'pattern', 'graph', 'addplot']);
708
+
709
+ /** Repair the `{text}` of every node in a path token list. */
710
+ function repairPathTokens(tokens, startsWithNode, context) {
711
+ let expecting = startsWithNode;
712
+ for (let i = 0; i < tokens.length; i++) {
713
+ const token = tokens[i];
714
+ if (isString(token, 'node')) { expecting = true; continue; }
715
+ if (isString(token, 'pic') || isString(token, 'coordinate') || isMacro(token, 'matrix')) { expecting = false; continue; }
716
+ if (!expecting) continue;
717
+ if (isSpace(token)) continue;
718
+ if (isString(token, '[')) { const close = matchingToken(tokens, i, '[', ']'); if (close < 0) return; i = close; continue; }
719
+ if (isString(token, '(')) { const close = matchingToken(tokens, i, '(', ')'); if (close < 0) return; i = close; continue; }
720
+ if (isString(token, 'at')) continue;
721
+ if (isGroup(token)) { repairTextNodes(token.content, context); expecting = false; continue; }
722
+ expecting = false;
723
+ }
724
+ }
725
+
726
+ function markDefinition(node) {
727
+ if (!node || typeof node !== 'object') return;
728
+ node._inDefinition = true;
729
+ if (Array.isArray(node.args)) for (const argument of node.args) for (const child of argument.content ?? []) markDefinition(child);
730
+ if (Array.isArray(node.content)) for (const child of node.content) markDefinition(child);
731
+ }
732
+
733
+ /** Find node texts everywhere in the tree and repair them. */
734
+ function repairNodeTexts(root) {
735
+ walkArrays(root, array => {
736
+ for (let i = 0; i < array.length; i++) {
737
+ const node = array[i];
738
+ if (!isMacro(node)) continue;
739
+ if (DEFINITION_MACROS.has(node.content)) {
740
+ // The parameters of a definition (`#1`) are not text; mark the subtree.
741
+ const end = hasArgs(node) ? i + 1 : takeArgs(array, i + 1);
742
+ for (let j = i; j < end; j++) markDefinition(array[j]);
743
+ }
744
+ }
745
+ });
746
+ walkArrays(root, (array, info) => {
747
+ const context = { inDefinition: Boolean(info.inDefinition) };
748
+ for (let i = 0; i < array.length; i++) {
749
+ const node = array[i];
750
+ if (!isMacro(node) || !TEXT_PATH_MACROS.has(node.content)) continue;
751
+ const pathArg = node.args?.find(argument => argument.closeMark === ';');
752
+ if (pathArg) repairPathTokens(pathArg.content, node.content === 'node', { ...context, inDefinition: context.inDefinition || Boolean(node._inDefinition) });
753
+ else {
754
+ const end = pathEnd(array, i + 1);
755
+ const tokens = array.slice(i + 1, end);
756
+ repairPathTokens(tokens, node.content === 'node', { ...context, inDefinition: context.inDefinition || Boolean(node._inDefinition) });
757
+ array.splice(i + 1, end - i - 1, ...tokens);
758
+ }
759
+ }
760
+ });
761
+ }
762
+
763
+ // ------------------------------------------------------------- body passes
764
+
765
+ /** `fill=#ff0000` → `fill=hexFF0000`; `{HTML}{#abc}` → `{HTML}{AABBCC}`. */
766
+ function rewriteHexColors(root) {
767
+ const expand = hex => (hex.length === 3 ? [...hex].map(c => c + c).join('') : hex).toUpperCase();
768
+ const hexOf = node => (isString(node) && /^(?:[0-9a-fA-F]{3}|[0-9a-fA-F]{6})$/.test(node.content) ? node.content : null);
769
+ walkArrays(root, array => {
770
+ for (let i = 0; i < array.length - 1; i++) {
771
+ if (!isString(array[i], '#')) continue;
772
+ const value = hexOf(array[i + 1]);
773
+ if (!value || (isString(array[i + 2]) && /^[0-9a-fA-F]/.test(array[i + 2].content))) continue;
774
+ array.splice(i, 2, str(`hex${expand(value)}`));
775
+ }
776
+ });
777
+ walkArrays(root, array => {
778
+ for (let i = 0; i < array.length - 1; i++) {
779
+ if (!isGroup(array[i]) || groupText(array[i]).toUpperCase() !== 'HTML') continue;
780
+ const next = array.slice(i + 1).find(node => !isSpace(node));
781
+ if (!isGroup(next)) continue;
782
+ const content = printRaw(next.content).trim().replace(/^#/, '').replace(/^hex/i, '');
783
+ if (/^(?:[0-9a-fA-F]{3}|[0-9a-fA-F]{6})$/.test(content)) next.content = [str(expand(content))];
784
+ }
785
+ });
786
+ }
787
+
788
+ /** `\providecolor` lines for every named or hex colour the source uses but does not define. */
789
+ function colorSupport(root, sourceText, defined, seen) {
790
+ walkArrays(root, array => {
791
+ for (const node of array) {
792
+ if (isMacro(node) && /^(?:definecolor|colorlet|providecolor|definecolorset)$/.test(node.content) && hasArgs(node)) {
793
+ const first = node.args.find(argument => argument.openMark === '{');
794
+ if (first) defined.add(printRaw(first.content).trim());
795
+ }
796
+ }
797
+ });
798
+ const lines = [];
799
+ const provide = (name, model, spec) => { if (!seen.has(name) && !defined.has(name)) { seen.add(name); lines.push(`\\providecolor{${name}}{${model}}{${spec}}`); } };
800
+ visitStrings(root, node => {
801
+ const token = node.content;
802
+ if (!/^[A-Za-z]{3,}$/.test(token) || T.BASE_COLORS.has(token)) return;
803
+ if (Object.hasOwn(T.DVIPS_COLORS, token)) provide(token, 'cmyk', T.DVIPS_COLORS[token]);
804
+ else { const hex = T.CSS_COLORS[token.toLowerCase()]; if (hex) provide(token, 'HTML', hex); }
805
+ });
806
+ for (const match of sourceText.matchAll(/\bhex([0-9A-F]{6})\b/g)) provide(`hex${match[1]}`, 'HTML', match[1]);
807
+ return lines;
808
+ }
809
+
810
+ /** Unicode declarations for the symbols used; unsupported glyphs are dropped from strings. */
811
+ function unicodeSupport(root, seen, declarations) {
812
+ visitStrings(root, node => {
813
+ if (!/[^\x00-\x7F]/.test(node.content)) return;
814
+ let cleaned = '';
815
+ for (const char of node.content) {
816
+ const code = char.codePointAt(0);
817
+ const replacement = T.UNICODE[char];
818
+ if (replacement) {
819
+ if (!seen.has(char)) { seen.add(char); declarations.push(`\\DeclareUnicodeCharacter{${code.toString(16).toUpperCase().padStart(4, '0')}}{${replacement}}`); }
820
+ cleaned += char;
821
+ } else if (code < 0x80 || T.isSupportedUnicode(char)) cleaned += char;
822
+ else cleaned += code > 0x2000 ? '' : '?'; // emoji, CJK, Cyrillic: no bundled font
823
+ }
824
+ node.content = cleaned;
825
+ });
826
+ }
827
+
828
+ /** `\ce{2H2 + O2 -> 2H2O}` → plain math, since mhchem is not bundled. */
829
+ function chemistryMath(formula) {
830
+ return formula
831
+ .replace(/<=>/g, '\\rightleftharpoons ').replace(/<->/g, '\\leftrightarrow ').replace(/->/g, '\\rightarrow ').replace(/<-/g, '\\leftarrow ')
832
+ .replace(/([A-Za-z\])])(\d+)/g, '$1_{$2}').replace(/\^(\d*[+-])/g, '^{$1}').replace(/(?<![\\\w])(\d+)([+-])(?=\s|$|\))/g, '$1^{$2}')
833
+ .replace(/\s*\+\s*/g, ' + ').replace(/\(\s*(s|l|g|aq)\s*\)/g, '(\\mathrm{$1})').replace(/\s{2,}/g, ' ').trim();
834
+ }
835
+
836
+ /** Replace macros that have no package here with something that typesets. */
837
+ function replaceUnavailableMacros(root, state) {
838
+ walkArrays(root, array => {
839
+ for (let i = 0; i < array.length; i++) {
840
+ const node = array[i];
841
+ if (!isMacro(node)) continue;
842
+ if (node.content === 'ce') { addPackage(state, 'mhchem', []); continue; }
843
+ if (/^fa[A-Z]/.test(node.content)) { state.unavailable.add('fontawesome'); array.splice(i, 1); i--; continue; }
844
+ if (node.content === 'includegraphics') {
845
+ const end = hasArgs(node) ? i + 1 : takeArgs(array, i + 1);
846
+ const file = (hasArgs(node) ? argText(node, node.args.length - 1) : groupText(array[end - 1])) || 'image';
847
+ array.splice(i, end - i, str(`\\fbox{\\strut\\texttt{${file.replace(/[\\{}#%&_^$~]/g, '')}}}`));
848
+ continue;
849
+ }
850
+ if (node.content === 'insertframenumber' || node.content === 'inserttotalframenumber') array[i] = str('1');
851
+ }
852
+ });
853
+ }
854
+
855
+ /**
856
+ * `ellipse (\a and \b)`: TeX drops the space after `\a`, so TikZ never sees
857
+ * its ` and ` delimiter and pgfmath chokes on `1.5and 0.7`. `{\a} and \b`
858
+ * keeps the delimiter visible.
859
+ */
860
+ function protectAndDelimiter(root) {
861
+ walkArrays(root, array => {
862
+ for (let i = 0; i + 2 < array.length; i++) {
863
+ const node = array[i];
864
+ if (!isMacro(node) || hasArgs(node) || !/^[A-Za-z@]+$/.test(node.content) || T.MACRO_SIGNATURES[node.content]) continue;
865
+ if (!isSpace(array[i + 1]) || !isString(array[i + 2]) || !/^and$/.test(array[i + 2].content)) continue;
866
+ array[i] = { type: 'group', content: [node] };
867
+ }
868
+ });
869
+ }
870
+
871
+ /** Macro-name typos models make, with the intended TeX. */
872
+ function replaceTypos(root) {
873
+ walkArrays(root, array => {
874
+ for (let i = 0; i < array.length; i++) {
875
+ const node = array[i];
876
+ if (!isMacro(node)) continue;
877
+ if ((node.content === '^' || node.content === '_') && hasArgs(node)) {
878
+ // `25^\circC` parsed as `^{\circC}`: only the first replacement token stays in the script.
879
+ const argument = node.args[node.args.length - 1];
880
+ const inner = argument.content.filter(child => !isSpace(child));
881
+ if (inner.length === 1 && isMacro(inner[0]) && T.MACRO_TYPOS[inner[0].content]) {
882
+ const [head, ...rest] = parseLatex(T.MACRO_TYPOS[inner[0].content], new Set()).content;
883
+ argument.content = [head];
884
+ array.splice(i + 1, 0, ...rest);
885
+ }
886
+ continue;
887
+ }
888
+ if (!T.MACRO_TYPOS[node.content] || hasArgs(node)) continue;
889
+ array.splice(i, 1, ...parseLatex(T.MACRO_TYPOS[node.content], new Set()).content);
85
890
  }
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);
891
+ });
892
+ }
893
+
894
+ /**
895
+ * `\foreach \x/\lab in {0.25/{\tfrac{1}{4}}, …}`: a label item that only
896
+ * works in math mode is wrapped in `\ensuremath`, which is right whether the
897
+ * variable later lands in a node text or inside `$…$`.
898
+ */
899
+ function repairForeachLists(root) {
900
+ walkArrays(root, array => {
901
+ for (let i = 0; i < array.length; i++) {
902
+ if (!isMacro(array[i], 'foreach')) continue;
903
+ let j = i + 1;
904
+ while (j < array.length && !isString(array[j], 'in') && !isGroup(array[j]) && !isMacro(array[j], 'foreach')) j++;
905
+ if (!isString(array[j], 'in')) continue;
906
+ j = skipSpace(array, j + 1);
907
+ if (!isGroup(array[j])) continue;
908
+ const list = array[j].content;
909
+ for (let k = 0; k < list.length; k++) {
910
+ const node = list[k];
911
+ if (isGroup(node)) { if (mathOnly(node.content)) node.content = [macro('ensuremath', [{ type: 'argument', openMark: '{', closeMark: '}', content: node.content }])]; continue; }
912
+ if (isMacro(node) && T.MATH_MACROS.has(node.content) && !hasArgs(node) && (k === 0 || isString(list[k - 1]) && /[,/]\s*$/.test(list[k - 1].content) || isSpace(list[k - 1]))) list[k] = macro('ensuremath', [{ type: 'argument', openMark: '{', closeMark: '}', content: [node] }]);
913
+ }
914
+ i = j;
915
+ }
916
+ });
917
+ }
918
+
919
+ // \boldmath expands unsafely while TikZ parses quoted labels. Move its effect
920
+ // into the actual math expression, keeping group scope and the displayed value.
921
+ function repairBoldMath(root) {
922
+ walkArrays(root, array => {
923
+ if (!array.some(node => node.type === 'inlinemath')) return;
924
+ let bold = false;
925
+ for (let i = 0; i < array.length; i++) {
926
+ if (isMacro(array[i], 'boldmath')) { bold = true; array.splice(i--, 1); continue; }
927
+ if (isMacro(array[i], 'unboldmath')) { bold = false; array.splice(i--, 1); continue; }
928
+ if (bold && array[i].type === 'inlinemath') array[i].content = [macro('boldsymbol', [{ type: 'argument', openMark: '{', closeMark: '}', content: array[i].content }])];
929
+ }
930
+ });
931
+ }
932
+ function mathOnly(nodes) {
933
+ if (nodes.some(node => node.type === 'inlinemath' || node.type === 'displaymath')) return false;
934
+ return needsMath(nodes.filter(node => !isSpace(node)));
935
+ }
936
+
937
+ /** `remember picture` / `overlay` only make sense inside a larger document. */
938
+ function stripOverlayKeys(root) {
939
+ for (const node of root) {
940
+ if (node.type !== 'environment' || node.env !== 'tikzpicture' || !Array.isArray(node.args)) continue;
941
+ for (const argument of node.args) {
942
+ if (argument.openMark !== '[') continue;
943
+ let items;
944
+ try { items = parsePgfkeys(argument.content); } catch { continue; }
945
+ const kept = items.filter(item => !/^(?:remember picture|overlay)$/.test(printRaw(item.itemParts?.[0] ?? []).trim()));
946
+ if (kept.length === items.length) continue;
947
+ argument.content = joinKeyItems(kept.map(item => keyItemNodes(item.itemParts ?? [])));
948
+ }
949
+ }
950
+ }
951
+
952
+ /** `\num{3,5}` → `3{,}5`, `\num{1e3}` → `1\times 10^{3}` inside siunitx-style numbers. */
953
+ function formatNumbers(root) {
954
+ walkArrays(root, array => {
955
+ for (const node of array) {
956
+ if (!isMacro(node) || !/^(?:SI|num|qty)$/.test(node.content) || !hasArgs(node)) continue;
957
+ const argument = node.args.find(candidate => candidate.openMark === '{');
958
+ if (!argument) continue;
959
+ const content = argument.content;
960
+ for (let i = 1; i < content.length - 1; i++) {
961
+ if (isString(content[i], ',') && isString(content[i - 1]) && /\d$/.test(content[i - 1].content) && isString(content[i + 1]) && /^\d/.test(content[i + 1].content)) content[i] = { type: 'group', content: [str(',')] };
962
+ }
963
+ for (const token of content) if (isString(token)) token.content = token.content.replace(/^(\d+(?:[.,]\d+)?)[eE](-?\d+)$/, '$1\\times 10^{$2}');
964
+ }
965
+ });
966
+ }
967
+
968
+ // ------------------------------------------------------------ main entry
969
+
970
+ /**
971
+ * Normalize any TikZ-ish input into one standalone LaTeX document.
972
+ * @returns {{ source: string, unavailable: string[], notes: string[] }}
973
+ */
974
+ export function prepareTikzDocument(code) {
975
+ const cleaned = recoverGeneratedSyntax(cleanInput(code));
976
+ if (!cleaned) throw new TikzSourceError('TikZ source is empty.');
977
+ const state = {
978
+ packages: new Map(), libraries: new Set(DEFAULT_LIBRARIES), pgfplotsLibraries: new Set(), gdLibraries: new Set(),
979
+ unavailable: new Set(), droppedLibraries: new Set(), shims: new Set(), statements: [], compat: false,
980
+ };
981
+
982
+ // 1. Structure: preamble statements and the body.
983
+ const excluded = definedMacroNames(cleaned);
984
+ const { preamble, body } = structure(cleaned, excluded);
985
+ if (!printRaw(body).trim()) throw new TikzSourceError('No picture found: add a \\begin{tikzpicture}…\\end{tikzpicture} block.');
986
+ for (const statement of preamble) absorbStatement(state, statement);
987
+
988
+ // 2. Reuse the parsed AST: wrapping paths does not require reparsing LaTeX.
989
+ const statementsRoot = parseLatex(state.statements.join('\n'), excluded).content;
990
+ const unicode = [];
991
+ const unicodeSeen = new Set();
992
+ const colorsDefined = new Set();
993
+ const colorsSeen = new Set();
994
+ const keyContext = { styleAliases: new Map() };
995
+ for (const root of [statementsRoot, body]) {
996
+ rewriteHexColors(root);
997
+ replaceUnavailableMacros(root, state);
998
+ processKeyLists(root, keyContext);
999
+ }
1000
+ stripOverlayKeys(body);
1001
+ protectAndDelimiter(body);
1002
+ repairForeachLists(body);
1003
+ replaceTypos(body);
1004
+ repairBoldMath(body);
1005
+ repairNodeTexts(body);
1006
+ // siunitx parses quantities itself; preserve its input grammar.
1007
+ unicodeSupport(body, unicodeSeen, unicode);
1008
+ unicodeSupport(statementsRoot, unicodeSeen, unicode);
1009
+ tightScripts(body);
1010
+ tightScripts(statementsRoot);
1011
+ const bodyOut = printRaw(body).trim();
1012
+ const statementsOut = printRaw(statementsRoot).trim();
1013
+ const everything = `${statementsOut}\n${bodyOut}`;
1014
+ const colors = [...colorSupport(statementsRoot, statementsOut, colorsDefined, colorsSeen), ...colorSupport(body, bodyOut, colorsDefined, colorsSeen)];
1015
+
1016
+ // 3. Usage-driven packages, libraries and shims.
1017
+ for (const [pattern, name] of T.AUTO_PACKAGES) if (pattern.test(everything)) addPackage(state, name, []);
1018
+ for (const [pattern, name] of T.AUTO_LIBRARIES) if (pattern.test(everything)) addLibrary(state, name);
1019
+ if (state.libraries.has('graphdrawing')) for (const [pattern, name] of T.AUTO_GD_LIBRARIES) if (pattern.test(everything)) state.gdLibraries.add(name);
1020
+ if (state.packages.has('pgfplots')) for (const [pattern, name] of T.AUTO_PGFPLOTS_LIBRARIES) if (pattern.test(everything)) state.pgfplotsLibraries.add(name);
1021
+ if (/\\(?:SI|si|num|qty|unit|ang|SIrange|qtyrange|numrange|SIlist|qtylist|numlist)\b/.test(everything)) addPackage(state, 'siunitx', []);
1022
+ if (/\\(?:dv|pdv|vb|va|vu|abs|norm|grad|curl|laplacian|order|eval|expval|ket|bra|braket|comm|dd)\b/.test(everything)) addPackage(state, 'physics', []);
1023
+ if (/\\up(?:alpha|beta|gamma|delta|epsilon|zeta|eta|theta|iota|kappa|lambda|mu|nu|xi|pi|rho|sigma|tau|upsilon|phi|chi|psi|omega)\b/i.test(everything)) state.shims.add('upgreek');
1024
+ if (/\\(?:text|math)(?:alpha|beta|gamma|delta|epsilon|zeta|eta|theta|iota|kappa|lambda|mu|nu|xi|pi|rho|sigma|tau|upsilon|phi|chi|psi|omega)\b/.test(everything)) state.shims.add('textgreek');
1025
+ const declarations = unicode.join('\n');
1026
+ const amsmath = T.NEEDS_AMSMATH.test(everything) || /\\tfrac/.test(declarations) || state.shims.size > 0;
1027
+ const amssymb = T.NEEDS_AMSSYMB.test(`${everything}\n${declarations}`);
1028
+
1029
+ // 4. Assemble. Latin Modern in T1 always: OT1 Computer Modern sends `\$`,
1030
+ // `\textdegree` and friends to the TS1 companion font, which has no Type 1
1031
+ // outlines in the bundle (dvisvgm: "no font file for tcrm1000").
1032
+ const lines = ['\\documentclass[border=6pt,varwidth]{standalone}', '\\usepackage{tikz}', '\\usepackage[T1]{fontenc}', '\\usepackage{lmodern}'];
1033
+ if (amsmath || amssymb) lines.push(`\\usepackage{${[amsmath && 'amsmath', amssymb && 'amssymb'].filter(Boolean).join(',')}}`);
1034
+ for (const [name, options] of state.packages) lines.push(options.length ? `\\usepackage[${options.join(',')}]{${name}}` : `\\usepackage{${name}}`);
1035
+ lines.push(`\\usetikzlibrary{${[...state.libraries].join(',')}}`);
1036
+ if (state.pgfplotsLibraries.size) lines.push(`\\usepgfplotslibrary{${[...state.pgfplotsLibraries].join(',')}}`);
1037
+ if (state.gdLibraries.size) lines.push(`\\usegdlibrary{${[...state.gdLibraries].join(',')}}`);
1038
+ if (state.packages.has('pgfplots') && !state.compat) lines.push('\\pgfplotsset{compat=1.18}');
1039
+ lines.push(...unicode, ...colors, T.COMPAT_SHIM);
1040
+ if (!excluded.has('pgfmathdistance') && /\\pgfmathdistance\b/.test(everything)) lines.push(String.raw`\makeatletter
1041
+ \providecommand{\pgfmathdistance}[2]{\pgfpointdiff{#1}{#2}\pgfmathveclen{\the\pgf@x}{\the\pgf@y}}
1042
+ \makeatother`);
1043
+ if (/\bparallelogram\b/.test(everything)) lines.push(String.raw`\pgfkeysifdefined{/tikz/parallelogram/.@cmd}{}{\tikzset{parallelogram/.style={shape=trapezium,trapezium left angle=70,trapezium right angle=110}}}`);
1044
+ lines.push(String.raw`\pgfkeysifdefined{/tikz/rounded cap/.@cmd}{}{\tikzset{rounded cap/.style={line cap=round}}}
1045
+ \pgfkeysifdefined{/tikz/final/.@cmd}{}{\tikzset{final/.style={accepting}}}`);
1046
+ if (state.shims.has('siunitx')) lines.push(T.siunitxShim());
1047
+ if (state.shims.has('physics')) lines.push(T.PHYSICS_SHIM);
1048
+ if (state.shims.has('upgreek')) lines.push(...T.GREEK_NAMES.map(name => `\\providecommand{\\up${name}}{\\ensuremath{\\${name}}}`));
1049
+ if (state.shims.has('textgreek')) lines.push(...T.GREEK_NAMES.map(name => `\\providecommand{\\text${name}}{\\ensuremath{\\${name}}}`));
1050
+ if (statementsOut) lines.push(statementsOut);
1051
+ lines.push('\\begin{document}', bodyOut, '\\end{document}');
1052
+ const notes = [];
1053
+ if (state.unavailable.size) notes.push(`Packages not available in this renderer were dropped: ${[...state.unavailable].join(', ')}.`);
1054
+ if (state.droppedLibraries.size) notes.push(`Unknown TikZ libraries were dropped: ${[...state.droppedLibraries].join(', ')}.`);
1055
+ return { source: lines.join('\n'), unavailable: [...state.unavailable], notes };
1056
+ }
1057
+
1058
+ /** Record one preamble statement: packages, libraries, declarations to keep. */
1059
+ function absorbStatement(state, nodes) {
1060
+ const first = nodes.find(node => !isSpace(node));
1061
+ if (!isMacro(first)) return;
1062
+ const name = first.content;
1063
+ const mandatory = first.args?.filter(argument => argument.openMark === '{') ?? [];
1064
+ const optional = first.args?.find(argument => argument.openMark === '[');
1065
+ const options = optional ? splitList(printRaw(optional.content)) : [];
1066
+ const lastGroup = nodes.filter(isGroup).at(-1);
1067
+ const list = index => splitList(mandatory.length > index ? printRaw(mandatory[index].content) : groupText(lastGroup));
1068
+ switch (name) {
1069
+ case 'usepackage':
1070
+ case 'RequirePackage':
1071
+ for (const pkg of list(mandatory.length - 1)) addPackage(state, pkg, options);
1072
+ return;
1073
+ case 'usetikzlibrary':
1074
+ case 'usepgflibrary':
1075
+ for (const library of list(0)) addLibrary(state, library);
1076
+ return;
1077
+ case 'usepgfplotslibrary':
1078
+ addPackage(state, 'pgfplots', []);
1079
+ for (const library of list(0)) { if (BUNDLED_PGFPLOTS_LIBRARIES.has(library)) state.pgfplotsLibraries.add(library); else state.droppedLibraries.add(`pgfplots/${library}`); }
1080
+ return;
1081
+ case 'usegdlibrary':
1082
+ addLibrary(state, 'graphdrawing');
1083
+ for (const library of list(0)) state.gdLibraries.add(library);
1084
+ return;
1085
+ case 'PassOptionsToPackage':
1086
+ if (mandatory.length === 2 && printRaw(mandatory[1].content).trim() === 'xcolor') addPackage(state, 'xcolor', splitList(printRaw(mandatory[0].content)));
1087
+ return;
1088
+ case 'pgfplotsset':
1089
+ addPackage(state, 'pgfplots', []);
1090
+ if (/compat\s*=/.test(printRaw(nodes))) state.compat = true;
1091
+ break;
1092
+ default:
1093
+ break;
90
1094
  }
91
- return text;
1095
+ if (T.DROP_PREAMBLE.has(name) || T.HOISTED_COMMANDS.has(name)) return;
1096
+ state.statements.push(printRaw(nodes));
92
1097
  }
93
1098
 
94
- /** Everything the TeX engine receives: colour-safe keys inside a complete document. */
1099
+ function addLibrary(state, name) {
1100
+ if (name === 'external') return;
1101
+ if (BUNDLED_TIKZ_LIBRARIES.has(name)) state.libraries.add(name);
1102
+ else state.droppedLibraries.add(name);
1103
+ }
1104
+
1105
+ function addPackage(state, name, options) {
1106
+ name = name.trim();
1107
+ if (!name) return;
1108
+ if (name === 'xcolor' || name === 'color') {
1109
+ const current = state.packages.get('xcolor') ?? [];
1110
+ state.packages.set('xcolor', [...new Set([...current, ...options.filter(option => option === 'dvipsnames')])]);
1111
+ return;
1112
+ }
1113
+ if (name === 'tkz-euklide') name = 'tkz-euclide';
1114
+ if (name === 'mathrsfs') { state.packages.set('eucal', ['mathscr']); return; }
1115
+ if (name === 'eucal') { state.packages.set('eucal', options.length ? options : ['mathscr']); return; }
1116
+ if (name === 'hyperref') { addPackage(state, 'url', []); return; }
1117
+ if (name === 'circuitikz') { if (!state.packages.has(name)) state.packages.set(name, options.filter(option => option !== 'siunitx')); return; }
1118
+ if (name === 'pgfplots') { if (!state.packages.has(name)) state.packages.set(name, options); return; }
1119
+ if (T.SHIM_PACKAGES.has(name)) { state.shims.add(name); return; }
1120
+ if (T.DROP_PACKAGES.has(name)) return;
1121
+ if (BUNDLED_PACKAGES.has(name)) { if (!state.packages.has(name)) state.packages.set(name, options); return; }
1122
+ state.unavailable.add(name);
1123
+ }
1124
+
1125
+ /** Everything the TeX engine receives, as a string (see prepareTikzDocument). */
95
1126
  export function prepareTikzSource(code) {
96
- return wrapStandalone(hoistFontColors(code));
1127
+ return prepareTikzDocument(code).source;
1128
+ }
1129
+
1130
+ /** Backwards-compatible alias. */
1131
+ export const wrapStandalone = prepareTikzSource;
1132
+
1133
+ /** Test hook: the font-colour and key-list rewrite applied to a fragment. */
1134
+ export function hoistFontColors(fragment) {
1135
+ const root = parseLatex(fragment).content;
1136
+ processKeyLists(root, {});
1137
+ tightScripts(root);
1138
+ return printRaw(root);
1139
+ }
1140
+
1141
+ /** Test hook: node-text repairs applied to a fragment. */
1142
+ export function repairNodeText(fragment) {
1143
+ const root = parseLatex(fragment).content;
1144
+ repairTextNodes(root, {});
1145
+ tightScripts(root);
1146
+ return printRaw(root);
1147
+ }
1148
+
1149
+ // --------------------------------------------------------- failure report
1150
+
1151
+ const UNDEFINED_HINTS = [
1152
+ [/^\\tkz/, 'check the tkz-euclide command name and arguments'],
1153
+ [/^\\(?:fa[A-Z]|faicon)/, 'fontawesome icons are not available'],
1154
+ [/^\\(?:tcb|begin\{tcolorbox)/, 'tcolorbox is not available'],
1155
+ [/^\\insert(?:frame|total)/, 'beamer commands do not work outside beamer'],
1156
+ [/^\\(?:addplot|axis|pgfplots)/, 'load pgfplots with \\usepackage{pgfplots}'],
1157
+ [/^\\(?:tdplot)/, 'load tikz-3dplot with \\usepackage{tikz-3dplot}'],
1158
+ [/^\\(?:chemfig|schemestart|lewis)/, 'load chemfig with \\usepackage{chemfig}'],
1159
+ [/^\\(?:Tree|forest)/, 'check the tikz-qtree/forest command name and arguments'],
1160
+ [/^\\(?:smartdiagram)/, 'check the smartdiagram command name and arguments'],
1161
+ [/^\\(?:pie)\b/, 'check the pgf-pie command name and arguments'],
1162
+ [/^\\(?:ce)\b/, 'check mhchem chemical formula syntax'],
1163
+ [/^\\(?:SI|si|num|qty)\b/, 'check siunitx quantity syntax'],
1164
+ [/^\\(?:text|math)(?:bb|cal|frak|scr|rm|bf|it)/, 'the command needs math mode'],
1165
+ ];
1166
+
1167
+ function describeUndefined(name, unavailable) {
1168
+ for (const [pattern, hint] of UNDEFINED_HINTS) if (pattern.test(name)) return `Undefined command ${name}: ${hint}`;
1169
+ if (unavailable.length) return `Undefined command ${name} (packages not available here: ${unavailable.join(', ')})`;
1170
+ return `Undefined command ${name}`;
1171
+ }
1172
+
1173
+ /**
1174
+ * One short, actionable line for a failed compilation.
1175
+ * @param {{ diagnostics?: any[], pages?: string[], texLog?: string, log?: string }} result
1176
+ */
1177
+ export function describeTikzFailure(result, { unavailable = [] } = {}) {
1178
+ const diagnostics = (result?.diagnostics ?? []).filter(item => item && item.severity === 'error');
1179
+ const pages = result?.pages?.length ?? 0;
1180
+ const log = String(result?.texLog ?? result?.log ?? '');
1181
+ const messages = [];
1182
+ const push = text => { if (text && !messages.includes(text)) messages.push(text); };
1183
+
1184
+ // TeX log: "! message" followed (eventually) by "l.<n> <context>".
1185
+ const lines = log.split('\n');
1186
+ const seenNames = new Set();
1187
+ for (let i = 0; i < lines.length && messages.length < 4; i++) {
1188
+ const line = lines[i];
1189
+ if (!line.startsWith('! ')) continue;
1190
+ const message = line.slice(2).trim();
1191
+ let context = '';
1192
+ for (let j = i + 1; j < Math.min(lines.length, i + 12); j++) {
1193
+ const at = /^l\.(\d+) (.*)$/.exec(lines[j]);
1194
+ if (at) { context = at[2]; break; }
1195
+ }
1196
+ const near = context ? ` near "${context.replace(/^\.\.\./, '').trim().slice(-70)}"` : '';
1197
+ if (/^Undefined control sequence/.test(message)) {
1198
+ const name = /(\\[A-Za-z@]+)\s*$/.exec(context)?.[1] ?? '';
1199
+ if (name && seenNames.has(name)) continue;
1200
+ if (name) seenNames.add(name);
1201
+ push(describeUndefined(name || '(unknown)', unavailable) + (name ? '' : near));
1202
+ } else if (/File `(.+?)' not found/.test(message)) {
1203
+ const file = /File `(.+?)' not found/.exec(message)[1];
1204
+ push(/\.(?:sty|cls)$/.test(file) ? `Package ${file.replace(/\.(?:sty|cls)$/, '')} is not available in this renderer` : `External file ${file} is not available (no files can be read)`);
1205
+ } else if (/Environment (\S+) undefined/.test(message)) {
1206
+ const env = /Environment (\S+) undefined/.exec(message)[1];
1207
+ const hint = /axis|plot/.test(env) ? ' (load pgfplots)' : /tikzcd/.test(env) ? ' (load tikz-cd)' : /circuitikz/.test(env) ? ' (load circuitikz)' : '';
1208
+ push(`Environment ${env} is undefined${hint}`);
1209
+ } else if (/I do not know the key '([^']+)'/.test(message)) {
1210
+ const key = /I do not know the key '([^']+)'/.exec(message)[1].replace(/^\/tikz\//, '');
1211
+ push(/^[A-Za-z][A-Za-z0-9]*(?:![\w!]*)?$/.test(key) ? `Unknown key '${key}': if it is a colour, define it with \\definecolor` : `Unknown TikZ key '${key}'${near}`);
1212
+ } else if (/Undefined color `([^']+)'/.test(message)) {
1213
+ push(`Undefined colour '${/Undefined color `([^']+)'/.exec(message)[1]}': define it with \\definecolor or use a standard name`);
1214
+ } else if (/Missing \$ inserted|Display math should end|Bad math environment/.test(message)) {
1215
+ push(`Math syntax (_ ^ \\frac …) used outside $…$${near}`);
1216
+ } else if (/Missing \} inserted|Extra \}|Missing \\endgroup|Runaway argument|Paragraph ended before|Missing \{ inserted|File ended while scanning/.test(message)) {
1217
+ push(`Unbalanced braces or an unterminated argument${near}`);
1218
+ } else if (/Giving up on this path/.test(message)) {
1219
+ push(`A path is missing its closing semicolon${near}`);
1220
+ } else if (/Unicode character (.+?) \(U\+([0-9A-F]+)\)/.test(message)) {
1221
+ const [, char, code] = /Unicode character (.+?) \(U\+([0-9A-F]+)\)/.exec(message);
1222
+ push(`Character ${char} (U+${code}) has no glyph here; use the TeX command instead`);
1223
+ } else if (/I did not find the tikz library '([^']+)'/.test(message)) {
1224
+ push(`TikZ library '${/I did not find the tikz library '([^']+)'/.exec(message)[1]}' is not available`);
1225
+ } else if (/Can be used only in preamble/.test(message)) {
1226
+ push(`A preamble command appears in the body${near}`);
1227
+ } else if (/Emergency stop|==> Fatal error|Interruption|cannot \\read|Output written/.test(message)) {
1228
+ continue;
1229
+ } else {
1230
+ push(`${message.replace(/^(?:LaTeX|Package \w+) Error: /, '').replace(/\.$/, '')}${near}`);
1231
+ }
1232
+ }
1233
+ if (!messages.length) {
1234
+ for (const item of diagnostics) {
1235
+ const text = String(item.message ?? '').trim();
1236
+ if (!text || /Emergency stop|Fatal error/.test(text)) continue;
1237
+ if (/missing closing tag/.test(text)) push('SVG conversion failed: a colour or font switch was left open inside the picture');
1238
+ else if (item.source === 'host' || item.source === 'dvisvgm') push(`SVG conversion failed: ${text.replace(/\.$/, '')}`);
1239
+ else push(text.replace(/^(?:LaTeX|Package \w+) Error: /, '').replace(/\.$/, ''));
1240
+ if (messages.length >= 3) break;
1241
+ }
1242
+ }
1243
+ if (!messages.length) {
1244
+ if (pages === 0) push('The document produced no page: is there a picture inside it?');
1245
+ else if (pages > 1) push(`The document produced ${pages} pages; keep one picture`);
1246
+ else push('TeX reported an error');
1247
+ }
1248
+ return `TikZ error: ${messages.slice(0, 3).join('; ')}.`;
97
1249
  }