rapier-math 1.0.2 → 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 +43 -15
- 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.0.2.js → rapier-math-1.1.0.js} +43 -15
- package/rehype.d.ts +6 -0
- package/rehype.mjs +37 -0
- package/src/math/README.md +210 -0
- package/src/math/adoption-verify.mjs +149 -0
- package/src/math/api.js +35 -13
- package/src/math/browser.js +7 -1
- 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/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 +1 -1
- 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 +176 -17
- package/src/tools/build-math.mjs +19 -3
- package/stream.d.ts +14 -0
- package/stream.mjs +46 -0
- package/verify.mjs +7 -2
|
@@ -2311,7 +2311,7 @@ function mathLayout(n,c) {
|
|
|
2311
2311
|
case 'smash': {const b=mathLayout(n.body,c);return {...b,h:n.mode==='b'?b.h:0,d:n.mode==='t'?b.d:0};}
|
|
2312
2312
|
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})});}
|
|
2313
2313
|
case 'rule': {const s=mathScale(c);return mathRule(n.width*s,(n.height+n.depth)*s,-n.height*s);}
|
|
2314
|
-
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;}
|
|
2314
|
+
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;}
|
|
2315
2315
|
default: throw Object.assign(new Error('Unsupported maths construct: '+n.type),{code:'unsupported-construct'});
|
|
2316
2316
|
}
|
|
2317
2317
|
}
|
|
@@ -2770,12 +2770,18 @@ function mathFitElement(svg, printing = false) {
|
|
|
2770
2770
|
if(screenKey!==state.screen || (target===null)!==(state.width===null) || target!==null && Math.abs(target-state.width)>.005) {
|
|
2771
2771
|
const rendered=renderMath(source,{displayMode:display,maxWidth:(screenKey!==state.screen?width:target) || undefined,screen});
|
|
2772
2772
|
if(!rendered.errors.length) {
|
|
2773
|
+
const active=doc.activeElement,links=active && svg.contains(active)?[...svg.querySelectorAll('a')]:[];
|
|
2774
|
+
const focusIndex=links.indexOf(active),focusHref=focusIndex<0?null:active.getAttribute('href');
|
|
2773
2775
|
const replacement=new view.DOMParser().parseFromString(rendered.svg,'image/svg+xml').documentElement;
|
|
2774
|
-
for(const name of ['width','height','viewBox','data-math-natural-width','data-math-max-width','data-math-lines','data-math-screen']) {
|
|
2776
|
+
for(const name of ['role','width','height','viewBox','data-math-natural-width','data-math-max-width','data-math-lines','data-math-screen']) {
|
|
2775
2777
|
if(replacement.hasAttribute(name))svg.setAttribute(name,replacement.getAttribute(name));else svg.removeAttribute(name);
|
|
2776
2778
|
}
|
|
2777
2779
|
svg.style.verticalAlign=replacement.style.verticalAlign;
|
|
2778
2780
|
svg.replaceChildren(...Array.from(replacement.childNodes,node=>doc.importNode(node,true)));
|
|
2781
|
+
if(focusIndex>=0 && (!doc.activeElement || doc.activeElement===doc.body)) {
|
|
2782
|
+
const link=svg.querySelectorAll('a')[focusIndex];
|
|
2783
|
+
if(link?.getAttribute('href')===focusHref)link.focus({preventScroll:true});
|
|
2784
|
+
}
|
|
2779
2785
|
state.natural=Number(svg.getAttribute('data-math-natural-width'));state.screen=screenKey;
|
|
2780
2786
|
state.width=state.natural>width?width:null;
|
|
2781
2787
|
if(state.width===null)svg.removeAttribute('data-math-max-width');
|
|
@@ -2871,23 +2877,27 @@ function mathSvg(box,source,error,utf16,options={}) {
|
|
|
2871
2877
|
const opacity=options.screen?.dark ? .94 : 1;
|
|
2872
2878
|
if (![width,height,depth,left,...viewBox].every(Number.isFinite)) throw new Error('Mathematical dimensions are not finite');
|
|
2873
2879
|
const escapedSource=mathEscape(source);
|
|
2874
|
-
|
|
2880
|
+
// A group exposes authored links to accessibility APIs; an image would hide them.
|
|
2881
|
+
const svg='<svg xmlns="http://www.w3.org/2000/svg" role="'+(box.svg.includes('<a ')?'group':'img')+'" aria-label="'+escapedSource+'"'+
|
|
2875
2882
|
' data-rapier-math="'+(options.displayMode?'display':'inline')+'" data-math-lines="'+(box.lines || 1)+'"'+
|
|
2876
2883
|
' data-math-natural-width="'+mathNumber(options.naturalWidth ?? width)+'"'+
|
|
2877
2884
|
(options.screen?' data-math-screen="'+mathNumber(options.screen.fontSize)+'|'+mathNumber(options.screen.pixelRatio)+'|'+options.screen.dark+'"':'')+
|
|
2878
2885
|
(options.maxWidth?' data-math-max-width="'+mathNumber(options.maxWidth)+'"':'')+
|
|
2879
2886
|
(utf16?' data-tex-utf16="'+utf16+'"':'')+
|
|
2887
|
+
(error?.sourceOmitted?' data-math-source-omitted="true"':'')+
|
|
2880
2888
|
(error?' data-math-error="'+mathEscape(error.code)+'"':'')+' width="'+mathNumber(width)+'em" height="'+mathNumber(height+depth)+'em"'+
|
|
2881
2889
|
' viewBox="'+viewBox.map(mathNumber).join(' ')+'"'+
|
|
2882
2890
|
' style="font-size:'+mathNumber(mathFontRatio)+'em;max-width:100%;height:auto;vertical-align:-'+mathNumber(depth)+'em;'+
|
|
2883
2891
|
'fill-opacity:'+opacity+';stroke-opacity:'+opacity+'" fill="currentColor">'+
|
|
2884
2892
|
'<title>'+escapedSource+'</title>'+(error?'<desc>'+mathEscape(error.message)+'</desc>':'')+
|
|
2885
2893
|
'<g transform="scale(1000)">'+box.svg+'</g></svg>';
|
|
2894
|
+
if(svg.length>24*1024*1024)throw Object.assign(new Error('The complete formula markup exceeds its output budget.'),{code:'resource-limit'});
|
|
2886
2895
|
return {svg,width,height,depth,lines:box.lines || 1,fitted:!!box.fitted,errors:error?[error]:[]};
|
|
2887
2896
|
}
|
|
2888
2897
|
|
|
2889
2898
|
function mathSourceAlternative(source) {
|
|
2890
|
-
|
|
2899
|
+
const alternatives=[],parts=[];
|
|
2900
|
+
let first=-1,kept=0;
|
|
2891
2901
|
for(let i=0;i<source.length;i++) {
|
|
2892
2902
|
const code=source.charCodeAt(i);
|
|
2893
2903
|
if(code>=0xd800 && code<=0xdbff && i+1<source.length) {
|
|
@@ -2896,19 +2906,32 @@ function mathSourceAlternative(source) {
|
|
|
2896
2906
|
}
|
|
2897
2907
|
if((code>=0x20 && code<=0xd7ff) || (code>=0xe000 && code<=0xfffd) || code===9 || code===10 || code===13)continue;
|
|
2898
2908
|
if(first<0)first=i;
|
|
2899
|
-
|
|
2909
|
+
if(i>kept)parts.push(source.slice(kept,i));
|
|
2910
|
+
parts.push('\\u'+code.toString(16).toUpperCase().padStart(4,'0'));kept=i+1;
|
|
2911
|
+
// Flatten small batches instead of retaining one rope node per bad code unit.
|
|
2912
|
+
if(parts.length>=1024) {alternatives.push(parts.join(''));parts.length=0;}
|
|
2900
2913
|
}
|
|
2901
2914
|
if(first<0)return {alternative:source};
|
|
2902
|
-
|
|
2915
|
+
parts.push(source.slice(kept));alternatives.push(parts.join(''));
|
|
2903
2916
|
// Only malformed XML text needs a separate carrier; each four hex digits keep one original code unit.
|
|
2904
|
-
|
|
2905
|
-
for(let i=0;i<source.length;i++)
|
|
2906
|
-
|
|
2917
|
+
const encoded=[];parts.length=0;
|
|
2918
|
+
for(let i=0;i<source.length;i++) {
|
|
2919
|
+
parts.push(source.charCodeAt(i).toString(16).padStart(4,'0'));
|
|
2920
|
+
if(parts.length===1024) {encoded.push(parts.join(''));parts.length=0;}
|
|
2921
|
+
}
|
|
2922
|
+
encoded.push(parts.join(''));
|
|
2923
|
+
return {alternative:alternatives.join(''),utf16:encoded.join(''),error:{code:'invalid-source-character',offset:first,
|
|
2907
2924
|
message:'A source character cannot be embedded in SVG. The text alternative shows Unicode escapes; data-tex-utf16 keeps the original source.'}};
|
|
2908
2925
|
}
|
|
2909
2926
|
|
|
2910
2927
|
function renderMath(source,options={}) {
|
|
2911
|
-
const text=String(source ?? '')
|
|
2928
|
+
const text=String(source ?? '');
|
|
2929
|
+
// Reject before scanning, escaping or parsing. Keep the diagnostic-return
|
|
2930
|
+
// contract, but explicitly omit refused source instead of expanding it.
|
|
2931
|
+
const safe=text.length>1000000?{alternative:'Formula source omitted: source limit exceeded.',error:{
|
|
2932
|
+
code:'resource-limit',offset:0,sourceOmitted:true,
|
|
2933
|
+
message:'This formula is too long to render safely. Its source was not embedded; retain the original input.'
|
|
2934
|
+
}}:mathSourceAlternative(text),context={size:1,level:0,variant:'normal',cramped:false,display:!!options?.displayMode,cache:new WeakMap(),budget:{nodes:0,outlineBytes:0}};
|
|
2912
2935
|
try {
|
|
2913
2936
|
if(safe.error)throw safe.error;
|
|
2914
2937
|
if(options?.screen!==undefined) {
|
|
@@ -2925,11 +2948,16 @@ function renderMath(source,options={}) {
|
|
|
2925
2948
|
return mathSvg(box,safe.alternative,undefined,undefined,{displayMode:context.display,maxWidth,naturalWidth:mathInkWidth(natural)+.05,screen:context.screen});
|
|
2926
2949
|
}
|
|
2927
2950
|
catch (cause) {
|
|
2928
|
-
const
|
|
2929
|
-
|
|
2951
|
+
const message=String(cause.message || cause);
|
|
2952
|
+
const error={code:cause.code || 'invalid-tex',message:message.length>1024?message.slice(0,1024).replace(/[\ud800-\udbff]$/,'')+'…':message,offset:Number.isFinite(cause.offset)?cause.offset:0};
|
|
2953
|
+
if(message.length>1024)error.messageTruncated=true;
|
|
2954
|
+
if(cause.sourceOmitted)error.sourceOmitted=true;
|
|
2955
|
+
if(cause.command) {
|
|
2956
|
+
error.command=String(cause.command).slice(0,128);
|
|
2957
|
+
if(cause.command.length>128)error.commandTruncated=true;
|
|
2958
|
+
}
|
|
2930
2959
|
// A diagnostic is visible, but remains distinct from a successfully typeset formula.
|
|
2931
|
-
//
|
|
2932
|
-
// while the visible notice stays finite and can render after the formula exhausted its budget.
|
|
2960
|
+
// Diagnostics have an explicit bound. Exact input stays in the source alternative.
|
|
2933
2961
|
const label=error.message.length>240?'This formula could not be rendered. The complete diagnostic is in its text description.':error.message.replace(/[^\x20-\x7e]/g,'?');
|
|
2934
2962
|
const diagnostic={...context,variant:'roman',size:0.8,budget:{nodes:0,outlineBytes:0}};
|
|
2935
2963
|
try {
|
|
@@ -2942,7 +2970,7 @@ function renderMath(source,options={}) {
|
|
|
2942
2970
|
}
|
|
2943
2971
|
|
|
2944
2972
|
globalThis.RapierMath=Object.freeze({
|
|
2945
|
-
version:'1.0
|
|
2973
|
+
version:'1.1.0',
|
|
2946
2974
|
parse:parseMathTex,
|
|
2947
2975
|
render:renderMath,
|
|
2948
2976
|
fit:mathFit,
|
package/rehype.d.ts
ADDED
|
@@ -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;
|
package/rehype.mjs
ADDED
|
@@ -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,210 @@
|
|
|
1
|
+
# Rapier Math
|
|
2
|
+
|
|
3
|
+
Self-contained TeX-to-SVG maths with embedded Latin Modern outlines. Rendering is synchronous and offline,
|
|
4
|
+
with no core runtime dependencies, DOM, global installation or remote font request. Optional integrations
|
|
5
|
+
keep the host's Markdown parser in charge.
|
|
6
|
+
|
|
7
|
+
## Start with a formula
|
|
8
|
+
|
|
9
|
+
```sh
|
|
10
|
+
npm install rapier-math
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
```js
|
|
14
|
+
import {render} from 'rapier-math';
|
|
15
|
+
const source = String.raw`\frac{-b\pm\sqrt{b^2-4ac}}{2a}`;
|
|
16
|
+
const result = render(source, {displayMode: true});
|
|
17
|
+
if (result.errors.length) console.error(result.errors);
|
|
18
|
+
else console.log(result.svg);
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
Keep `source` for editing and copying. A formula API accepts TeX without Markdown delimiters.
|
|
22
|
+
`String.raw` helps with JavaScript literals; JSON and transport decoding are separate layers. Never unescape
|
|
23
|
+
the TeX a second time. Native ESM works in Node 22+, current browsers and module workers; no CommonJS wrapper
|
|
24
|
+
is provided. Server output needs no client renderer unless fitting or further rendering is wanted.
|
|
25
|
+
|
|
26
|
+
## Markdown
|
|
27
|
+
|
|
28
|
+
```sh
|
|
29
|
+
npm install markdown-it
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
```js
|
|
33
|
+
import MarkdownIt from 'markdown-it';
|
|
34
|
+
import rapierMath from 'rapier-math/markdown-it';
|
|
35
|
+
const md = new MarkdownIt({html: false}).use(rapierMath);
|
|
36
|
+
const env = {};
|
|
37
|
+
const html = md.render('Price $5. Code: `$$x$$`. Formula: \\(x^2\\).', env);
|
|
38
|
+
console.log(env.rapierMathErrors || []);
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
Default delimiters are `\(...\)` (inline), `\[...\]` and `$$...$$` (display). Single `$...$` is opt-in:
|
|
42
|
+
`.use(rapierMath, {singleDollar: true})`. It cannot distinguish all currency from mathematics.
|
|
43
|
+
|
|
44
|
+
| Boundary rule | Contract |
|
|
45
|
+
| --- | --- |
|
|
46
|
+
| Escapes | An odd run of preceding backslashes escapes a delimiter; an even run does not. The body is never unescaped. |
|
|
47
|
+
| Dollar runs | Only complete runs of one or two dollars are delimiters. Three or more are literal. `\$$` is an escaped pair. |
|
|
48
|
+
| Single dollars | Nonempty body, no leading/trailing whitespace or CR/LF; a closer followed by an ASCII digit is rejected. |
|
|
49
|
+
| Other pairs | Whitespace and newlines are allowed. The first unescaped matching closer wins. No delimiter nesting. |
|
|
50
|
+
| Blocks | A display opener at the host's block start and a closer followed only by line whitespace makes a block. Otherwise it is inline content. |
|
|
51
|
+
| Incomplete input | No math node is emitted until a matching closer exists. Surrounding text stays under host control. |
|
|
52
|
+
| Protected source | Code spans, fences, indented code, link destinations, autolinks and HTML tags remain host syntax. Math in link labels is disabled. Math in image descriptions is retained as literal source in alt text. |
|
|
53
|
+
|
|
54
|
+
Use `html: false` for untrusted Markdown. If your application enables raw HTML, retain its sanitizer and
|
|
55
|
+
HTML policy. Inline HTML element contents follow the host grammar; the math adapter is not an HTML parser.
|
|
56
|
+
The `rapier-math/delimiters` export offers `createMathMatcher(source, options)(start, limit?)` for authors of
|
|
57
|
+
other host adapters. It indexes boundaries in a raw text region, returns exact UTF-16 offsets, and knows
|
|
58
|
+
nothing about Markdown structure. Do not scan rendered HTML with it.
|
|
59
|
+
|
|
60
|
+
The Rapier app explicitly keeps its established dollar-only dialect, including math in link labels.
|
|
61
|
+
The shared owner corrects escaped display closers and retains outer formula whitespace. No public
|
|
62
|
+
adapter or renderer imports the app's AGPL Markdown implementation.
|
|
63
|
+
|
|
64
|
+
## unified / remark / rehype
|
|
65
|
+
|
|
66
|
+
Use `remark-math`, `remark-rehype`, then `rehype-sanitize`, then `rapier-math/rehype`, then `rehype-stringify`.
|
|
67
|
+
The complete [unified example](examples/unified.mjs) grants only the math classes on sanitized code nodes.
|
|
68
|
+
The adapter replaces supported `language-math` + `math-inline`/`math-display` nodes with HAST parsed from
|
|
69
|
+
fresh renderer SVG. It does not turn authored raw HTML into trusted markup or loosen the global SVG sanitizer.
|
|
70
|
+
The optional `hast-util-from-html` peer is used only by this entrypoint.
|
|
71
|
+
|
|
72
|
+
Choose `remarkMath({singleDollarTextMath: false})` to avoid single-dollar ambiguity. Remark owns delimiter,
|
|
73
|
+
whitespace and code rules in this pipeline; its grammar is different from the markdown-it adapter and does
|
|
74
|
+
not enable backslash delimiters. Unsupported formulas remain copyable code and produce VFile messages
|
|
75
|
+
with `ruleId`, `mathOffset` and `mathSource`.
|
|
76
|
+
|
|
77
|
+
Both rendering adapters accept `renderOptions`, `maxFormulas` (default 1024) and `maxOutputLength` (default
|
|
78
|
+
16 Mi UTF-16 code units of successful SVG per document render). Rendering a cached markdown-it token tree
|
|
79
|
+
again starts a fresh budget; inline children share their enclosing document's budget. Exhaustion leaves subsequent source literal with
|
|
80
|
+
diagnostics. The host owns its own source, tree, prose and final HTML budgets. These are synchronous adapters.
|
|
81
|
+
|
|
82
|
+
## Streaming
|
|
83
|
+
|
|
84
|
+
```js
|
|
85
|
+
import {createMarkdownStream} from 'rapier-math/stream';
|
|
86
|
+
const stream = createMarkdownStream(source => md.render(source, {}));
|
|
87
|
+
const preview = stream.write('The answer is \\(x'); // may return null
|
|
88
|
+
stream.write('^2\\).');
|
|
89
|
+
const completed = stream.finish(); // {source, html, revision, final: true}
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
Use one stream per message. `write` accepts decoded strings and returns either a whole preview or `null`;
|
|
93
|
+
`finish` always runs the same batch renderer on the complete exact source. `abort` releases pending text.
|
|
94
|
+
`reset` starts a replacement and advances the revision. Cancel the old producer before reset, or reject its
|
|
95
|
+
chunks using your conversation generation. No callbacks, timers, DOM mutations or shared conversation state
|
|
96
|
+
are installed. See the [stream example](examples/stream.mjs).
|
|
97
|
+
|
|
98
|
+
Markdown references, setext headings and fences can change earlier interpretation, so previews are
|
|
99
|
+
replaceable, not committed prefixes. An open formula remains literal. Defaults: at most 1,000,000 retained
|
|
100
|
+
source code units; first preview at 256 units, then only after the source has at least doubled. Including
|
|
101
|
+
one `finish`, the total source sent to the host is less than three times final length, even if previews fail.
|
|
102
|
+
Explicit retries of `finish` are additional host calls. There is no full-prefix
|
|
103
|
+
render on every token. Late previews are consequently less frequent; show incoming chunks as text for
|
|
104
|
+
immediate progress. `previewEvery` sets the first threshold, not a fixed per-chunk interval.
|
|
105
|
+
|
|
106
|
+
Retained state is O(message length), not a claimed constant-memory Markdown parser. Stream chunks can split
|
|
107
|
+
delimiters, CRLF, escapes or surrogate pairs. Final interpretation is batch-equivalent; interim previews
|
|
108
|
+
may show an incomplete code unit. A rejected oversized chunk is not accepted or silently truncated; the
|
|
109
|
+
caller still owns it. A failed renderer leaves accumulated source available for retry or reset.
|
|
110
|
+
|
|
111
|
+
## Formula API and browser lifetime
|
|
112
|
+
|
|
113
|
+
| Export | Result |
|
|
114
|
+
| --- | --- |
|
|
115
|
+
| `render(tex, options?)` | `{svg, width, height, depth, lines, fitted, errors}`; dimensions in mathematical ems. |
|
|
116
|
+
| `renderToString(tex, options?)` | SVG, including diagnostic SVG for admitted malformed or unsupported syntax. |
|
|
117
|
+
| `parse(tex)` | Inspectable AST; throws structured errors. Internal node vocabulary can grow. |
|
|
118
|
+
| `fit(container)` | One synchronous DOM fitting pass, returning selected formula count. |
|
|
119
|
+
| `observe(container)` | Observe current formulas; returns idempotent cleanup. |
|
|
120
|
+
| `version` | The package version. Default export provides the same API. |
|
|
121
|
+
|
|
122
|
+
`displayMode`, `maxWidth` in mathematical ems, and optional `screen: {fontSize, pixelRatio, dark}` configure
|
|
123
|
+
rendering. Screen numbers must be positive finite values; `dark` must be boolean. Omit screen options for
|
|
124
|
+
SSR and the initial hydration render. Long rows break at available relations/operators; indivisible
|
|
125
|
+
oversized groups scale to fit. Fit after hydration, stop observation before replacement, then observe the
|
|
126
|
+
new formulas. Stop on unmount. [Browser](examples/browser.html), [worker](examples/worker.mjs) and
|
|
127
|
+
[React/SSR](examples/react.tsx) examples demonstrate these boundaries. React is not a package dependency.
|
|
128
|
+
|
|
129
|
+
## Errors, source and accessibility
|
|
130
|
+
|
|
131
|
+
Check `errors` before treating a formula as successful. Errors include `code`, `message`, UTF-16 `offset`
|
|
132
|
+
and sometimes `command`. Render-result messages are limited to 1024 code units plus an ellipsis and command
|
|
133
|
+
names to 128, with explicit `messageTruncated`/`commandTruncated` flags. Exact admitted source is retained
|
|
134
|
+
independently. `parse` throws the parser's details, bounded by its source admission limit. Do not use a
|
|
135
|
+
diagnostic message as a source archive.
|
|
136
|
+
|
|
137
|
+
Valid XML source is kept exactly in SVG `aria-label` and `title`, including BOM, tabs and CRLF (escaped as
|
|
138
|
+
character references). Invalid XML characters have visible Unicode escapes and a `data-tex-utf16` carrier:
|
|
139
|
+
each four hex digits represents one original UTF-16 code unit. This includes isolated surrogates. The
|
|
140
|
+
Markdown host may normalize its syntax-node content; retain the original Markdown separately. Streams
|
|
141
|
+
return the exact concatenated source, including malformed UTF-16, without normalization.
|
|
142
|
+
|
|
143
|
+
Source longer than 1,000,000 code units returns a bounded `resource-limit` diagnostic before source
|
|
144
|
+
scanning, escaping or parsing. Its error has `sourceOmitted: true` and its SVG has
|
|
145
|
+
`data-math-source-omitted="true"`; neither embeds or truncates the refused source. `renderToString` returns
|
|
146
|
+
the same diagnostic SVG. Keep the original input for editing or copying. Admitted source alternatives
|
|
147
|
+
remain exact. Other limits include 100,000 tokens, 50,000 parser nodes, depth 192, 4096 macro expansions, 100,000 layout
|
|
148
|
+
visits, 4 Mi outline characters and 8 Mi composed SVG characters. Complete returned SVG, including source
|
|
149
|
+
alternatives and diagnostics, is capped at 24 Mi UTF-16 code units. These are output/work bounds, not peak
|
|
150
|
+
heap or device-memory claims. A later good render has a fresh budget.
|
|
151
|
+
|
|
152
|
+
SVG labels contain raw TeX, not semantic MathML or mathematical speech. Ordinary equations are images in
|
|
153
|
+
the accessibility tree. Equations containing authored HTTP(S) links use a group so the links remain exposed;
|
|
154
|
+
each link is keyboard-focusable and labelled with its destination. Keep the editable source or a separate
|
|
155
|
+
copy-source affordance in your host. Full semantic navigation, speech and screen-reader parity are not
|
|
156
|
+
provided. A future semantic serializer needs an explicit AST-to-MathML mapping and assistive-technology
|
|
157
|
+
evaluation; adding a raw TeX label does not satisfy that requirement.
|
|
158
|
+
|
|
159
|
+
## Security and deployment
|
|
160
|
+
|
|
161
|
+
The renderer emits local SVG outlines, never executable HTML, remote font/image fetches or loaded TeX
|
|
162
|
+
extensions. Authored links permit only HTTP(S) and use `noopener noreferrer`. Insert only the returned
|
|
163
|
+
SVG, never concatenate TeX or arbitrary HTML into the sink. On failure, use a text node or the framework's
|
|
164
|
+
escaped text rendering. Formula safety does not sanitize surrounding Markdown.
|
|
165
|
+
|
|
166
|
+
The native ESM path needs no `eval` or `unsafe-eval`. Generated SVG does contain style attributes:
|
|
167
|
+
`script-src 'self'; style-src-attr 'unsafe-inline'; style-src-elem 'none'` permits same-origin external
|
|
168
|
+
modules and those attributes in browsers implementing CSP3. It does **not** work under a policy forbidding
|
|
169
|
+
all inline styles. `examples/browser.html` carries that policy; older browsers need their own reviewed
|
|
170
|
+
style policy. No network access is needed after local module loading; use `connect-src 'none'` if suitable.
|
|
171
|
+
|
|
172
|
+
With `require-trusted-types-for 'script'`, string HTML sinks and the DOMParser used by `fit`/`observe`
|
|
173
|
+
require TrustedHTML. The core does not create a policy, return TrustedHTML or claim fitting support under
|
|
174
|
+
that policy. Prefer server-rendered output or the structured rehype path in such applications. If the host
|
|
175
|
+
uses a policy for initial insertion, narrowly scope it to freshly generated renderer output; never use a
|
|
176
|
+
blanket identity policy for arbitrary strings. The browser/React examples assume string sinks are permitted.
|
|
177
|
+
|
|
178
|
+
## Syntax and limits
|
|
179
|
+
|
|
180
|
+
Supported: fractions, roots, scripts, limits, matrices/arrays/alignment, delimiters, accents, operators,
|
|
181
|
+
Greek and mathematical alphabets, text/color/size/spacing, local macros, and selected chemistry/physics.
|
|
182
|
+
Existing supported package names select built-in syntax; there is no external package/file loader.
|
|
183
|
+
This is not a complete TeX document engine. No automatic numbering or cross-formula reference guarantee.
|
|
184
|
+
Unsupported commands give diagnostics. Lowercase script/bold-script requests use ordinary-letter fallbacks
|
|
185
|
+
where Latin Modern lacks those alphabets. All embedded licensed outlines remain present.
|
|
186
|
+
|
|
187
|
+
## Package and proof
|
|
188
|
+
|
|
189
|
+
Declarations accompany every public entry. Formula imports do not pull in Markdown, unified or React.
|
|
190
|
+
For TypeScript, markdown-it 15 provides its own types; markdown-it 14 projects also install
|
|
191
|
+
`@types/markdown-it@14`. Use the declarations for your host version.
|
|
192
|
+
For a classic script, use `rapier-math-VERSION.js` and `globalThis.RapierMath`.
|
|
193
|
+
The payload includes the font: it is not a few-kilobyte renderer. Payload counts below describe this
|
|
194
|
+
build, not npm tarball size or a comparison against another library.
|
|
195
|
+
|
|
196
|
+
Run `node verify.mjs` and `node check.mjs` from an installed package. The carried source rebuilds the exact
|
|
197
|
+
classic payload and ESM module; receipts also cover the adapters, declarations and examples. Follow
|
|
198
|
+
`src/math/FONT-SOURCE.md` to regenerate font data. License and font provenance are carried with every build.
|
|
199
|
+
|
|
200
|
+
Verified-claim scope: synchronous self-contained formula rendering; opt-in Markdown integrations; bounded
|
|
201
|
+
replaceable previews with exact final source; structured diagnostics and explicit source fallback. This
|
|
202
|
+
does not establish all-device behavior, full TeX compatibility, semantic accessibility or universal
|
|
203
|
+
size/speed superiority. Test your host's sanitizer, CSP, browser lifetime and supported formula corpus.
|
|
204
|
+
|
|
205
|
+
## Licenses
|
|
206
|
+
|
|
207
|
+
The renderer and neutral adapters are MIT. Latin Modern Math 1.959 outlines/metrics retain the GUST Font
|
|
208
|
+
License, an instance of LPPL. `LICENSE` contains both complete grants. `LICENSE-MIT`, `math-LICENSE.txt`,
|
|
209
|
+
`math-font-LICENSE.txt` and the SPDX 2.3 `math-components.json` retain renderer and font notices. Both payloads
|
|
210
|
+
retain complete licenses. App-specific AGPL source is not included in this package.
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
// SPDX-License-Identifier: MIT
|
|
2
|
+
// Optional host checks: run from the installed package with the example peers.
|
|
3
|
+
// One source-custody table; no appearance or timing assertions.
|
|
4
|
+
import assert from 'node:assert/strict';
|
|
5
|
+
import MarkdownIt from 'markdown-it';
|
|
6
|
+
import {render} from 'rapier-math';
|
|
7
|
+
import rapierMath from 'rapier-math/markdown-it';
|
|
8
|
+
import {createMathMatcher} from 'rapier-math/delimiters';
|
|
9
|
+
import {createMarkdownStream} from 'rapier-math/stream';
|
|
10
|
+
import {processor} from './examples/unified.mjs';
|
|
11
|
+
|
|
12
|
+
const md = new MarkdownIt({html: false}).use(rapierMath);
|
|
13
|
+
const dollar = new MarkdownIt({html: false}).use(rapierMath, {singleDollar: true});
|
|
14
|
+
const mathNodes = (host, source) => host.parse(source, {}).flatMap(token => token.children || [token])
|
|
15
|
+
.filter(token => token.type === 'math_inline' || token.type === 'math_block');
|
|
16
|
+
const rows = [
|
|
17
|
+
['escaped pair incomplete', String.raw`$$x\$$`, 0],
|
|
18
|
+
['escaped pair then closer', String.raw`$$x\$$y$$`, 1],
|
|
19
|
+
['even escape run', String.raw`$$x\\$$`, 1],
|
|
20
|
+
['brackets', String.raw`A \(x+1\) B \[y\]`, 2],
|
|
21
|
+
['escaped bracket opener', String.raw`A \\(x\\)`, 0],
|
|
22
|
+
['currency', 'It costs $5 and $10.', 0],
|
|
23
|
+
['code and fence', '`$$x$$`\n\n```tex\n$$y$$\n```\n\n $$z$$\n', 0],
|
|
24
|
+
['links', String.raw`[\(x\)](https://example.com/$$x$$)`, 0],
|
|
25
|
+
['literal backslashes', String.raw`Path C:\\temp. \(\text{a\ b}\)`, 1],
|
|
26
|
+
['CRLF BOM Unicode', '\ufeffπ 🐈\r\n\r\n$$\r\nx+1\r\n$$\r\n', 1],
|
|
27
|
+
['incomplete', 'Before \\(x+1 and after', 0],
|
|
28
|
+
['malformed UTF-16', '\ud800 and \\(x\\) and \udfff', 1],
|
|
29
|
+
];
|
|
30
|
+
for (const [name, source, count] of rows) {
|
|
31
|
+
assert.equal(mathNodes(md, source).length, count, name);
|
|
32
|
+
const batch = md.render(source, {});
|
|
33
|
+
// Every two-piece boundary, including CRLF, escape runs and surrogate pairs.
|
|
34
|
+
for (let split = 0; split <= source.length; split++) {
|
|
35
|
+
const stream = createMarkdownStream(text => md.render(text, {}), {previewEvery: 8});
|
|
36
|
+
stream.write(source.slice(0, split)); stream.write(source.slice(split));
|
|
37
|
+
const final = stream.finish();
|
|
38
|
+
assert.equal(final.source, source, name + ': exact transport source');
|
|
39
|
+
assert.equal(final.html, batch, name + ': final batch agreement');
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
for (const [source, count] of [[String.raw`$x\$y$`, 1], ['$x$5', 0], ['$ x$', 0], ['$x $', 0], ['$$$x$$$', 0]])
|
|
43
|
+
assert.equal(mathNodes(dollar, source).length, count);
|
|
44
|
+
// Image descriptions are text-only host output: unknown math token types must
|
|
45
|
+
// not disappear from alt text or spend a formula-render budget.
|
|
46
|
+
for (const [source, alt] of [
|
|
47
|
+
[String.raw``, 'before $$x$$ after'],
|
|
48
|
+
[String.raw``, String.raw`before \(x\) after`],
|
|
49
|
+
[String.raw` c](outer.png)`, 'a b $$x$$ c'],
|
|
50
|
+
[String.raw``, 'a $$\\text{<>&"}$$ b'],
|
|
51
|
+
]) {
|
|
52
|
+
const host = new MarkdownIt({html: false}).use(rapierMath, {maxFormulas: 1});
|
|
53
|
+
const tokens = host.parse(source + String.raw` \(y\)`, {});
|
|
54
|
+
for (let pass = 0; pass < 2; pass++) {
|
|
55
|
+
const env = {}, html = host.renderer.render(tokens, host.options, env);
|
|
56
|
+
assert(html.includes('alt="' + alt + '"'), 'image math remains literal source');
|
|
57
|
+
assert.equal((html.match(/<svg /g) || []).length, 1, 'alt text does not spend the formula budget');
|
|
58
|
+
assert.equal(env.rapierMathErrors, undefined);
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
const exact = '\ufeff$$\r\n α+\\text{a\\ b} 🐈\r\n$$';
|
|
62
|
+
const span = createMathMatcher(exact)(1);
|
|
63
|
+
assert.equal(exact.slice(span.bodyStart, span.bodyEnd), '\r\n α+\\text{a\\ b} 🐈\r\n');
|
|
64
|
+
assert.equal(createMathMatcher(String.raw`$$x\$$`)(0), null);
|
|
65
|
+
|
|
66
|
+
const first = createMarkdownStream(text => text, {maxSourceLength: 8, previewEvery: 2});
|
|
67
|
+
const second = createMarkdownStream(text => text);
|
|
68
|
+
first.write('ab'); second.write('other'); first.abort(); first.abort();
|
|
69
|
+
assert.throws(() => first.write('c'));
|
|
70
|
+
first.reset(); first.write('new');
|
|
71
|
+
assert.throws(() => first.write('123456'), {code: 'resource-limit'});
|
|
72
|
+
assert.equal(first.finish().source, 'new');
|
|
73
|
+
assert.equal(second.finish().source, 'other');
|
|
74
|
+
assert.throws(() => second.finish());
|
|
75
|
+
let failingHost = true, supplied = 0;
|
|
76
|
+
const failing = createMarkdownStream(text => {
|
|
77
|
+
supplied += text.length;
|
|
78
|
+
if (failingHost) throw new Error('host failed');
|
|
79
|
+
return text;
|
|
80
|
+
}, {previewEvery: 2});
|
|
81
|
+
const failedSource = 'x'.repeat(32);
|
|
82
|
+
for (const chunk of failedSource) {
|
|
83
|
+
try { failing.write(chunk); } catch (error) { assert.equal(error.message, 'host failed'); }
|
|
84
|
+
}
|
|
85
|
+
failingHost = false;
|
|
86
|
+
assert.equal(failing.finish().source, failedSource, 'failed previews retain every accepted chunk');
|
|
87
|
+
assert(supplied < 3 * failedSource.length, 'failed previews keep the repeated-work bound');
|
|
88
|
+
failing.abort(); failing.reset();
|
|
89
|
+
|
|
90
|
+
const hostile = String.raw`Before \(\text{</title><script>alert(1)</script>}\). <img src=x onerror=alert(1)>`;
|
|
91
|
+
assert(!/<script|<img/i.test(md.render(hostile, {})));
|
|
92
|
+
// Cached syntax can serve multiple outputs. Budgets belong to each render, not
|
|
93
|
+
// to parsing or to an env object; every paragraph still shares one document cap.
|
|
94
|
+
const formulaSource = String.raw`\(x\)`, svgCount = html => (html.match(/<svg /g) || []).length;
|
|
95
|
+
for (const budgets of [{maxFormulas: 1}, {maxOutputLength: render('x').svg.length}]) {
|
|
96
|
+
const host = new MarkdownIt({html: false}).use(rapierMath, budgets);
|
|
97
|
+
for (const inline of [false, true]) {
|
|
98
|
+
const source = formulaSource + (inline ? ' ' : '\n\n') + formulaSource;
|
|
99
|
+
const tree = host.parse(source, {});
|
|
100
|
+
const tokens = inline ? tree[1].children : tree;
|
|
101
|
+
const renderer = inline ? host.renderer.renderInline : host.renderer.render;
|
|
102
|
+
for (let pass = 0; pass < 2; pass++) {
|
|
103
|
+
const env = {}, out = renderer.call(host.renderer, tokens, host.options, env);
|
|
104
|
+
assert.equal(svgCount(out), 1);
|
|
105
|
+
assert.equal(env.rapierMathErrors.length, 1);
|
|
106
|
+
assert.equal(env.rapierMathErrors[0].errors[0].code, 'resource-limit');
|
|
107
|
+
assert(out.includes(formulaSource));
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
const nestedHost = new MarkdownIt({html: false}).use(rapierMath, {maxFormulas: 1});
|
|
112
|
+
const nestedTree = nestedHost.parse(formulaSource + ' fail', {});
|
|
113
|
+
const textRule = nestedHost.renderer.rules.text;
|
|
114
|
+
nestedHost.renderer.rules.text = (tokens, index, opts, env, self) => {
|
|
115
|
+
if (tokens[index].content === ' fail') throw new Error('host render failed');
|
|
116
|
+
if (tokens[index].content.includes('nested')) {
|
|
117
|
+
for (const inline of [false, true]) {
|
|
118
|
+
const nestedEnv = {};
|
|
119
|
+
assert.throws(() => inline
|
|
120
|
+
? self.renderInline(nestedTree[1].children, opts, nestedEnv)
|
|
121
|
+
: self.render(nestedTree, opts, nestedEnv), /host render failed/);
|
|
122
|
+
assert.equal(nestedEnv.rapierMathErrors, undefined, 'nested render has its own budget');
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
return textRule(tokens, index, opts, env, self);
|
|
126
|
+
};
|
|
127
|
+
const outerTree = nestedHost.parse(formulaSource + ' nested ' + formulaSource, {});
|
|
128
|
+
// A custom host can run the same renderer twice; a failed nested render must
|
|
129
|
+
// restore the outer cap and leave subsequent root renders fresh.
|
|
130
|
+
for (let pass = 0; pass < 2; pass++) {
|
|
131
|
+
const env = {}, out = nestedHost.renderer.render(outerTree, nestedHost.options, env);
|
|
132
|
+
assert.equal(svgCount(out), 1);
|
|
133
|
+
assert.equal(env.rapierMathErrors.length, 1);
|
|
134
|
+
assert.equal(env.rapierMathErrors[0].errors[0].code, 'resource-limit');
|
|
135
|
+
}
|
|
136
|
+
const tiny = new MarkdownIt({html: false}).use(rapierMath, {maxOutputLength: 1});
|
|
137
|
+
assert(!tiny.render(String.raw`\(x\)`).includes('<svg'));
|
|
138
|
+
const bad = {}, fallback = md.render(String.raw`Good \(x\). Bad \(\unknown{x}\).`, bad);
|
|
139
|
+
assert.equal(bad.rapierMathErrors[0].errors[0].code, 'unsupported-command');
|
|
140
|
+
assert(fallback.includes('\\unknown{x}'));
|
|
141
|
+
|
|
142
|
+
const unified = await processor.process('Code `$$x$$`. Math $$x+1$$.\n\n<script>alert(1)</script>\n\n$$\\unknown{x}$$');
|
|
143
|
+
assert.equal((String(unified).match(/<svg /g) || []).length, 1);
|
|
144
|
+
assert(!String(unified).includes('<script'));
|
|
145
|
+
assert.equal(unified.messages[0].ruleId, 'unsupported-command');
|
|
146
|
+
assert(String(unified).includes('\\unknown{x}'));
|
|
147
|
+
console.log(JSON.stringify({sourceCustodyRows: rows.length, splitAgreement: 'all two-piece boundaries',
|
|
148
|
+
lifetimes: 'abort, reset, replacement, rejection, interleaved messages', adapters: 'markdown-it and sanitized unified',
|
|
149
|
+
security: 'hostile source stays inert; document math budgets fall back'}));
|
package/src/math/api.js
CHANGED
|
@@ -7,23 +7,27 @@ function mathSvg(box,source,error,utf16,options={}) {
|
|
|
7
7
|
const opacity=options.screen?.dark ? .94 : 1;
|
|
8
8
|
if (![width,height,depth,left,...viewBox].every(Number.isFinite)) throw new Error('Mathematical dimensions are not finite');
|
|
9
9
|
const escapedSource=mathEscape(source);
|
|
10
|
-
|
|
10
|
+
// A group exposes authored links to accessibility APIs; an image would hide them.
|
|
11
|
+
const svg='<svg xmlns="http://www.w3.org/2000/svg" role="'+(box.svg.includes('<a ')?'group':'img')+'" aria-label="'+escapedSource+'"'+
|
|
11
12
|
' data-rapier-math="'+(options.displayMode?'display':'inline')+'" data-math-lines="'+(box.lines || 1)+'"'+
|
|
12
13
|
' data-math-natural-width="'+mathNumber(options.naturalWidth ?? width)+'"'+
|
|
13
14
|
(options.screen?' data-math-screen="'+mathNumber(options.screen.fontSize)+'|'+mathNumber(options.screen.pixelRatio)+'|'+options.screen.dark+'"':'')+
|
|
14
15
|
(options.maxWidth?' data-math-max-width="'+mathNumber(options.maxWidth)+'"':'')+
|
|
15
16
|
(utf16?' data-tex-utf16="'+utf16+'"':'')+
|
|
17
|
+
(error?.sourceOmitted?' data-math-source-omitted="true"':'')+
|
|
16
18
|
(error?' data-math-error="'+mathEscape(error.code)+'"':'')+' width="'+mathNumber(width)+'em" height="'+mathNumber(height+depth)+'em"'+
|
|
17
19
|
' viewBox="'+viewBox.map(mathNumber).join(' ')+'"'+
|
|
18
20
|
' style="font-size:'+mathNumber(mathFontRatio)+'em;max-width:100%;height:auto;vertical-align:-'+mathNumber(depth)+'em;'+
|
|
19
21
|
'fill-opacity:'+opacity+';stroke-opacity:'+opacity+'" fill="currentColor">'+
|
|
20
22
|
'<title>'+escapedSource+'</title>'+(error?'<desc>'+mathEscape(error.message)+'</desc>':'')+
|
|
21
23
|
'<g transform="scale(1000)">'+box.svg+'</g></svg>';
|
|
24
|
+
if(svg.length>24*1024*1024)throw Object.assign(new Error('The complete formula markup exceeds its output budget.'),{code:'resource-limit'});
|
|
22
25
|
return {svg,width,height,depth,lines:box.lines || 1,fitted:!!box.fitted,errors:error?[error]:[]};
|
|
23
26
|
}
|
|
24
27
|
|
|
25
28
|
function mathSourceAlternative(source) {
|
|
26
|
-
|
|
29
|
+
const alternatives=[],parts=[];
|
|
30
|
+
let first=-1,kept=0;
|
|
27
31
|
for(let i=0;i<source.length;i++) {
|
|
28
32
|
const code=source.charCodeAt(i);
|
|
29
33
|
if(code>=0xd800 && code<=0xdbff && i+1<source.length) {
|
|
@@ -32,19 +36,32 @@ function mathSourceAlternative(source) {
|
|
|
32
36
|
}
|
|
33
37
|
if((code>=0x20 && code<=0xd7ff) || (code>=0xe000 && code<=0xfffd) || code===9 || code===10 || code===13)continue;
|
|
34
38
|
if(first<0)first=i;
|
|
35
|
-
|
|
39
|
+
if(i>kept)parts.push(source.slice(kept,i));
|
|
40
|
+
parts.push('\\u'+code.toString(16).toUpperCase().padStart(4,'0'));kept=i+1;
|
|
41
|
+
// Flatten small batches instead of retaining one rope node per bad code unit.
|
|
42
|
+
if(parts.length>=1024) {alternatives.push(parts.join(''));parts.length=0;}
|
|
36
43
|
}
|
|
37
44
|
if(first<0)return {alternative:source};
|
|
38
|
-
|
|
45
|
+
parts.push(source.slice(kept));alternatives.push(parts.join(''));
|
|
39
46
|
// Only malformed XML text needs a separate carrier; each four hex digits keep one original code unit.
|
|
40
|
-
|
|
41
|
-
for(let i=0;i<source.length;i++)
|
|
42
|
-
|
|
47
|
+
const encoded=[];parts.length=0;
|
|
48
|
+
for(let i=0;i<source.length;i++) {
|
|
49
|
+
parts.push(source.charCodeAt(i).toString(16).padStart(4,'0'));
|
|
50
|
+
if(parts.length===1024) {encoded.push(parts.join(''));parts.length=0;}
|
|
51
|
+
}
|
|
52
|
+
encoded.push(parts.join(''));
|
|
53
|
+
return {alternative:alternatives.join(''),utf16:encoded.join(''),error:{code:'invalid-source-character',offset:first,
|
|
43
54
|
message:'A source character cannot be embedded in SVG. The text alternative shows Unicode escapes; data-tex-utf16 keeps the original source.'}};
|
|
44
55
|
}
|
|
45
56
|
|
|
46
57
|
function renderMath(source,options={}) {
|
|
47
|
-
const text=String(source ?? '')
|
|
58
|
+
const text=String(source ?? '');
|
|
59
|
+
// Reject before scanning, escaping or parsing. Keep the diagnostic-return
|
|
60
|
+
// contract, but explicitly omit refused source instead of expanding it.
|
|
61
|
+
const safe=text.length>1000000?{alternative:'Formula source omitted: source limit exceeded.',error:{
|
|
62
|
+
code:'resource-limit',offset:0,sourceOmitted:true,
|
|
63
|
+
message:'This formula is too long to render safely. Its source was not embedded; retain the original input.'
|
|
64
|
+
}}:mathSourceAlternative(text),context={size:1,level:0,variant:'normal',cramped:false,display:!!options?.displayMode,cache:new WeakMap(),budget:{nodes:0,outlineBytes:0}};
|
|
48
65
|
try {
|
|
49
66
|
if(safe.error)throw safe.error;
|
|
50
67
|
if(options?.screen!==undefined) {
|
|
@@ -61,11 +78,16 @@ function renderMath(source,options={}) {
|
|
|
61
78
|
return mathSvg(box,safe.alternative,undefined,undefined,{displayMode:context.display,maxWidth,naturalWidth:mathInkWidth(natural)+.05,screen:context.screen});
|
|
62
79
|
}
|
|
63
80
|
catch (cause) {
|
|
64
|
-
const
|
|
65
|
-
|
|
81
|
+
const message=String(cause.message || cause);
|
|
82
|
+
const error={code:cause.code || 'invalid-tex',message:message.length>1024?message.slice(0,1024).replace(/[\ud800-\udbff]$/,'')+'…':message,offset:Number.isFinite(cause.offset)?cause.offset:0};
|
|
83
|
+
if(message.length>1024)error.messageTruncated=true;
|
|
84
|
+
if(cause.sourceOmitted)error.sourceOmitted=true;
|
|
85
|
+
if(cause.command) {
|
|
86
|
+
error.command=String(cause.command).slice(0,128);
|
|
87
|
+
if(cause.command.length>128)error.commandTruncated=true;
|
|
88
|
+
}
|
|
66
89
|
// A diagnostic is visible, but remains distinct from a successfully typeset formula.
|
|
67
|
-
//
|
|
68
|
-
// while the visible notice stays finite and can render after the formula exhausted its budget.
|
|
90
|
+
// Diagnostics have an explicit bound. Exact input stays in the source alternative.
|
|
69
91
|
const label=error.message.length>240?'This formula could not be rendered. The complete diagnostic is in its text description.':error.message.replace(/[^\x20-\x7e]/g,'?');
|
|
70
92
|
const diagnostic={...context,variant:'roman',size:0.8,budget:{nodes:0,outlineBytes:0}};
|
|
71
93
|
try {
|
|
@@ -78,7 +100,7 @@ function renderMath(source,options={}) {
|
|
|
78
100
|
}
|
|
79
101
|
|
|
80
102
|
globalThis.RapierMath=Object.freeze({
|
|
81
|
-
version:'1.0
|
|
103
|
+
version:'1.1.0',
|
|
82
104
|
parse:parseMathTex,
|
|
83
105
|
render:renderMath,
|
|
84
106
|
fit:mathFit,
|