@svjeez/math 0.0.0-stage → 0.6.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +66 -2
- package/index.d.ts +49 -0
- package/index.js +83 -0
- package/mathjax.d.ts +25 -0
- package/mathjax.js +125 -0
- package/matrix.d.ts +16 -0
- package/matrix.js +48 -0
- package/package.json +25 -4
- package/path.d.ts +9 -0
- package/path.js +45 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Sai Ashirwad
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,3 +1,67 @@
|
|
|
1
|
-
#
|
|
1
|
+
# @svjeez/math
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
TeX equations for [svjeez](https://www.npmjs.com/package/svjeez) scenes. `tex()` typesets a formula with MathJax while the scene compiles and gives back one `<path>` per glyph, placed in scene pixels. Because every glyph is its own piece, `morph()` can carry an equation from one form to the next the way Manim does, and the SVG it ends up in is still one file with no JavaScript and no font to load.
|
|
4
|
+
|
|
5
|
+
## Install
|
|
6
|
+
|
|
7
|
+
```sh
|
|
8
|
+
npm i -D @svjeez/math svjeez
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
## Use
|
|
12
|
+
|
|
13
|
+
```tsx
|
|
14
|
+
import { tex } from "@svjeez/math";
|
|
15
|
+
import { render } from "svjeez";
|
|
16
|
+
import { Brace, morph } from "svjeez/extras";
|
|
17
|
+
|
|
18
|
+
const at = { x: 40, y: 80, fontSize: 40 };
|
|
19
|
+
const square = tex(String.raw`(a+b)^2`, at);
|
|
20
|
+
const expanded = tex(String.raw`a^2 + \cssId{mid}{2ab} + b^2`, at);
|
|
21
|
+
const m = morph([square, expanded, square], { at: [1.5, 4], over: 1 });
|
|
22
|
+
|
|
23
|
+
export default () =>
|
|
24
|
+
render(
|
|
25
|
+
{ width: 480, height: 200, duration: 6, title: "Binomial square" },
|
|
26
|
+
<>
|
|
27
|
+
{m.node}
|
|
28
|
+
<Brace
|
|
29
|
+
target={expanded.get("mid")}
|
|
30
|
+
side="below"
|
|
31
|
+
label="cross term"
|
|
32
|
+
during={{ at: 2.5, over: 1.5 }}
|
|
33
|
+
/>
|
|
34
|
+
</>,
|
|
35
|
+
);
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
`tex(source, options)` takes:
|
|
39
|
+
|
|
40
|
+
- `x` and `y`, the top-left of the formula's box in px. They default to 0.
|
|
41
|
+
- `fontSize`, the pixels in one em. It defaults to 32.
|
|
42
|
+
- `theme`, a `Base16` palette whose `plain` color paints the formula. It defaults to `defaultDark`.
|
|
43
|
+
- `color`, which replaces the theme's plain color.
|
|
44
|
+
- `display`, which sets display style. It defaults to `true`.
|
|
45
|
+
|
|
46
|
+
The formula it returns has:
|
|
47
|
+
|
|
48
|
+
- `node`, the formula as one group of glyphs.
|
|
49
|
+
- `bounds`, `width` and `height`, the formula's box.
|
|
50
|
+
- `words`, every glyph and bar in reading order. Each has `char`, `size`, `d`, `matrix`, `fill`, `bounds` and the `text`, `x`, `y` and `id` that `morph()` pairs by.
|
|
51
|
+
- `get(id)`, the box of everything under `\cssId{id}{…}`.
|
|
52
|
+
- `find(text, nth)`, the box of the `nth` run of consecutive glyphs that spell `text`, such as `find("2ab")`.
|
|
53
|
+
- `draw(glyph, animation)`, one glyph in a group that carries the animation.
|
|
54
|
+
|
|
55
|
+
## How glyphs pair
|
|
56
|
+
|
|
57
|
+
`morph()` pairs a glyph in one version with the same character at the same size in the next, and moves it. It cannot scale, so a superscript `2` never pairs with a full-size `2`: it fades out while the other fades in. A fraction bar pairs only with a bar of the same length. Glyphs the next version lacks fade out, and new ones fade in. Wrap a part in `\cssId{name}{…}` in both versions to pair it by name whatever it holds.
|
|
58
|
+
|
|
59
|
+
`\color{red}{x}` paints a part, and the theme paints the rest.
|
|
60
|
+
|
|
61
|
+
## Errors
|
|
62
|
+
|
|
63
|
+
A formula MathJax cannot read throws `tex() could not parse "…": <MathJax's message>`, so a typo fails the build instead of drawing red text. A character the MathJax font lacks, such as `é` or an emoji, throws `tex() cannot draw "…"`. Write accents with TeX, as in `\'e`.
|
|
64
|
+
|
|
65
|
+
## Notes
|
|
66
|
+
|
|
67
|
+
MathJax runs once per formula on the build machine. `mathjax-full` is its only dependency, and it loads on Node 20 and later.
|
package/index.d.ts
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { type Color, type Rect, type SvgNode } from "svjeez";
|
|
2
|
+
import { type Base16, type Morphable, type MorphPiece } from "svjeez/extras";
|
|
3
|
+
import { type Matrix } from "./matrix.ts";
|
|
4
|
+
export interface TexOptions {
|
|
5
|
+
/** Left of the formula's box. Default 0. */
|
|
6
|
+
readonly x?: number;
|
|
7
|
+
/** Top of the formula's box. Default 0. */
|
|
8
|
+
readonly y?: number;
|
|
9
|
+
/** Pixels per em. Default 32. */
|
|
10
|
+
readonly fontSize?: number;
|
|
11
|
+
/** Colors the formula with its `plain` slot. Default `defaultDark`. */
|
|
12
|
+
readonly theme?: Base16;
|
|
13
|
+
/** Overrides the theme's plain color. `\color{…}` in the source still wins for the part it wraps. */
|
|
14
|
+
readonly color?: Color;
|
|
15
|
+
/** Display style: big operators stack their limits and fractions are full size. Default true. */
|
|
16
|
+
readonly display?: boolean;
|
|
17
|
+
}
|
|
18
|
+
/** One glyph or bar of a formula, placed in the scene. morph() pairs glyphs by `text`. */
|
|
19
|
+
export interface Glyph extends MorphPiece {
|
|
20
|
+
/** The character, NFKC-normalized (`𝑎` is `a`), or `rect` or `line` for a bar. */
|
|
21
|
+
readonly char: string;
|
|
22
|
+
/** Scale relative to `fontSize`: about 0.707 for a superscript. */
|
|
23
|
+
readonly size: number;
|
|
24
|
+
/** Path data in glyph units, 1000 to the em. */
|
|
25
|
+
readonly d: string;
|
|
26
|
+
/** Glyph units to scene pixels, absolute. `x` and `y` are its translation. */
|
|
27
|
+
readonly matrix: Matrix;
|
|
28
|
+
/** Set by `\color`. */
|
|
29
|
+
readonly fill?: string;
|
|
30
|
+
/** In scene pixels. */
|
|
31
|
+
readonly bounds: Rect;
|
|
32
|
+
}
|
|
33
|
+
/** A typeset formula. Lines up with code(): `get` and `find` return rects for annotations. */
|
|
34
|
+
export interface Tex extends Morphable<Glyph> {
|
|
35
|
+
readonly node: SvgNode;
|
|
36
|
+
readonly bounds: Rect;
|
|
37
|
+
readonly width: number;
|
|
38
|
+
readonly height: number;
|
|
39
|
+
/** Bounds of every glyph under `\cssId{id}{…}`. */
|
|
40
|
+
get(id: string): Rect;
|
|
41
|
+
/** Bounds of the `nth` run of consecutive glyphs spelling `text`, such as `2ab`. */
|
|
42
|
+
find(text: string, nth?: number): Rect;
|
|
43
|
+
}
|
|
44
|
+
export type { Matrix };
|
|
45
|
+
/**
|
|
46
|
+
* Typesets TeX into glyph paths while the scene compiles. Nothing reaches the viewer's browser but `<path>`s.
|
|
47
|
+
* `x` and `y` place the top-left of the formula's box; one em is `fontSize` pixels.
|
|
48
|
+
*/
|
|
49
|
+
export declare function tex(source: string, options?: TexOptions): Tex;
|
package/index.js
ADDED
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import { el, rect } from "svjeez";
|
|
2
|
+
import { defaultDark, tint } from "svjeez/extras";
|
|
3
|
+
import { compile } from "./mathjax.js";
|
|
4
|
+
import { apply, multiply, round, roundMatrix, scale, translate } from "./matrix.js";
|
|
5
|
+
import { pathPoints } from "./path.js";
|
|
6
|
+
const glyphBounds = (d, matrix) => {
|
|
7
|
+
const points = pathPoints(d).map(([x, y]) => apply(matrix, x, y));
|
|
8
|
+
const [xs, ys] = [points.map(([x]) => x), points.map(([, y]) => y)];
|
|
9
|
+
const [left, top] = [Math.min(...xs), Math.min(...ys)];
|
|
10
|
+
return rect(round(left), round(top), round(Math.max(...xs) - left), round(Math.max(...ys) - top));
|
|
11
|
+
};
|
|
12
|
+
/**
|
|
13
|
+
* Typesets TeX into glyph paths while the scene compiles. Nothing reaches the viewer's browser but `<path>`s.
|
|
14
|
+
* `x` and `y` place the top-left of the formula's box; one em is `fontSize` pixels.
|
|
15
|
+
*/
|
|
16
|
+
export function tex(source, options = {}) {
|
|
17
|
+
const { x = 0, y = 0, fontSize = 32, theme = defaultDark, display = true } = options;
|
|
18
|
+
if (!(fontSize > 0) || !Number.isFinite(x + y))
|
|
19
|
+
throw new Error(`tex() needs finite coordinates and a positive fontSize, got x ${x}, y ${y} and fontSize ${fontSize}.`);
|
|
20
|
+
const compiled = compile(source, display);
|
|
21
|
+
const [minX, minY, boxW, boxH] = compiled.box;
|
|
22
|
+
const k = fontSize / 1000;
|
|
23
|
+
const place = multiply(translate(x, y), multiply(scale(k), translate(-minX, -minY)));
|
|
24
|
+
const glyphs = compiled.marks.map((mark) => {
|
|
25
|
+
const placed = multiply(place, mark.matrix);
|
|
26
|
+
const size = round(mark.size);
|
|
27
|
+
const matrix = roundMatrix(placed);
|
|
28
|
+
return {
|
|
29
|
+
// A bar's size is in its key, because morph only translates and cannot stretch one.
|
|
30
|
+
text: `${mark.key}@${size}`,
|
|
31
|
+
char: mark.char,
|
|
32
|
+
size,
|
|
33
|
+
d: mark.d,
|
|
34
|
+
matrix,
|
|
35
|
+
x: matrix[4],
|
|
36
|
+
y: matrix[5],
|
|
37
|
+
fill: mark.fill,
|
|
38
|
+
id: mark.id,
|
|
39
|
+
bounds: glyphBounds(mark.d, matrix),
|
|
40
|
+
};
|
|
41
|
+
});
|
|
42
|
+
const ink = options.color ?? tint(theme, "plain");
|
|
43
|
+
const draw = (glyph, animation) => el.g({ animation }, el.path({
|
|
44
|
+
d: glyph.d,
|
|
45
|
+
transform: `matrix(${glyph.matrix.join(" ")})`,
|
|
46
|
+
// SAFETY: the fill is a CSS color that the author wrote in `\color{…}`, which MathJax passes through unchecked.
|
|
47
|
+
fill: glyph.fill ?? ink,
|
|
48
|
+
}));
|
|
49
|
+
const bounds = rect(x, y, round(boxW * k), round(boxH * k));
|
|
50
|
+
const get = (id) => {
|
|
51
|
+
const named = glyphs.filter((g) => g.id === id);
|
|
52
|
+
if (named.length === 0)
|
|
53
|
+
throw new Error(`tex.get() found no part named ${JSON.stringify(id)}.`);
|
|
54
|
+
return named.map((g) => g.bounds).reduce((a, b) => a.union(b));
|
|
55
|
+
};
|
|
56
|
+
const spelled = glyphs.map((g) => g.char).join("");
|
|
57
|
+
const starts = [0];
|
|
58
|
+
for (const g of glyphs)
|
|
59
|
+
starts.push(starts.at(-1) + g.char.length);
|
|
60
|
+
const find = (needle, nth = 1) => {
|
|
61
|
+
let seen = 0;
|
|
62
|
+
for (let pos = needle ? spelled.indexOf(needle) : -1; pos >= 0; pos = spelled.indexOf(needle, pos + 1)) {
|
|
63
|
+
const [first, last] = [starts.indexOf(pos), starts.indexOf(pos + needle.length)];
|
|
64
|
+
if (first < 0 || last < 0 || ++seen !== nth)
|
|
65
|
+
continue;
|
|
66
|
+
return glyphs
|
|
67
|
+
.slice(first, last)
|
|
68
|
+
.map((g) => g.bounds)
|
|
69
|
+
.reduce((a, b) => a.union(b));
|
|
70
|
+
}
|
|
71
|
+
throw new Error(`tex.find() found ${seen} of ${JSON.stringify(needle)}, not ${nth}.`);
|
|
72
|
+
};
|
|
73
|
+
return {
|
|
74
|
+
node: el.g({}, glyphs.map((g) => draw(g))),
|
|
75
|
+
words: glyphs,
|
|
76
|
+
bounds,
|
|
77
|
+
width: bounds.w,
|
|
78
|
+
height: bounds.h,
|
|
79
|
+
get,
|
|
80
|
+
find,
|
|
81
|
+
draw,
|
|
82
|
+
};
|
|
83
|
+
}
|
package/mathjax.d.ts
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { type Matrix } from "./matrix.ts";
|
|
2
|
+
/** One filled mark of a formula, in MathJax's units: 1000 to the em, y up before `matrix` flips it. */
|
|
3
|
+
export interface Mark {
|
|
4
|
+
/** The character of a glyph, NFKC-normalized; `rect` or `line` for a drawn bar. */
|
|
5
|
+
readonly char: string;
|
|
6
|
+
/** What decides whether two marks are the same drawing: the character, or a bar's size. */
|
|
7
|
+
readonly key: string;
|
|
8
|
+
readonly d: string;
|
|
9
|
+
/** Glyph units to the SVG user space of the formula, including MathJax's flip. */
|
|
10
|
+
readonly matrix: Matrix;
|
|
11
|
+
/** Product of the nested `scale()`s: 0.707 for a superscript. */
|
|
12
|
+
readonly size: number;
|
|
13
|
+
readonly fill?: string;
|
|
14
|
+
readonly id?: string;
|
|
15
|
+
}
|
|
16
|
+
export interface Compiled {
|
|
17
|
+
/** The SVG viewBox: the formula's box in user space. */
|
|
18
|
+
readonly box: readonly [x: number, y: number, w: number, h: number];
|
|
19
|
+
readonly marks: readonly Mark[];
|
|
20
|
+
}
|
|
21
|
+
/**
|
|
22
|
+
* Typesets `source` with MathJax and reads the SVG back as marks. Throws when MathJax reports a TeX error
|
|
23
|
+
* or draws something this package cannot place, such as a `<text>` fallback for a character the font lacks.
|
|
24
|
+
*/
|
|
25
|
+
export declare function compile(source: string, display: boolean): Compiled;
|
package/mathjax.js
ADDED
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
import { mathjax } from "mathjax-full/js/mathjax.js";
|
|
2
|
+
import { TeX } from "mathjax-full/js/input/tex.js";
|
|
3
|
+
import { SVG } from "mathjax-full/js/output/svg.js";
|
|
4
|
+
import { liteAdaptor } from "mathjax-full/js/adaptors/liteAdaptor.js";
|
|
5
|
+
import { RegisterHTMLHandler } from "mathjax-full/js/handlers/html.js";
|
|
6
|
+
import { AllPackages } from "mathjax-full/js/input/tex/AllPackages.js";
|
|
7
|
+
import { identity, multiply, parseTransform, round } from "./matrix.js";
|
|
8
|
+
import { boxPath, holePath } from "./path.js";
|
|
9
|
+
/** `noundefined` paints an unknown macro red and `noerrors` hides errors, and both turn a typo into a silent picture. */
|
|
10
|
+
const PACKAGES = AllPackages.filter((name) => name !== "noundefined" && name !== "noerrors");
|
|
11
|
+
const adaptor = liteAdaptor();
|
|
12
|
+
let document;
|
|
13
|
+
const convert = (source, display) => {
|
|
14
|
+
if (!document) {
|
|
15
|
+
RegisterHTMLHandler(adaptor);
|
|
16
|
+
document = mathjax.document("", {
|
|
17
|
+
InputJax: new TeX({ packages: PACKAGES }),
|
|
18
|
+
OutputJax: new SVG({ fontCache: "none" }),
|
|
19
|
+
});
|
|
20
|
+
}
|
|
21
|
+
return document.convert(source, { display });
|
|
22
|
+
};
|
|
23
|
+
const num = (node, name) => Number(adaptor.getAttribute(node, name) ?? 0);
|
|
24
|
+
/** A stroked rectangle or line has no outline in the SVG, so it becomes the outline's own mark. */
|
|
25
|
+
function stroked(kind, node) {
|
|
26
|
+
const width = num(node, "stroke-width");
|
|
27
|
+
if (kind === "line") {
|
|
28
|
+
const [x1, y1, x2, y2] = [num(node, "x1"), num(node, "y1"), num(node, "x2"), num(node, "y2")];
|
|
29
|
+
const length = Math.hypot(x2 - x1, y2 - y1) || 1;
|
|
30
|
+
const [nx, ny] = [((y2 - y1) / length) * (width / 2), ((x1 - x2) / length) * (width / 2)];
|
|
31
|
+
return {
|
|
32
|
+
key: `line:${round(x2 - x1)}x${round(y2 - y1)}`,
|
|
33
|
+
d: `M${round(x1 + nx)} ${round(y1 + ny)}L${round(x2 + nx)} ${round(y2 + ny)}L${round(x2 - nx)} ${round(y2 - ny)}L${round(x1 - nx)} ${round(y1 - ny)}Z`,
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
const [x, y, w, h] = [num(node, "x"), num(node, "y"), num(node, "width"), num(node, "height")];
|
|
37
|
+
const half = width / 2;
|
|
38
|
+
return {
|
|
39
|
+
key: `rect:${round(w)}x${round(h)}`,
|
|
40
|
+
d: boxPath(x - half, y - half, w + width, h + width) +
|
|
41
|
+
holePath(x + half, y + half, w - width, h - width),
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
/**
|
|
45
|
+
* Typesets `source` with MathJax and reads the SVG back as marks. Throws when MathJax reports a TeX error
|
|
46
|
+
* or draws something this package cannot place, such as a `<text>` fallback for a character the font lacks.
|
|
47
|
+
*/
|
|
48
|
+
export function compile(source, display) {
|
|
49
|
+
const container = convert(source, display);
|
|
50
|
+
// SAFETY: MathJax's container holds elements only, and the walk below skips text by its kind.
|
|
51
|
+
const svg = adaptor.childNodes(container).find((n) => adaptor.kind(n) === "svg");
|
|
52
|
+
if (!svg)
|
|
53
|
+
throw new Error(`tex() got no drawing back from MathJax for ${JSON.stringify(source)}.`);
|
|
54
|
+
const view = (adaptor.getAttribute(svg, "viewBox") ?? "").split(/\s+/).map(Number);
|
|
55
|
+
const [minX, minY, boxW, boxH] = view;
|
|
56
|
+
if (view.length !== 4 || view.some(Number.isNaN))
|
|
57
|
+
throw new Error(`tex() could not read the box MathJax drew for ${JSON.stringify(source)}. A \\tag draws a full-width table that has none.`);
|
|
58
|
+
const marks = [];
|
|
59
|
+
const walk = (parent, inherited) => {
|
|
60
|
+
// SAFETY: a LiteNode is a LiteElement or a LiteText, and the loop skips text by its kind.
|
|
61
|
+
for (const node of adaptor.childNodes(parent)) {
|
|
62
|
+
const kind = adaptor.kind(node);
|
|
63
|
+
if (kind === "#text" || kind === "title")
|
|
64
|
+
continue;
|
|
65
|
+
const message = adaptor.getAttribute(node, "data-mjx-error");
|
|
66
|
+
if (message !== undefined)
|
|
67
|
+
throw new Error(`tex() could not parse ${JSON.stringify(source)}: ${message}`);
|
|
68
|
+
const own = adaptor.getAttribute(node, "fill");
|
|
69
|
+
const here = {
|
|
70
|
+
matrix: multiply(inherited.matrix, parseTransform(adaptor.getAttribute(node, "transform"))),
|
|
71
|
+
fill: own && own !== "none" && own !== "currentColor" ? own : inherited.fill,
|
|
72
|
+
id: adaptor.getAttribute(node, "id") ?? inherited.id,
|
|
73
|
+
};
|
|
74
|
+
if (kind === "g" || kind === "a")
|
|
75
|
+
walk(node, here);
|
|
76
|
+
else if (kind === "text") {
|
|
77
|
+
const drawn = adaptor.textContent(node).trim();
|
|
78
|
+
if (drawn)
|
|
79
|
+
throw new Error(`tex() cannot draw ${JSON.stringify(drawn)} in ${JSON.stringify(source)}: the MathJax font has no glyph for it.`);
|
|
80
|
+
}
|
|
81
|
+
else if (kind === "path") {
|
|
82
|
+
const d = adaptor.getAttribute(node, "d");
|
|
83
|
+
if (!d)
|
|
84
|
+
continue;
|
|
85
|
+
const code = parseInt(adaptor.getAttribute(node, "data-c") ?? "", 16);
|
|
86
|
+
const char = Number.isNaN(code) ? "path" : String.fromCodePoint(code).normalize("NFKC");
|
|
87
|
+
marks.push({
|
|
88
|
+
char,
|
|
89
|
+
key: char,
|
|
90
|
+
d,
|
|
91
|
+
matrix: here.matrix,
|
|
92
|
+
size: here.matrix[0],
|
|
93
|
+
fill: here.fill,
|
|
94
|
+
id: here.id,
|
|
95
|
+
});
|
|
96
|
+
}
|
|
97
|
+
else if (kind === "rect" || kind === "line") {
|
|
98
|
+
if (adaptor.getAttribute(node, "data-hitbox") !== undefined)
|
|
99
|
+
continue;
|
|
100
|
+
const outlined = own === "none" || kind === "line";
|
|
101
|
+
const [w, h] = [num(node, "width"), num(node, "height")];
|
|
102
|
+
const { d, key } = outlined
|
|
103
|
+
? stroked(kind, node)
|
|
104
|
+
: {
|
|
105
|
+
d: boxPath(num(node, "x"), num(node, "y"), w, h),
|
|
106
|
+
key: `rect:${round(w)}x${round(h)}`,
|
|
107
|
+
};
|
|
108
|
+
marks.push({
|
|
109
|
+
char: kind,
|
|
110
|
+
key,
|
|
111
|
+
d,
|
|
112
|
+
matrix: here.matrix,
|
|
113
|
+
size: here.matrix[0],
|
|
114
|
+
fill: here.fill,
|
|
115
|
+
id: here.id,
|
|
116
|
+
});
|
|
117
|
+
}
|
|
118
|
+
else {
|
|
119
|
+
throw new Error(`tex() cannot draw a <${kind}> element in ${JSON.stringify(source)}. MathJax builds stretchy parts such as \\overbrace from clipped pieces.`);
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
};
|
|
123
|
+
walk(svg, { matrix: identity });
|
|
124
|
+
return { box: [minX, minY, boxW, boxH], marks };
|
|
125
|
+
}
|
package/matrix.d.ts
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
/** An SVG `matrix(a b c d e f)`: x' = a·x + c·y + e, y' = b·x + d·y + f. */
|
|
2
|
+
export type Matrix = readonly [a: number, b: number, c: number, d: number, e: number, f: number];
|
|
3
|
+
export declare const identity: Matrix;
|
|
4
|
+
/** `m` applied after `n`. */
|
|
5
|
+
export declare const multiply: (m: Matrix, n: Matrix) => Matrix;
|
|
6
|
+
export declare const translate: (x: number, y: number) => Matrix;
|
|
7
|
+
export declare const scale: (x: number, y?: number) => Matrix;
|
|
8
|
+
export declare const roundMatrix: (m: Matrix) => Matrix;
|
|
9
|
+
export declare const apply: (m: Matrix, x: number, y: number) => [number, number];
|
|
10
|
+
/** Rounds to three decimals, which is a thousandth of a pixel at the sizes scenes use. */
|
|
11
|
+
export declare const round: (n: number) => number;
|
|
12
|
+
/**
|
|
13
|
+
* The matrix of an SVG `transform` list made of `translate()` and `scale()`, the only two MathJax writes.
|
|
14
|
+
* MathJax separates arguments with a comma, a space or both.
|
|
15
|
+
*/
|
|
16
|
+
export declare function parseTransform(list: string | null | undefined): Matrix;
|
package/matrix.js
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
export const identity = [1, 0, 0, 1, 0, 0];
|
|
2
|
+
/** `m` applied after `n`. */
|
|
3
|
+
export const multiply = (m, n) => [
|
|
4
|
+
m[0] * n[0] + m[2] * n[1],
|
|
5
|
+
m[1] * n[0] + m[3] * n[1],
|
|
6
|
+
m[0] * n[2] + m[2] * n[3],
|
|
7
|
+
m[1] * n[2] + m[3] * n[3],
|
|
8
|
+
m[0] * n[4] + m[2] * n[5] + m[4],
|
|
9
|
+
m[1] * n[4] + m[3] * n[5] + m[5],
|
|
10
|
+
];
|
|
11
|
+
export const translate = (x, y) => [1, 0, 0, 1, x, y];
|
|
12
|
+
export const scale = (x, y = x) => [x, 0, 0, y, 0, 0];
|
|
13
|
+
export const roundMatrix = (m) => [
|
|
14
|
+
round(m[0]),
|
|
15
|
+
round(m[1]),
|
|
16
|
+
round(m[2]),
|
|
17
|
+
round(m[3]),
|
|
18
|
+
round(m[4]),
|
|
19
|
+
round(m[5]),
|
|
20
|
+
];
|
|
21
|
+
export const apply = (m, x, y) => [
|
|
22
|
+
m[0] * x + m[2] * y + m[4],
|
|
23
|
+
m[1] * x + m[3] * y + m[5],
|
|
24
|
+
];
|
|
25
|
+
/** Rounds to three decimals, which is a thousandth of a pixel at the sizes scenes use. */
|
|
26
|
+
export const round = (n) => Math.round(n * 1000) / 1000 + 0;
|
|
27
|
+
/**
|
|
28
|
+
* The matrix of an SVG `transform` list made of `translate()` and `scale()`, the only two MathJax writes.
|
|
29
|
+
* MathJax separates arguments with a comma, a space or both.
|
|
30
|
+
*/
|
|
31
|
+
export function parseTransform(list) {
|
|
32
|
+
let m = identity;
|
|
33
|
+
for (const [, name, args] of (list ?? "").matchAll(/(\w+)\(([^)]*)\)/g)) {
|
|
34
|
+
const n = args
|
|
35
|
+
.trim()
|
|
36
|
+
.split(/[\s,]+/)
|
|
37
|
+
.map(Number);
|
|
38
|
+
if (n.some(Number.isNaN))
|
|
39
|
+
throw new Error(`tex() met a transform it cannot read: ${list}`);
|
|
40
|
+
if (name === "translate")
|
|
41
|
+
m = multiply(m, translate(n[0], n[1] ?? 0));
|
|
42
|
+
else if (name === "scale")
|
|
43
|
+
m = multiply(m, scale(n[0], n[1] ?? n[0]));
|
|
44
|
+
else
|
|
45
|
+
throw new Error(`tex() met a ${name}() transform it cannot place: ${list}`);
|
|
46
|
+
}
|
|
47
|
+
return m;
|
|
48
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,27 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@svjeez/math",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.6.1",
|
|
4
|
+
"description": "TeX equations for svjeez: formulas compiled to glyph paths while the scene builds, ready for morph() and annotations.",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"repository": {
|
|
7
|
+
"type": "git",
|
|
8
|
+
"url": "git+https://github.com/saiashirwad/svjeez.git",
|
|
9
|
+
"directory": "packages/math"
|
|
10
|
+
},
|
|
11
|
+
"type": "module",
|
|
12
|
+
"exports": {
|
|
13
|
+
".": {
|
|
14
|
+
"types": "./index.d.ts",
|
|
15
|
+
"default": "./index.js"
|
|
16
|
+
}
|
|
17
|
+
},
|
|
18
|
+
"dependencies": {
|
|
19
|
+
"mathjax-full": "3.2.2"
|
|
20
|
+
},
|
|
21
|
+
"peerDependencies": {
|
|
22
|
+
"svjeez": ">=0.6.0 <1"
|
|
23
|
+
},
|
|
24
|
+
"engines": {
|
|
25
|
+
"node": ">=20"
|
|
26
|
+
}
|
|
27
|
+
}
|
package/path.d.ts
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Every endpoint and control point of an absolute path, in order. A Bezier curve stays inside the hull of
|
|
3
|
+
* these points, so their box is a safe stand-in for the glyph's box.
|
|
4
|
+
*/
|
|
5
|
+
export declare function pathPoints(d: string): [number, number][];
|
|
6
|
+
/** A closed box as path data. */
|
|
7
|
+
export declare const boxPath: (x: number, y: number, w: number, h: number) => string;
|
|
8
|
+
/** The same box wound the other way, so that two of them make a hole under the nonzero rule. */
|
|
9
|
+
export declare const holePath: (x: number, y: number, w: number, h: number) => string;
|
package/path.js
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { round } from "./matrix.js";
|
|
2
|
+
/** The number of coordinates each absolute command takes. MathJax's font only uses the first six. */
|
|
3
|
+
const ARITY = new Map([
|
|
4
|
+
["M", 2],
|
|
5
|
+
["L", 2],
|
|
6
|
+
["H", 1],
|
|
7
|
+
["V", 1],
|
|
8
|
+
["Q", 4],
|
|
9
|
+
["T", 2],
|
|
10
|
+
["C", 6],
|
|
11
|
+
["S", 4],
|
|
12
|
+
["Z", 0],
|
|
13
|
+
]);
|
|
14
|
+
/**
|
|
15
|
+
* Every endpoint and control point of an absolute path, in order. A Bezier curve stays inside the hull of
|
|
16
|
+
* these points, so their box is a safe stand-in for the glyph's box.
|
|
17
|
+
*/
|
|
18
|
+
export function pathPoints(d) {
|
|
19
|
+
const points = [];
|
|
20
|
+
let [x, y] = [0, 0];
|
|
21
|
+
for (const [, command, body] of d.matchAll(/([A-Za-z])([^A-Za-z]*)/g)) {
|
|
22
|
+
const arity = ARITY.get(command);
|
|
23
|
+
if (arity === undefined)
|
|
24
|
+
throw new Error(`tex() met a "${command}" path command, and only absolute commands are drawn.`);
|
|
25
|
+
const n = (body.match(/-?(?:\d+\.?\d*|\.\d+)(?:e[-+]?\d+)?/gi) ?? []).map(Number);
|
|
26
|
+
for (let i = 0; i < n.length; i += arity) {
|
|
27
|
+
if (command === "H")
|
|
28
|
+
x = n[i];
|
|
29
|
+
else if (command === "V")
|
|
30
|
+
y = n[i];
|
|
31
|
+
else {
|
|
32
|
+
for (let k = 0; k < arity; k += 2)
|
|
33
|
+
points.push([n[i + k], n[i + k + 1]]);
|
|
34
|
+
[x, y] = [n[i + arity - 2], n[i + arity - 1]];
|
|
35
|
+
continue;
|
|
36
|
+
}
|
|
37
|
+
points.push([x, y]);
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
return points;
|
|
41
|
+
}
|
|
42
|
+
/** A closed box as path data. */
|
|
43
|
+
export const boxPath = (x, y, w, h) => `M${round(x)} ${round(y)}H${round(x + w)}V${round(y + h)}H${round(x)}Z`;
|
|
44
|
+
/** The same box wound the other way, so that two of them make a hole under the nonzero rule. */
|
|
45
|
+
export const holePath = (x, y, w, h) => `M${round(x)} ${round(y)}V${round(y + h)}H${round(x + w)}V${round(y)}Z`;
|