@motionscript/latex 0.0.0-stage → 0.1.0-alpha.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +5 -0
- package/LICENSE +201 -0
- package/README.md +67 -3
- package/dist/browser/index.js +61 -0
- package/dist/browser/index.js.map +7 -0
- package/dist/browser/manifest.json +11 -0
- package/dist/cache.d.ts +30 -0
- package/dist/cache.d.ts.map +1 -0
- package/dist/cache.js +53 -0
- package/dist/cache.js.map +1 -0
- package/dist/engine.d.ts +5 -0
- package/dist/engine.d.ts.map +1 -0
- package/dist/engine.js +10 -0
- package/dist/engine.js.map +1 -0
- package/dist/geometry.d.ts +34 -0
- package/dist/geometry.d.ts.map +1 -0
- package/dist/geometry.js +524 -0
- package/dist/geometry.js.map +1 -0
- package/dist/index.d.ts +4 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +4 -0
- package/dist/index.js.map +1 -0
- package/dist/node.d.ts +151 -0
- package/dist/node.d.ts.map +1 -0
- package/dist/node.js +359 -0
- package/dist/node.js.map +1 -0
- package/dist/nodes.d.ts +18 -0
- package/dist/nodes.d.ts.map +1 -0
- package/dist/nodes.js +18 -0
- package/dist/nodes.js.map +1 -0
- package/dist/tween.d.ts +44 -0
- package/dist/tween.d.ts.map +1 -0
- package/dist/tween.js +233 -0
- package/dist/tween.js.map +1 -0
- package/package.json +68 -3
- package/registry.json +6 -0
- package/src/cache.ts +61 -0
- package/src/engine.ts +11 -0
- package/src/geometry.ts +578 -0
- package/src/index.ts +3 -0
- package/src/node.ts +381 -0
- package/src/nodes.ts +18 -0
- package/src/tween.ts +286 -0
package/src/geometry.ts
ADDED
|
@@ -0,0 +1,578 @@
|
|
|
1
|
+
|
|
2
|
+
import { mathjax } from '@mathjax/src/js/mathjax.js';
|
|
3
|
+
import { TeX } from '@mathjax/src/js/input/tex.js';
|
|
4
|
+
import { SVG } from '@mathjax/src/js/output/svg.js';
|
|
5
|
+
import { browserAdaptor } from '@mathjax/src/js/adaptors/browserAdaptor.js';
|
|
6
|
+
import { liteAdaptor } from '@mathjax/src/js/adaptors/liteAdaptor.js';
|
|
7
|
+
import { RegisterHTMLHandler } from '@mathjax/src/js/handlers/html.js';
|
|
8
|
+
|
|
9
|
+
import '@mathjax/src/js/input/tex/base/BaseConfiguration.js';
|
|
10
|
+
import '@mathjax/src/js/input/tex/ams/AmsConfiguration.js';
|
|
11
|
+
import '@mathjax/src/js/input/tex/boldsymbol/BoldsymbolConfiguration.js';
|
|
12
|
+
import '@mathjax/src/js/input/tex/newcommand/NewcommandConfiguration.js';
|
|
13
|
+
import '@mathjax/src/js/input/tex/cancel/CancelConfiguration.js';
|
|
14
|
+
import '@mathjax/src/js/input/tex/color/ColorConfiguration.js';
|
|
15
|
+
import '@mathjax/src/js/input/tex/mhchem/MhchemConfiguration.js';
|
|
16
|
+
import '@mathjax/src/js/input/tex/physics/PhysicsConfiguration.js';
|
|
17
|
+
import '@mathjax/src/js/input/tex/bbox/BboxConfiguration.js';
|
|
18
|
+
import '@mathjax/src/js/input/tex/mathtools/MathtoolsConfiguration.js';
|
|
19
|
+
import { PathCommand } from '@motionscript/core';
|
|
20
|
+
|
|
21
|
+
import { createBoundedCache } from './cache';
|
|
22
|
+
|
|
23
|
+
// --- MathJax Setup (synchronous) ---
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* MathJax needs a DOM to build its output in, and there isn't always one.
|
|
27
|
+
*
|
|
28
|
+
* `browserAdaptor()` reads `window` **as it is constructed**, at module scope —
|
|
29
|
+
* so importing this file at all threw in Node, and a project with one LaTeX
|
|
30
|
+
* scene could not be rendered headlessly no matter which scene was asked for.
|
|
31
|
+
* `liteAdaptor` is MathJax's own DOM-free implementation and produces the same
|
|
32
|
+
* SVG; we only ever read paths back out of it, never attach it to a page.
|
|
33
|
+
*/
|
|
34
|
+
// The two adaptors are structurally different (a `LiteElement` tree vs. a real
|
|
35
|
+
// one) and MathJax's generics track that, but nothing here touches an element
|
|
36
|
+
// except through the adaptor's own accessors — so the element type is genuinely
|
|
37
|
+
// opaque to us, and pinning it to either concrete one would be a lie.
|
|
38
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
39
|
+
const adaptor: any = typeof window === 'undefined' && typeof document === 'undefined'
|
|
40
|
+
? liteAdaptor()
|
|
41
|
+
: browserAdaptor();
|
|
42
|
+
RegisterHTMLHandler(adaptor);
|
|
43
|
+
|
|
44
|
+
const texJax = new TeX({
|
|
45
|
+
packages: [
|
|
46
|
+
'base', 'ams', 'boldsymbol', 'newcommand',
|
|
47
|
+
'cancel', 'color', 'mhchem', 'physics',
|
|
48
|
+
'bbox', 'mathtools',
|
|
49
|
+
],
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
const svgJax = new SVG({ fontCache: 'local' });
|
|
53
|
+
const mjDoc = mathjax.document('', { InputJax: texJax, OutputJax: svgJax });
|
|
54
|
+
|
|
55
|
+
// --- Public API ---
|
|
56
|
+
|
|
57
|
+
/** A single rendered glyph or shape with its character key and path commands. */
|
|
58
|
+
export interface LatexToken {
|
|
59
|
+
/** Character this token represents, or a synthetic key for non-glyph shapes. */
|
|
60
|
+
token: string;
|
|
61
|
+
path: PathCommand[];
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export interface LatexPathResult {
|
|
65
|
+
/** All tokens in render order. */
|
|
66
|
+
tokens: LatexToken[];
|
|
67
|
+
/** Flat array of all path commands (for legacy single-path rendering). */
|
|
68
|
+
commands: PathCommand[];
|
|
69
|
+
width: number;
|
|
70
|
+
height: number;
|
|
71
|
+
/**
|
|
72
|
+
* Bounding box over every token, in token coordinate space:
|
|
73
|
+
* [minX, minY, maxX, maxY]. All tokens share this frame, so passing it as
|
|
74
|
+
* each path's center frame keeps their relative layout (instead of each
|
|
75
|
+
* glyph centering on its own bbox).
|
|
76
|
+
*/
|
|
77
|
+
bounds: [number, number, number, number];
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* A formula's glyph geometry, before a type size is applied to it.
|
|
82
|
+
*
|
|
83
|
+
* The dividing line this whole module's caching turns on. Getting here is the
|
|
84
|
+
* expensive part — MathJax lays the formula out, then the SVG it produces is
|
|
85
|
+
* parsed, its glyph dictionary resolved and every `<use>` walked through its
|
|
86
|
+
* transform stack — and **none of it depends on `fontSize`**: `mjDoc.convert`
|
|
87
|
+
* is called at a fixed em/ex, and the size arrives at the very end as one
|
|
88
|
+
* uniform scale (see {@link scaleLatexGeometry}).
|
|
89
|
+
*
|
|
90
|
+
* So this is what gets remembered per formula, and the size is applied to it
|
|
91
|
+
* afresh. A formula animating from 24pt to 120pt is one trip through MathJax
|
|
92
|
+
* and one DOM parse, not one per frame.
|
|
93
|
+
*/
|
|
94
|
+
interface LatexGeometry {
|
|
95
|
+
tokens: RawToken[];
|
|
96
|
+
/** Box over every command, in the SVG's own coordinates. */
|
|
97
|
+
box: { minX: number; minY: number; maxX: number; maxY: number };
|
|
98
|
+
/** The formula's height in `ex`, as MathJax reported it. */
|
|
99
|
+
heightEx: number;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Formulas already worked out, and formulas already sized.
|
|
104
|
+
*
|
|
105
|
+
* `buildLatexPath` is a pure function of `(text, fontSize)` and used to have no
|
|
106
|
+
* memory, which made every caller pay full price for a question it had already
|
|
107
|
+
* asked. The callers are not occasional about it either: constructing a `Latex`
|
|
108
|
+
* resolves its formula, and so does every `to({ latex })` the moment it starts,
|
|
109
|
+
* so a scene re-resolves every formula in it whenever it is rebuilt *and*
|
|
110
|
+
* whenever the playhead moves backwards, since a backward seek replays the
|
|
111
|
+
* scene's generator from its first frame. An editor that rebuilds on a keystroke
|
|
112
|
+
* and seeks on a click asks for the same handful of formulas hundreds of times.
|
|
113
|
+
*
|
|
114
|
+
* Two memos, each keyed by what its stage actually depends on, and deliberately
|
|
115
|
+
* different sizes:
|
|
116
|
+
*
|
|
117
|
+
* - {@link geometryCache} is everything up to the type size, keyed by the text
|
|
118
|
+
* alone. This is the expensive one, and it is generously sized because
|
|
119
|
+
* nothing a scene does can churn it: a deck holds as many distinct formulas
|
|
120
|
+
* as it holds, and it holds them for as long as it is open.
|
|
121
|
+
* - {@link pathCache} is the finished result, keyed by text *and* size. Small
|
|
122
|
+
* on purpose: an animated `fontSize` inserts a distinct key every frame, and
|
|
123
|
+
* the right thing for a stream like that is to churn a little memo rather
|
|
124
|
+
* than a big one. What a miss costs here is arithmetic over glyphs already
|
|
125
|
+
* worked out — no MathJax, no parse.
|
|
126
|
+
*/
|
|
127
|
+
const geometryCache = createBoundedCache<LatexGeometry>(128);
|
|
128
|
+
const pathCache = createBoundedCache<LatexPathResult>(64);
|
|
129
|
+
|
|
130
|
+
export function buildLatexPath(text: string, fontSize: number): LatexPathResult {
|
|
131
|
+
// NUL rather than a punctuation separator, so no formula can spell a key
|
|
132
|
+
// belonging to another size.
|
|
133
|
+
const key = `${fontSize}\0${text}`;
|
|
134
|
+
|
|
135
|
+
const hit = pathCache.get(key);
|
|
136
|
+
if (hit) return lend(hit);
|
|
137
|
+
|
|
138
|
+
let geometry = geometryCache.get(text);
|
|
139
|
+
if (!geometry) {
|
|
140
|
+
geometry = buildLatexGeometry(text);
|
|
141
|
+
geometryCache.set(text, geometry);
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
const result = scaleLatexGeometry(geometry, fontSize);
|
|
145
|
+
pathCache.set(key, result);
|
|
146
|
+
return lend(result);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/** Typeset `text` and reduce the SVG MathJax returns to glyph geometry. */
|
|
150
|
+
function buildLatexGeometry(text: string): LatexGeometry {
|
|
151
|
+
const node = mjDoc.convert(text, {
|
|
152
|
+
display: true,
|
|
153
|
+
em: 16,
|
|
154
|
+
ex: 8,
|
|
155
|
+
containerWidth: 80 * 16,
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
const svgEl = adaptor.firstChild(node);
|
|
159
|
+
const heightAttr = adaptor.getAttribute(svgEl, 'height') ?? '';
|
|
160
|
+
|
|
161
|
+
return parseLatexSvg(svgEl, parseFloat(heightAttr) || 0);
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* A cached result, handed out safely.
|
|
166
|
+
*
|
|
167
|
+
* `tokens`, `commands` and the path arrays inside them are shared: they are read
|
|
168
|
+
* by everything downstream and written by none of it — `Latex` maps tokens into
|
|
169
|
+
* its own wrappers, and the morph builds new paths rather than editing the ones
|
|
170
|
+
* it is given — so copying them would be copying the whole formula for nothing.
|
|
171
|
+
*
|
|
172
|
+
* `bounds` is copied, because it is the one field a caller *stores*: `Latex`
|
|
173
|
+
* assigns `result.bounds` straight to `this._bounds` and hands it to the
|
|
174
|
+
* renderer as a shared centring frame. That was one node holding its own array
|
|
175
|
+
* before there was a memo, and would be two nodes drawing the same formula
|
|
176
|
+
* holding one array between them after — four numbers is not a price worth
|
|
177
|
+
* paying to find out whether anything downstream ever writes to it.
|
|
178
|
+
*/
|
|
179
|
+
function lend(result: LatexPathResult): LatexPathResult {
|
|
180
|
+
const [minX, minY, maxX, maxY] = result.bounds;
|
|
181
|
+
return { ...result, bounds: [minX, minY, maxX, maxY] };
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
// --- High-Speed Matrix Math ---
|
|
185
|
+
|
|
186
|
+
type Matrix = [number, number, number, number, number, number]; // [a, b, c, d, tx, ty]
|
|
187
|
+
const identityMatrix: Matrix = [1, 0, 0, 1, 0, 0];
|
|
188
|
+
|
|
189
|
+
function multiplyMatrix(m1: Matrix, m2: Matrix): Matrix {
|
|
190
|
+
return [
|
|
191
|
+
m1[0] * m2[0] + m1[2] * m2[1],
|
|
192
|
+
m1[1] * m2[0] + m1[3] * m2[1],
|
|
193
|
+
m1[0] * m2[2] + m1[2] * m2[3],
|
|
194
|
+
m1[1] * m2[2] + m1[3] * m2[3],
|
|
195
|
+
m1[0] * m2[4] + m1[2] * m2[5] + m1[4],
|
|
196
|
+
m1[1] * m2[4] + m1[3] * m2[5] + m1[5],
|
|
197
|
+
];
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
function parseTransform(str: string | null): Matrix {
|
|
201
|
+
if (!str) return identityMatrix;
|
|
202
|
+
let m: Matrix = [...identityMatrix];
|
|
203
|
+
const re = /([a-z]+)\(([^)]+)\)/g;
|
|
204
|
+
let match;
|
|
205
|
+
while ((match = re.exec(str)) !== null) {
|
|
206
|
+
const type = match[1];
|
|
207
|
+
const args = match[2].split(/[ ,]+/).map(parseFloat);
|
|
208
|
+
if (type === 'translate') {
|
|
209
|
+
m = multiplyMatrix(m, [1, 0, 0, 1, args[0] || 0, args[1] || 0]);
|
|
210
|
+
} else if (type === 'scale') {
|
|
211
|
+
const sx = args[0] || 1;
|
|
212
|
+
const sy = args[1] !== undefined ? args[1] : sx;
|
|
213
|
+
m = multiplyMatrix(m, [sx, 0, 0, sy, 0, 0]);
|
|
214
|
+
} else if (type === 'matrix') {
|
|
215
|
+
m = multiplyMatrix(m, [args[0], args[1], args[2], args[3], args[4], args[5]]);
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
return m;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
function applyMatrix(x: number, y: number, m: Matrix): [number, number] {
|
|
222
|
+
return [
|
|
223
|
+
x * m[0] + y * m[2] + m[4],
|
|
224
|
+
x * m[1] + y * m[3] + m[5],
|
|
225
|
+
];
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
// --- Glyph ID → character decoding ---
|
|
229
|
+
|
|
230
|
+
/**
|
|
231
|
+
* MathJax SVG glyph IDs look like "MJX-1-TEX-N-78" or "MJX-1-TEX-I-1D465".
|
|
232
|
+
* The last segment is the Unicode codepoint in hex.
|
|
233
|
+
*/
|
|
234
|
+
function glyphIdToChar(id: string): string {
|
|
235
|
+
const parts = id.split('-');
|
|
236
|
+
const hex = parts[parts.length - 1];
|
|
237
|
+
const cp = parseInt(hex, 16);
|
|
238
|
+
if (!isNaN(cp)) {
|
|
239
|
+
try { return String.fromCodePoint(cp); } catch { /* fall through */ }
|
|
240
|
+
}
|
|
241
|
+
return id;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
// --- Core Fast Traversal Engine ---
|
|
245
|
+
|
|
246
|
+
type RawToken = { token: string; cmds: Array<{ cmd: string; args: number[] }> };
|
|
247
|
+
|
|
248
|
+
/** The tag name of a node's parent, or "" at the root. */
|
|
249
|
+
function parentKind(node: unknown): string {
|
|
250
|
+
const parent = adaptor.parent(node);
|
|
251
|
+
return parent ? String(adaptor.kind(parent)).toLowerCase() : "";
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
/**
|
|
255
|
+
* The size-independent half: MathJax's own SVG tree in, glyph geometry out.
|
|
256
|
+
*
|
|
257
|
+
* Walked through the **adaptor** rather than a `DOMParser`. MathJax already
|
|
258
|
+
* builds this tree, and the adaptor is its own abstraction over "a real DOM" and
|
|
259
|
+
* "the DOM-free `LiteElement` tree" — the two this module already picks between
|
|
260
|
+
* at load. Serialising it to a string and parsing it back needed a `DOMParser`,
|
|
261
|
+
* which Node does not have, so every LaTeX node threw `DOMParser is not defined`
|
|
262
|
+
* and could not render headlessly at all: no export, and nothing the visual
|
|
263
|
+
* suite could check.
|
|
264
|
+
*
|
|
265
|
+
* Reading the tree directly is also one representation instead of three (tree →
|
|
266
|
+
* string → tree), so a browser and a headless host cannot disagree about how the
|
|
267
|
+
* markup re-parsed.
|
|
268
|
+
*/
|
|
269
|
+
function parseLatexSvg(svgEl: unknown, heightEx: number): LatexGeometry {
|
|
270
|
+
// Pre-parse the glyph dictionary. `tags` reaches every <path> in the tree,
|
|
271
|
+
// including the ones inside <defs> that the `use` elements reference.
|
|
272
|
+
const glyphMap = new Map<string, Array<{ cmd: string; args: number[] }>>();
|
|
273
|
+
for (const el of adaptor.tags(svgEl, 'path') as unknown[]) {
|
|
274
|
+
const id = adaptor.getAttribute(el, 'id');
|
|
275
|
+
const d = adaptor.getAttribute(el, 'd');
|
|
276
|
+
if (id && d) {
|
|
277
|
+
glyphMap.set(`#${id}`, expandToAbsolute(tokenisePath(d)));
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
// Collect raw tokens — each <use> is one token, rects/paths are grouped as synthetic tokens
|
|
282
|
+
const rawTokens: RawToken[] = [];
|
|
283
|
+
let synthIdx = 0;
|
|
284
|
+
|
|
285
|
+
function pushCmds(token: string, cmds: Array<{ cmd: string; args: number[] }>) {
|
|
286
|
+
rawTokens.push({ token, cmds });
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
function traverse(node: unknown, currentMatrix: Matrix) {
|
|
290
|
+
let nodeMatrix = currentMatrix;
|
|
291
|
+
|
|
292
|
+
const transformStr = adaptor.getAttribute(node, "transform");
|
|
293
|
+
if (transformStr) {
|
|
294
|
+
nodeMatrix = multiplyMatrix(nodeMatrix, parseTransform(transformStr));
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
const nodeName = String(adaptor.kind(node)).toLowerCase();
|
|
298
|
+
|
|
299
|
+
if (nodeName === "use") {
|
|
300
|
+
const href = adaptor.getAttribute(node, "href") || adaptor.getAttribute(node, "xlink:href");
|
|
301
|
+
const x = parseFloat(adaptor.getAttribute(node, "x") || "0");
|
|
302
|
+
const y = parseFloat(adaptor.getAttribute(node, "y") || "0");
|
|
303
|
+
|
|
304
|
+
if (href && glyphMap.has(href)) {
|
|
305
|
+
const glyphCommands = glyphMap.get(href)!;
|
|
306
|
+
const cmds: Array<{ cmd: string; args: number[] }> = [];
|
|
307
|
+
for (const gc of glyphCommands) {
|
|
308
|
+
const shiftedArgs = [...gc.args];
|
|
309
|
+
for (let j = 0; j < shiftedArgs.length; j += 2) {
|
|
310
|
+
const [nx, ny] = applyMatrix(shiftedArgs[j] + x, shiftedArgs[j + 1] + y, nodeMatrix);
|
|
311
|
+
shiftedArgs[j] = nx;
|
|
312
|
+
shiftedArgs[j + 1] = ny;
|
|
313
|
+
}
|
|
314
|
+
cmds.push({ cmd: gc.cmd, args: shiftedArgs });
|
|
315
|
+
}
|
|
316
|
+
// Derive character from glyph ID
|
|
317
|
+
const rawId = href.startsWith('#') ? href.slice(1) : href;
|
|
318
|
+
pushCmds(glyphIdToChar(rawId), cmds);
|
|
319
|
+
}
|
|
320
|
+
} else if (nodeName === "rect") {
|
|
321
|
+
const x = parseFloat(adaptor.getAttribute(node, "x") || "0");
|
|
322
|
+
const y = parseFloat(adaptor.getAttribute(node, "y") || "0");
|
|
323
|
+
const w = parseFloat(adaptor.getAttribute(node, "width") || "0");
|
|
324
|
+
const h = parseFloat(adaptor.getAttribute(node, "height") || "0");
|
|
325
|
+
|
|
326
|
+
if (w > 0 && h > 0) {
|
|
327
|
+
const pts = [[x, y], [x + w, y], [x + w, y + h], [x, y + h]];
|
|
328
|
+
const tp = pts.map(p => applyMatrix(p[0], p[1], nodeMatrix));
|
|
329
|
+
pushCmds(`__rect_${synthIdx++}`, [
|
|
330
|
+
{ cmd: 'M', args: [tp[0][0], tp[0][1]] },
|
|
331
|
+
{ cmd: 'L', args: [tp[1][0], tp[1][1]] },
|
|
332
|
+
{ cmd: 'L', args: [tp[2][0], tp[2][1]] },
|
|
333
|
+
{ cmd: 'L', args: [tp[3][0], tp[3][1]] },
|
|
334
|
+
{ cmd: 'Z', args: [] },
|
|
335
|
+
]);
|
|
336
|
+
}
|
|
337
|
+
} else if (nodeName === "path" && parentKind(node) !== "defs") {
|
|
338
|
+
const d = adaptor.getAttribute(node, "d");
|
|
339
|
+
if (d) {
|
|
340
|
+
const abs = expandToAbsolute(tokenisePath(d));
|
|
341
|
+
const cmds: Array<{ cmd: string; args: number[] }> = [];
|
|
342
|
+
for (const gc of abs) {
|
|
343
|
+
const shiftedArgs = [...gc.args];
|
|
344
|
+
for (let j = 0; j < shiftedArgs.length; j += 2) {
|
|
345
|
+
const [nx, ny] = applyMatrix(shiftedArgs[j], shiftedArgs[j + 1], nodeMatrix);
|
|
346
|
+
shiftedArgs[j] = nx;
|
|
347
|
+
shiftedArgs[j + 1] = ny;
|
|
348
|
+
}
|
|
349
|
+
cmds.push({ cmd: gc.cmd, args: shiftedArgs });
|
|
350
|
+
}
|
|
351
|
+
pushCmds(`__path_${synthIdx++}`, cmds);
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
for (const child of adaptor.childNodes(node) as unknown[]) {
|
|
356
|
+
// A text node's kind is "#text"; only elements carry geometry, and
|
|
357
|
+
// <defs> holds the dictionary rather than anything drawn.
|
|
358
|
+
const kind = String(adaptor.kind(child)).toLowerCase();
|
|
359
|
+
if (kind.startsWith("#") || kind === "defs") continue;
|
|
360
|
+
traverse(child, nodeMatrix);
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
traverse(svgEl, identityMatrix);
|
|
365
|
+
|
|
366
|
+
// Bounding box across all raw commands, in the SVG's own coordinates.
|
|
367
|
+
const box = svgBBox(rawTokens.flatMap(t => t.cmds));
|
|
368
|
+
return { tokens: rawTokens, box, heightEx };
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
/**
|
|
372
|
+
* The size-dependent half: glyph geometry in, a laid-out formula out.
|
|
373
|
+
*
|
|
374
|
+
* Everything here is one uniform scale about the geometry's own centre, which
|
|
375
|
+
* is exactly why {@link LatexGeometry} is worth remembering on its own.
|
|
376
|
+
*/
|
|
377
|
+
function scaleLatexGeometry(geometry: LatexGeometry, fontSize: number): LatexPathResult {
|
|
378
|
+
const { tokens: rawTokens, box, heightEx } = geometry;
|
|
379
|
+
if (rawTokens.length === 0) return { tokens: [], commands: [], width: 0, height: 0, bounds: [0, 0, 0, 0] };
|
|
380
|
+
|
|
381
|
+
const { minX, minY, maxX, maxY } = box;
|
|
382
|
+
const svgW = maxX - minX;
|
|
383
|
+
const svgH = maxY - minY;
|
|
384
|
+
|
|
385
|
+
let scale: number;
|
|
386
|
+
if (heightEx > 0 && svgH > 0) {
|
|
387
|
+
const exInCoords = svgH / heightEx;
|
|
388
|
+
scale = fontSize / (2 * exInCoords);
|
|
389
|
+
} else {
|
|
390
|
+
scale = svgH > 0 ? fontSize / svgH : 1;
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
const ocx = (minX + maxX) / 2;
|
|
394
|
+
const ocy = (minY + maxY) / 2;
|
|
395
|
+
|
|
396
|
+
const tx = (svgX: number) => (svgX - ocx) * scale;
|
|
397
|
+
const ty = (svgY: number) => (svgY - ocy) * scale;
|
|
398
|
+
|
|
399
|
+
function rawCmdsToPathCommands(cmds: Array<{ cmd: string; args: number[] }>): PathCommand[] {
|
|
400
|
+
const out: PathCommand[] = [];
|
|
401
|
+
for (const { cmd, args: a } of cmds) {
|
|
402
|
+
switch (cmd) {
|
|
403
|
+
case 'M': out.push({ type: 'M', x: tx(a[0]), y: ty(a[1]) }); break;
|
|
404
|
+
case 'L': out.push({ type: 'L', x: tx(a[0]), y: ty(a[1]) }); break;
|
|
405
|
+
case 'Q': out.push({ type: 'Q', x1: tx(a[0]), y1: ty(a[1]), x: tx(a[2]), y: ty(a[3]) }); break;
|
|
406
|
+
case 'C': out.push({ type: 'C', x1: tx(a[0]), y1: ty(a[1]), x2: tx(a[2]), y2: ty(a[3]), x: tx(a[4]), y: ty(a[5]) }); break;
|
|
407
|
+
case 'Z': out.push({ type: 'Z' }); break;
|
|
408
|
+
}
|
|
409
|
+
}
|
|
410
|
+
return out;
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
const tokens: LatexToken[] = rawTokens.map(rt => ({
|
|
414
|
+
token: rt.token,
|
|
415
|
+
path: rawCmdsToPathCommands(rt.cmds),
|
|
416
|
+
}));
|
|
417
|
+
|
|
418
|
+
const commands = tokens.flatMap(t => t.path);
|
|
419
|
+
|
|
420
|
+
// Shared center frame for all tokens, in token coordinate space. tx/ty map
|
|
421
|
+
// the SVG bbox corners; this is the frame every token's path is laid out in.
|
|
422
|
+
const bounds: [number, number, number, number] = [
|
|
423
|
+
tx(minX), ty(minY), tx(maxX), ty(maxY),
|
|
424
|
+
];
|
|
425
|
+
|
|
426
|
+
return { tokens, commands, width: svgW * scale, height: svgH * scale, bounds };
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
// --- SVG Path Utilities ---
|
|
430
|
+
|
|
431
|
+
type RawCmd = { cmd: string; args: number[] };
|
|
432
|
+
|
|
433
|
+
function tokenisePath(d: string): RawCmd[] {
|
|
434
|
+
const re = /([MmLlHhVvCcSsQqTtAaZz])|([+-]?(?:\d+\.?\d*|\.\d+)(?:[eE][+-]?\d+)?)/g;
|
|
435
|
+
const out: RawCmd[] = [];
|
|
436
|
+
let cur: RawCmd | null = null;
|
|
437
|
+
let m: RegExpExecArray | null;
|
|
438
|
+
while ((m = re.exec(d)) !== null) {
|
|
439
|
+
if (m[1]) { cur = { cmd: m[1], args: [] }; out.push(cur); }
|
|
440
|
+
else if (cur) cur.args.push(parseFloat(m[2]));
|
|
441
|
+
}
|
|
442
|
+
return out;
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
function argCount(cmd: string): number {
|
|
446
|
+
switch (cmd.toUpperCase()) {
|
|
447
|
+
case 'M': return 2; case 'L': return 2; case 'H': return 1; case 'V': return 1;
|
|
448
|
+
case 'C': return 6; case 'S': return 4; case 'Q': return 4; case 'T': return 2;
|
|
449
|
+
case 'A': return 7; case 'Z': return 0; default: return 0;
|
|
450
|
+
}
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
interface Pt { x: number; y: number }
|
|
454
|
+
|
|
455
|
+
function expandToAbsolute(raw: RawCmd[]): Array<{ cmd: string; args: number[] }> {
|
|
456
|
+
const out: Array<{ cmd: string; args: number[] }> = [];
|
|
457
|
+
let cx = 0, cy = 0;
|
|
458
|
+
let mx = 0, my = 0;
|
|
459
|
+
let prevCmd = '';
|
|
460
|
+
let prevCtrl: Pt = { x: 0, y: 0 };
|
|
461
|
+
|
|
462
|
+
for (const { cmd, args } of raw) {
|
|
463
|
+
const up = cmd.toUpperCase();
|
|
464
|
+
const rel = cmd === cmd.toLowerCase() && up !== 'Z';
|
|
465
|
+
const n = argCount(up);
|
|
466
|
+
const reps = n === 0 ? 1 : Math.max(1, Math.ceil(args.length / n));
|
|
467
|
+
|
|
468
|
+
for (let r = 0; r < reps; r++) {
|
|
469
|
+
const a = args.slice(r * n, r * n + n);
|
|
470
|
+
const absX = (i: number) => rel ? a[i] + cx : a[i];
|
|
471
|
+
const absY = (i: number) => rel ? a[i] + cy : a[i];
|
|
472
|
+
|
|
473
|
+
switch (up) {
|
|
474
|
+
case 'M': {
|
|
475
|
+
const x = absX(0), y = absY(1);
|
|
476
|
+
const emitCmd = r === 0 ? 'M' : 'L';
|
|
477
|
+
out.push({ cmd: emitCmd, args: [x, y] });
|
|
478
|
+
cx = x; cy = y;
|
|
479
|
+
if (r === 0) { mx = x; my = y; }
|
|
480
|
+
prevCmd = emitCmd;
|
|
481
|
+
prevCtrl = { x: cx, y: cy };
|
|
482
|
+
break;
|
|
483
|
+
}
|
|
484
|
+
case 'Z':
|
|
485
|
+
out.push({ cmd: 'Z', args: [] });
|
|
486
|
+
cx = mx; cy = my;
|
|
487
|
+
prevCmd = 'Z';
|
|
488
|
+
break;
|
|
489
|
+
case 'L': {
|
|
490
|
+
const x = absX(0), y = absY(1);
|
|
491
|
+
out.push({ cmd: 'L', args: [x, y] });
|
|
492
|
+
cx = x; cy = y;
|
|
493
|
+
prevCmd = 'L';
|
|
494
|
+
prevCtrl = { x: cx, y: cy };
|
|
495
|
+
break;
|
|
496
|
+
}
|
|
497
|
+
case 'H': {
|
|
498
|
+
const x = rel ? a[0] + cx : a[0];
|
|
499
|
+
out.push({ cmd: 'L', args: [x, cy] });
|
|
500
|
+
cx = x;
|
|
501
|
+
prevCmd = 'L';
|
|
502
|
+
prevCtrl = { x: cx, y: cy };
|
|
503
|
+
break;
|
|
504
|
+
}
|
|
505
|
+
case 'V': {
|
|
506
|
+
const y = rel ? a[0] + cy : a[0];
|
|
507
|
+
out.push({ cmd: 'L', args: [cx, y] });
|
|
508
|
+
cy = y;
|
|
509
|
+
prevCmd = 'L';
|
|
510
|
+
prevCtrl = { x: cx, y: cy };
|
|
511
|
+
break;
|
|
512
|
+
}
|
|
513
|
+
case 'C': {
|
|
514
|
+
const x1 = absX(0), y1 = absY(1);
|
|
515
|
+
const x2 = absX(2), y2 = absY(3);
|
|
516
|
+
const x = absX(4), y = absY(5);
|
|
517
|
+
out.push({ cmd: 'C', args: [x1, y1, x2, y2, x, y] });
|
|
518
|
+
prevCtrl = { x: x2, y: y2 };
|
|
519
|
+
cx = x; cy = y;
|
|
520
|
+
prevCmd = 'C';
|
|
521
|
+
break;
|
|
522
|
+
}
|
|
523
|
+
case 'S': {
|
|
524
|
+
const x1 = prevCmd === 'C' || prevCmd === 'S' ? 2 * cx - prevCtrl.x : cx;
|
|
525
|
+
const y1 = prevCmd === 'C' || prevCmd === 'S' ? 2 * cy - prevCtrl.y : cy;
|
|
526
|
+
const x2 = absX(0), y2 = absY(1);
|
|
527
|
+
const x = absX(2), y = absY(3);
|
|
528
|
+
out.push({ cmd: 'C', args: [x1, y1, x2, y2, x, y] });
|
|
529
|
+
prevCtrl = { x: x2, y: y2 };
|
|
530
|
+
cx = x; cy = y;
|
|
531
|
+
prevCmd = 'S';
|
|
532
|
+
break;
|
|
533
|
+
}
|
|
534
|
+
case 'Q': {
|
|
535
|
+
const x1 = absX(0), y1 = absY(1);
|
|
536
|
+
const x = absX(2), y = absY(3);
|
|
537
|
+
out.push({ cmd: 'Q', args: [x1, y1, x, y] });
|
|
538
|
+
prevCtrl = { x: x1, y: y1 };
|
|
539
|
+
cx = x; cy = y;
|
|
540
|
+
prevCmd = 'Q';
|
|
541
|
+
break;
|
|
542
|
+
}
|
|
543
|
+
case 'T': {
|
|
544
|
+
const x1 = prevCmd === 'Q' || prevCmd === 'T' ? 2 * cx - prevCtrl.x : cx;
|
|
545
|
+
const y1 = prevCmd === 'Q' || prevCmd === 'T' ? 2 * cy - prevCtrl.y : cy;
|
|
546
|
+
const x = absX(0), y = absY(1);
|
|
547
|
+
out.push({ cmd: 'Q', args: [x1, y1, x, y] });
|
|
548
|
+
prevCtrl = { x: x1, y: y1 };
|
|
549
|
+
cx = x; cy = y;
|
|
550
|
+
prevCmd = 'T';
|
|
551
|
+
break;
|
|
552
|
+
}
|
|
553
|
+
}
|
|
554
|
+
}
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
if (out.length > 0 && out[out.length - 1].cmd !== 'Z') {
|
|
558
|
+
out.push({ cmd: 'Z', args: [] });
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
return out;
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
function svgBBox(abs: Array<{ cmd: string; args: number[] }>): { minX: number; minY: number; maxX: number; maxY: number } {
|
|
565
|
+
let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
|
|
566
|
+
const touch = (x: number, y: number) => {
|
|
567
|
+
if (x < minX) minX = x; if (x > maxX) maxX = x;
|
|
568
|
+
if (y < minY) minY = y; if (y > maxY) maxY = y;
|
|
569
|
+
};
|
|
570
|
+
for (const { cmd, args } of abs) {
|
|
571
|
+
switch (cmd) {
|
|
572
|
+
case 'M': case 'L': touch(args[0], args[1]); break;
|
|
573
|
+
case 'C': touch(args[0], args[1]); touch(args[2], args[3]); touch(args[4], args[5]); break;
|
|
574
|
+
case 'Q': touch(args[0], args[1]); touch(args[2], args[3]); break;
|
|
575
|
+
}
|
|
576
|
+
}
|
|
577
|
+
return { minX, minY, maxX, maxY };
|
|
578
|
+
}
|
package/src/index.ts
ADDED