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.
Files changed (69) hide show
  1. package/README.md +187 -85
  2. package/adoption-check.mjs +149 -0
  3. package/check.mjs +21 -0
  4. package/classic.d.ts +4 -0
  5. package/delimiters.d.ts +8 -0
  6. package/delimiters.mjs +39 -0
  7. package/examples/README.md +25 -0
  8. package/examples/browser.html +10 -0
  9. package/examples/browser.mjs +24 -0
  10. package/examples/formula.mjs +12 -0
  11. package/examples/markdown.mjs +9 -0
  12. package/examples/react.tsx +19 -0
  13. package/examples/stream.mjs +19 -0
  14. package/examples/unified.mjs +26 -0
  15. package/examples/worker.mjs +7 -0
  16. package/index.d.ts +43 -0
  17. package/index.mjs +205 -108
  18. package/integration.d.ts +9 -0
  19. package/integration.mjs +28 -0
  20. package/markdown-it.d.ts +6 -0
  21. package/markdown-it.mjs +45 -0
  22. package/markdown-rules.mjs +65 -0
  23. package/math-components.json +6 -6
  24. package/package.json +78 -5
  25. package/rapier-math-1.1.0.js +2980 -0
  26. package/rehype.d.ts +6 -0
  27. package/rehype.mjs +37 -0
  28. package/src/math/FONT-SOURCE.md +32 -6
  29. package/src/math/README.md +210 -0
  30. package/src/math/adoption-verify.mjs +149 -0
  31. package/src/math/api.js +37 -14
  32. package/src/math/browser.js +67 -30
  33. package/src/math/build-font.py +237 -95
  34. package/src/math/classic.d.ts +4 -0
  35. package/src/math/delimiters.d.ts +8 -0
  36. package/src/math/delimiters.mjs +39 -0
  37. package/src/math/examples/README.md +25 -0
  38. package/src/math/examples/browser.html +10 -0
  39. package/src/math/examples/browser.mjs +24 -0
  40. package/src/math/examples/formula.mjs +12 -0
  41. package/src/math/examples/markdown.mjs +9 -0
  42. package/src/math/examples/react.tsx +19 -0
  43. package/src/math/examples/stream.mjs +19 -0
  44. package/src/math/examples/unified.mjs +26 -0
  45. package/src/math/examples/worker.mjs +7 -0
  46. package/src/math/fallbacks.js +5 -5
  47. package/src/math/font.js +59 -26
  48. package/src/math/index.d.ts +43 -0
  49. package/src/math/integration.d.ts +9 -0
  50. package/src/math/integration.mjs +28 -0
  51. package/src/math/layout.js +37 -33
  52. package/src/math/markdown-it.d.ts +6 -0
  53. package/src/math/markdown-it.mjs +45 -0
  54. package/src/math/markdown-rules.mjs +65 -0
  55. package/src/math/package-build.mjs +4 -4
  56. package/src/math/package-check.mjs +118 -0
  57. package/src/math/package-readme.mjs +8 -0
  58. package/src/math/package-verify.mjs +7 -2
  59. package/src/math/rehype.d.ts +6 -0
  60. package/src/math/rehype.mjs +37 -0
  61. package/src/math/stream.d.ts +14 -0
  62. package/src/math/stream.mjs +46 -0
  63. package/src/package.json +1 -1
  64. package/src/shell/math-resources.json +184 -25
  65. package/src/tools/build-math.mjs +23 -4
  66. package/stream.d.ts +14 -0
  67. package/stream.mjs +46 -0
  68. package/verify.mjs +7 -2
  69. package/rapier-math-1.0.1.js +0 -2883
package/README.md CHANGED
@@ -1,111 +1,213 @@
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
+ 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.
5
6
 
6
- ## Install
7
+ ## Start with a formula
7
8
 
8
9
  ```sh
9
10
  npm install rapier-math
10
11
  ```
11
12
 
12
13
  ```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});
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});
16
17
  if (result.errors.length) console.error(result.errors);
17
- const svg = renderToString(String.raw`E = mc^2`);
18
+ else console.log(result.svg);
18
19
  ```
19
20
 
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
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.
24
25
 
25
- Serve `index.mjs` from this package alongside your page:
26
+ ## Markdown
26
27
 
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>
28
+ ```sh
29
+ npm install markdown-it
37
30
  ```
38
31
 
39
- For a classic script, load `rapier-math-1.0.1.js` and call `globalThis.RapierMath.render(tex)`.
40
- The classic payload is 803451 bytes (496298 bytes gzip); it is the same renderer
41
- used by Rapier's optional maths plug-in. Its SHA-384 is `1kdn6mohx3a5MAiwqRWY+6oviUHUheHfY1VMsxjDsUrn7woTaa7FSKo4juYtuNaF`.
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.
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
+ ```
44
40
 
45
- ## API
41
+ Default delimiters are `\(...\)` (inline), `\[...\]` and `$$...$$` (display). Single `$...$` is opt-in:
42
+ `.use(rapierMath, {singleDollar: true})`. It cannot distinguish all currency from mathematics.
46
43
 
47
- | Export | Result |
44
+ | Boundary rule | Contract |
48
45
  | --- | --- |
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.1`. |
55
-
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.
60
-
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.
66
-
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:
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
89
83
 
90
- ```sh
91
- node verify.mjs
92
- node check.mjs
93
- node src/math/package-build.mjs index.mjs
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}
94
90
  ```
95
91
 
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.
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.
101
105
 
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/`.
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-1.1.0.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.
104
204
 
105
205
  ## Licenses
106
206
 
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.
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.
211
+
212
+ Build: 1.1.0. Classic payload: 714051 bytes; gzip (level 9): 442525 bytes.
213
+ SHA-384: `dYIPrqCf4rrTcAKw1Zzzo7vujbNtk2C7Zik/tIYOaLVT0U66WOXcGNE6waFbKych`
@@ -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](image.png)`, 'before $$x$$ after'],
48
+ [String.raw`![before \(x\) after](image.png)`, String.raw`before \(x\) after`],
49
+ [String.raw`![a ![b $$x$$](inner.png) c](outer.png)`, 'a b $$x$$ c'],
50
+ [String.raw`![a $$\text{<>&"}$$ b](image.png)`, 'a $$\\text{&lt;&gt;&amp;&quot;}$$ 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/check.mjs CHANGED
@@ -23,6 +23,9 @@ for (const name of ['./LICENSE', './index.mjs', './' + resources.bundle]) {
23
23
  assert(text.includes(fontLicense.trim()), name + ': complete font license');
24
24
  }
25
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();
26
29
  const globals = Object.getOwnPropertyDescriptors(globalThis);
27
30
  const module = await import('./index.mjs');
28
31
  const afterImport = Object.getOwnPropertyDescriptors(globalThis);
@@ -93,5 +96,23 @@ for (const source of hostile) {
93
96
  assert.deepEqual(plain(result), plain(classic.render(source)), 'module/classic hostile result');
94
97
  }
95
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');
96
117
  console.log(JSON.stringify({name: pkg.name, version: module.version, module: 'native ESM; no global changes',
97
118
  rebuild: 'exact module', classicAgreement: examples.length + hostile.length, sourceAlternatives: 'exact', hostileContent: 'inert', licenses: 'complete'}));
package/classic.d.ts ADDED
@@ -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;
package/delimiters.mjs ADDED
@@ -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,25 @@
1
+ # Run the installed examples
2
+
3
+ From a consumer directory, install `rapier-math`, then run
4
+ `node node_modules/rapier-math/examples/formula.mjs`.
5
+
6
+ For Markdown and streaming, also install `markdown-it` and run `markdown.mjs` or `stream.mjs`.
7
+ For unified, install `unified remark-parse remark-math remark-rehype rehype-sanitize rehype-stringify hast-util-from-html`
8
+ and run `unified.mjs`. The remark example uses remark's syntax; it does not recognize backslash delimiters.
9
+
10
+ Serve the package directory over HTTP and open `examples/browser.html`. Its external module imports the
11
+ installed ESM renderer, fits the output and releases observation before replacement and on page exit.
12
+ Start `new Worker('./worker.mjs', {type: 'module'})` from this directory and post `{id, source}`.
13
+ Use an id owned by the current message and ignore stale replies. Workers render SVG; they do not call `observe`.
14
+
15
+ `react.tsx` is a small React 19/SSR component to copy into an existing TypeScript application. Install
16
+ `react react-dom` and the development types `@types/react @types/react-dom`. No framework is required by the core.
17
+
18
+ For streamed UTF-8, use `TextDecoderStream` or `decoder.decode(bytes, {stream: true})` and flush the decoder
19
+ at EOF. Feed decoded strings into one stream per message. Append incoming chunks to a text node for immediate
20
+ literal progress, and replace the whole preview when `write` returns a snapshot. Replace it with `finish()` at EOF.
21
+ Do not concatenate transport data into HTML. On cancellation call `abort`; on replacement cancel the old producer
22
+ before `reset`. A producer that still runs needs its own generation check before calling `write`.
23
+
24
+ The examples retain the original source as the editable/copyable authority. Markdown hosts can normalize
25
+ newlines or escapes in their syntax nodes; an HTML serialization is not an exact source archive.
@@ -0,0 +1,10 @@
1
+ <!doctype html>
2
+ <meta charset="utf-8">
3
+ <meta name="viewport" content="width=device-width">
4
+ <meta http-equiv="Content-Security-Policy" content="default-src 'none'; script-src 'self'; style-src-attr 'unsafe-inline'; style-src-elem 'none'; worker-src 'self'; connect-src 'none'; base-uri 'none'">
5
+ <title>Rapier Math example</title>
6
+ <label>TeX <textarea id="source">\frac{-b\pm\sqrt{b^2-4ac}}{2a}</textarea></label>
7
+ <button id="render" type="button">Render</button>
8
+ <main id="equation"></main>
9
+ <pre id="errors"></pre>
10
+ <script type="module" src="./browser.mjs"></script>
@@ -0,0 +1,24 @@
1
+ // SPDX-License-Identifier: MIT
2
+ import {render, observe} from '../index.mjs';
3
+ const container = document.querySelector('#equation');
4
+ const source = document.querySelector('#source');
5
+ let stop = () => {};
6
+ function update() {
7
+ stop();
8
+ try {
9
+ const result = render(source.value, {displayMode: true});
10
+ document.querySelector('#errors').textContent = JSON.stringify(result.errors);
11
+ if (result.errors.length) container.replaceChildren(document.createTextNode(source.value));
12
+ else {
13
+ // Only this renderer's freshly returned SVG reaches the HTML sink.
14
+ container.innerHTML = result.svg;
15
+ stop = observe(container);
16
+ }
17
+ } catch (error) {
18
+ container.replaceChildren(document.createTextNode(source.value));
19
+ document.querySelector('#errors').textContent = error.message;
20
+ }
21
+ }
22
+ document.querySelector('#render').addEventListener('click', update);
23
+ window.addEventListener('pagehide', () => stop());
24
+ update();
@@ -0,0 +1,12 @@
1
+ // SPDX-License-Identifier: MIT
2
+ import {render, renderToString} from 'rapier-math';
3
+
4
+ // Works in Node/SSR: the result needs no browser JavaScript or remote font.
5
+ const source = String.raw`\frac{-b\pm\sqrt{b^2-4ac}}{2a}`;
6
+ const result = render(source, {displayMode: true});
7
+ if (result.errors.length) throw new Error(JSON.stringify(result.errors));
8
+ console.log(result.svg);
9
+ console.log(renderToString('E=mc^2'));
10
+ const failure = render(String.raw`\unsupported{x}`);
11
+ console.error({source: String.raw`\unsupported{x}`, errors: failure.errors});
12
+ // Retain the original source for editing/copying, even if admission throws.