Skip to main content

moss_core/ast/
math_text.rs

1//! The P1 math fallback node and its inverse.
2//!
3//! P1's contract is that math is **never silently deleted**. Two shapes
4//! carry an equation through the AST, and this module owns both plus the
5//! conversion between them, so the format and its parser cannot drift:
6//!
7//! | Shape | Built by | Used where |
8//! |---|---|---|
9//! | `<code class="moss-math" data-moss-math="…">` in [`Inline::Other`] | [`math_inline`] | rendered HTML |
10//! | `$…$` / `$$…$$` markdown source | [`math_source`] | every plain-text collector |
11//!
12//! **Why plain-text collectors get the source and not bare TeX.** pulldown
13//! hands a walker the *inner* TeX only — delimiters stripped, inner bytes
14//! otherwise untouched — so `$` + tex + `$` reproduces the author's
15//! original bytes exactly. Restoring the delimiters is what keeps three
16//! independent surfaces in agreement:
17//!
18//! 1. **The math=off slug.** `# Euler $e^{i\pi}=-1$ identity` must produce
19//!    the same `<h1 id>` whether or not `[site].math` is on, or flipping
20//!    the flag silently breaks every existing deep link, in-page TOC entry
21//!    and `[[Page#Heading]]` wikilink pointing at that heading.
22//! 2. **The wikilink graph.** `build/scan/scan.rs` slugs headings off the
23//!    RAW heading line; being a line scanner it cannot know where math
24//!    begins, so it always includes the `$` bytes. The render side has to
25//!    match it, or the graph resolves a link to a fragment the page lacks.
26//! 3. **`heading::extract`**, whose module doc calls byte-identity with the
27//!    rendered `<hN id>` "the keystone invariant".
28//!
29//! Bare TeX would satisfy none of the three. Markup would be actively wrong
30//! in these contexts — `alt` is an HTML attribute and the slug feeds a URL
31//! fragment.
32
33use super::hooks::escape_text;
34use super::node::Inline;
35
36const PREFIX_INLINE: &str = r#"<code class="moss-math" data-moss-math="inline">"#;
37const PREFIX_DISPLAY: &str = r#"<code class="moss-math" data-moss-math="display">"#;
38const SUFFIX: &str = "</code>";
39
40/// Build the P1 math fallback node: the equation's own markdown source —
41/// delimiters included — HTML-escaped, in a marked `<code>` span.
42///
43/// The `data-moss-math` attribute carries display-vs-inline so a later
44/// phase's renderer can typeset from the AST without re-deriving it, and so
45/// the CSS can size display math differently without a second class.
46///
47/// **Why the `$` delimiters are kept.** P1 ships no typesetting engine, so
48/// this span is what the reader actually sees. Emitting the bare inner TeX
49/// would silently swallow two characters of the author's prose, which is the
50/// same content-loss P1 exists to prevent — just moved from "equation
51/// deleted" to "delimiters deleted". It is invisible for a real equation and
52/// destructive for a false positive:
53///
54/// ```text
55/// 一个$5,两个$10     bare TeX → 一个5,两个10      (prices corrupted)
56///                     source   → 一个$5,两个$10    (byte-identical)
57/// ```
58///
59/// pulldown's close rule fires on any non-whitespace byte, so unspaced CJK
60/// currency parses as math (`moss doctor --math` exists to surface exactly
61/// this), and `[site].math` defaults on — the false positive is the case to
62/// optimize for. Keeping the delimiters also makes this node agree with
63/// [`math_source`], so an equation has ONE spelling across the body, image
64/// alt text, heading slugs and meta descriptions instead of two.
65///
66/// P2/P3 replace this span with typeset SVG, at which point the delimiters
67/// disappear along with the fallback.
68pub(crate) fn math_inline(tex: &str, display: bool) -> Inline {
69    let prefix = if display { PREFIX_DISPLAY } else { PREFIX_INLINE };
70    Inline::Other(format!("{prefix}{}{SUFFIX}", escape_text(&math_source(tex, display))))
71}
72
73/// Reconstruct an equation's markdown source — the TeX with its `$` / `$$`
74/// delimiters restored — from the inner TeX pulldown hands the walker.
75///
76/// `pub` because plain-text collectors are not confined to this crate: the
77/// email walker in `moss::infra::newsletter` builds an image `alt` attribute
78/// and needs the same restored-delimiter form, or the two surfaces disagree
79/// about what an equation looks like in plain text.
80pub fn math_source(tex: &str, display: bool) -> String {
81    let delim = if display { "$$" } else { "$" };
82    format!("{delim}{tex}{delim}")
83}
84
85/// Recover the markdown source of a math node from an [`Inline::Other`]
86/// payload, or `None` if the payload is some other raw-HTML passthrough.
87///
88/// The inverse of [`math_inline`]. Plain-text walkers that see the AST
89/// rather than the event stream (`heading::text::inlines_to_text`, the
90/// crate's one AST walker — `extract_hero` delegates to it) have no access
91/// to the original event, so recovering from the node is the only way to
92/// honor P1's never-silently-delete contract. Round-tripping is pinned by
93/// `math_source_round_trips_through_the_node` below — that test is what
94/// keeps this from drifting away from the builder three lines above it.
95pub(crate) fn math_source_from_other(html: &str) -> Option<String> {
96    let prefix = if html.starts_with(PREFIX_DISPLAY) {
97        PREFIX_DISPLAY
98    } else if html.starts_with(PREFIX_INLINE) {
99        PREFIX_INLINE
100    } else {
101        return None;
102    };
103    // The node's payload is ALREADY the delimited source — `math_inline`
104    // builds it with `math_source` — so this only has to unescape. Re-adding
105    // the delimiters here would double them (`$$E=mc^2$$` for inline math).
106    Some(
107        html.strip_prefix(prefix)?
108            .strip_suffix(SUFFIX)?
109            // Only the three characters `escape_text` writes, unescaped in the
110            // order that makes `&amp;lt;` come back as the literal `&lt;`
111            // rather than as `<`.
112            .replace("&lt;", "<")
113            .replace("&gt;", ">")
114            .replace("&amp;", "&"),
115    )
116}
117
118/// Decode a math [`Inline::Other`] node into `(inner_tex, display)` — the raw
119/// LaTeX the author typed (delimiters stripped, unescaped) and whether it was
120/// `$$…$$` (display) or `$…$` (inline), or `None` for any non-math passthrough.
121///
122/// This is what lets the renderer route a math node through
123/// [`RenderHooks::render_math`](crate::ast::RenderHooks::render_math) without a
124/// dedicated `Inline::Math` AST variant (ADR-030 D3): the P1 node already
125/// carries the source verbatim, so P2's typesetter recovers the exact bytes the
126/// engine needs. Round-tripping `math_inline` → this is pinned by
127/// `node_parts_round_trips` below.
128///
129/// `pub` for the same reason as [`math_source`]: the email HTML renderer in
130/// `moss::infra::newsletter` walks `Document` directly (ADR-036) rather than
131/// going through `RenderHooks`, so it decodes `Inline::Other` math nodes here
132/// to route them through its own hosted-PNG math path, falling back to the
133/// escaped source on refusal — the same three-question gate `render_math`
134/// documents, just applied by a second, email-specific lowering.
135pub fn math_node_parts(html: &str) -> Option<(String, bool)> {
136    let source = math_source_from_other(html)?;
137    // `math_source_from_other` returns the *delimited* source ($tex$ / $$tex$$);
138    // `math_source_from_other` already told prefix-vs-display via the same
139    // prefix check, so recover `display` the same way and strip the matching
140    // delimiter off both ends. `$$` before `$` so display is not mis-read.
141    let display = html.starts_with(PREFIX_DISPLAY);
142    let delim = if display { "$$" } else { "$" };
143    let inner = source
144        .strip_prefix(delim)
145        .and_then(|s| s.strip_suffix(delim))?;
146    Some((inner.to_string(), display))
147}
148
149#[cfg(test)]
150mod tests {
151    use super::*;
152
153    #[test]
154    fn node_parts_round_trips() {
155        for tex in ["E=mc^2", "a < b", "S = \\{x : x > 0\\}", "\\frac{a}{b}"] {
156            for display in [false, true] {
157                let Inline::Other(html) = math_inline(tex, display) else {
158                    panic!("math_inline must build an Inline::Other");
159                };
160                assert_eq!(
161                    math_node_parts(&html),
162                    Some((tex.to_string(), display)),
163                    "round-trip failed for {tex:?} display={display}"
164                );
165            }
166        }
167    }
168
169    #[test]
170    fn node_parts_rejects_non_math() {
171        assert_eq!(math_node_parts("<div>hi</div>"), None);
172        assert_eq!(math_node_parts("<code>plain</code>"), None);
173    }
174
175    #[test]
176    fn source_restores_delimiters() {
177        assert_eq!(math_source("E=mc^2", false), "$E=mc^2$");
178        assert_eq!(math_source("a+b", true), "$$a+b$$");
179    }
180
181    #[test]
182    fn math_source_round_trips_through_the_node() {
183        // Every TeX here exercises a character `escape_text` rewrites, so a
184        // change to either side of the escape pair fails this test.
185        for tex in ["E=mc^2", "a < b", "a > b", "x &amp; y", "S = \\{x : x > 0\\}"] {
186            for display in [false, true] {
187                let Inline::Other(html) = math_inline(tex, display) else {
188                    panic!("math_inline must build an Inline::Other");
189                };
190                assert_eq!(
191                    math_source_from_other(&html).as_deref(),
192                    Some(math_source(tex, display).as_str()),
193                    "round-trip failed for {tex:?} display={display}"
194                );
195            }
196        }
197    }
198
199    #[test]
200    fn non_math_passthrough_is_not_claimed() {
201        assert_eq!(math_source_from_other("<div>hello</div>"), None);
202        assert_eq!(math_source_from_other("<code>plain</code>"), None);
203        // A `moss-math-error` node (P2) must not be mistaken for a fallback.
204        assert_eq!(
205            math_source_from_other(r#"<code class="moss-math-error">x</code>"#),
206            None
207        );
208    }
209}