Skip to content

Commit 9b66b02

Browse files
committed
feat(math): add optional native formula rendering
1 parent a7f206f commit 9b66b02

16 files changed

Lines changed: 2085 additions & 135 deletions

Cargo.lock

Lines changed: 468 additions & 7 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Cargo.toml

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,11 @@ markdown = "1.0.0"
1515
mdxjs = "1.0.4"
1616
napi = { version = "3.12.0", default-features = false, features = ["napi8"] }
1717
napi-derive = "3.6.2"
18+
ratex-layout = "=0.1.14"
19+
ratex-parser = "=0.1.14"
20+
ratex-svg = { version = "=0.1.14", features = ["embed-fonts"] }
21+
ratex-types = "=0.1.14"
22+
roxmltree = "0.21.1"
1823
serde = { version = "=1.0.224", features = ["derive"] }
1924
serde_json = "1.0.145"
2025
sha2 = "0.10.9"

apps/docs/docs/configuration.mdx

Lines changed: 41 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
---
2-
description: Exact defaults and behavior for collections, MDX, highlighting, media, derived fields, manifests, cache, and generated output.
2+
description: Exact defaults and behavior for collections, MDX, math, highlighting, media, derived fields, manifests, cache, and generated output.
33
---
44

55
# Configuration
@@ -30,6 +30,7 @@ At least one collection is required.
3030
| `root` | `process.cwd()` | Base for relative configuration paths. |
3131
| `collections` | required | Content directories, schemas, locales, and slugs. |
3232
| `mdx` | see below | MDX parser and JSX runtime options. |
33+
| `math` | disabled | Build-time math rendering. Use `{}` to enable. |
3334
| `highlight` | Shiki enabled | Syntax highlighting policy. Use `false` to disable. |
3435
| `media` | rewriting enabled | Markdown media import policy. Use `false` to disable. |
3536
| `derived` | all disabled | Reading time and last-modified metadata. |
@@ -114,6 +115,45 @@ mdx: {
114115
Authored ESM and MDX JSX remain part of the compiled module. `providerImportSource` controls MDX
115116
component-provider wiring; it is not a switch for preserving or stripping JSX.
116117

118+
## Math
119+
120+
```ts
121+
math: {
122+
singleDollar: true,
123+
macros: {
124+
'\\RR': '\\mathbb{R}',
125+
},
126+
}
127+
```
128+
129+
Math syntax is opt-in. Set `math: {}` to render inline `$x$` and display `$$` expressions, or
130+
`math: false` to keep dollar signs as ordinary text. `singleDollar: false` disables the `$x$`
131+
inline form, which is useful for prose containing currency. With the default enabled, write a
132+
literal currency sign as `\$`.
133+
134+
`macros` maps one TeX control sequence to a fixed expansion. Each expansion is limited to 1 KiB,
135+
and names plus expansions are limited to 16 KiB in total. Parameter placeholders and dynamic
136+
definition commands such as `\\def` and `\\newcommand` are rejected. The map is copied and applied
137+
independently to each expression, so definitions cannot leak between documents.
138+
139+
RaTeX parses, lays out, and renders each expression inside the Rust compiler. The generated module
140+
contains self-contained SVG output with no browser math runtime, stylesheet, webfont, raw HTML, or
141+
`dangerouslySetInnerHTML`. Invalid TeX reports `AMAMO_MATH_PARSE`; unsafe, external, or non-embedded
142+
SVG output reports `AMAMO_MATH_RENDER`. A glyph that cannot be embedded fails the build instead of
143+
falling back to an external font. Non-KaTeX Unicode glyphs can use an installed system font, so
144+
their exact outlines may differ between build machines. Only publish those glyphs when the build
145+
machine's fallback-font license permits embedding and redistribution.
146+
147+
Each expression is limited to 64 KiB of TeX, 100,000 expanded tokens, an 8 MiB or 100,000-node
148+
parsed AST, 100,000 expanded array cells in total, 10,000 display items, 10,000 em in either
149+
dimension, a 16 MiB SVG, and 100,000 SVG nodes. Parse and structural limit failures report
150+
`AMAMO_MATH_PARSE`; layout or SVG limit failures report `AMAMO_MATH_RENDER`.
151+
152+
Inline SVG uses RaTeX's depth for baseline alignment and inherits `currentColor`; explicit TeX
153+
colors remain explicit. The wrapper exposes `role="math"` and the source expression as an accessible
154+
label. RaTeX does not currently emit MathML, so formula text is not selectable and assistive
155+
technology receives the TeX label rather than a MathML tree.
156+
117157
## Highlighting
118158

119159
```ts

apps/docs/docs/configuration.zh-CN.mdx

Lines changed: 36 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
---
2-
description: 集合、MDX、高亮、媒体、派生字段、manifest、缓存和生成产物的准确默认值与行为。
2+
description: 集合、MDX、数学公式、高亮、媒体、派生字段、manifest、缓存和生成产物的准确默认值与行为。
33
---
44

55
# 配置
@@ -29,6 +29,7 @@ export default defineConfig({
2929
| `root` | `process.cwd()` | 相对配置路径的基准目录。 |
3030
| `collections` | 必填 | 内容目录、schema、locale 和 slug。 |
3131
| `mdx` | 见下文 | MDX 解析和 JSX runtime 选项。 |
32+
| `math` | 关闭 | 构建期数学渲染;设为 `{}` 启用。 |
3233
| `highlight` | 启用 Shiki | 语法高亮策略;设为 `false` 可禁用。 |
3334
| `media` | 启用重写 | Markdown 媒体 import 策略;设为 `false` 可禁用。 |
3435
| `derived` | 全部关闭 | 阅读时间和最后修改时间。 |
@@ -109,6 +110,40 @@ mdx: {
109110
手写的 ESM 和 MDX JSX 仍会进入编译模块。`providerImportSource` 控制 MDX 组件 provider 的
110111
接线,不是保留或删除 JSX 的开关。
111112

113+
## 数学公式
114+
115+
```ts
116+
math: {
117+
singleDollar: true,
118+
macros: {
119+
'\\RR': '\\mathbb{R}',
120+
},
121+
}
122+
```
123+
124+
数学语法需要显式启用。`math: {}` 会渲染行内 `$x$` 与块级 `$$` 公式;`math: false` 会把美元
125+
符号继续当成普通文字。`singleDollar: false` 可关闭 `$x$` 行内形式,适合货币内容较多的站点。
126+
使用默认值时,文字中的美元符号应写成 `\$`
127+
128+
`macros` 把单个 TeX 控制序列映射到固定展开值。每个展开值不超过 1 KiB,命令名与展开值合计
129+
不超过 16 KiB。参数占位符以及 `\\def``\\newcommand` 等动态定义命令会被拒绝。配置会复制到每个
130+
公式的独立解析器中,因此定义不会在公式或文档之间泄漏。
131+
132+
RaTeX 在 Rust 编译器内完成解析、排版与渲染。编译模块只包含自包含 SVG 输出,不需要浏览器
133+
数学运行时、样式表、webfont、原始 HTML 或 `dangerouslySetInnerHTML`。非法 TeX 产生
134+
`AMAMO_MATH_PARSE`;不安全、外部引用或无法嵌入字形的 SVG 产生 `AMAMO_MATH_RENDER`,不会
135+
静默回退到外部字体。KaTeX 字体未覆盖的 Unicode 字形可能使用构建机上的系统字体,因此不同
136+
构建机生成的轮廓可能不同。只有当构建机的回退字体许可嵌入与再分发时,才应发布这些字形。
137+
138+
每个表达式最多包含 64 KiB TeX、100,000 个展开 token、8 MiB 或 100,000 个节点的解析 AST、
139+
合计 100,000 个展开后的 array 单元格、10,000 个排版项、任一方向 10,000 em、16 MiB SVG 与
140+
100,000 个 SVG 节点。语法与结构限制失败会产生 `AMAMO_MATH_PARSE`;排版或 SVG 限制失败会产生
141+
`AMAMO_MATH_RENDER`
142+
143+
行内 SVG 使用 RaTeX 的 depth 对齐基线,并通过 `currentColor` 继承文字颜色;显式 TeX 颜色仍会
144+
保留。外层元素提供 `role="math"`,并把原始表达式作为无障碍标签。RaTeX 当前不生成 MathML,
145+
因此公式文本不可选择,辅助技术接收到的是 TeX 标签而不是 MathML 树。
146+
112147
## 语法高亮
113148

114149
```ts

apps/docs/package.json

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,13 +12,13 @@
1212
"dependencies": {
1313
"@amamo/doctrine": "0.2.1",
1414
"@base-ui/react": "1.7.0",
15-
"lucide-react": "1.29.0",
15+
"lucide-react": "1.31.0",
1616
"pagefind": "1.5.2",
1717
"react": "19.2.8",
1818
"react-dom": "19.2.8"
1919
},
2020
"devDependencies": {
21-
"@types/node": "^26.1.2",
21+
"@types/node": "^26.2.0",
2222
"@types/react": "19.2.18",
2323
"@types/react-dom": "19.2.4",
2424
"typescript": "^7.0.2"

native/src/config.rs

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,8 @@ pub struct NativeConfig {
2222
pub derived: NativeDerivedConfig,
2323
pub highlight: NativeHighlightConfig,
2424
pub manifests: HashMap<String, NativeManifestConfig>,
25+
#[serde(default)]
26+
pub math: NativeMathConfig,
2527
pub media: NativeMediaConfig,
2628
pub mdx: NativeMdxConfig,
2729
pub root: String,
@@ -91,6 +93,25 @@ pub struct NativeMdxConfig {
9193
pub provider_import_source: String,
9294
}
9395

96+
#[derive(Debug, Clone, Deserialize)]
97+
#[serde(rename_all = "camelCase")]
98+
pub struct NativeMathConfig {
99+
pub enabled: bool,
100+
#[serde(default)]
101+
pub macros: HashMap<String, String>,
102+
pub single_dollar: bool,
103+
}
104+
105+
impl Default for NativeMathConfig {
106+
fn default() -> Self {
107+
Self {
108+
enabled: false,
109+
macros: HashMap::new(),
110+
single_dollar: true,
111+
}
112+
}
113+
}
114+
94115
#[derive(Debug, Deserialize)]
95116
#[serde(rename_all = "camelCase")]
96117
pub struct NativeDerivedConfig {

native/src/document.rs

Lines changed: 19 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -10,9 +10,11 @@ use swc_core::ecma::visit::{VisitMut, VisitMutWith};
1010

1111
use crate::Diagnostic;
1212
use crate::config::{
13-
NativeCollectionConfig, NativeMdxConfig, NativeMediaConfig, apply_schema_defaults_and_validate,
13+
NativeCollectionConfig, NativeMathConfig, NativeMdxConfig, NativeMediaConfig,
14+
apply_schema_defaults_and_validate,
1415
};
1516
use crate::hast::{remove_table_line_breaks, rewrite_media};
17+
use crate::math::replace_math;
1618

1719
#[derive(Debug, serde::Serialize)]
1820
#[serde(rename_all = "camelCase")]
@@ -89,11 +91,17 @@ pub fn prepare_mdx(
8991
file: &str,
9092
body: &str,
9193
config: &NativeMdxConfig,
94+
math: &NativeMathConfig,
9295
highlight: bool,
9396
root: &Path,
9497
media: &NativeMediaConfig,
9598
) -> Result<PreparedMdx, Vec<Diagnostic>> {
96-
let options = mdx_options(file, config);
99+
let mut options = mdx_options(file, config);
100+
if math.enabled {
101+
options.parse.constructs.math_flow = true;
102+
options.parse.constructs.math_text = true;
103+
options.parse.math_text_single_dollar = math.single_dollar;
104+
}
97105
let mut mdast = mdxjs::mdast_util_from_mdx(body, &options)
98106
.map_err(|error| vec![mdx_diagnostic(file, error)])?;
99107
let reading_words = reading_units(&mdast);
@@ -105,6 +113,7 @@ pub fn prepare_mdx(
105113
apply_hard_breaks(&mut mdast);
106114
}
107115
let mut tree = mdxjs::mdast_util_to_hast(&mdast);
116+
replace_math(&mut tree, file, math)?;
108117
remove_table_line_breaks(&mut tree);
109118
let media = rewrite_media(&mut tree, root, Path::new(file), media)?;
110119

@@ -378,7 +387,9 @@ mod tests {
378387
use mdxjs::hast::Node;
379388
use serde_json::json;
380389

381-
use crate::config::{MediaMissing, NativeCollectionConfig, NativeMdxConfig, NativeMediaConfig};
390+
use crate::config::{
391+
MediaMissing, NativeCollectionConfig, NativeMathConfig, NativeMdxConfig, NativeMediaConfig,
392+
};
382393

383394
use super::{finish_mdx, parse_frontmatter, prepare_mdx};
384395

@@ -440,6 +451,7 @@ mod tests {
440451
"/project/hello.mdx",
441452
source,
442453
&options,
454+
&NativeMathConfig::default(),
443455
false,
444456
std::path::Path::new("/project"),
445457
&NativeMediaConfig::default(),
@@ -478,6 +490,7 @@ mod tests {
478490
"/project/table.mdx",
479491
source,
480492
&options,
493+
&NativeMathConfig::default(),
481494
false,
482495
std::path::Path::new("/project"),
483496
&NativeMediaConfig::default(),
@@ -501,6 +514,7 @@ mod tests {
501514
"/project/hello.mdx",
502515
source,
503516
&options,
517+
&NativeMathConfig::default(),
504518
false,
505519
std::path::Path::new("/project"),
506520
&NativeMediaConfig::default(),
@@ -540,6 +554,7 @@ mod tests {
540554
file.to_str().unwrap(),
541555
source,
542556
&options,
557+
&NativeMathConfig::default(),
543558
false,
544559
&root,
545560
&media,
@@ -585,6 +600,7 @@ mod tests {
585600
"/project/content/post.mdx",
586601
"![](../../outside.png)",
587602
&options,
603+
&NativeMathConfig::default(),
588604
false,
589605
std::path::Path::new("/project"),
590606
&media,

native/src/hast.rs

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -225,10 +225,24 @@ fn is_code_block(node: &Node) -> bool {
225225
node,
226226
Node::Element(element)
227227
if element.tag_name == "pre"
228-
&& matches!(element.children.first(), Some(Node::Element(code)) if code.tag_name == "code")
228+
&& matches!(
229+
element.children.first(),
230+
Some(Node::Element(code))
231+
if code.tag_name == "code" && !has_class(code, "math-display")
232+
)
229233
)
230234
}
231235

236+
fn has_class(element: &Element, expected: &str) -> bool {
237+
element.properties.iter().any(|(name, value)| {
238+
name == "className"
239+
&& matches!(
240+
value,
241+
PropertyValue::SpaceSeparated(classes) if classes.iter().any(|class| class == expected)
242+
)
243+
})
244+
}
245+
232246
pub fn rewrite_media(
233247
tree: &mut Node,
234248
root: &Path,

native/src/lib.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ mod config;
33
mod document;
44
mod hast;
55
mod manifest;
6+
mod math;
67

78
use std::collections::{HashMap, HashSet};
89
use std::path::Path;
@@ -341,6 +342,7 @@ pub fn prepare_batch(config_json: String, inputs_json: String) -> Result<Prepare
341342
&input.file,
342343
&parsed.body,
343344
&config.mdx,
345+
&config.math,
344346
config.highlight.enabled,
345347
Path::new(&config.root),
346348
&config.media,

0 commit comments

Comments
 (0)