rapier-math 0.0.0-stage → 1.0.2

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.
@@ -0,0 +1,1064 @@
1
+ // SPDX-License-Identifier: MIT
2
+ // TeX is tokenized before parsing. Expansions use a stack, never executable code.
3
+ class MathTexError extends Error {
4
+ constructor(code, message, offset = 0, command = '') {
5
+ super(message);
6
+ this.name = 'MathTexError';
7
+ this.code = code;
8
+ this.offset = offset;
9
+ this.command = command;
10
+ }
11
+ }
12
+
13
+ function mathTexTokens(source, offset = 0) {
14
+ const tokens = [];
15
+ for (let i = 0; i < source.length;) {
16
+ const start = i, value = String.fromCodePoint(source.codePointAt(i));
17
+ i += value.length;
18
+ if (value === '%') {
19
+ while (i < source.length && source[i] !== '\n' && source[i] !== '\r') i++;
20
+ if (source[i] === '\r') i++;
21
+ if (source[i] === '\n') i++;
22
+ continue;
23
+ }
24
+ if (value === '\\') {
25
+ if (i === source.length) throw new MathTexError('unfinished-command', 'A command is missing after the backslash.', offset + start);
26
+ let command = '';
27
+ if (/[A-Za-z@]/.test(source[i])) {
28
+ while (i < source.length && /[A-Za-z@]/.test(source[i])) command += source[i++];
29
+ tokens.push({kind: 'command', value: command, offset: offset + start});
30
+ while (i < source.length && /\s/.test(source[i])) i++;
31
+ } else {
32
+ command = String.fromCodePoint(source.codePointAt(i));
33
+ i += command.length;
34
+ tokens.push({kind: 'command', value: /\s/.test(command) ? ' ' : command, offset: offset + start});
35
+ }
36
+ } else if (/\s/.test(value)) {
37
+ while (i < source.length && /\s/.test(source[i])) i++;
38
+ tokens.push({kind: 'space', value: ' ', offset: offset + start});
39
+ } else tokens.push({kind: 'char', value, offset: offset + start});
40
+ if (tokens.length > 100000)
41
+ throw new MathTexError('resource-limit', 'This formula has too many tokens to render safely.', offset + start);
42
+ }
43
+ return tokens;
44
+ }
45
+
46
+ const MATH_TEX_VARIANTS = Object.freeze({
47
+ mathrm: 'roman', mathnormal: 'normal', mathit: 'italic', mathbf: 'bold', boldsymbol: 'bold-italic', bm: 'bold-italic',
48
+ mathbb: 'double-struck', mathbbm: 'double-struck', mathbbmss: 'double-struck', mathbbmtt: 'double-struck', mathds: 'double-struck',
49
+ Bbb: 'double-struck', mathcal: 'script', mathscr: 'script', mathfrak: 'fraktur', frak: 'fraktur',
50
+ mathsf: 'sans-serif', mathss: 'sans-serif', mathtt: 'monospace', mathbfit: 'bold-italic',
51
+ mathbfsf: 'sans-serif-bold', mathsfit: 'sans-serif-italic', mathbfsfit: 'sans-serif-bold-italic',
52
+ mathbfscr: 'bold-script', mathbffrak: 'bold-fraktur', bold: 'bold'
53
+ });
54
+ const MATH_TEX_TEXT_VARIANTS = Object.freeze({
55
+ text: 'roman', textrm: 'roman', textnormal: 'roman', mbox: 'roman', hbox: 'roman', textup: 'roman',
56
+ textbf: 'bold', textit: 'italic', emph: 'italic', textsl: 'italic', textsf: 'sans-serif', texttt: 'monospace',
57
+ textsc: 'roman'
58
+ });
59
+ const MATH_TEX_DECLARATIONS = Object.freeze({
60
+ rm: {variant: 'roman'}, rmfamily: {variant: 'roman'}, normalfont: {variant: 'roman'},
61
+ it: {variant: 'italic'}, itshape: {variant: 'italic'}, sl: {variant: 'italic'},
62
+ bf: {variant: 'bold'}, bfseries: {variant: 'bold'}, sf: {variant: 'sans-serif'}, sffamily: {variant: 'sans-serif'},
63
+ tt: {variant: 'monospace'}, ttfamily: {variant: 'monospace'}, cal: {variant: 'script'},
64
+ displaystyle: {mathStyle: 'display'}, textstyle: {mathStyle: 'text'}, scriptstyle: {mathStyle: 'script'}, scriptscriptstyle: {mathStyle: 'scriptscript'},
65
+ tiny: {absoluteSize: .5}, scriptsize: {absoluteSize: .7}, footnotesize: {absoluteSize: .8}, small: {absoluteSize: .9}, normalsize: {absoluteSize: 1},
66
+ large: {absoluteSize: 1.2}, Large: {absoluteSize: 1.44}, LARGE: {absoluteSize: 1.728}, huge: {absoluteSize: 2.074}, Huge: {absoluteSize: 2.488}
67
+ });
68
+ const MATH_TEX_ACCENTS = Object.freeze({
69
+ hat: ['̂', 'over'], widehat: ['̂', 'over', true], check: ['̌', 'over'], widecheck: ['̌', 'over', true],
70
+ tilde: ['̃', 'over'], widetilde: ['̃', 'over', true], acute: ['́', 'over'], grave: ['̀', 'over'],
71
+ breve: ['̆', 'over'], bar: ['̄', 'over'], vec: ['⃗', 'over'], dot: ['̇', 'over'], ddot: ['̈', 'over'],
72
+ dddot: ['⃛', 'over'], ddddot: ['⃜', 'over'], mathring: ['̊', 'over'], ring: ['̊', 'over'],
73
+ overline: ['¯', 'over', true], underline: ['_', 'under', true], overbrace: ['⏞', 'over', true], underbrace: ['⏟', 'under', true],
74
+ overparen: ['⏜', 'over', true], underparen: ['⏝', 'under', true], overbracket: ['⎴', 'over', true], underbracket: ['⎵', 'under', true],
75
+ overrightarrow: ['→', 'over', true], overleftarrow: ['←', 'over', true], overleftrightarrow: ['↔', 'over', true],
76
+ underrightarrow: ['→', 'under', true], underleftarrow: ['←', 'under', true], underleftrightarrow: ['↔', 'under', true],
77
+ overleftharpoon: ['↼', 'over', true], overrightharpoon: ['⇀', 'over', true], utilde: ['̃', 'under', true]
78
+ });
79
+ const MATH_TEX_LONG_ARROWS = {iff: 'Longleftrightarrow', implies: 'Longrightarrow', impliedby: 'Longleftarrow'};
80
+ const MATH_TEX_SPACES = Object.freeze({
81
+ ',': 3 / 18, ':': 4 / 18, ';': 5 / 18, '!': -3 / 18, ' ': 1 / 3,
82
+ quad: 1, qquad: 2, enspace: .5, enskip: .5, thinspace: 3 / 18, medspace: 4 / 18, thickspace: 5 / 18,
83
+ negthinspace: -3 / 18, negmedspace: -4 / 18, negthickspace: -5 / 18
84
+ });
85
+ const MATH_TEX_PACKAGES = new Set(('ams amsmath amssymb amsfonts mathtools color newcommand unicode mhchem physics bbm bboldx dsfont cancel bbox boldsymbol').split(' '));
86
+ const MATH_TEX_ENVIRONMENTS = new Set(('matrix pmatrix bmatrix Bmatrix vmatrix Vmatrix smallmatrix psmallmatrix bsmallmatrix Bsmallmatrix vsmallmatrix Vsmallmatrix array aligned alignedat align alignat flalign gathered gather split cases dcases rcases drcases subarray substack eqnarray multline equation').split(' '));
87
+ const MATH_TEX_LITERAL_CLASSES = Object.freeze({
88
+ '+': 'bin', '−': 'bin', '-': 'bin', '±': 'bin', '∓': 'bin', '×': 'bin', '÷': 'bin', '*': 'bin', '⋅': 'bin',
89
+ '=': 'rel', '<': 'rel', '>': 'rel', '≤': 'rel', '≥': 'rel', '≠': 'rel', ':': 'rel',
90
+ '(': 'open', '[': 'open', '⟨': 'open', ')': 'close', ']': 'close', '⟩': 'close', ',': 'punct', ';': 'punct',
91
+ '∑': 'op', '∏': 'op', '∫': 'op', '∮': 'op', '∬': 'op', '∭': 'op'
92
+ });
93
+
94
+ function mathTexTokenText(tokens) {
95
+ return tokens.map(token => token.kind === 'command' ? '\\' + token.value + (/^[A-Za-z@]+$/.test(token.value) ? ' ' : '') : token.value).join('');
96
+ }
97
+ function mathTexTokenEqual(a, b) { return !!a && !!b && a.kind === b.kind && a.value === b.value; }
98
+
99
+ class MathTexParser {
100
+ constructor(source, options = {}) {
101
+ this.source = source;
102
+ this.frames = [{tokens: options.tokens || mathTexTokens(source), at: 0}];
103
+ this.scopes = options.scopes || [Object.create(null)];
104
+ this.budget = options.budget || {nodes: 0, expansions: 0, expandedTokens: 0, depth: 0};
105
+ this.fenceDepth = options.fenceDepth || 0;
106
+ this.colors = options.colors || Object.create(null);
107
+ this.eof = {kind: 'eof', value: '', offset: source.length};
108
+ }
109
+ error(code, message, token = this.rawPeek(), command) {
110
+ throw new MathTexError(code, message, token.offset, command === undefined && token.kind === 'command' ? token.value : command || '');
111
+ }
112
+ node(type, fields = {}) {
113
+ this.reserveParts(1);
114
+ return {type, ...fields};
115
+ }
116
+ reserveParts(count, token = this.rawPeek()) {
117
+ if (!Number.isSafeInteger(count) || count < 0 || this.budget.nodes + count > 50000)
118
+ this.error('resource-limit', 'This formula has too many parts to render safely.', token);
119
+ this.budget.nodes += count;
120
+ }
121
+ row(children) { return this.node('row', {children: children.filter(Boolean)}); }
122
+ rawPeek(skipSpace = true) {
123
+ while (this.frames.length) {
124
+ const frame = this.frames[this.frames.length - 1];
125
+ if (frame.at >= frame.tokens.length) { this.frames.pop(); continue; }
126
+ const token = frame.tokens[frame.at];
127
+ if (skipSpace && token.kind === 'space') { frame.at++; continue; }
128
+ return token;
129
+ }
130
+ return this.eof;
131
+ }
132
+ rawTake(skipSpace = true) {
133
+ const token = this.rawPeek(skipSpace);
134
+ if (token.kind !== 'eof') this.frames[this.frames.length - 1].at++;
135
+ return token;
136
+ }
137
+ macro(name) {
138
+ for (let i = this.scopes.length - 1; i >= 0; i--)
139
+ if (Object.hasOwn(this.scopes[i], name)) return this.scopes[i][name];
140
+ return null;
141
+ }
142
+ peek(skipSpace = true) {
143
+ for (;;) {
144
+ const token = this.rawPeek(skipSpace);
145
+ const macro = token.kind === 'command' && !token.builtin && this.macro(token.value);
146
+ if (!macro) return token;
147
+ this.rawTake(skipSpace);
148
+ this.expand(macro, token);
149
+ }
150
+ }
151
+ take(skipSpace = true) {
152
+ const token = this.peek(skipSpace);
153
+ return token.kind === 'eof' ? token : this.rawTake(skipSpace);
154
+ }
155
+ is(value) { return this.peek().value === value && this.peek().kind === 'char'; }
156
+ commandIs(value) { const token = this.peek(); return token.kind === 'command' && token.value === value; }
157
+ expect(value, kind = 'char') {
158
+ const token = this.take();
159
+ if (token.kind !== kind || token.value !== value) this.error('unexpected-token', 'Expected ' + (kind === 'command' ? '\\' : '') + value + '.', token);
160
+ return token;
161
+ }
162
+ nested(fn) {
163
+ if (++this.budget.depth > 192) this.error('resource-limit', 'This formula is nested too deeply to render safely.');
164
+ this.scopes.push(Object.create(null));
165
+ try { return fn(); }
166
+ finally { this.scopes.pop(); this.budget.depth--; }
167
+ }
168
+ parse() {
169
+ const result = this.parseRow(() => false);
170
+ if (this.peek().kind !== 'eof') this.error('unexpected-token', 'Unexpected content after the formula.');
171
+ return result;
172
+ }
173
+ parseRow(stop, allowFraction = true) {
174
+ const children = [];
175
+ for (;;) {
176
+ const token = this.peek();
177
+ if (token.kind === 'eof' || stop(token)) break;
178
+ if (token.kind === 'command' && (MATH_TEX_DECLARATIONS[token.value] || token.value === 'color')) {
179
+ this.take();
180
+ const fields = token.value === 'color' ? {color: this.readColor()} : MATH_TEX_DECLARATIONS[token.value];
181
+ children.push(this.node('style', {...fields, body: this.nested(() => this.parseRow(stop, allowFraction))}));
182
+ break;
183
+ }
184
+ if (token.kind === 'command' && /^(over|atop|choose|brace|brack|above|overwithdelims|atopwithdelims|abovewithdelims)$/.test(token.value)) {
185
+ if (!allowFraction) this.error('ambiguous-fraction', 'Put each infix fraction in its own group.', token);
186
+ this.take();
187
+ const name = token.value, fields = {num: this.row(children), den: null, bar: !/atop|choose|brace|brack/.test(name)};
188
+ if (name.includes('withdelims')) { fields.left = this.readDelimiter(); fields.right = this.readDelimiter(); }
189
+ else if (name === 'choose') { fields.left = '('; fields.right = ')'; }
190
+ else if (name === 'brace') { fields.left = '{'; fields.right = '}'; }
191
+ else if (name === 'brack') { fields.left = '['; fields.right = ']'; }
192
+ if (name.startsWith('above')) { fields.barThickness = this.readDimension(); fields.bar = fields.barThickness !== 0; }
193
+ fields.den = this.parseRow(stop, false);
194
+ return this.row([this.node('frac', fields)]);
195
+ }
196
+ if (token.kind === 'char' && (token.value === '^' || token.value === '_' || token.value === "'")) {
197
+ let base = children.pop() || this.row([]);
198
+ children.push(this.readScripts(base));
199
+ continue;
200
+ }
201
+ if (token.kind === 'command' && /^(limits|nolimits|displaylimits)$/.test(token.value)) {
202
+ this.take();
203
+ const base = children[children.length - 1];
204
+ if (!base) this.error('missing-operator', '\\' + token.value + ' needs an operator.', token);
205
+ base.limits = token.value === 'limits' ? 'always' : token.value === 'nolimits' ? 'never' : 'display';
206
+ continue;
207
+ }
208
+ const atom = this.parseAtom();
209
+ if (atom) children.push(this.readScripts(atom));
210
+ }
211
+ return this.row(children);
212
+ }
213
+ readScripts(base) {
214
+ let result = base;
215
+ for (;;) {
216
+ const token = this.peek();
217
+ if (token.kind === 'command' && /^(limits|nolimits|displaylimits)$/.test(token.value)) {
218
+ this.take();
219
+ result.limits = token.value === 'limits' ? 'always' : token.value === 'nolimits' ? 'never' : 'display';
220
+ continue;
221
+ }
222
+ if (token.kind !== 'char' || !['_', '^', "'"].includes(token.value)) break;
223
+ this.take();
224
+ if (result.type !== 'scripts') result = this.node('scripts', {base: result, limits: result.limits});
225
+ const key = token.value === '_' ? 'sub' : 'sup';
226
+ if (token.value === "'") {
227
+ const primes = [this.node('glyph', {value: '′', class: 'ord', variant: 'roman'})];
228
+ while (this.is("'")) { this.take(); primes.push(this.node('glyph', {value: '′', class: 'ord', variant: 'roman'})); }
229
+ if (this.is('^')) { this.take(); primes.push(this.argument()); }
230
+ if (result.sup) this.error('duplicate-script', 'A base cannot have two superscripts.', token);
231
+ result.sup = this.row(primes);
232
+ } else {
233
+ if (result[key]) this.error('duplicate-script', 'A base cannot have two ' + (key === 'sub' ? 'subscripts' : 'superscripts') + '.', token);
234
+ result[key] = this.argument();
235
+ }
236
+ }
237
+ return result;
238
+ }
239
+ argument() {
240
+ if (++this.budget.depth > 192) this.error('resource-limit', 'This formula is nested too deeply to render safely.');
241
+ try { return this.argumentValue(); }
242
+ finally { this.budget.depth--; }
243
+ }
244
+ argumentValue() {
245
+ const token = this.peek();
246
+ if (token.kind === 'eof' || (token.kind === 'char' && ['}', '^', '_', '&'].includes(token.value)))
247
+ this.error('missing-argument', 'A mathematical argument is missing.', token);
248
+ if (this.is('{')) {
249
+ this.take();
250
+ return this.nested(() => {
251
+ const body = this.parseRow(item => item.kind === 'char' && item.value === '}');
252
+ this.expect('}');
253
+ return body;
254
+ });
255
+ }
256
+ const atom = this.parseAtom();
257
+ if (!atom) this.error('missing-argument', 'An argument must contain a mathematical expression.', token);
258
+ return atom;
259
+ }
260
+ rawArgument() {
261
+ const first = this.rawTake();
262
+ if (first.kind === 'eof') this.error('missing-argument', 'A command argument is missing.', first);
263
+ if (first.kind !== 'char' || first.value !== '{') return [first];
264
+ const result = [];
265
+ let depth = 1;
266
+ while (depth) {
267
+ const token = this.rawTake(false);
268
+ if (token.kind === 'eof') this.error('unclosed-group', 'A closing brace is missing.', first);
269
+ if (token.kind === 'char' && token.value === '{') depth++;
270
+ if (token.kind === 'char' && token.value === '}') depth--;
271
+ if (depth > 192) this.error('resource-limit', 'This argument is nested too deeply.', token);
272
+ if (depth) result.push(token);
273
+ }
274
+ return result;
275
+ }
276
+ optionalRaw() {
277
+ if (this.rawPeek().kind !== 'char' || this.rawPeek().value !== '[') return null;
278
+ const start = this.rawTake(), result = [];
279
+ let braces = 0;
280
+ for (;;) {
281
+ const token = this.rawTake(false);
282
+ if (token.kind === 'eof') this.error('unclosed-option', 'A closing bracket is missing.', start);
283
+ if (token.kind === 'char') {
284
+ if (token.value === ']' && !braces) break;
285
+ if (token.value === '{') braces++;
286
+ if (token.value === '}') braces--;
287
+ if (braces < 0) this.error('unclosed-option', 'An option ended before its closing bracket.', token);
288
+ }
289
+ result.push(token);
290
+ }
291
+ return result;
292
+ }
293
+ subparse(tokens, text = false, variant = 'roman') {
294
+ return this.nested(() => {
295
+ const parser = new MathTexParser(this.source, {tokens, scopes: this.scopes, budget: this.budget, fenceDepth: this.fenceDepth, colors: this.colors});
296
+ return text ? parser.parseText(variant) : parser.parse();
297
+ });
298
+ }
299
+ plainArgument() {
300
+ return mathTexTokenText(this.rawArgument()).trim();
301
+ }
302
+ literal(value, fields = {}) {
303
+ return this.node('glyph', {value: value === '-' ? '−' : value === '*' ? '∗' : value, class: MATH_TEX_LITERAL_CLASSES[value] || 'ord', ...fields});
304
+ }
305
+ parseAtom() {
306
+ const token = this.take();
307
+ if (token.kind === 'eof') this.error('missing-argument', 'A mathematical expression is missing.', token);
308
+ if (token.kind === 'command') return this.command(token);
309
+ if (token.value === '{') {
310
+ return this.nested(() => {
311
+ const body = this.parseRow(item => item.kind === 'char' && item.value === '}');
312
+ this.expect('}');
313
+ return body;
314
+ });
315
+ }
316
+ if (token.value === '}') this.error('unexpected-brace', 'A closing brace has no opening brace.', token);
317
+ if (token.value === '&') this.error('unexpected-alignment', 'An alignment tab needs a matrix or aligned environment.', token);
318
+ if (token.value === '#') this.error('unexpected-parameter', 'A macro parameter is outside a macro definition.', token);
319
+ if (token.value === '$') this.error('unexpected-delimiter', 'A math delimiter is inside the formula.', token);
320
+ if (token.value === '~') return this.node('space', {width: 1 / 3});
321
+ if (/^[\u0000-\u001f\u007f]$/.test(token.value)) this.error('invalid-character', 'A control character cannot be typeset.', token);
322
+ return this.literal(token.value, /[∑∏∫∮∬∭]/.test(token.value) ? {large: true, limits: /[∫∮∬∭]/.test(token.value) ? 'never' : 'display'} : {});
323
+ }
324
+ readDelimiter() {
325
+ const token = this.take();
326
+ if (token.kind === 'command') {
327
+ if (token.value === '{' || token.value === '}') return token.value;
328
+ if (token.value === '|') return '‖';
329
+ const symbol = MATH_SYMBOLS[token.value];
330
+ if (symbol && (['open', 'close'].includes(symbol.class) || /^(vert|Vert|backslash|slash|[uUdD].*arrow)$/.test(token.value))) return symbol.value;
331
+ }
332
+ if (token.kind === 'char' && '.()[]{}|/<>‖⌈⌉⌊⌋⟨⟩↑↓↕⇑⇓⇕'.includes(token.value))
333
+ return token.value === '.' ? '' : token.value === '<' ? '⟨' : token.value === '>' ? '⟩' : token.value;
334
+ this.error('invalid-delimiter', 'Expected a mathematical delimiter.', token);
335
+ }
336
+ command(token) {
337
+ const name = token.value;
338
+ if (Object.hasOwn(MATH_TEX_SPACES, name)) return this.node('space', {width: MATH_TEX_SPACES[name]});
339
+ // amsmath: \iff, \implies and \impliedby are a long double arrow between thick spaces, one relation.
340
+ if (Object.hasOwn(MATH_TEX_LONG_ARROWS, name)) return this.node('style', {class: 'rel', body: this.row([this.node('space', {width: 5 / 18}), this.node('glyph', MATH_SYMBOLS[MATH_TEX_LONG_ARROWS[name]]), this.node('space', {width: 5 / 18})])});
341
+ // amsmath \dots: centred dots before a binary operator, a relation or an integral, low dots otherwise.
342
+ if (name === 'dots') {
343
+ const next = this.rawPeek(), after = next.kind === 'command' ? MATH_SYMBOLS[next.value] : null;
344
+ const centred = next.kind === 'char' ? '+-=<>*:'.includes(next.value) : !!after && (['bin', 'rel'].includes(after.class) || /^(?:int|iint|iiint|oint)$/.test(next.value));
345
+ if (centred) return this.node('glyph', MATH_SYMBOLS.cdots);
346
+ }
347
+ if (Object.hasOwn(MATH_SYMBOLS, name)) return this.node('glyph', MATH_SYMBOLS[name]);
348
+ if (Object.hasOwn(MATH_TEX_VARIANTS, name)) return this.node('style', {variant: MATH_TEX_VARIANTS[name], body: this.argument()});
349
+ if (Object.hasOwn(MATH_TEX_TEXT_VARIANTS, name)) return this.subparse(this.rawArgument(), true, MATH_TEX_TEXT_VARIANTS[name]);
350
+ if (MATH_NAMED_OPERATORS.has(name) || MATH_LIMIT_OPERATORS.has(name)) {
351
+ const values = {limsup: 'lim sup', liminf: 'lim inf', injlim: 'lim', projlim: 'lim', varlimsup: 'lim', varliminf: 'lim', varinjlim: 'lim', varprojlim: 'lim'};
352
+ let body = this.node('text', {value: values[name] || name, class: 'op', limits: MATH_LIMIT_OPERATORS.has(name) ? 'display' : 'never', variant: 'roman'});
353
+ if (['injlim', 'varinjlim', 'projlim', 'varprojlim'].includes(name)) body = this.node('accent', {base: body, value: /inj/.test(name) ? '→' : '←', position: 'under', stretch: true, class: 'op', limits: 'display'});
354
+ if (name === 'varlimsup' || name === 'varliminf') body = this.node('accent', {base: body, value: '¯', position: name === 'varlimsup' ? 'over' : 'under', stretch: true, class: 'op', limits: 'display'});
355
+ return body;
356
+ }
357
+ if (Object.hasOwn(MATH_TEX_ACCENTS, name)) {
358
+ const [value, position, stretch] = MATH_TEX_ACCENTS[name];
359
+ return this.node('accent', {base: this.argument(), value, position, stretch, ...(/brace$/.test(name) ? {limits: 'always'} : {})});
360
+ }
361
+ if (/^[{}%$&#_]$/.test(name)) return this.literal(name, {variant: 'roman'});
362
+ if (name === '|') return this.literal('‖', {variant: 'roman'});
363
+ if (name === 'frac' || name === 'dfrac' || name === 'tfrac' || name === 'cfrac') {
364
+ const align = name === 'cfrac' ? this.optionalRaw() : null;
365
+ if (align && !/^[lr]$/.test(mathTexTokenText(align).trim())) this.error('invalid-option', 'Continued-fraction alignment must be l or r.', token);
366
+ return this.node('frac', {num: this.argument(), den: this.argument(), bar: true, ...(name === 'dfrac' || name === 'cfrac' ? {style: 'display'} : name === 'tfrac' ? {style: 'text'} : {}), ...(align ? {align: mathTexTokenText(align).trim()} : {})});
367
+ }
368
+ if (/^(binom|dbinom|tbinom)$/.test(name)) return this.node('frac', {num: this.argument(), den: this.argument(), bar: false, left: '(', right: ')', ...(name[0] === 'd' ? {style: 'display'} : name[0] === 't' ? {style: 'text'} : {})});
369
+ if (name === 'genfrac') {
370
+ const left = this.delimiterArgument(), right = this.delimiterArgument(), thickness = this.plainArgument(), style = this.plainArgument();
371
+ if (!/^[0-3]?$/.test(style)) this.error('invalid-style', 'The fraction style must be 0, 1, 2 or 3.', token);
372
+ const barThickness = thickness ? this.dimension(thickness, token) : undefined;
373
+ return this.node('frac', {num: this.argument(), den: this.argument(), bar: barThickness !== 0, left, right, ...(barThickness === undefined ? {} : {barThickness}), ...(style ? {style: ['display', 'text', 'script', 'scriptscript'][Number(style)]} : {})});
374
+ }
375
+ if (name === 'sqrt') {
376
+ const index = this.optionalRaw();
377
+ return this.node('root', {body: this.argument(), ...(index ? {index: this.subparse(index)} : {})});
378
+ }
379
+ if (name === 'root') {
380
+ const index = this.parseRow(item => item.kind === 'command' && item.value === 'of');
381
+ this.expect('of', 'command');
382
+ return this.node('root', {body: this.argument(), index});
383
+ }
384
+ if (name === 'left') {
385
+ const left = this.readDelimiter();
386
+ this.fenceDepth++;
387
+ try {
388
+ const body = this.nested(() => this.parseRow(item => item.kind === 'command' && item.value === 'right'));
389
+ this.expect('right', 'command');
390
+ return this.node('fence', {left, right: this.readDelimiter(), body});
391
+ } finally { this.fenceDepth--; }
392
+ }
393
+ if (name === 'middle') {
394
+ if (!this.fenceDepth) this.error('unexpected-delimiter', '\\middle needs an enclosing \\left and \\right.', token);
395
+ const value = this.readDelimiter();
396
+ return value ? this.literal(value, {class: 'rel', delimiter: true, variant: 'roman'}) : this.row([]);
397
+ }
398
+ if (/^(big|Big|bigg|Bigg)[lrm]?$/.test(name)) {
399
+ const value = this.readDelimiter(), sizes = {big: 1.2, Big: 1.8, bigg: 2.4, Bigg: 3};
400
+ const base = name.replace(/[lrm]$/, '');
401
+ return this.literal(value, {delimiterSize: sizes[base], class: name.endsWith('l') ? 'open' : name.endsWith('r') ? 'close' : name.endsWith('m') ? 'rel' : 'ord', variant: 'roman'});
402
+ }
403
+ if (name === 'begin') return this.environment(this.plainArgument(), token);
404
+ if (name === 'substack') return this.tableFromTokens(this.rawArgument(), 'substack');
405
+ if (/^(matrix|pmatrix|bmatrix|Bmatrix|vmatrix|Vmatrix|cases|smallmatrix)$/.test(name)) return this.tableFromTokens(this.rawArgument(), name);
406
+ if (name === 'operatorname') {
407
+ const starred = this.is('*'); if (starred) this.take();
408
+ const body = this.subparse(this.rawArgument(), true, 'roman');
409
+ return this.node('style', {body, variant: 'roman', class: 'op', limits: starred ? 'display' : 'never'});
410
+ }
411
+ if (/^math(ord|op|bin|rel|open|close|punct|inner)$/.test(name)) return this.node('style', {body: this.argument(), class: name.slice(4), ...(name === 'mathop' ? {limits: 'display'} : {})});
412
+ if (name === 'overset' || name === 'underset' || name === 'stackrel' || name === 'stackbin') {
413
+ const decoration = this.argument(), base = this.argument();
414
+ return this.node('overunder', {base, [name === 'underset' ? 'under' : 'over']: decoration, ...(name === 'stackrel' ? {class: 'rel'} : name === 'stackbin' ? {class: 'bin'} : {})});
415
+ }
416
+ if (name === 'overunderset') { const over = this.argument(), under = this.argument(); return this.node('overunder', {base: this.argument(), over, under}); }
417
+ if (/^x(leftarrow|rightarrow|leftrightarrow|Leftarrow|Rightarrow|Leftrightarrow|mapsto|hookleftarrow|hookrightarrow|leftharpoonup|leftharpoondown|rightharpoonup|rightharpoondown|rightleftharpoons|leftrightharpoons|twoheadleftarrow|twoheadrightarrow|longequal)$/.test(name)) {
418
+ const under = this.optionalRaw(), over = this.argument(), symbol = name === 'xlongequal' ? '=' : MATH_SYMBOLS[name.slice(1)].value;
419
+ return this.node('overunder', {base: this.literal(symbol, {class: 'rel', stretch: true}), over, ...(under ? {under: this.subparse(under)} : {}), class: 'rel'});
420
+ }
421
+ if (name === 'not') {
422
+ const base = this.argument(), negated = {'=': '≠', '<': '≮', '>': '≯', '≤': '≰', '≥': '≱', '∈': '∉', '∋': '∌', '⊂': '⊄', '⊃': '⊅', '⊆': '⊈', '⊇': '⊉', '∣': '∤', '∥': '∦', '∼': '≁', '≈': '≉', '≡': '≢', '→': '↛', '←': '↚', '↔': '↮', '⇒': '⇏', '⇐': '⇍', '⇔': '⇎'};
423
+ if (base.type === 'glyph' && negated[base.value]) return this.literal(negated[base.value], {class: base.class, variant: 'roman'});
424
+ return this.node('accent', {base, value: '̸', position: 'over', overlay: true, class: 'rel'});
425
+ }
426
+ if (name === 'phantom' || name === 'hphantom' || name === 'vphantom') return this.node('phantom', {body: this.argument(), mode: name[0] === 'h' ? 'h' : name[0] === 'v' ? 'v' : 'both'});
427
+ if (name === 'smash') {
428
+ const option = this.optionalRaw(), mode = option ? mathTexTokenText(option).trim() : '';
429
+ if (option && mode !== 't' && mode !== 'b') this.error('invalid-option', 'A smash option must be t or b.', token);
430
+ return this.node('smash', {body: this.argument(), ...(mode ? {mode} : {})});
431
+ }
432
+ if (name === 'mathstrut' || name === 'strut') return this.node('phantom', {body: this.literal('('), mode: 'v'});
433
+ if (name === 'rlap' || name === 'llap' || name === 'clap' || name === 'mathrlap' || name === 'mathllap' || name === 'mathclap')
434
+ return this.node('style', {body: this.argument(), width: 0, align: name.includes('rlap') ? 'left' : name.includes('llap') ? 'right' : 'center'});
435
+ if (name === 'hspace' || name === 'mspace') {
436
+ if (this.is('*')) this.take();
437
+ return this.node('space', {width: this.dimension(this.plainArgument(), token)});
438
+ }
439
+ if (['kern', 'mkern', 'hskip', 'mskip'].includes(name)) return this.node('space', {width: this.readDimension()});
440
+ if (name === 'rule') {
441
+ const raise = this.optionalRaw(), width = this.dimension(this.plainArgument(), token), height = this.dimension(this.plainArgument(), token);
442
+ const body = this.node('rule', {width, height, depth: 0});
443
+ return raise ? this.node('raise', {body, amount: this.dimension(mathTexTokenText(raise), token)}) : body;
444
+ }
445
+ if (name === 'raisebox') {
446
+ const amount = this.dimension(this.plainArgument(), token), height = this.optionalRaw(), depth = this.optionalRaw();
447
+ return this.node('raise', {body: this.argument(), amount, ...(height ? {height: this.dimension(mathTexTokenText(height), token)} : {}), ...(depth ? {depth: this.dimension(mathTexTokenText(depth), token)} : {})});
448
+ }
449
+ if (name === 'raise' || name === 'lower') return this.node('raise', {amount: this.readDimension() * (name === 'lower' ? -1 : 1), body: this.argument()});
450
+ if (name === 'textcolor') { const color = this.readColor(); return this.node('style', {body: this.argument(), color}); }
451
+ if (name === 'colorbox' || name === 'fcolorbox') {
452
+ const border = name === 'fcolorbox' ? this.readColor() : undefined, background = this.readColor();
453
+ return this.node('style', {body: this.subparse(this.rawArgument(), true, 'roman'), background, ...(border ? {border} : {})});
454
+ }
455
+ if (name === 'definecolor') {
456
+ const colorName = this.plainArgument(), model = this.plainArgument(), value = this.plainArgument();
457
+ if (!/^[A-Za-z][A-Za-z0-9_-]{0,63}$/.test(colorName)) this.error('invalid-color', 'Invalid colour name.', token);
458
+ this.colors[colorName] = this.color(value, model, token); return null;
459
+ }
460
+ if (name === 'boxed' || name === 'fbox') return this.node('style', {body: name === 'fbox' ? this.subparse(this.rawArgument(), true, 'roman') : this.argument(), border: 'currentColor', padding: .25});
461
+ if (name === 'cancel' || name === 'bcancel' || name === 'xcancel') return this.node('accent', {base: this.argument(), value: name === 'bcancel' ? '╲' : name === 'xcancel' ? '╳' : '╱', position: 'over', overlay: true, stretch: true});
462
+ if (name === 'cancelto') { const over = this.argument(); return this.node('overunder', {base: this.node('accent', {base: this.argument(), value: '↗', position: 'over', overlay: true, stretch: true}), over}); }
463
+ if (['newcommand', 'renewcommand', 'providecommand', 'def', 'gdef', 'DeclareMathOperator', 'let'].includes(name)) { this.define(name, token); return null; }
464
+ if (name === 'require') {
465
+ const value = this.plainArgument();
466
+ if (!MATH_TEX_PACKAGES.has(value)) this.error('unsupported-package', 'Unsupported maths package: ' + value + '.', token, name);
467
+ return null;
468
+ }
469
+ if (name === 'href') {
470
+ const href = this.readLink(token);
471
+ return this.node('link', {href, body: this.argument()});
472
+ }
473
+ if (name === 'url') {
474
+ const href = this.readLink(token);
475
+ return this.node('link', {href, body: this.node('text', {value: href, variant: 'monospace'})});
476
+ }
477
+ if (name === 'label') { this.plainArgument(); return null; }
478
+ if (['notag', 'nonumber', 'relax', 'allowbreak', 'nobreak', 'protect', 'displaystylelimits'].includes(name)) return null;
479
+ if (name === 'tag') {
480
+ const starred = this.is('*'); if (starred) this.take();
481
+ const body = this.subparse(this.rawArgument(), true);
482
+ return this.row([this.node('space', {width: 2}), starred ? body : this.node('fence', {left: '(', right: ')', body})]);
483
+ }
484
+ if (name === 'mod' || name === 'bmod') return this.row([this.node('space', {width: name === 'bmod' ? 5 / 18 : 1}), this.node('text', {value: 'mod', variant: 'roman', class: name === 'bmod' ? 'bin' : 'op'}), this.node('space', {width: name === 'bmod' ? 5 / 18 : 1 / 3})]);
485
+ if (name === 'pmod' || name === 'pod') return this.row([this.node('space', {width: 1}), this.node('fence', {left: '(', right: ')', body: this.row([...(name === 'pmod' ? [this.node('text', {value: 'mod', variant: 'roman'}), this.node('space', {width: 1 / 3})] : []), this.argument()])})]);
486
+ if (name === 'bmod') return this.node('text', {value: 'mod', variant: 'roman', class: 'bin'});
487
+ if (name === 'ce') return this.chemistry(this.rawArgument());
488
+ if (name === 'pu') return this.units(this.rawArgument());
489
+ if (this.physicsCommand(name)) return this.physics(name, token);
490
+ if (name === 'mmlToken') return this.mathmlToken(token);
491
+ if (name === 'unicode' || name === 'char') return this.character(name, token);
492
+ if (name === 'TeX' || name === 'LaTeX') return this.node('text', {value: name, variant: 'roman'});
493
+ if (name === 'textbackslash') return this.literal('\\', {variant: 'roman'});
494
+ if (name === 'textasciitilde') return this.literal('~', {variant: 'roman'});
495
+ if (name === 'textasciicircum') return this.literal('^', {variant: 'roman'});
496
+ if (['end', 'right', '\\', 'cr', 'of'].includes(name)) this.error('unexpected-command', '\\' + name + ' has no matching opening command.', token);
497
+ this.error('unsupported-command', 'Unsupported maths command: \\' + name + '.', token);
498
+ }
499
+
500
+ delimiterArgument() {
501
+ const tokens = this.rawArgument().filter(token => token.kind !== 'space');
502
+ if (!tokens.length) return '';
503
+ const parser = new MathTexParser(this.source, {tokens, scopes: this.scopes, budget: this.budget});
504
+ const delimiter = parser.readDelimiter();
505
+ if (parser.peek().kind !== 'eof') this.error('invalid-delimiter', 'A fraction delimiter must be one symbol.', tokens[0]);
506
+ return delimiter;
507
+ }
508
+ readLink(token) {
509
+ const href = this.rawArgument().map(item => {
510
+ if (item.kind !== 'command' || /^[#$%&_]$/.test(item.value)) return item.value;
511
+ this.error('unsafe-link', 'A link address cannot contain TeX commands.', item);
512
+ }).join('').trim();
513
+ if (!/^https?:\/\/[^\s<>"'\\]+$/i.test(href) || /[\u0000-\u0020\u007f]/.test(href))
514
+ this.error('unsafe-link', 'A maths link must use an HTTP or HTTPS address.', token);
515
+ return href;
516
+ }
517
+ dimension(value, token = this.rawPeek()) {
518
+ const match = /^\s*([+-]?(?:\d+(?:\.\d*)?|\.\d+))\s*(em|ex|mu|pt|px|pc|in|cm|mm|bp|dd|cc|sp)\s*$/.exec(value);
519
+ if (!match) this.error('invalid-dimension', 'A length needs a number and a TeX unit.', token);
520
+ const factors = {em: 1, ex: .45, mu: 1 / 18, pt: .1, px: .075, pc: 1.2, in: 7.227, cm: 7.227 / 2.54, mm: 7.227 / 25.4, bp: 7.227 / 72, dd: .107, cc: 1.284, sp: .1 / 65536};
521
+ const result = Number(match[1]) * factors[match[2]];
522
+ if (!Number.isFinite(result) || Math.abs(result) > 1000) this.error('resource-limit', 'This length is too large to render safely.', token);
523
+ return result;
524
+ }
525
+ readDimension() {
526
+ if (this.is('{')) return this.dimension(this.plainArgument());
527
+ const start = this.peek(), tokens = [];
528
+ while (this.peek().kind === 'char' && /^[+\-\d.]$/.test(this.peek().value)) tokens.push(this.take());
529
+ while (this.peek().kind === 'char' && /^[A-Za-z]$/.test(this.peek().value) && tokens.filter(item => /[A-Za-z]/.test(item.value)).length < 2) tokens.push(this.take());
530
+ const value = mathTexTokenText(tokens);
531
+ return this.dimension(value, start);
532
+ }
533
+ readColor() {
534
+ const model = this.optionalRaw();
535
+ return this.color(this.plainArgument(), model ? mathTexTokenText(model).trim() : '', this.rawPeek());
536
+ }
537
+ color(value, model, token) {
538
+ if (!model) {
539
+ if (Object.hasOwn(this.colors, value)) return this.colors[value];
540
+ if (/^#[a-fA-F0-9]{3,8}$/.test(value) && [4, 5, 7, 9].includes(value.length)) return value;
541
+ if (/^[A-Za-z]{1,32}$/.test(value) && !/^(inherit|unset|initial|revert)$/i.test(value)) return value;
542
+ } else if (model === 'HTML' && /^[a-fA-F0-9]{6}$/.test(value)) return '#' + value;
543
+ else if (['rgb', 'RGB', 'gray', 'cmyk'].includes(model)) {
544
+ const numbers = value.split(',').map(Number), count = model === 'gray' ? 1 : model === 'cmyk' ? 4 : 3, max = model === 'RGB' ? 255 : 1;
545
+ if (numbers.length === count && numbers.every(number => Number.isFinite(number) && number >= 0 && number <= max)) {
546
+ let rgb = model === 'gray' ? Array(3).fill(numbers[0]) : numbers;
547
+ if (model === 'cmyk') rgb = numbers.slice(0, 3).map(number => 1 - Math.min(1, number + numbers[3]));
548
+ return '#' + rgb.map(number => Math.round(number * (model === 'RGB' ? 1 : 255)).toString(16).padStart(2, '0')).join('');
549
+ }
550
+ }
551
+ this.error('invalid-color', 'Unsupported or invalid colour value.', token, 'color');
552
+ }
553
+
554
+ environment(fullName, token) {
555
+ const env = fullName.replace(/\*$/, '');
556
+ if (!MATH_TEX_ENVIRONMENTS.has(env)) this.error('unsupported-environment', 'Unsupported maths environment: ' + fullName + '.', token, 'begin');
557
+ return this.nested(() => {
558
+ let options = {};
559
+ const position = this.optionalRaw();
560
+ if (position) {
561
+ const value = mathTexTokenText(position).trim();
562
+ if (!/^[tbc l r]$/.test(value)) this.error('invalid-option', 'Invalid matrix alignment.', token);
563
+ if (['l', 'r', 'c'].includes(value)) options.columnAlign = value;
564
+ else options.position = value;
565
+ }
566
+ if (env === 'array' || env === 'subarray') options = {...options, ...this.columnSpec(this.rawArgument(), token)};
567
+ if (env === 'alignedat' || env === 'alignat') {
568
+ const value = this.plainArgument();
569
+ if (!/^\d+$/.test(value) || Number(value) < 1 || Number(value) > 256) this.error('invalid-alignment', 'An alignedat environment needs its number of column pairs.', token);
570
+ options.pairs = Number(value);
571
+ }
572
+ const body = this.readTable(env, options, item => item.kind === 'command' && item.value === 'end');
573
+ this.expect('end', 'command');
574
+ const ending = this.plainArgument();
575
+ if (ending !== fullName) this.error('mismatched-environment', 'Expected \\end{' + fullName + '}, received \\end{' + ending + '}.', token);
576
+ return body;
577
+ });
578
+ }
579
+ tableFromTokens(tokens, env) {
580
+ return this.nested(() => {
581
+ const parser = new MathTexParser(this.source, {tokens, scopes: this.scopes, budget: this.budget, colors: this.colors});
582
+ const result = parser.readTable(env, {}, item => item.kind === 'eof');
583
+ if (parser.peek().kind !== 'eof') parser.error('unexpected-token', 'Unexpected content after the matrix.');
584
+ return result;
585
+ });
586
+ }
587
+ columnSpec(tokens, token) {
588
+ const parser = new MathTexParser(this.source, {tokens, scopes: this.scopes, budget: this.budget, colors: this.colors});
589
+ const align = [], columnRules = [], columnInsertions = [];
590
+ while (parser.peek().kind !== 'eof') {
591
+ const item = parser.take();
592
+ if (item.kind === 'char' && /^[lcr]$/.test(item.value)) { this.reserveParts(1, item); align.push(item.value); }
593
+ else if (item.kind === 'char' && item.value === '|') { this.reserveParts(1, item); columnRules.push(align.length); }
594
+ else if (item.kind === 'char' && (item.value === '@' || item.value === '!')) {
595
+ this.reserveParts(1, item);
596
+ columnInsertions.push({before: align.length, body: this.subparse(parser.rawArgument()), replaceGap: item.value === '@'});
597
+ }
598
+ else if (item.kind === 'char' && item.value === '*') {
599
+ const count = parser.plainArgument();
600
+ if (!/^\d+$/.test(count) || Number(count) > 256) this.error('invalid-alignment', 'Invalid array column repetition.', item);
601
+ const repeated = this.nested(() => this.columnSpec(parser.rawArgument(), item));
602
+ if (align.length + Number(count) * repeated.align.length > 256)
603
+ this.error('resource-limit', 'This array has too many columns to render safely.', item);
604
+ // Reserve the complete expansion before allocating any repeated metadata.
605
+ this.reserveParts(Number(count) * (repeated.align.length + repeated.columnRules.length + repeated.columnInsertions.length), item);
606
+ for (let i = 0; i < Number(count); i++) {
607
+ const at = align.length;
608
+ columnRules.push(...repeated.columnRules.map(index => at + index));
609
+ columnInsertions.push(...repeated.columnInsertions.map(insertion => ({...insertion, before: at + insertion.before})));
610
+ align.push(...repeated.align);
611
+ }
612
+ } else this.error('unsupported-alignment', 'Unsupported array column specification: ' + item.value + '.', item);
613
+ if (align.length > 256) this.error('resource-limit', 'This array has too many columns to render safely.', token);
614
+ }
615
+ if (!align.length) this.error('invalid-alignment', 'An array needs a column specification.', token);
616
+ return {align, columnRules, columnInsertions};
617
+ }
618
+ readTable(env, options, end) {
619
+ const rows = [], rowGaps = [], rowRules = [];
620
+ let cells = [], endedAfterBreak = false;
621
+ const separator = item => (item.kind === 'char' && item.value === '&') || (item.kind === 'command' && ['\\', 'cr', 'crcr'].includes(item.value)) || end(item);
622
+ for (;;) {
623
+ if (this.peek().kind === 'eof' && !end(this.peek())) this.error('unclosed-environment', 'A closing \\end command is missing.');
624
+ while (this.commandIs('hline') || this.commandIs('hdashline') || this.commandIs('cline')) {
625
+ const line = this.take();
626
+ if (line.value === 'cline') {
627
+ const range = this.plainArgument(), match = /^(\d+)-(\d+)$/.exec(range);
628
+ if (!match || Number(match[1]) < 1 || Number(match[2]) < Number(match[1])) this.error('invalid-rule', 'A partial rule needs a column range.', line);
629
+ rowRules.push({before: rows.length, from: Number(match[1]) - 1, to: Number(match[2])});
630
+ } else rowRules.push({before: rows.length, dashed: line.value === 'hdashline'});
631
+ }
632
+ if (end(this.peek())) {
633
+ if (cells.length || (!rows.length && !endedAfterBreak)) { cells.push(this.row([])); rows.push(cells); }
634
+ break;
635
+ }
636
+ cells.push(this.nested(() => this.parseRow(separator)));
637
+ endedAfterBreak = false;
638
+ const next = this.peek();
639
+ if (next.kind === 'char' && next.value === '&') { this.take(); continue; }
640
+ rows.push(cells); cells = [];
641
+ if (end(next)) break;
642
+ if (next.kind === 'command' && ['\\', 'cr', 'crcr'].includes(next.value)) {
643
+ this.take();
644
+ if (this.is('*')) this.take();
645
+ const gap = this.optionalRaw();
646
+ rowGaps[rows.length - 1] = gap ? this.dimension(mathTexTokenText(gap), next) : 0;
647
+ endedAfterBreak = true;
648
+ } else this.error('unexpected-alignment', 'Expected a row separator or the end of the environment.', next);
649
+ if (rows.length > 2048 || cells.length > 256) this.error('resource-limit', 'This matrix is too large to render safely.', next);
650
+ }
651
+ const columns = rows.reduce((max, row) => Math.max(max, row.length), 0);
652
+ if (columns > 256) this.error('resource-limit', 'This matrix has too many columns to render safely.');
653
+ if (options.align && rows.some(row => row.length > options.align.length)) this.error('invalid-alignment', 'This row has more cells than the array declares.');
654
+ if (options.pairs && rows.some(row => row.length > options.pairs * 2)) this.error('invalid-alignment', 'This row has more cells than alignedat declares.');
655
+ let align = options.align;
656
+ if (!align) align = Array.from({length: columns}, (_, index) => /^(align|aligned|alignat|alignedat|flalign|split)$/.test(env) ? (index % 2 ? 'l' : 'r') : /cases$/.test(env) ? 'l' : options.columnAlign || 'c');
657
+ return this.node('table', {env, rows, ...options, align, rowGaps, rowRules});
658
+ }
659
+
660
+ define(name, token) {
661
+ let starred = false;
662
+ if (this.rawPeek().kind === 'char' && this.rawPeek().value === '*') { this.rawTake(); starred = true; }
663
+ const nameTokens = this.rawArgument().filter(item => item.kind !== 'space');
664
+ if (nameTokens.length !== 1 || nameTokens[0].kind !== 'command') this.error('invalid-macro', 'A macro name must be one command.', token);
665
+ const macroName = nameTokens[0].value;
666
+ if (name === 'DeclareMathOperator') {
667
+ const body = this.rawArgument();
668
+ const make = value => ({kind: 'command', value, offset: token.offset});
669
+ const literal = value => ({kind: 'char', value, offset: token.offset});
670
+ this.scopes[this.scopes.length - 1][macroName] = {params: [], prefix: [], body: [make('operatorname'), ...(starred ? [literal('*')] : []), literal('{'), ...body, literal('}')], optional: null};
671
+ return;
672
+ }
673
+ if (name === 'let') {
674
+ if (this.rawPeek().kind === 'char' && this.rawPeek().value === '=') this.rawTake();
675
+ const target = this.rawTake();
676
+ if (target.kind === 'eof') this.error('invalid-macro', '\\let needs a replacement token.', token);
677
+ const existing = target.kind === 'command' && this.macro(target.value);
678
+ this.scopes[this.scopes.length - 1][macroName] = existing || {params: [], prefix: [], body: [{...target, builtin: true}], optional: null};
679
+ return;
680
+ }
681
+ let params = [], prefix = [], optional = null, body;
682
+ if (name === 'def' || name === 'gdef') {
683
+ const specification = [];
684
+ while (!(this.rawPeek().kind === 'char' && this.rawPeek().value === '{')) {
685
+ const item = this.rawTake(false);
686
+ if (item.kind === 'eof') this.error('invalid-macro', 'A macro definition needs a replacement group.', token);
687
+ specification.push(item);
688
+ }
689
+ let current = prefix;
690
+ for (let i = 0; i < specification.length; i++) {
691
+ const item = specification[i];
692
+ if (item.kind === 'char' && item.value === '#') {
693
+ const number = specification[++i];
694
+ if (!number || number.kind !== 'char' || Number(number.value) !== params.length + 1 || !/^[1-9]$/.test(number.value)) this.error('invalid-macro', 'Macro parameters must be numbered consecutively from 1.', item);
695
+ const parameter = {index: Number(number.value), delimiter: []};
696
+ params.push(parameter); current = parameter.delimiter;
697
+ } else current.push(item);
698
+ }
699
+ body = this.rawArgument();
700
+ } else {
701
+ const countTokens = this.optionalRaw();
702
+ const count = countTokens ? mathTexTokenText(countTokens).trim() : '0';
703
+ if (!/^[0-9]$/.test(count)) this.error('invalid-macro', 'A macro accepts zero to nine arguments.', token);
704
+ params = Array.from({length: Number(count)}, (_, index) => ({index: index + 1, delimiter: []}));
705
+ optional = this.optionalRaw();
706
+ if (optional && !params.length) this.error('invalid-macro', 'An optional argument needs a parameter.', token);
707
+ body = this.rawArgument();
708
+ }
709
+ for (let i = 0; i < body.length; i++) if (body[i].kind === 'char' && body[i].value === '#') {
710
+ const next = body[++i];
711
+ if (!next || next.kind !== 'char' || !(next.value === '#' || (/^[1-9]$/.test(next.value) && Number(next.value) <= params.length)))
712
+ this.error('invalid-macro', 'A replacement refers to a missing macro parameter.', body[i - 1]);
713
+ }
714
+ const existing = this.macro(macroName) || MATH_SYMBOLS[macroName] || MATH_TEX_VARIANTS[macroName] || MATH_NAMED_OPERATORS.has(macroName) || MATH_LIMIT_OPERATORS.has(macroName);
715
+ if (name === 'providecommand' && existing) return;
716
+ if (name === 'newcommand' && existing) this.error('duplicate-macro', '\\' + macroName + ' is already defined.', token);
717
+ this.scopes[name === 'gdef' ? 0 : this.scopes.length - 1][macroName] = {params, prefix, body, optional};
718
+ }
719
+ expand(macro, token) {
720
+ if (++this.budget.expansions > 4096) this.error('resource-limit', 'Macro expansion did not finish within the rendering limit.', token);
721
+ for (const prefix of macro.prefix) {
722
+ const actual = this.rawTake(prefix.kind !== 'space');
723
+ if (!mathTexTokenEqual(actual, prefix)) this.error('invalid-macro-argument', 'A macro argument does not match its definition.', actual, token.value);
724
+ }
725
+ const args = [];
726
+ for (const param of macro.params) {
727
+ if (param.index === 1 && macro.optional) args[param.index] = this.optionalRaw() || macro.optional;
728
+ else if (!param.delimiter.length) args[param.index] = this.rawArgument();
729
+ else {
730
+ const value = [];
731
+ let depth = 0;
732
+ for (;;) {
733
+ const item = this.rawTake(false);
734
+ if (item.kind === 'eof') this.error('invalid-macro-argument', 'A delimited macro argument is incomplete.', token);
735
+ value.push(item);
736
+ if (item.kind === 'char' && item.value === '{') depth++;
737
+ if (item.kind === 'char' && item.value === '}') depth--;
738
+ if (depth < 0) this.error('invalid-macro-argument', 'A macro argument escaped its group.', item);
739
+ if (depth === 0 && value.length >= param.delimiter.length && param.delimiter.every((expected, index) => mathTexTokenEqual(value[value.length - param.delimiter.length + index], expected))) {
740
+ value.length -= param.delimiter.length;
741
+ break;
742
+ }
743
+ }
744
+ if (value[0]?.value === '{' && value[value.length - 1]?.value === '}') {
745
+ let depth = 0, whole = true;
746
+ for (let i = 0; i < value.length - 1; i++) {
747
+ if (value[i].kind === 'char' && value[i].value === '{') depth++;
748
+ if (value[i].kind === 'char' && value[i].value === '}') depth--;
749
+ if (!depth) whole = false;
750
+ }
751
+ args[param.index] = whole ? value.slice(1, -1) : value;
752
+ } else args[param.index] = value;
753
+ }
754
+ }
755
+ const replacement = [];
756
+ for (let i = 0; i < macro.body.length; i++) {
757
+ const item = macro.body[i];
758
+ if (item.kind === 'char' && item.value === '#') {
759
+ const next = macro.body[++i];
760
+ if (next.value === '#') replacement.push(next);
761
+ else for (const replacementToken of args[Number(next.value)]) {
762
+ replacement.push(replacementToken);
763
+ if (replacement.length > 100000) this.error('resource-limit', 'A macro expands to too much content.', token);
764
+ }
765
+ } else replacement.push(item);
766
+ if (replacement.length > 100000) this.error('resource-limit', 'A macro expands to too much content.', token);
767
+ }
768
+ this.budget.expandedTokens += replacement.length;
769
+ if (this.budget.expandedTokens > 100000) this.error('resource-limit', 'Macro expansion produced too much content to render safely.', token);
770
+ this.frames.push({tokens: replacement, at: 0});
771
+ }
772
+
773
+ parseText(variant = 'roman') {
774
+ const children = [];
775
+ let value = '';
776
+ const flush = () => { if (value) children.push(this.node('text', {value, variant})); value = ''; };
777
+ for (;;) {
778
+ const token = this.take(false);
779
+ if (token.kind === 'eof') break;
780
+ if (token.kind === 'space') { value += ' '; continue; }
781
+ if (token.kind === 'char') {
782
+ if (token.value === '{') {
783
+ flush();
784
+ this.frames.push({tokens: [token], at: 0});
785
+ children.push(this.subparse(this.rawArgument(), true, variant));
786
+ } else if (token.value === '$') {
787
+ flush();
788
+ children.push(this.nested(() => this.parseRow(item => item.kind === 'char' && item.value === '$')));
789
+ this.expect('$');
790
+ } else if (token.value === '}') this.error('unexpected-brace', 'A closing brace has no opening brace.', token);
791
+ else if (token.value === '#' || token.value === '&' || token.value === '_') this.error('invalid-text', 'Escape ' + token.value + ' inside text.', token);
792
+ else value += token.value === '~' ? '\u00a0' : token.value;
793
+ continue;
794
+ }
795
+ const name = token.value;
796
+ if (/^[{}%$&#_ ]$/.test(name)) { value += name; continue; }
797
+ if (name === '-') continue;
798
+ if (name === 'TeX' || name === 'LaTeX') { value += name; continue; }
799
+ if (name === 'textbackslash') { value += '\\'; continue; }
800
+ if (name === 'textasciitilde') { value += '~'; continue; }
801
+ if (name === 'textasciicircum') { value += '^'; continue; }
802
+ if (name === '(' || name === '[') {
803
+ flush();
804
+ const end = name === '(' ? ')' : ']';
805
+ children.push(this.nested(() => this.parseRow(item => item.kind === 'command' && item.value === end)));
806
+ this.expect(end, 'command');
807
+ continue;
808
+ }
809
+ if (Object.hasOwn(MATH_TEX_TEXT_VARIANTS, name)) {
810
+ flush(); children.push(this.subparse(this.rawArgument(), true, MATH_TEX_TEXT_VARIANTS[name])); continue;
811
+ }
812
+ if (name === 'color' || name === 'textcolor') {
813
+ flush();
814
+ const color = this.readColor();
815
+ children.push(this.node('style', {color, body: name === 'color' ? this.nested(() => this.parseText(variant)) : this.subparse(this.rawArgument(), true, variant)}));
816
+ if (name === 'color') break;
817
+ continue;
818
+ }
819
+ if (name === 'href') {
820
+ flush();
821
+ const href = this.readLink(token);
822
+ children.push(this.node('link', {href, body: this.subparse(this.rawArgument(), true, variant)}));
823
+ continue;
824
+ }
825
+ if (name === 'fbox' || name === 'colorbox' || name === 'fcolorbox') {
826
+ flush();
827
+ const border = name === 'fbox' ? 'currentColor' : name === 'fcolorbox' ? this.readColor() : undefined;
828
+ const background = name === 'fbox' ? undefined : this.readColor();
829
+ children.push(this.node('style', {body: this.subparse(this.rawArgument(), true, variant), ...(border ? {border} : {}), ...(background ? {background} : {}), padding: .25}));
830
+ continue;
831
+ }
832
+ if (name === 'underline') {
833
+ flush();
834
+ children.push(this.node('accent', {base: this.subparse(this.rawArgument(), true, variant), value: '_', position: 'under', stretch: true}));
835
+ continue;
836
+ }
837
+ if (Object.hasOwn(MATH_TEX_DECLARATIONS, name)) {
838
+ flush(); children.push(this.node('style', {...MATH_TEX_DECLARATIONS[name], body: this.nested(() => this.parseText(MATH_TEX_DECLARATIONS[name].variant || variant))})); break;
839
+ }
840
+ if (/^['`"^~=.]$/.test(name) || ['c', 'v', 'u', 'H', 'r', 'k', 'b', 'd'].includes(name)) {
841
+ const marks = {"'": '\u0301', '`': '\u0300', '"': '\u0308', '^': '\u0302', '~': '\u0303', '=': '\u0304', '.': '\u0307', c: '\u0327', v: '\u030c', u: '\u0306', H: '\u030b', r: '\u030a', k: '\u0328', b: '\u0331', d: '\u0323'};
842
+ const raw = this.rawArgument();
843
+ const letters = mathTexTokenText(raw).trim();
844
+ if ([...letters].length !== 1) this.error('invalid-accent', 'A text accent needs one character.', token);
845
+ value += (letters + marks[name]).normalize('NFC'); continue;
846
+ }
847
+ if (MATH_SYMBOLS[name] && !MATH_SYMBOLS[name].large) { value += MATH_SYMBOLS[name].value; continue; }
848
+ flush();
849
+ const body = this.command(token);
850
+ if (body) children.push(body);
851
+ }
852
+ flush();
853
+ return this.row(children);
854
+ }
855
+
856
+ physicsCommand(name) {
857
+ return /^(dv|derivative|pdv|pderivative|fdv|functionalderivative|dd|differential|qty|quantity|pqty|bqty|Bqty|vqty|abs|absolutevalue|norm|bra|ket|braket|innerproduct|ketbra|dyad|outerproduct|expval|expectationvalue|comm|commutator|acomm|anticommutator|vb|va|vu|vectorbold|vectorarrow|vectorunit|grad|gradient|divergence|curl|laplacian|eval|evaluated|order|Tr|tr|trace|rank|diag|mqty|pmqty|bmqty|vmqty|smqty)$/.test(name);
858
+ }
859
+ physics(name, token) {
860
+ const starred = this.is('*'); if (starred) this.take();
861
+ if (/^(dv|derivative|pdv|pderivative|fdv|functionalderivative)$/.test(name)) {
862
+ const orderTokens = this.optionalRaw(), first = this.argument(), second = this.is('{') ? this.argument() : null;
863
+ const variables = [second || first];
864
+ if (/^(pdv|pderivative)$/.test(name)) while (this.is('{') && variables.length < 9) variables.push(this.argument());
865
+ const d = () => this.literal(/^(pdv|pderivative)$/.test(name) ? '∂' : /^(fdv|functionalderivative)$/.test(name) ? 'δ' : 'd', {variant: 'roman'});
866
+ const order = orderTokens ? this.subparse(orderTokens) : variables.length > 1 ? this.literal(String(variables.length), {variant: 'roman'}) : null;
867
+ const numerator = this.row([order ? this.node('scripts', {base: d(), sup: order}) : d(), ...(second ? [first] : [])]);
868
+ const denominator = this.row(variables.flatMap((variable, index) => [d(), order && variables.length === 1 ? this.node('scripts', {base: variable, sup: order}) : variable, ...(index < variables.length - 1 ? [this.node('space', {width: .1})] : [])]));
869
+ return this.node('frac', {num: numerator, den: denominator, bar: true, ...(starred ? {style: 'text'} : {})});
870
+ }
871
+ if (name === 'dd' || name === 'differential') {
872
+ const order = this.optionalRaw(), base = this.literal('d', {variant: 'roman'});
873
+ return this.row([this.node('space', {width: 3 / 18}), order ? this.node('scripts', {base, sup: this.subparse(order)}) : base, ...(this.is('{') ? [this.argument()] : [])]);
874
+ }
875
+ if (/^(vb|va|vu|vectorbold|vectorarrow|vectorunit)$/.test(name)) {
876
+ const body = this.node('style', {body: this.argument(), variant: starred ? 'bold-italic' : 'bold'});
877
+ return /^(va|vu|vectorarrow|vectorunit)$/.test(name) ? this.node('accent', {base: body, value: /^(vu|vectorunit)$/.test(name) ? '̂' : '⃗', position: 'over'}) : body;
878
+ }
879
+ if (/^(grad|gradient|divergence|curl|laplacian)$/.test(name)) {
880
+ let base = this.literal('∇', {variant: 'roman'});
881
+ if (name === 'laplacian') base = this.node('scripts', {base, sup: this.literal('2', {variant: 'roman'})});
882
+ return this.row([base, ...(/^(divergence|curl)$/.test(name) ? [this.literal(name === 'curl' ? '×' : '⋅', {class: 'bin', variant: 'roman'})] : []), ...(this.is('{') ? [this.argument()] : [])]);
883
+ }
884
+ if (['Tr', 'tr', 'trace', 'rank', 'diag'].includes(name)) return this.node('text', {value: name === 'trace' ? 'tr' : name, variant: 'roman', class: 'op', limits: 'never'});
885
+ if (name === 'eval' || name === 'evaluated') return this.node('fence', {left: '', right: '|', body: this.argument()});
886
+ if (name === 'order') return this.row([this.literal('O'), this.node('fence', {left: '(', right: ')', body: this.argument()})]);
887
+ if (/^(mqty|pmqty|bmqty|vmqty|smqty)$/.test(name)) {
888
+ const env = name === 'bmqty' ? 'bmatrix' : name === 'vmqty' ? 'vmatrix' : name === 'smqty' ? 'smallmatrix' : name === 'pmqty' ? 'pmatrix' : 'matrix';
889
+ return this.tableFromTokens(this.rawArgument(), env);
890
+ }
891
+ if (name === 'qty' || name === 'quantity') {
892
+ if (this.is('{')) return this.node('fence', {left: '(', right: ')', body: this.argument()});
893
+ const left = this.readDelimiter(), right = {'(': ')', '[': ']', '{': '}', '|': '|', '‖': '‖', '⟨': '⟩'}[left];
894
+ if (!right) this.error('invalid-delimiter', 'A quantity needs paired delimiters.', token);
895
+ const body = this.nested(() => this.parseRow(item => item.kind === 'char' && item.value === right));
896
+ this.expect(right);
897
+ return this.node('fence', {left, right, body});
898
+ }
899
+ const first = this.argument();
900
+ if (/^(bra|ket|pqty|bqty|Bqty|vqty|abs|absolutevalue|norm)$/.test(name)) {
901
+ const ends = {bra: ['⟨', '|'], ket: ['|', '⟩'], pqty: ['(', ')'], bqty: ['[', ']'], Bqty: ['{', '}'], vqty: ['|', '|'], abs: ['|', '|'], absolutevalue: ['|', '|'], norm: ['‖', '‖']}[name];
902
+ return this.node('fence', {left: ends[0], right: ends[1], body: first});
903
+ }
904
+ if (/^(comm|commutator|acomm|anticommutator)$/.test(name)) return this.node('fence', {left: /^(acomm|anticommutator)$/.test(name) ? '{' : '[', right: /^(acomm|anticommutator)$/.test(name) ? '}' : ']', body: this.row([first, this.literal(',', {class: 'punct'}), this.argument()])});
905
+ const second = this.is('{') ? this.argument() : null;
906
+ if (/^(ketbra|dyad|outerproduct)$/.test(name)) return this.row([this.node('fence', {left: '|', right: '⟩', body: first}), this.node('fence', {left: '⟨', right: '|', body: second || first})]);
907
+ if (/^(expval|expectationvalue)$/.test(name)) return this.node('fence', {left: '⟨', right: '⟩', body: second ? this.row([second, this.literal('|', {delimiter: true}), first, this.literal('|', {delimiter: true}), second]) : first});
908
+ return this.node('fence', {left: '⟨', right: '⟩', body: second ? this.row([first, this.literal('|', {delimiter: true}), second]) : first});
909
+ }
910
+
911
+ chemistry(tokens) {
912
+ return this.nested(() => {
913
+ const parser = new MathTexParser(this.source, {tokens, scopes: this.scopes, budget: this.budget, colors: this.colors});
914
+ return this.node('style', {variant: 'roman', body: parser.chemicalRow(() => false)});
915
+ });
916
+ }
917
+ chemicalRow(stop) {
918
+ const children = [];
919
+ let spaced = true, subAllowed = false, moleculeParts = 0;
920
+ const script = (key, body) => {
921
+ let base = children.pop() || this.row([]);
922
+ if (base.type !== 'scripts') base = this.node('scripts', {base});
923
+ if (base[key]) base[key] = this.row([base[key], body]); else base[key] = body;
924
+ children.push(base);
925
+ };
926
+ const consumeRun = predicate => {
927
+ let value = '';
928
+ while (this.rawPeek(false).kind === 'char' && predicate(this.rawPeek(false).value)) value += this.rawTake(false).value;
929
+ return value;
930
+ };
931
+ for (;;) {
932
+ const next = this.peek(false);
933
+ if (next.kind === 'eof' || stop(next)) break;
934
+ const token = this.take(false);
935
+ if (token.kind === 'space') { spaced = true; subAllowed = false; moleculeParts = 0; children.push(this.node('space', {width: 3 / 18})); continue; }
936
+ if (token.kind === 'command') {
937
+ if (token.value === 'bond') {
938
+ const value = this.plainArgument(), bonds = {'-': '−', '=': '=', '#': '≡', '~': '∼', '->': '→', '<-': '←', '...': '⋯'};
939
+ if (!bonds[value]) this.error('unsupported-bond', 'Unsupported chemical bond: ' + value + '.', token);
940
+ children.push(this.literal(bonds[value], {class: 'ord', variant: 'roman'}));
941
+ } else {
942
+ const body = this.command(token);
943
+ if (body) children.push(this.readScripts(body));
944
+ }
945
+ spaced = false; subAllowed = true; continue;
946
+ }
947
+ if (token.value === '$') {
948
+ children.push(this.nested(() => this.parseRow(item => item.kind === 'char' && item.value === '$')));
949
+ this.expect('$'); spaced = false; subAllowed = false; continue;
950
+ }
951
+ if (token.value === '(' || token.value === '[') {
952
+ const end = token.value === '(' ? ')' : ']';
953
+ const body = this.nested(() => this.chemicalRow(item => item.kind === 'char' && item.value === end));
954
+ this.expect(end);
955
+ children.push(this.node('fence', {left: token.value, right: end, body}));
956
+ spaced = false; subAllowed = true; moleculeParts++; continue;
957
+ }
958
+ if (token.value === '{') {
959
+ const body = this.nested(() => this.chemicalRow(item => item.kind === 'char' && item.value === '}'));
960
+ this.expect('}'); children.push(body); spaced = false; subAllowed = true; continue;
961
+ }
962
+ if (token.value === '}' || token.value === ')' || token.value === ']') this.error('unexpected-delimiter', 'A chemical group has an unmatched delimiter.', token);
963
+ if (token.value === '^' || token.value === '_') {
964
+ const key = token.value === '^' ? 'sup' : 'sub';
965
+ if (token.value === '^' && (this.rawPeek(false).kind === 'space' || this.rawPeek(false).kind === 'eof')) {
966
+ children.push(this.literal('↑', {class: 'ord', variant: 'roman'})); spaced = false; subAllowed = false; continue;
967
+ }
968
+ let body;
969
+ if (this.is('{')) body = this.subparse(this.rawArgument());
970
+ else {
971
+ const value = consumeRun(character => /^[0-9+\-−IVX]$/.test(character));
972
+ body = value ? this.node('text', {value: value.replace(/-/g, '−'), variant: 'roman'}) : this.argument();
973
+ }
974
+ script(key, body); spaced = false; continue;
975
+ }
976
+ if (/^[0-9]$/.test(token.value)) {
977
+ const number = token.value + consumeRun(character => /^[0-9.]$/.test(character));
978
+ if (subAllowed && !spaced) {
979
+ if (moleculeParts === 1 && ['+', '-'].includes(this.rawPeek(false).value)) {
980
+ const sign = this.rawTake(false).value;
981
+ script('sup', this.node('text', {value: number + (sign === '-' ? '−' : '+'), variant: 'roman'}));
982
+ } else script('sub', this.node('text', {value: number, variant: 'roman'}));
983
+ } else children.push(this.node('text', {value: number, variant: 'roman'}));
984
+ spaced = false; continue;
985
+ }
986
+ if (token.value === '<' || token.value === '-' || token.value === '=') {
987
+ let arrow = token.value;
988
+ while (this.rawPeek(false).kind === 'char' && /^[<=>-]$/.test(this.rawPeek(false).value) && arrow.length < 5) arrow += this.rawTake(false).value;
989
+ const arrows = {'->': '→', '<-': '←', '<->': '↔', '<=>': '⇌', '=>': '⇒', '<=': '⇐', '<<=>': '⇋', '<=>>': '⇌'};
990
+ if (arrows[arrow]) {
991
+ const over = this.optionalRaw(), under = this.optionalRaw();
992
+ const base = this.literal(arrows[arrow], {class: 'rel', variant: 'roman', stretch: true});
993
+ children.push(over || under ? this.node('overunder', {base, ...(over ? {over: this.chemistry(over)} : {}), ...(under ? {under: this.chemistry(under)} : {}), class: 'rel'}) : base);
994
+ spaced = true; subAllowed = false; moleculeParts = 0; continue;
995
+ }
996
+ if (arrow.length !== 1) this.error('unsupported-reaction', 'Unsupported chemical reaction arrow: ' + arrow + '.', token);
997
+ if (arrow === '-' && !spaced && subAllowed && (this.rawPeek(false).kind === 'space' || this.rawPeek(false).kind === 'eof' || /^[)\]}]$/.test(this.rawPeek(false).value))) script('sup', this.literal('−', {variant: 'roman'}));
998
+ else children.push(this.literal(arrow, {class: spaced ? 'bin' : 'ord', variant: 'roman'}));
999
+ spaced = false; continue;
1000
+ }
1001
+ if (token.value === '+') {
1002
+ if (!spaced && subAllowed && (this.rawPeek(false).kind === 'space' || this.rawPeek(false).kind === 'eof' || /^[)\]}]$/.test(this.rawPeek(false).value))) script('sup', this.literal('+', {variant: 'roman'}));
1003
+ else { children.push(this.literal('+', {class: 'bin', variant: 'roman'})); moleculeParts = 0; }
1004
+ spaced = false; subAllowed = false; continue;
1005
+ }
1006
+ if (/^[A-Za-z]$/.test(token.value)) {
1007
+ const word = token.value + consumeRun(character => /^[a-z]$/.test(character));
1008
+ children.push(this.node('text', {value: word === 'v' && spaced ? '↓' : word, variant: 'roman'}));
1009
+ moleculeParts++; spaced = false; subAllowed = true; continue;
1010
+ }
1011
+ if (token.value === '#') children.push(this.literal('≡', {class: 'ord', variant: 'roman'}));
1012
+ else if (token.value === '.' || token.value === '·') children.push(this.literal('·', {class: 'ord', variant: 'roman'}));
1013
+ else if (token.value === '~') children.push(this.node('space', {width: 1 / 3}));
1014
+ else children.push(this.literal(token.value, {variant: 'roman'}));
1015
+ spaced = false;
1016
+ }
1017
+ return this.row(children);
1018
+ }
1019
+ units(tokens) {
1020
+ let value = mathTexTokenText(tokens);
1021
+ value = value.replace(/(\d(?:[\d.]*\d)?)[eE]([+-]?\d+)/g, '$1\\times 10^{$2}');
1022
+ value = value.replace(/([A-Za-zµΩ°]+)([-+]\d+)/g, '$1^{$2}');
1023
+ value = value.replace(/\s+/g, '\\,');
1024
+ const parser = new MathTexParser(this.source, {tokens: mathTexTokens(value, tokens[0]?.offset || 0), scopes: this.scopes, budget: this.budget, colors: this.colors});
1025
+ return this.node('style', {variant: 'roman', body: parser.parse()});
1026
+ }
1027
+
1028
+ mathmlToken(token) {
1029
+ const kind = this.plainArgument(), attributes = this.optionalRaw(), text = this.plainArgument();
1030
+ if (!['mi', 'mn', 'mo', 'mtext'].includes(kind)) this.error('unsupported-token', 'Unsupported mathematical token kind: ' + kind + '.', token);
1031
+ let variant = kind === 'mi' ? 'normal' : 'roman';
1032
+ if (attributes) {
1033
+ const value = mathTexTokenText(attributes).trim(), match = /^mathvariant\s*=\s*["']([a-z-]+)["']$/.exec(value);
1034
+ const variants = new Set(['normal', 'bold', 'italic', 'bold-italic', 'double-struck', 'script', 'bold-script', 'fraktur', 'bold-fraktur', 'sans-serif', 'bold-sans-serif', 'sans-serif-italic', 'sans-serif-bold-italic', 'sans-serif-bold', 'monospace']);
1035
+ if (!match || !variants.has(match[1])) this.error('unsupported-attribute', 'Only a mathematical font variant is supported on a token.', token);
1036
+ variant = match[1] === 'normal' ? 'roman' : match[1] === 'bold-sans-serif' ? 'sans-serif-bold' : match[1];
1037
+ }
1038
+ return this.node('style', {variant, body: kind === 'mtext' ? this.node('text', {value: text, variant}) : this.row([...text].map(value => this.literal(value, {variant})))});
1039
+ }
1040
+ character(name, token) {
1041
+ let raw;
1042
+ if (name === 'unicode') {
1043
+ const metrics = this.optionalRaw(), font = this.optionalRaw();
1044
+ if (metrics || font) this.error('unsupported-character-option', 'Unicode characters use the embedded maths font.', token);
1045
+ raw = this.plainArgument();
1046
+ } else if (this.is('{')) raw = this.plainArgument();
1047
+ else {
1048
+ let prefix = '';
1049
+ if (this.is('"') || this.is("'")) prefix = this.take().value;
1050
+ raw = prefix;
1051
+ while (this.peek().kind === 'char' && (prefix === '"' ? /^[0-9a-fA-F]$/ : prefix === "'" ? /^[0-7]$/ : /^[0-9]$/).test(this.peek().value)) raw += this.take().value;
1052
+ }
1053
+ if (!/^(?:(?:x|0x|U\+|\")[0-9a-f]+|'[0-7]+|[0-9]+)$/i.test(raw)) this.error('invalid-character', 'Invalid Unicode character number.', token);
1054
+ const value = /^(?:x|0x|U\+|\")/i.test(raw) ? parseInt(raw.replace(/^(?:0x|U\+|x|\")/i, ''), 16) : raw.startsWith("'") ? parseInt(raw.slice(1), 8) : Number(raw);
1055
+ if (!Number.isInteger(value) || value < 32 || value > 0x10ffff || (value >= 0xd800 && value <= 0xdfff)) this.error('invalid-character', 'Invalid Unicode character number.', token);
1056
+ return this.literal(String.fromCodePoint(value));
1057
+ }
1058
+ }
1059
+
1060
+ function parseMathTex(source) {
1061
+ if (typeof source !== 'string') throw new MathTexError('invalid-source', 'The mathematical source must be text.');
1062
+ if (source.length > 1000000) throw new MathTexError('resource-limit', 'This formula is too long to render safely.');
1063
+ return new MathTexParser(source).parse();
1064
+ }