rapier-math 1.0.1 → 1.1.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/README.md +187 -85
- package/adoption-check.mjs +149 -0
- package/check.mjs +21 -0
- package/classic.d.ts +4 -0
- package/delimiters.d.ts +8 -0
- package/delimiters.mjs +39 -0
- package/examples/README.md +25 -0
- package/examples/browser.html +10 -0
- package/examples/browser.mjs +24 -0
- package/examples/formula.mjs +12 -0
- package/examples/markdown.mjs +9 -0
- package/examples/react.tsx +19 -0
- package/examples/stream.mjs +19 -0
- package/examples/unified.mjs +26 -0
- package/examples/worker.mjs +7 -0
- package/index.d.ts +43 -0
- package/index.mjs +205 -108
- package/integration.d.ts +9 -0
- package/integration.mjs +28 -0
- package/markdown-it.d.ts +6 -0
- package/markdown-it.mjs +45 -0
- package/markdown-rules.mjs +65 -0
- package/math-components.json +6 -6
- package/package.json +78 -5
- package/rapier-math-1.1.0.js +2980 -0
- package/rehype.d.ts +6 -0
- package/rehype.mjs +37 -0
- package/src/math/FONT-SOURCE.md +32 -6
- package/src/math/README.md +210 -0
- package/src/math/adoption-verify.mjs +149 -0
- package/src/math/api.js +37 -14
- package/src/math/browser.js +67 -30
- package/src/math/build-font.py +237 -95
- package/src/math/classic.d.ts +4 -0
- package/src/math/delimiters.d.ts +8 -0
- package/src/math/delimiters.mjs +39 -0
- package/src/math/examples/README.md +25 -0
- package/src/math/examples/browser.html +10 -0
- package/src/math/examples/browser.mjs +24 -0
- package/src/math/examples/formula.mjs +12 -0
- package/src/math/examples/markdown.mjs +9 -0
- package/src/math/examples/react.tsx +19 -0
- package/src/math/examples/stream.mjs +19 -0
- package/src/math/examples/unified.mjs +26 -0
- package/src/math/examples/worker.mjs +7 -0
- package/src/math/fallbacks.js +5 -5
- package/src/math/font.js +59 -26
- package/src/math/index.d.ts +43 -0
- package/src/math/integration.d.ts +9 -0
- package/src/math/integration.mjs +28 -0
- package/src/math/layout.js +37 -33
- package/src/math/markdown-it.d.ts +6 -0
- package/src/math/markdown-it.mjs +45 -0
- package/src/math/markdown-rules.mjs +65 -0
- package/src/math/package-build.mjs +4 -4
- package/src/math/package-check.mjs +118 -0
- package/src/math/package-readme.mjs +8 -0
- package/src/math/package-verify.mjs +7 -2
- package/src/math/rehype.d.ts +6 -0
- package/src/math/rehype.mjs +37 -0
- package/src/math/stream.d.ts +14 -0
- package/src/math/stream.mjs +46 -0
- package/src/package.json +1 -1
- package/src/shell/math-resources.json +184 -25
- package/src/tools/build-math.mjs +23 -4
- package/stream.d.ts +14 -0
- package/stream.mjs +46 -0
- package/verify.mjs +7 -2
- package/rapier-math-1.0.1.js +0 -2883
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
// SPDX-License-Identifier: MIT
|
|
2
|
+
export interface MathError {
|
|
3
|
+
code: string;
|
|
4
|
+
message: string;
|
|
5
|
+
/** Zero-based UTF-16 offset in the formula. */
|
|
6
|
+
offset: number;
|
|
7
|
+
command?: string;
|
|
8
|
+
messageTruncated?: boolean;
|
|
9
|
+
commandTruncated?: boolean;
|
|
10
|
+
/** True only when refused oversized source is not embedded in diagnostic SVG. */
|
|
11
|
+
sourceOmitted?: boolean;
|
|
12
|
+
}
|
|
13
|
+
export interface RenderOptions {
|
|
14
|
+
displayMode?: boolean;
|
|
15
|
+
maxWidth?: number;
|
|
16
|
+
screen?: {fontSize: number; pixelRatio: number; dark: boolean};
|
|
17
|
+
}
|
|
18
|
+
export interface RenderResult {
|
|
19
|
+
svg: string;
|
|
20
|
+
width: number;
|
|
21
|
+
height: number;
|
|
22
|
+
depth: number;
|
|
23
|
+
lines: number;
|
|
24
|
+
fitted: boolean;
|
|
25
|
+
errors: MathError[];
|
|
26
|
+
}
|
|
27
|
+
/** Parser nodes are inspectable; the internal node vocabulary can grow. */
|
|
28
|
+
export interface MathNode {type: string; [field: string]: unknown}
|
|
29
|
+
/** Browser nodes (including Element, Document and DocumentFragment) satisfy this
|
|
30
|
+
* structural type without requiring the DOM type library in server projects. */
|
|
31
|
+
export interface MathContainer {readonly nodeType: number}
|
|
32
|
+
export const version: string;
|
|
33
|
+
/** Oversized source returns a bounded resource-limit diagnostic with sourceOmitted. */
|
|
34
|
+
export function render(source: unknown, options?: RenderOptions): RenderResult;
|
|
35
|
+
export function renderToString(source: unknown, options?: RenderOptions): string;
|
|
36
|
+
export function parse(source: string): MathNode;
|
|
37
|
+
export function fit(container: MathContainer): number;
|
|
38
|
+
export function observe(container: MathContainer): () => void;
|
|
39
|
+
declare const RapierMath: Readonly<{
|
|
40
|
+
version: typeof version; render: typeof render; renderToString: typeof renderToString;
|
|
41
|
+
parse: typeof parse; fit: typeof fit; observe: typeof observe;
|
|
42
|
+
}>;
|
|
43
|
+
export default RapierMath;
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
// SPDX-License-Identifier: MIT
|
|
2
|
+
import type {RenderOptions} from './index.js';
|
|
3
|
+
export interface IntegrationOptions {
|
|
4
|
+
renderOptions?: RenderOptions;
|
|
5
|
+
/** Per document. Default 1024. Exhaustion leaves subsequent math as source. */
|
|
6
|
+
maxFormulas?: number;
|
|
7
|
+
/** Combined successful SVG code units per document. Default 16 * 1024 * 1024. */
|
|
8
|
+
maxOutputLength?: number;
|
|
9
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
// SPDX-License-Identifier: MIT
|
|
2
|
+
import {render} from './index.mjs';
|
|
3
|
+
|
|
4
|
+
export const escapeText = text => text.replace(/[&<>"'\r]/g, char =>
|
|
5
|
+
({'&': '&', '<': '<', '>': '>', '"': '"', "'": ''', '\r': ' '}[char]));
|
|
6
|
+
|
|
7
|
+
// One budget per document render. Rejection leaves the host's text node intact.
|
|
8
|
+
export function createFormulaRenderer(options = {}) {
|
|
9
|
+
const maxFormulas = options.maxFormulas ?? 1024, maxOutputLength = options.maxOutputLength ?? 16 * 1024 * 1024;
|
|
10
|
+
for (const value of [maxFormulas, maxOutputLength])
|
|
11
|
+
if (!Number.isSafeInteger(value) || value < 0) throw new RangeError('Math integration budgets must be finite nonnegative integers.');
|
|
12
|
+
let count = 0, length = 0;
|
|
13
|
+
return (source, displayMode) => {
|
|
14
|
+
let result;
|
|
15
|
+
if (count >= maxFormulas || length >= maxOutputLength) return {errors: [{code: 'resource-limit', offset: 0,
|
|
16
|
+
message: 'The document math budget is exhausted.'}]};
|
|
17
|
+
count++;
|
|
18
|
+
try { result = render(source, {...options.renderOptions, displayMode}); }
|
|
19
|
+
catch (error) { return {errors: [{code: error.code || 'invalid-tex', message: String(error.message), offset: error.offset || 0}]}; }
|
|
20
|
+
if (result.errors.length) return {errors: result.errors};
|
|
21
|
+
if (result.svg.length > maxOutputLength - length) {
|
|
22
|
+
length = maxOutputLength;
|
|
23
|
+
return {errors: [{code: 'resource-limit', offset: 0, message: 'The document math output budget is exhausted.'}]};
|
|
24
|
+
}
|
|
25
|
+
length += result.svg.length;
|
|
26
|
+
return result;
|
|
27
|
+
};
|
|
28
|
+
}
|
package/src/math/layout.js
CHANGED
|
@@ -13,9 +13,9 @@ function mathScale(c) {
|
|
|
13
13
|
return c.size * (c.screen ? Math.max(scale, Math.min(1, (c.level === 1 ? 12 : 10) / c.screen.fontSize)) : scale);
|
|
14
14
|
}
|
|
15
15
|
|
|
16
|
-
function mathScreenStroke(c) {
|
|
16
|
+
function mathScreenStroke(c, scale) {
|
|
17
17
|
if (!c.screen) return 0;
|
|
18
|
-
const {fontSize, pixelRatio, dark} = c.screen, size = fontSize * mathScale(c);
|
|
18
|
+
const {fontSize, pixelRatio, dark} = c.screen, size = fontSize * (scale ?? mathScale(c));
|
|
19
19
|
if (!(size > 0)) return 0;
|
|
20
20
|
const small = Math.max(0, Math.min(1, (32 - size) / 16));
|
|
21
21
|
const hairline = MATH_FONT.constants.FractionRuleThickness / MATH_FONT.units * size;
|
|
@@ -41,35 +41,37 @@ function mathCompose(items, width, extra = {}) {
|
|
|
41
41
|
return mathBox(width, h, d, svg, {left, right, inkTop:Number.isFinite(inkTop)?inkTop:-h, inkBottom:Number.isFinite(inkBottom)?inkBottom:d,...glyph,...extra});
|
|
42
42
|
}
|
|
43
43
|
|
|
44
|
+
const MATH_STYLED_RANGES = {
|
|
45
|
+
italic: [0x1d434, 0x1d44e, null], bold: [0x1d400, 0x1d41a, 0x1d7ce],
|
|
46
|
+
'bold-italic': [0x1d468, 0x1d482, null], script: [0x1d49c, 0x1d4b6, null],
|
|
47
|
+
'bold-script': [0x1d4d0, 0x1d4ea, null], fraktur: [0x1d504, 0x1d51e, null],
|
|
48
|
+
'bold-fraktur': [0x1d56c, 0x1d586, null], 'double-struck': [0x1d538, 0x1d552, 0x1d7d8],
|
|
49
|
+
'sans-serif': [0x1d5a0, 0x1d5ba, 0x1d7e2], 'sans-serif-bold': [0x1d5d4, 0x1d5ee, 0x1d7ec],
|
|
50
|
+
'sans-serif-italic': [0x1d608, 0x1d622, null], 'sans-serif-bold-italic': [0x1d63c, 0x1d656, null],
|
|
51
|
+
monospace: [0x1d670, 0x1d68a, 0x1d7f6],
|
|
52
|
+
};
|
|
53
|
+
const MATH_STYLED_LEGACY = {
|
|
54
|
+
italic: {104: 0x210e},
|
|
55
|
+
script: {66:0x212c,69:0x2130,70:0x2131,72:0x210b,73:0x2110,76:0x2112,77:0x2133,82:0x211b,101:0x212f,103:0x210a,111:0x2134},
|
|
56
|
+
fraktur: {67:0x212d,72:0x210c,73:0x2111,82:0x211c,90:0x2128},
|
|
57
|
+
'double-struck': {67:0x2102,72:0x210d,78:0x2115,80:0x2119,81:0x211a,82:0x211d,90:0x2124},
|
|
58
|
+
};
|
|
59
|
+
const MATH_STYLED_GREEK = {bold:0x1d6a8, italic:0x1d6e2, 'bold-italic':0x1d71c, 'sans-serif-bold':0x1d756, 'sans-serif-bold-italic':0x1d790};
|
|
60
|
+
const MATH_STYLED_GREEK_SPECIAL = [0x2207,0x2202,0x3f5,0x3d1,0x3f0,0x3d5,0x3f1,0x3d6];
|
|
44
61
|
function mathStyledCodePoint(cp, variant) {
|
|
45
|
-
const ranges = {
|
|
46
|
-
italic: [0x1d434, 0x1d44e, null], bold: [0x1d400, 0x1d41a, 0x1d7ce],
|
|
47
|
-
'bold-italic': [0x1d468, 0x1d482, null], script: [0x1d49c, 0x1d4b6, null],
|
|
48
|
-
'bold-script': [0x1d4d0, 0x1d4ea, null], fraktur: [0x1d504, 0x1d51e, null],
|
|
49
|
-
'bold-fraktur': [0x1d56c, 0x1d586, null], 'double-struck': [0x1d538, 0x1d552, 0x1d7d8],
|
|
50
|
-
'sans-serif': [0x1d5a0, 0x1d5ba, 0x1d7e2], 'sans-serif-bold': [0x1d5d4, 0x1d5ee, 0x1d7ec],
|
|
51
|
-
'sans-serif-italic': [0x1d608, 0x1d622, null], 'sans-serif-bold-italic': [0x1d63c, 0x1d656, null],
|
|
52
|
-
monospace: [0x1d670, 0x1d68a, 0x1d7f6],
|
|
53
|
-
};
|
|
54
|
-
const legacy = {
|
|
55
|
-
italic: {104: 0x210e},
|
|
56
|
-
script: {66:0x212c,69:0x2130,70:0x2131,72:0x210b,73:0x2110,76:0x2112,77:0x2133,82:0x211b,101:0x212f,103:0x210a,111:0x2134},
|
|
57
|
-
fraktur: {67:0x212d,72:0x210c,73:0x2111,82:0x211c,90:0x2128},
|
|
58
|
-
'double-struck': {67:0x2102,72:0x210d,78:0x2115,80:0x2119,81:0x211a,82:0x211d,90:0x2124},
|
|
59
|
-
};
|
|
60
62
|
if (variant === 'normal') variant = 'italic';
|
|
61
|
-
if (
|
|
62
|
-
const r =
|
|
63
|
+
if (MATH_STYLED_LEGACY[variant]?.[cp] !== undefined) return MATH_STYLED_LEGACY[variant][cp];
|
|
64
|
+
const r = MATH_STYLED_RANGES[variant];
|
|
63
65
|
if (r) {
|
|
64
66
|
if (cp >= 65 && cp <= 90) return r[0] + cp - 65;
|
|
65
67
|
if (cp >= 97 && cp <= 122) return r[1] + cp - 97;
|
|
66
68
|
if (r[2] && cp >= 48 && cp <= 57) return r[2] + cp - 48;
|
|
67
69
|
}
|
|
68
|
-
const greek =
|
|
70
|
+
const greek = MATH_STYLED_GREEK[variant];
|
|
69
71
|
if (greek !== undefined) {
|
|
70
72
|
if (cp >= 0x391 && cp <= 0x3a9 && cp !== 0x3a2) return greek + cp - 0x391;
|
|
71
73
|
if (cp >= 0x3b1 && cp <= 0x3c9) return greek + 26 + cp - 0x3b1;
|
|
72
|
-
const special =
|
|
74
|
+
const special = MATH_STYLED_GREEK_SPECIAL.indexOf(cp);
|
|
73
75
|
if (special === 0) return greek + 25;
|
|
74
76
|
if (special > 0) return greek + 50 + special;
|
|
75
77
|
}
|
|
@@ -78,19 +80,19 @@ function mathStyledCodePoint(cp, variant) {
|
|
|
78
80
|
|
|
79
81
|
function mathGlyphIndex(id, c, extra = {}) {
|
|
80
82
|
if(c.dotless)id=MATH_FONT.dotless?.[id] ?? id;
|
|
81
|
-
const alternates=MATH_FONT.scriptAlternates[id];
|
|
82
|
-
const pixels=c.screen?c.screen.fontSize*
|
|
83
|
+
const alternates=MATH_FONT.scriptAlternates[id],scale=mathScale(c);
|
|
84
|
+
const pixels=c.screen?c.screen.fontSize*scale:Infinity,
|
|
83
85
|
optical=Math.max(c.level,pixels<10?2:pixels<15?1:0);
|
|
84
86
|
if(optical && alternates)id=alternates[Math.min(optical,alternates.length)-1];
|
|
85
87
|
if(c.flattenAccent)id=MATH_FONT.flatAccents[id] ?? id;
|
|
86
|
-
const g = MATH_FONT.
|
|
88
|
+
const g = MATH_FONT.glyph(id), s = scale / MATH_FONT.units, stroke = mathScreenStroke(c,scale), radius = stroke / 2;
|
|
87
89
|
if (!g) throw Object.assign(new Error('Unsupported mathematical glyph'), {code:'unsupported-glyph'});
|
|
88
90
|
if(c.budget) {
|
|
89
91
|
c.budget.outlineBytes=(c.budget.outlineBytes || 0)+g[7].length+96;
|
|
90
92
|
if(c.budget.outlineBytes>4*1024*1024)throw Object.assign(new Error('Mathematical outlines exceed the rendering budget'),{code:'resource-limit'});
|
|
91
93
|
}
|
|
92
94
|
const svg = g[7] ? '<path d="' + g[7] + '" transform="scale(' + mathNumber(s) + ' ' + mathNumber(-s) + ')"'+
|
|
93
|
-
(c.screen ? ' data-math-ink="' + mathNumber(
|
|
95
|
+
(c.screen ? ' data-math-ink="' + mathNumber(scale) + '"' : '') +
|
|
94
96
|
(stroke ? ' stroke="currentColor" stroke-width="' + mathNumber(stroke / s) + '" stroke-linejoin="round" paint-order="stroke fill"' : '') + '/>' : '';
|
|
95
97
|
return mathBox(g[0] * s, Math.max(0, g[4] * s + radius), Math.max(0, -g[2] * s + radius), svg,
|
|
96
98
|
{left: Math.min(0,g[1]*s-radius), right: Math.max(g[0]*s,g[3]*s+radius), inkTop:-g[4]*s-radius, inkBottom:-g[2]*s+radius, italic: g[5]*s, stroke,
|
|
@@ -98,8 +100,9 @@ function mathGlyphIndex(id, c, extra = {}) {
|
|
|
98
100
|
}
|
|
99
101
|
|
|
100
102
|
function mathGlyph(value, c, extra = {}) {
|
|
101
|
-
|
|
102
|
-
|
|
103
|
+
const cp = value.codePointAt(0);
|
|
104
|
+
if (value.length !== (cp > 0xffff ? 2 : 1)) return mathText(value, c, extra);
|
|
105
|
+
const mapped = mathStyledCodePoint(cp, extra.variant || c.variant);
|
|
103
106
|
const id = MATH_FONT.chars[mapped] ?? MATH_FONT.chars[cp];
|
|
104
107
|
if (id === undefined) {
|
|
105
108
|
const composed = mathFallbackGlyph(value, c, extra);
|
|
@@ -134,7 +137,7 @@ function mathStretch(value, target, c, horizontal = false) {
|
|
|
134
137
|
const shape = (glyph, advance) => {
|
|
135
138
|
const b = mathGlyphIndex(glyph,c);
|
|
136
139
|
if (!horizontal || b.w !== 0) return b;
|
|
137
|
-
const g = MATH_FONT.
|
|
140
|
+
const g = MATH_FONT.glyph(b.glyph), unit = b.unitScale, x = -g[1] * unit;
|
|
138
141
|
const width = (advance ?? g[3] - g[1]) * unit;
|
|
139
142
|
return mathCompose([[b,x,0]],width,{italic:b.italic,accent:b.accent+x});
|
|
140
143
|
};
|
|
@@ -150,7 +153,7 @@ function mathStretch(value, target, c, horizontal = false) {
|
|
|
150
153
|
// These font glyphs have straight central shafts but no MATH assembly.
|
|
151
154
|
// Preserve both end shapes and widen only a narrow central strip.
|
|
152
155
|
if('=⇌⇋↞↠→↔↦'.includes(value)) {
|
|
153
|
-
const g=MATH_FONT.
|
|
156
|
+
const g=MATH_FONT.glyph(natural.glyph),unit=natural.unitScale,added=target-natural.w,
|
|
154
157
|
split=g[0]*(value==='↞'?0.75:value==='↠'?0.25:0.5),half=g[0]*0.05,
|
|
155
158
|
left=split-half,right=split+half,delta=added/unit;
|
|
156
159
|
// The two-headed arrows have their plain shaft opposite both heads.
|
|
@@ -201,7 +204,7 @@ function mathStretch(value, target, c, horizontal = false) {
|
|
|
201
204
|
length=(full-overlap*connections.length)*unit;
|
|
202
205
|
const items=[]; let pos=0, width=0;
|
|
203
206
|
for (let i=0;i<pieces.length;i++) {
|
|
204
|
-
const p=pieces[i], b=mathGlyphIndex(p[0],c), g=MATH_FONT.
|
|
207
|
+
const p=pieces[i], b=mathGlyphIndex(p[0],c), g=MATH_FONT.glyph(b.glyph);
|
|
205
208
|
if (horizontal) items.push([b,pos,0]);
|
|
206
209
|
else items.push([b,0,-pos+g[2]*unit]);
|
|
207
210
|
width=Math.max(width,b.w);
|
|
@@ -273,8 +276,9 @@ function mathKern(b,corner,height) {
|
|
|
273
276
|
}
|
|
274
277
|
|
|
275
278
|
function mathContourBands(box, dy = 0) {
|
|
276
|
-
|
|
277
|
-
|
|
279
|
+
if (!box.unitScale || box.extended) return null;
|
|
280
|
+
const profile = MATH_FONT.profile(box.glyph);
|
|
281
|
+
if (!profile) return null;
|
|
278
282
|
const bands = [], step = MATH_FONT.edgeStep, unit = box.unitScale;
|
|
279
283
|
const x = box.glyphX || 0, y = (box.glyphY || 0) + dy, radius = (box.stroke || 0) / 2;
|
|
280
284
|
for (let i = profile.length - 1; i > 0; i--) if (profile[i]) {
|
|
@@ -570,7 +574,7 @@ function mathLayout(n,c) {
|
|
|
570
574
|
case 'smash': {const b=mathLayout(n.body,c);return {...b,h:n.mode==='b'?b.h:0,d:n.mode==='t'?b.d:0};}
|
|
571
575
|
case 'raise': {const b=mathLayout(n.body,c),s=mathScale(c);return mathCompose([[b,0,-n.amount*s]],b.w,{class:b.class,...(n.height===undefined?{}:{h:n.height*s}),...(n.depth===undefined?{}:{d:n.depth*s})});}
|
|
572
576
|
case 'rule': {const s=mathScale(c);return mathRule(n.width*s,(n.height+n.depth)*s,-n.height*s);}
|
|
573
|
-
case 'link': {const b=mathLayout(n.body,c);const url=new URL(n.href);if(!['http:','https:'].includes(url.protocol))throw Object.assign(new Error('Unsupported equation link'),{code:'unsafe-url'});b.svg='<a href="'+mathEscape(n.href)+'" target="_blank" rel="noopener noreferrer">'+b.svg+'</a>';return b;}
|
|
577
|
+
case 'link': {const b=mathLayout(n.body,c);const url=new URL(n.href);if(!['http:','https:'].includes(url.protocol))throw Object.assign(new Error('Unsupported equation link'),{code:'unsafe-url'});b.svg='<a href="'+mathEscape(n.href)+'" aria-label="'+mathEscape(n.href)+'" tabindex="0" target="_blank" rel="noopener noreferrer">'+b.svg+'</a>';return b;}
|
|
574
578
|
default: throw Object.assign(new Error('Unsupported maths construct: '+n.type),{code:'unsupported-construct'});
|
|
575
579
|
}
|
|
576
580
|
}
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
// SPDX-License-Identifier: MIT
|
|
2
|
+
import type MarkdownIt from 'markdown-it';
|
|
3
|
+
import type {DelimiterOptions} from './delimiters.js';
|
|
4
|
+
import type {IntegrationOptions} from './integration.js';
|
|
5
|
+
export interface MarkdownMathOptions extends DelimiterOptions, IntegrationOptions {}
|
|
6
|
+
export default function rapierMath(md: InstanceType<typeof MarkdownIt>, options?: MarkdownMathOptions): void;
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
// SPDX-License-Identifier: MIT
|
|
2
|
+
import {installMathRules} from './markdown-rules.mjs';
|
|
3
|
+
import {createFormulaRenderer, escapeText} from './integration.mjs';
|
|
4
|
+
|
|
5
|
+
export default function rapierMath(md, options = {}) {
|
|
6
|
+
installMathRules(md, options);
|
|
7
|
+
let session;
|
|
8
|
+
function withSession(renderer, render, tokens, opts, env) {
|
|
9
|
+
const previous = session, current = {render: createFormulaRenderer(options), children: new WeakSet()};
|
|
10
|
+
const visit = tokens => {
|
|
11
|
+
for (const token of tokens) {
|
|
12
|
+
if (token.children) { current.children.add(token.children); visit(token.children); }
|
|
13
|
+
}
|
|
14
|
+
};
|
|
15
|
+
visit(tokens);
|
|
16
|
+
session = current;
|
|
17
|
+
try { return render.call(renderer, tokens, opts, env); }
|
|
18
|
+
finally { session = previous; }
|
|
19
|
+
}
|
|
20
|
+
const render = md.renderer.render, renderInline = md.renderer.renderInline;
|
|
21
|
+
md.renderer.render = function(tokens, opts, env) {
|
|
22
|
+
return withSession(this, render, tokens, opts, env);
|
|
23
|
+
};
|
|
24
|
+
md.renderer.renderInline = function(tokens, opts, env) {
|
|
25
|
+
// The block renderer's inline children share its document budget. A separate
|
|
26
|
+
// inline tree, including a nested host renderInline call, owns a fresh one.
|
|
27
|
+
if (session?.children.has(tokens)) return renderInline.call(this, tokens, opts, env);
|
|
28
|
+
return withSession(this, renderInline, tokens, opts, env);
|
|
29
|
+
};
|
|
30
|
+
function formula(tokens, index, _options, env) {
|
|
31
|
+
const token = tokens[index], display = token.type === 'math_block';
|
|
32
|
+
const result = (session?.render || createFormulaRenderer(options))(token.content, display);
|
|
33
|
+
if (result.errors.length) {
|
|
34
|
+
(env.rapierMathErrors ||= []).push({source: token.content, errors: result.errors});
|
|
35
|
+
return '<code data-math-error="' + escapeText(result.errors[0].code) + '">' +
|
|
36
|
+
escapeText(token.meta?.mathSource ?? token.content) + '</code>';
|
|
37
|
+
}
|
|
38
|
+
return result.svg;
|
|
39
|
+
}
|
|
40
|
+
md.renderer.rules.math_inline = formula;
|
|
41
|
+
md.renderer.rules.math_block = (tokens, index, opts, env) => {
|
|
42
|
+
const result = formula(tokens, index, opts, env);
|
|
43
|
+
return tokens[index].block ? '<div class="math-display">' + result + '</div>\n' : result;
|
|
44
|
+
};
|
|
45
|
+
}
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
// SPDX-License-Identifier: MIT
|
|
2
|
+
// Original neutral adapter. The host owns Markdown structure and normalization.
|
|
3
|
+
import {createMathMatcher} from './delimiters.mjs';
|
|
4
|
+
|
|
5
|
+
export function installMathRules(md, {singleDollar = false, brackets = true, ruleName = 'rapier_math', inLink = false} = {}) {
|
|
6
|
+
// The host's image-alt renderer ignores unknown token types. Keep math as
|
|
7
|
+
// literal source in text-only output, while leaving escaping to that host.
|
|
8
|
+
const renderText = md.renderer.renderInlineAsText;
|
|
9
|
+
md.renderer.renderInlineAsText = function(tokens, options, env) {
|
|
10
|
+
return renderText.call(this, tokens.map(token =>
|
|
11
|
+
token.type === 'math_inline' || token.type === 'math_block'
|
|
12
|
+
? {...token, type: 'text', content: token.meta?.mathSource ?? token.content}
|
|
13
|
+
: token), options, env);
|
|
14
|
+
};
|
|
15
|
+
const matchers = new WeakMap();
|
|
16
|
+
function match(state, at, limit) {
|
|
17
|
+
let entry = matchers.get(state);
|
|
18
|
+
if (!entry || entry.source !== state.src) {
|
|
19
|
+
entry = {source: state.src, match: createMathMatcher(state.src, {singleDollar, brackets})};
|
|
20
|
+
matchers.set(state, entry);
|
|
21
|
+
}
|
|
22
|
+
return entry.match(at, limit);
|
|
23
|
+
}
|
|
24
|
+
md.inline.ruler.before('escape', ruleName, (state, silent) => {
|
|
25
|
+
if (!inLink && state.linkLevel > 0) return false;
|
|
26
|
+
if (state.src[state.pos] !== '$' && state.src[state.pos] !== '\\') return false;
|
|
27
|
+
const span = match(state, state.pos, state.posMax);
|
|
28
|
+
if (!span) return false;
|
|
29
|
+
if (!silent) {
|
|
30
|
+
const token = state.push(span.displayMode ? 'math_block' : 'math_inline', '', 0);
|
|
31
|
+
token.markup = span.open;
|
|
32
|
+
token.content = state.src.slice(span.bodyStart, span.bodyEnd);
|
|
33
|
+
token.meta = {mathSource: state.src.slice(span.start, span.end)};
|
|
34
|
+
}
|
|
35
|
+
state.pos = span.end;
|
|
36
|
+
return true;
|
|
37
|
+
});
|
|
38
|
+
md.block.ruler.before('fence', ruleName, (state, startLine, endLine, silent) => {
|
|
39
|
+
// An indented code block belongs to the host even when it begins with dollars.
|
|
40
|
+
if (state.sCount[startLine] - state.blkIndent >= 4) return false;
|
|
41
|
+
const start = state.bMarks[startLine] + state.tShift[startLine];
|
|
42
|
+
if (state.src.slice(start, start + 2) !== '$$' && (!brackets || state.src.slice(start, start + 2) !== '\\[')) return false;
|
|
43
|
+
const span = match(state, start, state.eMarks[endLine - 1]);
|
|
44
|
+
if (!span?.displayMode) return false;
|
|
45
|
+
let last = startLine;
|
|
46
|
+
while (last < endLine && state.eMarks[last] < span.end) last++;
|
|
47
|
+
if (last >= endLine || span.bodyEnd < state.bMarks[last] + state.tShift[last] ||
|
|
48
|
+
state.src.slice(span.end, state.eMarks[last]).trim()) return false;
|
|
49
|
+
if (silent) return true;
|
|
50
|
+
const parts = [];
|
|
51
|
+
for (let line = startLine; line <= last; line++) {
|
|
52
|
+
const from = line === startLine ? span.bodyStart : state.bMarks[line] + state.tShift[line];
|
|
53
|
+
const to = line === last ? span.bodyEnd : state.eMarks[line];
|
|
54
|
+
parts.push(state.src.slice(from, to));
|
|
55
|
+
}
|
|
56
|
+
const token = state.push('math_block', 'math', 0);
|
|
57
|
+
token.block = true;
|
|
58
|
+
token.markup = span.open;
|
|
59
|
+
token.content = parts.join('\n');
|
|
60
|
+
token.meta = {mathSource: span.open + token.content + span.close};
|
|
61
|
+
token.map = [startLine, last + 1];
|
|
62
|
+
state.line = last + 1;
|
|
63
|
+
return true;
|
|
64
|
+
}, {alt: ['paragraph', 'reference', 'blockquote', 'list']});
|
|
65
|
+
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
// SPDX-License-Identifier: MIT
|
|
2
2
|
// The module has the exact classic renderer body, with module-local exports.
|
|
3
|
-
import {
|
|
3
|
+
import {writeFile} from 'node:fs/promises';
|
|
4
4
|
import {dirname, resolve} from 'node:path';
|
|
5
5
|
import {fileURLToPath} from 'node:url';
|
|
6
6
|
|
|
@@ -19,9 +19,9 @@ export function mathModule(bundle) {
|
|
|
19
19
|
if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
|
|
20
20
|
if (!process.argv[2]) throw new Error('usage: node src/math/package-build.mjs <module-file>');
|
|
21
21
|
const root = resolve(dirname(fileURLToPath(import.meta.url)), '..');
|
|
22
|
-
const {
|
|
23
|
-
const
|
|
24
|
-
const text = mathModule(
|
|
22
|
+
const {assembleMath} = await import('../tools/build-math.mjs');
|
|
23
|
+
const {bytes} = await assembleMath({root});
|
|
24
|
+
const text = mathModule(bytes);
|
|
25
25
|
await writeFile(resolve(process.argv[2]), text);
|
|
26
26
|
console.log(JSON.stringify({file: process.argv[2], bytes: Buffer.byteLength(text)}));
|
|
27
27
|
}
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
// SPDX-License-Identifier: MIT
|
|
2
|
+
// Package entry, exact authored text and hostile-content boundaries; no appearance assertions.
|
|
3
|
+
import assert from 'node:assert/strict';
|
|
4
|
+
import {readFile} from 'node:fs/promises';
|
|
5
|
+
import {Script, createContext} from 'node:vm';
|
|
6
|
+
import {mathModule} from './src/math/package-build.mjs';
|
|
7
|
+
|
|
8
|
+
const read = path => readFile(new URL(path, import.meta.url), 'utf8');
|
|
9
|
+
const pkg = JSON.parse(await read('./package.json'));
|
|
10
|
+
const resources = JSON.parse(await read('./src/shell/math-resources.json'));
|
|
11
|
+
const classicSource = await read('./' + resources.bundle);
|
|
12
|
+
assert.equal(pkg.name, 'rapier-math');
|
|
13
|
+
assert.equal(pkg.version, resources.version);
|
|
14
|
+
assert.equal(pkg.type, 'module');
|
|
15
|
+
assert.equal(pkg.license, 'SEE LICENSE IN LICENSE');
|
|
16
|
+
assert.equal(Object.keys(pkg.dependencies || {}).length, 0);
|
|
17
|
+
assert.equal(Object.keys(pkg.scripts || {}).length, 0);
|
|
18
|
+
assert.equal(await read('./index.mjs'), mathModule(classicSource), 'exact generated module');
|
|
19
|
+
const rendererLicense = await read('./LICENSE-MIT'), fontLicense = await read('./math-font-LICENSE.txt');
|
|
20
|
+
for (const name of ['./LICENSE', './index.mjs', './' + resources.bundle]) {
|
|
21
|
+
const text = await read(name);
|
|
22
|
+
assert(text.includes(rendererLicense.trim()), name + ': renderer license');
|
|
23
|
+
assert(text.includes(fontLicense.trim()), name + ': complete font license');
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
// Initialize Node's lazy Web API globals before observing module purity. This
|
|
27
|
+
// allocates a local Response only; no network request or renderer evaluation.
|
|
28
|
+
new Response();
|
|
29
|
+
const globals = Object.getOwnPropertyDescriptors(globalThis);
|
|
30
|
+
const module = await import('./index.mjs');
|
|
31
|
+
const afterImport = Object.getOwnPropertyDescriptors(globalThis);
|
|
32
|
+
assert.deepEqual(Reflect.ownKeys(afterImport), Reflect.ownKeys(globals), 'module adds no globals');
|
|
33
|
+
for (const key of Reflect.ownKeys(globals)) for (const field of Object.keys(globals[key]))
|
|
34
|
+
assert(Object.is(afterImport[key][field], globals[key][field]), 'module preserves global ' + String(key));
|
|
35
|
+
assert.deepEqual(Object.keys(module).sort(), ['default', 'fit', 'observe', 'parse', 'render', 'renderToString', 'version']);
|
|
36
|
+
for (const name of Object.keys(module.default)) assert.equal(module[name], module.default[name], 'named/default export ' + name);
|
|
37
|
+
assert.equal(module.version, resources.version);
|
|
38
|
+
assert(Object.isFrozen(module.default));
|
|
39
|
+
|
|
40
|
+
const context = createContext({atob, URL});
|
|
41
|
+
new Script(classicSource, {filename: resources.bundle}).runInContext(context);
|
|
42
|
+
const classic = context.RapierMath;
|
|
43
|
+
const plain = value => JSON.parse(JSON.stringify(value));
|
|
44
|
+
const unescape = text => text.replace(/&(amp|lt|gt|quot|apos|#\d+|#x[\da-f]+);/gi, (_, entity) =>
|
|
45
|
+
entity[0] === '#' ? String.fromCodePoint(entity[1] === 'x' ? parseInt(entity.slice(2), 16) : Number(entity.slice(1))) :
|
|
46
|
+
({amp: '&', lt: '<', gt: '>', quot: '"', apos: "'"})[entity]);
|
|
47
|
+
function sourceAlternative(result, source) {
|
|
48
|
+
const label = /\baria-label="([^"]*)"/.exec(result.svg)?.[1];
|
|
49
|
+
const title = /<title>([^<]*)<\/title>/.exec(result.svg)?.[1];
|
|
50
|
+
const utf16 = /\bdata-tex-utf16="([0-9a-f]+)"/.exec(result.svg)?.[1];
|
|
51
|
+
if (utf16) assert.equal(utf16.match(/.{4}/g).map(part => String.fromCharCode(parseInt(part, 16))).join(''), source, 'exact UTF-16 alternative');
|
|
52
|
+
else { assert.equal(unescape(label), source, 'exact aria alternative'); assert.equal(unescape(title), source, 'exact title alternative'); }
|
|
53
|
+
}
|
|
54
|
+
function inert(svg) {
|
|
55
|
+
assert(!/<\s*(?:script|foreignObject|iframe|object|embed|image|style|animate|set)\b/i.test(svg), 'no executable or remote SVG element');
|
|
56
|
+
for (const tag of svg.match(/<[^>]+>/g) || []) {
|
|
57
|
+
const attributes = [...tag.matchAll(/\s([^\s=]+)="([^"]*)"/g)];
|
|
58
|
+
for (const [, name, value] of attributes) {
|
|
59
|
+
assert(!/^on/i.test(name), 'no event attribute');
|
|
60
|
+
if (/^(?:href|xlink:href)$/i.test(name)) assert(/^https?:\/\//i.test(unescape(value)), 'only HTTP(S) links');
|
|
61
|
+
if (!/^(?:aria-label|data-tex-utf16)$/.test(name)) assert(!/url\s*\(|javascript:|vbscript:/i.test(unescape(value)), 'no executable attribute');
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const examples = [
|
|
67
|
+
[String.raw`\frac{-b\pm\sqrt{b^2-4ac}}{2a}`, {displayMode: true}],
|
|
68
|
+
[String.raw`\begin{aligned}a&=b+c\\d&=e+f\end{aligned}`, {displayMode: true, maxWidth: 4}],
|
|
69
|
+
[String.raw`\newcommand{\squareof}[1]{#1^2}\squareof{x}`, {screen: {fontSize: 16, pixelRatio: 2, dark: false}}],
|
|
70
|
+
[String.raw`\text{A < B & "C" 'D'} + α`, {}],
|
|
71
|
+
[String.raw`\href{https://example.com/?a=1&b=2}{x}`, {}],
|
|
72
|
+
[String.raw`\unsupported{content}`, {}],
|
|
73
|
+
['x\u0000y\ud800z\udfff', {}],
|
|
74
|
+
];
|
|
75
|
+
for (const [source, options] of examples) {
|
|
76
|
+
const result = module.render(source, options);
|
|
77
|
+
assert.deepEqual(plain(result), plain(classic.render(source, options)), 'module/classic result');
|
|
78
|
+
assert.equal(module.renderToString(source, options), result.svg, 'string export agrees with render');
|
|
79
|
+
sourceAlternative(result, source); inert(result.svg);
|
|
80
|
+
}
|
|
81
|
+
assert.deepEqual(plain(module.parse(examples[0][0])), plain(classic.parse(examples[0][0])), 'module/classic parse');
|
|
82
|
+
assert.equal(module.render(examples.at(-1)[0]).errors[0].code, 'invalid-source-character');
|
|
83
|
+
assert.equal(module.render(examples.at(-2)[0]).errors[0].code, 'unsupported-command');
|
|
84
|
+
|
|
85
|
+
const hostile = [
|
|
86
|
+
String.raw`\text{</title><script>alert(1)</script>}`,
|
|
87
|
+
String.raw`\href{javascript:alert(1)}{x}`,
|
|
88
|
+
String.raw`\href{data:text/html,test}{x}`,
|
|
89
|
+
String.raw`\href{https://example.com/" onload="alert(1)}{x}`,
|
|
90
|
+
String.raw`\color{url(https://example.com/payload)}x`,
|
|
91
|
+
String.raw`\def\again{\again}\again`,
|
|
92
|
+
];
|
|
93
|
+
for (const source of hostile) {
|
|
94
|
+
const result = module.render(source);
|
|
95
|
+
sourceAlternative(result, source); inert(result.svg);
|
|
96
|
+
assert.deepEqual(plain(result), plain(classic.render(source)), 'module/classic hostile result');
|
|
97
|
+
}
|
|
98
|
+
for (const source of hostile.slice(1)) assert(module.render(source).errors.length, 'unsafe or exhausted input produces a diagnostic');
|
|
99
|
+
for (const source of ['x'.repeat(1_000_001), '\ud800'.repeat(1_000_001), '&'.repeat(8_000_000)]) {
|
|
100
|
+
const result = module.render(source);
|
|
101
|
+
assert.equal(result.errors[0].code, 'resource-limit');
|
|
102
|
+
assert.equal(result.errors[0].sourceOmitted, true);
|
|
103
|
+
assert(result.svg.includes('data-math-source-omitted="true"'));
|
|
104
|
+
assert(!result.svg.includes('data-tex-utf16'), 'refused source never creates a carrier');
|
|
105
|
+
assert(result.svg.length < 100_000, 'refusal diagnostic is fixed-size');
|
|
106
|
+
assert.equal(module.renderToString(source), result.svg);
|
|
107
|
+
assert.deepEqual(plain(result), plain(classic.render(source)), 'module/classic refusal result');
|
|
108
|
+
inert(result.svg);
|
|
109
|
+
}
|
|
110
|
+
const longCommand = '\\' + 'a'.repeat(10_000), diagnostic = module.render(longCommand);
|
|
111
|
+
assert(diagnostic.errors[0].messageTruncated && diagnostic.errors[0].commandTruncated);
|
|
112
|
+
sourceAlternative(diagnostic, longCommand);
|
|
113
|
+
assert(diagnostic.errors[0].message.length <= 1025 && diagnostic.errors[0].command.length <= 128);
|
|
114
|
+
const unicodeFailure = module.render('\\begin{' + '😀'.repeat(600) + '}x');
|
|
115
|
+
assert(unicodeFailure.errors[0].message.isWellFormed(), 'diagnostic admission cannot split a source surrogate pair');
|
|
116
|
+
assert.equal(module.render('x+1').errors.length, 0, 'refused input cannot poison later rendering');
|
|
117
|
+
console.log(JSON.stringify({name: pkg.name, version: module.version, module: 'native ESM; no global changes',
|
|
118
|
+
rebuild: 'exact module', classicAgreement: examples.length + hostile.length, sourceAlternatives: 'exact', hostileContent: 'inert', licenses: 'complete'}));
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
// SPDX-License-Identifier: MIT
|
|
2
|
+
import {readFileSync} from 'node:fs';
|
|
3
|
+
export function mathReadme(resources) {
|
|
4
|
+
return readFileSync(new URL('./README.md', import.meta.url), 'utf8')
|
|
5
|
+
.replace('rapier-math-VERSION.js', resources.bundle) +
|
|
6
|
+
'\nBuild: ' + resources.version + '. Classic payload: ' + resources.bytes +
|
|
7
|
+
' bytes; gzip (level 9): ' + resources.gzipBytes + ' bytes.\nSHA-384: `' + resources.sha384 + '`\n';
|
|
8
|
+
}
|
|
@@ -15,10 +15,11 @@ const payload = await read(resources.bundle);
|
|
|
15
15
|
assert.equal(payload.length, resources.bytes, 'payload length');
|
|
16
16
|
assert.equal(hash(payload), resources.sha256, 'payload SHA-256');
|
|
17
17
|
assert.equal(hash(payload, 'sha384', 'base64'), resources.sha384, 'payload SHA-384');
|
|
18
|
-
for (const input of [...resources.sources, ...resources.recipe]) {
|
|
18
|
+
for (const input of [...resources.sources, ...resources.recipe, ...resources.adoption]) {
|
|
19
19
|
const bytes = await read('src/' + input.file);
|
|
20
20
|
assert.equal(bytes.length, input.bytes, input.file + ': length');
|
|
21
21
|
assert.equal(hash(bytes), input.sha256, input.file + ': SHA-256');
|
|
22
|
+
if (input.packageFile) assert.deepEqual(await read(input.packageFile), bytes, input.packageFile + ': exact adoption source');
|
|
22
23
|
}
|
|
23
24
|
const rendererLicense = await read('src/math/LICENSE-MIT');
|
|
24
25
|
for (const file of ['LICENSE-MIT', 'math-LICENSE.txt']) assert.deepEqual(await read(file), rendererLicense, file);
|
|
@@ -34,7 +35,11 @@ assert.equal(font.licenseDeclared, resources.font.license, 'font license identit
|
|
|
34
35
|
assert.equal(sbom.hasExtractedLicensingInfos.find(item => item.licenseId === resources.font.license)?.extractedText,
|
|
35
36
|
fontLicense.toString('utf8'), 'complete extracted font license');
|
|
36
37
|
const {buildMath} = await import('./src/tools/build-math.mjs');
|
|
38
|
+
const {mathReadme} = await import('./src/math/package-readme.mjs');
|
|
39
|
+
assert.equal((await read('README.md')).toString('utf8'), mathReadme(resources), 'generated README source');
|
|
40
|
+
for (const [file, owner] of [['check.mjs', 'package-check.mjs'], ['verify.mjs', 'package-verify.mjs'], ['adoption-check.mjs', 'adoption-verify.mjs']])
|
|
41
|
+
assert.deepEqual(await read(file), await read('src/math/' + owner), file + ': exact carried owner');
|
|
37
42
|
await buildMath({root: source, updatePins: false});
|
|
38
43
|
assert.deepEqual(await read('src/shell/vendor/' + resources.bundle), payload, 'rebuilt payload bytes');
|
|
39
44
|
console.log(JSON.stringify({file: resources.bundle, bytes: payload.length, sha256: resources.sha256,
|
|
40
|
-
sources: resources.sources.length + resources.recipe.length, rebuild: 'exact', licenses: 'complete'}));
|
|
45
|
+
sources: resources.sources.length + resources.recipe.length, adoptionFiles: resources.adoption.length, rebuild: 'exact', licenses: 'complete'}));
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
// SPDX-License-Identifier: MIT
|
|
2
|
+
import type {Root} from 'hast';
|
|
3
|
+
import type {VFile} from 'vfile';
|
|
4
|
+
import type {IntegrationOptions} from './integration.js';
|
|
5
|
+
/** Install after sanitization. Recognizes remark-rehype math code nodes. */
|
|
6
|
+
export default function rehypeRapierMath(options?: IntegrationOptions): (tree: Root, file: VFile) => void;
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
// SPDX-License-Identifier: MIT
|
|
2
|
+
import {fromHtml} from 'hast-util-from-html';
|
|
3
|
+
import {createFormulaRenderer} from './integration.mjs';
|
|
4
|
+
|
|
5
|
+
// Run AFTER the host sanitizer. Only known math code nodes are replaced, using
|
|
6
|
+
// fresh renderer output parsed into HAST. Authored raw nodes never become HTML here.
|
|
7
|
+
export default function rehypeRapierMath(options = {}) {
|
|
8
|
+
return (tree, file) => {
|
|
9
|
+
const render = createFormulaRenderer(options);
|
|
10
|
+
function math(node) {
|
|
11
|
+
const names = node?.properties?.className;
|
|
12
|
+
if (node?.type !== 'element' || node.tagName !== 'code' || !Array.isArray(names) ||
|
|
13
|
+
!names.includes('language-math') || !names.some(name => name === 'math-inline' || name === 'math-display') ||
|
|
14
|
+
!Array.isArray(node.children) || !node.children.every(child => child.type === 'text' && typeof child.value === 'string')) return null;
|
|
15
|
+
return {source: node.children.map(child => child.value).join(''), display: names.includes('math-display')};
|
|
16
|
+
}
|
|
17
|
+
function visit(parent) {
|
|
18
|
+
for (let index = 0; index < (parent.children?.length || 0); index++) {
|
|
19
|
+
const node = parent.children[index];
|
|
20
|
+
const code = node.type === 'element' && node.tagName === 'pre' && node.children?.length === 1 ? node.children[0] : node;
|
|
21
|
+
const input = math(code);
|
|
22
|
+
if (!input) { visit(node); continue; }
|
|
23
|
+
const result = render(input.source, input.display);
|
|
24
|
+
if (result.errors.length) {
|
|
25
|
+
for (const error of result.errors) {
|
|
26
|
+
const message = file?.message(error.message, {place: code.position, source: 'rapier-math', ruleId: error.code});
|
|
27
|
+
if (message) { message.mathSource = input.source; message.mathOffset = error.offset; }
|
|
28
|
+
}
|
|
29
|
+
continue;
|
|
30
|
+
}
|
|
31
|
+
const svg = fromHtml(result.svg, {fragment: true}).children[0];
|
|
32
|
+
parent.children[index] = input.display ? {type: 'element', tagName: 'div', properties: {className: ['math-display']}, children: [svg]} : svg;
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
visit(tree);
|
|
36
|
+
};
|
|
37
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
// SPDX-License-Identifier: MIT
|
|
2
|
+
export interface MarkdownSnapshot {source: string; html: string; revision: number; final: boolean}
|
|
3
|
+
export interface MarkdownStream {
|
|
4
|
+
/** null means keep the previous preview; the chunk was still accepted. */
|
|
5
|
+
write(chunk: string): MarkdownSnapshot | null;
|
|
6
|
+
finish(): MarkdownSnapshot;
|
|
7
|
+
abort(): void;
|
|
8
|
+
/** Replace a message by resetting, then writing the replacement source. */
|
|
9
|
+
reset(): number;
|
|
10
|
+
readonly revision: number;
|
|
11
|
+
}
|
|
12
|
+
export function createMarkdownStream(renderMarkdown: (source: string) => string, options?: {
|
|
13
|
+
maxSourceLength?: number; previewEvery?: number;
|
|
14
|
+
}): MarkdownStream;
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
// SPDX-License-Identifier: MIT
|
|
2
|
+
// Full Markdown previews have no append-only promise. Geometric admission bounds
|
|
3
|
+
// the total source passed to a synchronous host, including the final batch pass.
|
|
4
|
+
export function createMarkdownStream(renderMarkdown, {maxSourceLength = 1_000_000, previewEvery = 256} = {}) {
|
|
5
|
+
if (typeof renderMarkdown !== 'function') throw new TypeError('Supply a synchronous Markdown renderer.');
|
|
6
|
+
for (const value of [maxSourceLength, previewEvery])
|
|
7
|
+
if (!Number.isSafeInteger(value) || value < 1) throw new RangeError('Stream limits must be positive finite integers.');
|
|
8
|
+
let chunks = [], length = 0, threshold = previewEvery, closed = false, revision = 0, rendering = false;
|
|
9
|
+
function open() {
|
|
10
|
+
if (closed || rendering) throw new Error(closed ? 'This stream is closed; reset it before writing.' : 'A stream renderer cannot reenter its stream.');
|
|
11
|
+
}
|
|
12
|
+
function snapshot(final) {
|
|
13
|
+
const source = chunks.join('');
|
|
14
|
+
chunks = source ? [source] : [];
|
|
15
|
+
// Failed previews consumed work too. Further chunks must still wait for the
|
|
16
|
+
// next geometric threshold; only an explicit finish retries immediately.
|
|
17
|
+
threshold = Math.max(previewEvery, length * 2);
|
|
18
|
+
rendering = true;
|
|
19
|
+
let html;
|
|
20
|
+
try {
|
|
21
|
+
html = renderMarkdown(source);
|
|
22
|
+
if (typeof html !== 'string') throw new TypeError('The Markdown renderer must return a string synchronously.');
|
|
23
|
+
} finally { rendering = false; }
|
|
24
|
+
if (final) { closed = true; chunks = []; length = 0; }
|
|
25
|
+
return {source, html, revision, final};
|
|
26
|
+
}
|
|
27
|
+
return {
|
|
28
|
+
write(chunk) {
|
|
29
|
+
open();
|
|
30
|
+
if (typeof chunk !== 'string') throw new TypeError('Decode transport bytes to strings before writing.');
|
|
31
|
+
if (chunk.length > maxSourceLength - length)
|
|
32
|
+
throw Object.assign(new RangeError('The message exceeds its source budget; this chunk was not accepted.'), {code: 'resource-limit'});
|
|
33
|
+
if (!chunk.length) return null;
|
|
34
|
+
chunks.push(chunk); length += chunk.length; revision++;
|
|
35
|
+
return length >= threshold ? snapshot(false) : null;
|
|
36
|
+
},
|
|
37
|
+
finish() { open(); revision++; return snapshot(true); },
|
|
38
|
+
abort() {
|
|
39
|
+
if (rendering) throw new Error('A stream renderer cannot abort its stream.');
|
|
40
|
+
if (closed) return;
|
|
41
|
+
chunks = []; length = 0; closed = true; revision++;
|
|
42
|
+
},
|
|
43
|
+
reset() { if (rendering) throw new Error('A stream renderer cannot reset its stream.'); chunks = []; length = 0; threshold = previewEvery; closed = false; return ++revision; },
|
|
44
|
+
get revision() { return revision; },
|
|
45
|
+
};
|
|
46
|
+
}
|