rapier-math 1.0.2 → 1.1.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.
Files changed (47) hide show
  1. package/README.md +23 -93
  2. package/dist/classic.d.ts +4 -0
  3. package/dist/delimiters.d.ts +8 -0
  4. package/dist/delimiters.mjs +39 -0
  5. package/dist/index.d.ts +43 -0
  6. package/{index.mjs → dist/index.mjs} +43 -15
  7. package/dist/integration.d.ts +9 -0
  8. package/dist/integration.mjs +28 -0
  9. package/dist/markdown-it.d.ts +6 -0
  10. package/dist/markdown-it.mjs +45 -0
  11. package/dist/markdown-rules.mjs +65 -0
  12. package/{rapier-math-1.0.2.js → dist/rapier-math.js} +43 -15
  13. package/dist/rehype.d.ts +6 -0
  14. package/dist/rehype.mjs +37 -0
  15. package/dist/stream.d.ts +14 -0
  16. package/dist/stream.mjs +46 -0
  17. package/docs/api.md +210 -0
  18. package/{math-components.json → docs/math-components.json} +7 -7
  19. package/docs/math-resources.json +290 -0
  20. package/examples/README.md +25 -0
  21. package/examples/browser.html +10 -0
  22. package/examples/browser.mjs +24 -0
  23. package/examples/formula.mjs +12 -0
  24. package/examples/markdown.mjs +9 -0
  25. package/examples/react.tsx +19 -0
  26. package/examples/stream.mjs +19 -0
  27. package/examples/unified.mjs +26 -0
  28. package/examples/worker.mjs +7 -0
  29. package/package.json +68 -19
  30. package/src/math/api.js +35 -13
  31. package/src/math/browser.js +7 -1
  32. package/src/math/layout.js +1 -1
  33. package/src/math/package-build.mjs +0 -13
  34. package/src/math/package-layout.mjs +17 -0
  35. package/src/math/package-readme.mjs +45 -0
  36. package/src/tools/build-math.mjs +48 -13
  37. package/tests/adoption.mjs +149 -0
  38. package/{check.mjs → tests/check.mjs} +29 -8
  39. package/tests/verify.mjs +46 -0
  40. package/LICENSE-MIT +0 -21
  41. package/math-LICENSE.txt +0 -21
  42. package/math-font-LICENSE.txt +0 -457
  43. package/src/math/package-verify.mjs +0 -40
  44. package/src/package.json +0 -9
  45. package/src/shell/math-resources.json +0 -105
  46. package/verify.mjs +0 -40
  47. /package/{src/math/FONT-SOURCE.md → docs/font-source.md} +0 -0
package/README.md CHANGED
@@ -1,111 +1,41 @@
1
1
  # Rapier Math
2
2
 
3
- TeX-to-SVG maths with embedded Latin Modern outlines. Synchronous, offline and self-contained: no runtime
4
- dependencies, DOM, font installation or network request is needed to render a formula.
3
+ A self-contained TeX-to-SVG renderer with Latin Modern outlines. Synchronous, no runtime downloads.
5
4
 
6
- ## Install
5
+ ## Use
7
6
 
8
7
  ```sh
9
8
  npm install rapier-math
10
9
  ```
11
10
 
12
11
  ```js
13
- import {render, renderToString} from 'rapier-math';
14
-
15
- const result = render(String.raw`\frac{-b \pm \sqrt{b^2-4ac}}{2a}`, {displayMode: true});
16
- if (result.errors.length) console.error(result.errors);
17
- const svg = renderToString(String.raw`E = mc^2`);
18
- ```
19
-
20
- ES modules work in browsers, workers and server JavaScript runtimes. The default export also provides the
21
- same API as named exports. Importing the module does not install a global or access the DOM.
22
-
23
- ## Browser
24
-
25
- Serve `index.mjs` from this package alongside your page:
26
-
27
- ```html
28
- <div id="equation"></div>
29
- <script type="module">
30
- import {render, observe} from './index.mjs';
31
- const result = render(String.raw`\int_0^1 x^2\,dx = \frac{1}{3}`, {displayMode: true});
32
- const container = document.querySelector('#equation');
33
- container.innerHTML = result.svg;
34
- const stop = observe(container);
35
- // Call stop() when this container is no longer observed.
36
- </script>
12
+ import {renderToString} from 'rapier-math';
13
+ const svg = renderToString(String.raw`\frac{a}{b}`);
37
14
  ```
38
15
 
39
- For a classic script, load `rapier-math-1.0.2.js` and call `globalThis.RapierMath.render(tex)`.
40
- The classic payload is 712164 bytes (441919 bytes gzip); it is the same renderer
41
- used by Rapier's optional maths plug-in. Its SHA-384 is `Pg2MvS4Eg4s41yUZiSPn+TCRuA4gM3JHxxbCEkuSFyHgC+t143Iv48n8P4XlkE23`.
42
- To serve Rapier's plug-in from your own origin, place that classic payload beside `rapier.html` or
43
- `rapier-document.html`, or in the reader's `plugins` directory.
44
-
45
- ## API
46
-
47
- | Export | Result |
48
- | --- | --- |
49
- | `render(tex, options?)` | `{svg, width, height, depth, lines, fitted, errors}`. Dimensions are mathematical ems. |
50
- | `renderToString(tex, options?)` | The SVG string, including a diagnostic when rendering fails. |
51
- | `parse(tex)` | Parsed syntax tree; throws a structured error for unsupported or invalid input. |
52
- | `fit(container)` | One synchronous browser fitting pass; returns the number of selected formulas. |
53
- | `observe(container)` | Observe the current formulas for column, font and theme changes; returns a cleanup function. |
54
- | `version` | `1.0.2`. |
16
+ For a browser script, load [dist/rapier-math.js](dist/rapier-math.js) and use
17
+ `globalThis.RapierMath`. The native ESM entry is [dist/index.mjs](dist/index.mjs).
18
+ Both include the font and return synchronously. Type declarations accompany the runtime files.
55
19
 
56
- `displayMode: true` selects display mathematics. `maxWidth` sets an optional width in mathematical ems.
57
- Long rows break before relations or binary operators; aligned derivations share their relation column.
58
- An indivisible oversized group scales uniformly to fit. `fit` and `observe` need a browser DOM;
59
- call `observe` again after replacing the formulas in a container.
20
+ - [API and integration guide](docs/api.md)
21
+ - [Runnable examples](examples/README.md)
22
+ - [Font source and regeneration](docs/font-source.md)
60
23
 
61
- For a known screen, pass `screen: {fontSize: 16, pixelRatio: 2, dark: false}`. Font size is the surrounding
62
- text's CSS pixel size. The two numbers must be positive and finite, and `dark` must be a boolean.
63
- Invalid device scales return an `invalid-screen` diagnostic. Omit `screen` for resolution-independent
64
- output. SVGs match Latin Modern's x-height to Geist through the fonts' own metrics and inherit
65
- `currentColor`; explicit TeX colors retain their authored values.
24
+ ## Develop
66
25
 
67
- The root SVG retains exact valid XML source in `aria-label` and `title`. Source containing malformed
68
- XML characters is represented with Unicode escapes in those alternatives, while `data-tex-utf16` stores
69
- every original UTF-16 code unit as four hexadecimal digits. Rendering errors include `code`, `message`
70
- and `offset`, with `command` when applicable. Check `errors` before treating a result as successful.
71
-
72
- ## Supported mathematics
73
-
74
- Fractions, roots, scripts, limits, matrices, arrays, aligned equations, delimiters, accents, operators,
75
- Greek, mathematical alphabets and symbols, text, color, size and spacing commands are supported. The parser
76
- also supports local TeX macros and selected chemistry and physics notation. Macro expansion and rendering
77
- have bounded resource limits. HTTP and HTTPS links are allowed; executable URLs and injected HTML are refused.
78
-
79
- This is a formula renderer, not a complete TeX document processor. Package loading, file input and arbitrary
80
- TeX extensions are not supported. Labels do not establish cross-formula references or automatic equation
81
- numbers. Unsupported syntax produces an explicit diagnostic. Latin Modern Math has no lowercase script
82
- or bold-script alphabet: those lowercase requests use ordinary-letter fallbacks. SVG text alternatives
83
- retain the TeX; this package does not produce semantic MathML.
84
-
85
- ## Rebuild and verify
86
-
87
- The complete renderer source and font regeneration instructions are in `src/`. No package installation
88
- is needed to rebuild from the carried font data. Use Node 22 or later:
89
-
90
- ```sh
91
- node verify.mjs
92
- node check.mjs
93
- node src/math/package-build.mjs index.mjs
94
- ```
26
+ The current runtime formats live in `dist/`; editable renderer source and its build tools live in
27
+ `src/`. Optional adapters have one source copy in `dist/`. Examples, documentation and tests each
28
+ have their own directory. Only the current release is kept in this checkout.
95
29
 
96
- `verify.mjs` checks the classic payload, source digests and complete licenses, then rebuilds the exact payload
97
- in `src/shell/vendor/`. `check.mjs` checks the module entry, exact source alternatives, classic/module
98
- agreement and hostile-content isolation. `package-build.mjs` rebuilds both the classic source output and
99
- the module entry. The module carries the same renderer body with module-local exports; no dynamic evaluation
100
- or global installation is needed.
30
+ From a repository checkout, run `node tests/check.mjs` and `node tests/verify.mjs` to check the package and rebuild its exact bytes.
31
+ Run `node src/tools/build-math.mjs --package .` to regenerate the classic and ESM files from source.
32
+ Optional host checks run with `node tests/adoption.mjs` after installing the example dependencies.
101
33
 
102
- To regenerate the font itself, follow `src/math/FONT-SOURCE.md` and run `python src/math/build-font.py`.
103
- The source note uses `repo/` paths for the full Rapier checkout; this package uses `src/`.
34
+ ## License
104
35
 
105
- ## Licenses
36
+ The renderer and adapters are MIT-licensed. Embedded Latin Modern Math outlines and metrics are
37
+ licensed under the GUST Font License. [LICENSE](LICENSE) contains both complete grants.
38
+ [Component provenance](docs/math-components.json) and [source hashes](docs/math-resources.json)
39
+ travel with the package.
106
40
 
107
- The renderer is MIT-licensed. Embedded Latin Modern Math 1.959 outlines and mathematical metrics use the
108
- GUST Font License, an instance of the LaTeX Project Public License. `LICENSE` contains both complete grants;
109
- `LICENSE-MIT` and `math-LICENSE.txt` reproduce the renderer license, and `math-font-LICENSE.txt` retains
110
- the exact font copyright, conversion notice, GUST grant and LPPL 1.3c terms. Both payloads retain the notices.
111
- `math-components.json` records the renderer and font as SPDX 2.3 components with the complete font grant.
41
+ Version 1.1.1. Classic payload: 714051 bytes; gzip (level 9): 442525 bytes.
@@ -0,0 +1,4 @@
1
+ // SPDX-License-Identifier: MIT
2
+ import type API from './index.js';
3
+ declare global {var RapierMath: typeof API;}
4
+ export {};
@@ -0,0 +1,8 @@
1
+ // SPDX-License-Identifier: MIT
2
+ export interface DelimiterOptions {singleDollar?: boolean; brackets?: boolean}
3
+ export interface MathSpan {
4
+ start: number; end: number; bodyStart: number; bodyEnd: number;
5
+ open: string; close: string; displayMode: boolean;
6
+ }
7
+ /** Index a raw, host-owned text region. Does not parse Markdown or unescape source. */
8
+ export function createMathMatcher(source: string, options?: DelimiterOptions): (start: number, limit?: number) => MathSpan | null;
@@ -0,0 +1,39 @@
1
+ // SPDX-License-Identifier: MIT
2
+ // A boundary index for a host-owned text region, not a Markdown parser.
3
+ export function createMathMatcher(source, {singleDollar = false, brackets = true} = {}) {
4
+ if (typeof source !== 'string') throw new TypeError('Math boundaries require source text.');
5
+ const markers = new Map(), next = Object.create(null);
6
+ let slashes = 0, line = 0;
7
+ for (let at = 0; at < source.length;) {
8
+ const char = source[at];
9
+ if (char === '\\') { slashes++; at++; continue; }
10
+ if (char === '\n' || char === '\r') line++;
11
+ if (char === '$') {
12
+ let end = at + 1;
13
+ while (source[end] === '$') end++;
14
+ const count = end - at;
15
+ if (!(slashes & 1) && (count === 2 || count === 1 && singleDollar))
16
+ markers.set(at, {at, marker: count === 2 ? '$$' : '$', line});
17
+ at = end;
18
+ } else {
19
+ if (brackets && slashes & 1 && '()[]'.includes(char))
20
+ markers.set(at - 1, {at: at - 1, marker: '\\' + char, line});
21
+ at++;
22
+ }
23
+ slashes = 0;
24
+ }
25
+ for (const entry of [...markers.values()].reverse()) {
26
+ const closing = entry.marker === '\\(' ? '\\)' : entry.marker === '\\[' ? '\\]' : entry.marker;
27
+ entry.close = next[closing];
28
+ next[entry.marker] = entry;
29
+ }
30
+ return function match(start, limit = source.length) {
31
+ const open = markers.get(start), close = open?.close;
32
+ if (!close || open.marker === '\\)' || open.marker === '\\]' || close.at + close.marker.length > limit) return null;
33
+ const bodyStart = start + open.marker.length, bodyEnd = close.at;
34
+ if (open.marker === '$' && (open.line !== close.line || bodyStart === bodyEnd ||
35
+ /\s/.test(source[bodyStart]) || /\s/.test(source[bodyEnd - 1]) || /[0-9]/.test(source[bodyEnd + 1] || ''))) return null;
36
+ return {start, end: bodyEnd + close.marker.length, bodyStart, bodyEnd,
37
+ open: open.marker, close: close.marker, displayMode: open.marker === '$$' || open.marker === '\\['};
38
+ };
39
+ }
@@ -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;
@@ -2309,7 +2309,7 @@ function mathLayout(n,c) {
2309
2309
  case 'smash': {const b=mathLayout(n.body,c);return {...b,h:n.mode==='b'?b.h:0,d:n.mode==='t'?b.d:0};}
2310
2310
  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})});}
2311
2311
  case 'rule': {const s=mathScale(c);return mathRule(n.width*s,(n.height+n.depth)*s,-n.height*s);}
2312
- 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;}
2312
+ 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;}
2313
2313
  default: throw Object.assign(new Error('Unsupported maths construct: '+n.type),{code:'unsupported-construct'});
2314
2314
  }
2315
2315
  }
@@ -2768,12 +2768,18 @@ function mathFitElement(svg, printing = false) {
2768
2768
  if(screenKey!==state.screen || (target===null)!==(state.width===null) || target!==null && Math.abs(target-state.width)>.005) {
2769
2769
  const rendered=renderMath(source,{displayMode:display,maxWidth:(screenKey!==state.screen?width:target) || undefined,screen});
2770
2770
  if(!rendered.errors.length) {
2771
+ const active=doc.activeElement,links=active && svg.contains(active)?[...svg.querySelectorAll('a')]:[];
2772
+ const focusIndex=links.indexOf(active),focusHref=focusIndex<0?null:active.getAttribute('href');
2771
2773
  const replacement=new view.DOMParser().parseFromString(rendered.svg,'image/svg+xml').documentElement;
2772
- for(const name of ['width','height','viewBox','data-math-natural-width','data-math-max-width','data-math-lines','data-math-screen']) {
2774
+ for(const name of ['role','width','height','viewBox','data-math-natural-width','data-math-max-width','data-math-lines','data-math-screen']) {
2773
2775
  if(replacement.hasAttribute(name))svg.setAttribute(name,replacement.getAttribute(name));else svg.removeAttribute(name);
2774
2776
  }
2775
2777
  svg.style.verticalAlign=replacement.style.verticalAlign;
2776
2778
  svg.replaceChildren(...Array.from(replacement.childNodes,node=>doc.importNode(node,true)));
2779
+ if(focusIndex>=0 && (!doc.activeElement || doc.activeElement===doc.body)) {
2780
+ const link=svg.querySelectorAll('a')[focusIndex];
2781
+ if(link?.getAttribute('href')===focusHref)link.focus({preventScroll:true});
2782
+ }
2777
2783
  state.natural=Number(svg.getAttribute('data-math-natural-width'));state.screen=screenKey;
2778
2784
  state.width=state.natural>width?width:null;
2779
2785
  if(state.width===null)svg.removeAttribute('data-math-max-width');
@@ -2869,23 +2875,27 @@ function mathSvg(box,source,error,utf16,options={}) {
2869
2875
  const opacity=options.screen?.dark ? .94 : 1;
2870
2876
  if (![width,height,depth,left,...viewBox].every(Number.isFinite)) throw new Error('Mathematical dimensions are not finite');
2871
2877
  const escapedSource=mathEscape(source);
2872
- const svg='<svg xmlns="http://www.w3.org/2000/svg" role="img" aria-label="'+escapedSource+'"'+
2878
+ // A group exposes authored links to accessibility APIs; an image would hide them.
2879
+ const svg='<svg xmlns="http://www.w3.org/2000/svg" role="'+(box.svg.includes('<a ')?'group':'img')+'" aria-label="'+escapedSource+'"'+
2873
2880
  ' data-rapier-math="'+(options.displayMode?'display':'inline')+'" data-math-lines="'+(box.lines || 1)+'"'+
2874
2881
  ' data-math-natural-width="'+mathNumber(options.naturalWidth ?? width)+'"'+
2875
2882
  (options.screen?' data-math-screen="'+mathNumber(options.screen.fontSize)+'|'+mathNumber(options.screen.pixelRatio)+'|'+options.screen.dark+'"':'')+
2876
2883
  (options.maxWidth?' data-math-max-width="'+mathNumber(options.maxWidth)+'"':'')+
2877
2884
  (utf16?' data-tex-utf16="'+utf16+'"':'')+
2885
+ (error?.sourceOmitted?' data-math-source-omitted="true"':'')+
2878
2886
  (error?' data-math-error="'+mathEscape(error.code)+'"':'')+' width="'+mathNumber(width)+'em" height="'+mathNumber(height+depth)+'em"'+
2879
2887
  ' viewBox="'+viewBox.map(mathNumber).join(' ')+'"'+
2880
2888
  ' style="font-size:'+mathNumber(mathFontRatio)+'em;max-width:100%;height:auto;vertical-align:-'+mathNumber(depth)+'em;'+
2881
2889
  'fill-opacity:'+opacity+';stroke-opacity:'+opacity+'" fill="currentColor">'+
2882
2890
  '<title>'+escapedSource+'</title>'+(error?'<desc>'+mathEscape(error.message)+'</desc>':'')+
2883
2891
  '<g transform="scale(1000)">'+box.svg+'</g></svg>';
2892
+ if(svg.length>24*1024*1024)throw Object.assign(new Error('The complete formula markup exceeds its output budget.'),{code:'resource-limit'});
2884
2893
  return {svg,width,height,depth,lines:box.lines || 1,fitted:!!box.fitted,errors:error?[error]:[]};
2885
2894
  }
2886
2895
 
2887
2896
  function mathSourceAlternative(source) {
2888
- let alternative='',first=-1,kept=0;
2897
+ const alternatives=[],parts=[];
2898
+ let first=-1,kept=0;
2889
2899
  for(let i=0;i<source.length;i++) {
2890
2900
  const code=source.charCodeAt(i);
2891
2901
  if(code>=0xd800 && code<=0xdbff && i+1<source.length) {
@@ -2894,19 +2904,32 @@ function mathSourceAlternative(source) {
2894
2904
  }
2895
2905
  if((code>=0x20 && code<=0xd7ff) || (code>=0xe000 && code<=0xfffd) || code===9 || code===10 || code===13)continue;
2896
2906
  if(first<0)first=i;
2897
- alternative+=source.slice(kept,i)+'\\u'+code.toString(16).toUpperCase().padStart(4,'0');kept=i+1;
2907
+ if(i>kept)parts.push(source.slice(kept,i));
2908
+ parts.push('\\u'+code.toString(16).toUpperCase().padStart(4,'0'));kept=i+1;
2909
+ // Flatten small batches instead of retaining one rope node per bad code unit.
2910
+ if(parts.length>=1024) {alternatives.push(parts.join(''));parts.length=0;}
2898
2911
  }
2899
2912
  if(first<0)return {alternative:source};
2900
- alternative+=source.slice(kept);
2913
+ parts.push(source.slice(kept));alternatives.push(parts.join(''));
2901
2914
  // Only malformed XML text needs a separate carrier; each four hex digits keep one original code unit.
2902
- let utf16='';
2903
- for(let i=0;i<source.length;i++)utf16+=source.charCodeAt(i).toString(16).padStart(4,'0');
2904
- return {alternative,utf16,error:{code:'invalid-source-character',offset:first,
2915
+ const encoded=[];parts.length=0;
2916
+ for(let i=0;i<source.length;i++) {
2917
+ parts.push(source.charCodeAt(i).toString(16).padStart(4,'0'));
2918
+ if(parts.length===1024) {encoded.push(parts.join(''));parts.length=0;}
2919
+ }
2920
+ encoded.push(parts.join(''));
2921
+ return {alternative:alternatives.join(''),utf16:encoded.join(''),error:{code:'invalid-source-character',offset:first,
2905
2922
  message:'A source character cannot be embedded in SVG. The text alternative shows Unicode escapes; data-tex-utf16 keeps the original source.'}};
2906
2923
  }
2907
2924
 
2908
2925
  function renderMath(source,options={}) {
2909
- const text=String(source ?? ''),safe=mathSourceAlternative(text),context={size:1,level:0,variant:'normal',cramped:false,display:!!options?.displayMode,cache:new WeakMap(),budget:{nodes:0,outlineBytes:0}};
2926
+ const text=String(source ?? '');
2927
+ // Reject before scanning, escaping or parsing. Keep the diagnostic-return
2928
+ // contract, but explicitly omit refused source instead of expanding it.
2929
+ const safe=text.length>1000000?{alternative:'Formula source omitted: source limit exceeded.',error:{
2930
+ code:'resource-limit',offset:0,sourceOmitted:true,
2931
+ message:'This formula is too long to render safely. Its source was not embedded; retain the original input.'
2932
+ }}:mathSourceAlternative(text),context={size:1,level:0,variant:'normal',cramped:false,display:!!options?.displayMode,cache:new WeakMap(),budget:{nodes:0,outlineBytes:0}};
2910
2933
  try {
2911
2934
  if(safe.error)throw safe.error;
2912
2935
  if(options?.screen!==undefined) {
@@ -2923,11 +2946,16 @@ function renderMath(source,options={}) {
2923
2946
  return mathSvg(box,safe.alternative,undefined,undefined,{displayMode:context.display,maxWidth,naturalWidth:mathInkWidth(natural)+.05,screen:context.screen});
2924
2947
  }
2925
2948
  catch (cause) {
2926
- const error={code:cause.code || 'invalid-tex',message:String(cause.message || cause),offset:Number.isFinite(cause.offset)?cause.offset:0};
2927
- if(cause.command)error.command=cause.command;
2949
+ const message=String(cause.message || cause);
2950
+ 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};
2951
+ if(message.length>1024)error.messageTruncated=true;
2952
+ if(cause.sourceOmitted)error.sourceOmitted=true;
2953
+ if(cause.command) {
2954
+ error.command=String(cause.command).slice(0,128);
2955
+ if(cause.command.length>128)error.commandTruncated=true;
2956
+ }
2928
2957
  // A diagnostic is visible, but remains distinct from a successfully typeset formula.
2929
- // A hostile command can have a very long name. Keep its full diagnostic in desc/errors,
2930
- // while the visible notice stays finite and can render after the formula exhausted its budget.
2958
+ // Diagnostics have an explicit bound. Exact input stays in the source alternative.
2931
2959
  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,'?');
2932
2960
  const diagnostic={...context,variant:'roman',size:0.8,budget:{nodes:0,outlineBytes:0}};
2933
2961
  try {
@@ -2940,7 +2968,7 @@ function renderMath(source,options={}) {
2940
2968
  }
2941
2969
 
2942
2970
  const RapierMath=Object.freeze({
2943
- version:'1.0.2',
2971
+ version:'1.1.1',
2944
2972
  parse:parseMathTex,
2945
2973
  render:renderMath,
2946
2974
  fit:mathFit,
@@ -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
+ ({'&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;', '\r': '&#13;'}[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
+ }
@@ -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
+ }