Skip to main content

fmd_math/
mathml.rs

1//! Node-tree → MathML Core serializer.
2//!
3//! Deterministic: attribute order is fixed per element, every element has an
4//! explicit close tag (no self-closing form), and text/attr values are XML
5//! escaped. The walk never panics; a hostile or fragment tree still yields a
6//! well-formed fragment.
7
8use crate::atom::AtomClass;
9use crate::node::{
10    AccentKind, Delim, FragmentKind, Limits, MathFont, Node, NodeKind, PhantomKind, SpaceKind,
11    Span, StackKind, TextStyle,
12};
13use crate::style::Style;
14
15const MATHML_NS: &str = "http://www.w3.org/1998/Math/MathML";
16
17/// Capacity-hint multiplier: MathML output bytes per TeX source byte.
18/// Measured over the repo math fixtures plus the round-2 pass-20 math-heavy
19/// corpus (120 distinct (tex, display) shapes, 6,046 occurrence-weighted
20/// renders): ratio min 4.74, distinct p50 8.88, occurrence-weighted median
21/// 7.94; together with the floor below, 8x covers every measured shape —
22/// see tests/artifacts/perf/round2-reserve-r1-mathml-presize/ratio-measurement.txt.
23const MATHML_BYTES_PER_TEX_BYTE: usize = 8;
24
25/// Capacity-hint floor covering the fixed `<math …>` wrapper: the largest
26/// output for a one-byte TeX source is 83 B (`+`), so 160 B doubles that
27/// margin and absorbs every measured short formula on its own.
28const MATHML_CAPACITY_FLOOR: usize = 160;
29
30/// Serialize `node` as a complete `<math>…</math>` fragment.
31///
32/// `display = true` sets `display="block"` (TeX display / `$$`); `false` sets
33/// `display="inline"` (`$…$`).
34#[must_use]
35pub fn to_mathml(node: &Node, display: bool) -> String {
36    to_mathml_with_capacity(node, display, 0)
37}
38
39/// [`to_mathml`] with a TeX-source-length hint used only to presize the
40/// output buffer (`MATHML_CAPACITY_FLOOR + tex_len *
41/// MATHML_BYTES_PER_TEX_BYTE` bytes, so the serializer does not walk the
42/// realloc-doubling chain for typical formulas). The emitted bytes are
43/// identical to [`to_mathml`] for every `(node, display, tex_len)` — buffer
44/// capacity is never observable in the output.
45#[must_use]
46pub fn to_mathml_with_capacity(node: &Node, display: bool, tex_len: usize) -> String {
47    let cap = MATHML_CAPACITY_FLOOR + tex_len.saturating_mul(MATHML_BYTES_PER_TEX_BYTE);
48    let mut w = Writer::with_capacity(cap);
49    let display_val = if display { "block" } else { "inline" };
50    w.open("math", &[("xmlns", MATHML_NS), ("display", display_val)]);
51    let style = if display { Style::Display } else { Style::Text };
52    match &node.kind {
53        NodeKind::List(items) => emit_run(&mut w, items, style, None, None),
54        _ => emit_node(&mut w, node, style),
55    }
56    w.close("math");
57    w.buf
58}
59
60/// Serialize `node` as a MathML element (no outer `<math>` wrapper).
61///
62/// A top-level [`NodeKind::List`] becomes a single `<mrow>`.
63pub fn to_mathml_element(node: &Node) -> String {
64    let mut w = Writer::with_capacity(0);
65    emit_node(&mut w, node, Style::Display);
66    w.buf
67}
68
69/// Std-only well-formedness check: balanced tags, quoted attributes, escaped
70/// text. Accepts the serializer's output contract (no self-closing tags).
71pub fn mathml_well_formed(xml: &str) -> Result<(), String> {
72    check_well_formed(xml)
73}
74
75struct Writer {
76    buf: String,
77}
78
79impl Writer {
80    fn with_capacity(cap: usize) -> Self {
81        Self {
82            buf: String::with_capacity(cap),
83        }
84    }
85
86    #[inline(always)]
87    fn open(&mut self, tag: &str, attrs: &[(&str, &str)]) {
88        self.buf.push('<');
89        self.buf.push_str(tag);
90        for &(name, value) in attrs {
91            self.buf.push(' ');
92            self.buf.push_str(name);
93            self.buf.push_str("=\"");
94            push_escaped(&mut self.buf, value, true);
95            self.buf.push('"');
96        }
97        self.buf.push('>');
98    }
99
100    #[inline(always)]
101    fn close(&mut self, tag: &str) {
102        self.buf.push('<');
103        self.buf.push('/');
104        self.buf.push_str(tag);
105        self.buf.push('>');
106    }
107
108    #[inline(always)]
109    fn text(&mut self, s: &str) {
110        push_escaped(&mut self.buf, s, false);
111    }
112
113    #[inline(always)]
114    fn char_text(&mut self, ch: char) {
115        match ch {
116            '&' => self.buf.push_str("&amp;"),
117            '<' => self.buf.push_str("&lt;"),
118            '>' => self.buf.push_str("&gt;"),
119            _ => self.buf.push(ch),
120        }
121    }
122}
123
124#[inline(always)]
125fn push_escaped(buf: &mut String, s: &str, attr: bool) {
126    if !s
127        .as_bytes()
128        .iter()
129        .any(|&b| b == b'&' || b == b'<' || b == b'>' || (attr && b == b'"'))
130    {
131        buf.push_str(s);
132        return;
133    }
134    let bytes = s.as_bytes();
135    let mut clean_start = 0;
136    for (i, &b) in bytes.iter().enumerate() {
137        let esc = match b {
138            b'&' => "&amp;",
139            b'<' => "&lt;",
140            b'>' => "&gt;",
141            b'"' if attr => "&quot;",
142            _ => continue,
143        };
144        if clean_start < i {
145            buf.push_str(&s[clean_start..i]);
146        }
147        buf.push_str(esc);
148        clean_start = i + 1;
149    }
150    if clean_start < s.len() {
151        buf.push_str(&s[clean_start..]);
152    }
153}
154
155fn emit_node(w: &mut Writer, node: &Node, style: Style) {
156    match &node.kind {
157        NodeKind::List(items) => {
158            w.open("mrow", &[]);
159            emit_run(w, items, style, None, None);
160            w.close("mrow");
161        }
162        NodeKind::Symbol { ch, class } => emit_symbol(w, *ch, *class),
163        NodeKind::BigOp { ch, .. } => {
164            w.open("mo", &[("movablelimits", "true")]);
165            w.char_text(*ch);
166            w.close("mo");
167        }
168        NodeKind::OpName { name, .. } => {
169            w.open("mi", &[("mathvariant", "normal")]);
170            w.text(name);
171            w.close("mi");
172        }
173        NodeKind::Scripts {
174            base,
175            sub,
176            sup,
177            primes,
178        } => emit_scripts(
179            w,
180            base.as_deref(),
181            sub.as_deref(),
182            sup.as_deref(),
183            primes,
184            style,
185        ),
186        NodeKind::Frac { num, den, spec } => {
187            emit_frac(w, num, den, spec.bar, spec.delims, spec.forced_style, style)
188        }
189        NodeKind::Radical { index, radicand } => emit_radical(w, index.as_deref(), radicand, style),
190        NodeKind::Accent { accent, base } => emit_accent(w, *accent, base, style),
191        NodeKind::LeftRight { left, right, body } => emit_left_right(w, left, right, body, style),
192        NodeKind::SizedDelim { delim, .. } => {
193            if let Some(ch) = delim.ch {
194                w.open("mo", &[]);
195                w.char_text(ch);
196                w.close("mo");
197            }
198        }
199        NodeKind::Text { body } => emit_mtext_nodes(w, body),
200        NodeKind::TextRun { text, .. } => {
201            w.open("mtext", &[]);
202            w.text(text);
203            w.close("mtext");
204        }
205        NodeKind::TextStyled { style: ts, body } => emit_text_styled(w, *ts, body),
206        NodeKind::MathIsland { body, display } => {
207            let inner_style = if *display {
208                Style::Display
209            } else {
210                Style::Text
211            };
212            w.open("mrow", &[]);
213            emit_run(w, body, inner_style, None, None);
214            w.close("mrow");
215        }
216        NodeKind::StyleChange(_)
217        | NodeKind::AlignChange(_)
218        | NodeKind::SizeChange(_)
219        | NodeKind::ColorChange(_)
220        | NodeKind::LineSpacing(_) => {
221            // Remainder markers only have meaning inside a list walk.
222        }
223        NodeKind::MathFont { font, body } => {
224            w.open("mstyle", &[("mathvariant", math_font_variant(*font))]);
225            emit_node(w, body, style);
226            w.close("mstyle");
227        }
228        NodeKind::Phantom { kind, body } => emit_phantom(w, *kind, body, style),
229        NodeKind::Stack {
230            kind,
231            annotation,
232            base,
233        } => emit_stack(w, *kind, annotation, base, style),
234        NodeKind::XArrow {
235            mapsto,
236            above,
237            below,
238        } => emit_xarrow(w, *mapsto, above, below.as_deref(), style),
239        NodeKind::Space(kind) => emit_space(w, *kind),
240        NodeKind::Tie => {
241            w.open("mtext", &[]);
242            w.buf.push('\u{00A0}');
243            w.close("mtext");
244        }
245        NodeKind::Linebreak => {
246            w.open("mspace", &[("linebreak", "newline")]);
247            w.close("mspace");
248        }
249        NodeKind::AlignTab => {}
250        NodeKind::AlignBlock { lines, .. } => emit_align_block(w, lines, style),
251        NodeKind::Environment { name, spec, rows } => {
252            emit_environment(w, name, spec.as_deref(), rows, style)
253        }
254        NodeKind::Fragment(kind) => emit_fragment(w, kind),
255    }
256}
257
258fn emit_run(w: &mut Writer, items: &[Node], style: Style, color: Option<&str>, size: Option<f64>) {
259    // Remainder markers (`\color`, `\displaystyle`, …) are siblings, not
260    // nested groups. Walking them recursively is O(markers) stack frames, so
261    // a long run of `\color{red}` would overflow. Fold style in a loop.
262    let mut items = items;
263    let mut style = style;
264    let mut color = color;
265    let mut size = size;
266    while !items.is_empty() {
267        let marker_at = items.iter().position(|n| is_remainder_marker(&n.kind));
268        match marker_at {
269            None => {
270                emit_styled_siblings(w, items, style, color, size);
271                return;
272            }
273            Some(0) => {
274                let Some((first, rest)) = items.split_first() else {
275                    return;
276                };
277                let (next_style, next_color, next_size) =
278                    apply_marker(&first.kind, style, color, size);
279                style = next_style;
280                color = next_color;
281                size = next_size;
282                items = rest;
283            }
284            Some(k) => {
285                emit_styled_siblings(w, &items[..k], style, color, size);
286                items = &items[k..];
287            }
288        }
289    }
290}
291
292fn is_remainder_marker(kind: &NodeKind) -> bool {
293    matches!(
294        kind,
295        NodeKind::StyleChange(_)
296            | NodeKind::AlignChange(_)
297            | NodeKind::SizeChange(_)
298            | NodeKind::ColorChange(_)
299            | NodeKind::LineSpacing(_)
300    )
301}
302
303fn apply_marker<'a>(
304    kind: &'a NodeKind,
305    style: Style,
306    color: Option<&'a str>,
307    size: Option<f64>,
308) -> (Style, Option<&'a str>, Option<f64>) {
309    match kind {
310        NodeKind::StyleChange(s) => (*s, color, size),
311        NodeKind::ColorChange(c) => (style, Some(c.as_str()), size),
312        NodeKind::SizeChange(f) => (style, color, Some(*f)),
313        NodeKind::AlignChange(_) | NodeKind::LineSpacing(_) => (style, color, size),
314        _ => (style, color, size),
315    }
316}
317
318fn emit_styled_siblings(
319    w: &mut Writer,
320    items: &[Node],
321    style: Style,
322    color: Option<&str>,
323    size: Option<f64>,
324) {
325    if items.is_empty() {
326        return;
327    }
328    let wrap = color.is_some() || size.is_some() || style_needs_mstyle(style);
329    if wrap {
330        let ds = if matches!(style, Style::Display) {
331            "true"
332        } else {
333            "false"
334        };
335        let sl = match style {
336            Style::Display | Style::Text => "0",
337            Style::Script => "1",
338            Style::ScriptScript => "2",
339        };
340        // An <mstyle> here carries at most MSTYLE_MAX_ATTRS attributes
341        // (displaystyle, scriptlevel, mathcolor, mathsize), so a
342        // fixed-capacity stack array + len replaces the old per-group heap
343        // Vec, and the mathsize percentage is written digit-by-digit into a
344        // stack buffer instead of a format! String.
345        let mut pct_buf = [0u8; PERCENT_BUF_LEN];
346        let size_val = size.map(|f| write_percent(&mut pct_buf, f));
347        let mut attrs: [(&str, &str); MSTYLE_MAX_ATTRS] = [("", ""); MSTYLE_MAX_ATTRS];
348        let mut n_attrs = 0usize;
349        if style_needs_mstyle(style) {
350            attrs[n_attrs] = ("displaystyle", ds);
351            n_attrs += 1;
352            attrs[n_attrs] = ("scriptlevel", sl);
353            n_attrs += 1;
354        }
355        if let Some(c) = color {
356            attrs[n_attrs] = ("mathcolor", c);
357            n_attrs += 1;
358        }
359        if let Some(s) = size_val {
360            attrs[n_attrs] = ("mathsize", s);
361            n_attrs += 1;
362        }
363        w.open("mstyle", &attrs[..n_attrs]);
364        for n in items {
365            emit_node(w, n, style);
366        }
367        w.close("mstyle");
368    } else {
369        for n in items {
370            emit_node(w, n, style);
371        }
372    }
373}
374
375fn style_needs_mstyle(style: Style) -> bool {
376    !matches!(style, Style::Display | Style::Text)
377}
378
379/// An `<mstyle>` emitted by `emit_styled_siblings` carries at most four
380/// attributes: displaystyle, scriptlevel, mathcolor, mathsize.
381const MSTYLE_MAX_ATTRS: usize = 4;
382
383/// Longest possible `mathsize` value: sign + 10 digits + `%` (the clamp
384/// bounds the value to `-10000%` = 7 bytes, but the buffer is sized for any
385/// i32 so the digit writer cannot overflow).
386const PERCENT_BUF_LEN: usize = 12;
387
388/// Write the `mathsize` percentage for a size factor into `buf` and return
389/// it as a `&str`, avoiding a per-styled-group `format!` allocation.
390/// Byte-identical to the previous `format!("{n}%")` shape where `n` is the
391/// rounded, clamped percentage — locked by the
392/// `percent_digits_match_format_reference` test.
393fn write_percent(buf: &mut [u8; PERCENT_BUF_LEN], factor: f64) -> &str {
394    let pct = (factor * 100.0).round();
395    let n = if pct.is_finite() {
396        pct.clamp(-10_000.0, 10_000.0) as i32
397    } else {
398        100
399    };
400    let mut len = 0usize;
401    if n < 0 {
402        buf[len] = b'-';
403        len += 1;
404    }
405    let mut mag = n.unsigned_abs();
406    let mut digits = [0u8; 10];
407    let mut nd = 0usize;
408    loop {
409        digits[nd] = b'0' + (mag % 10) as u8;
410        nd += 1;
411        mag /= 10;
412        if mag == 0 {
413            break;
414        }
415    }
416    while nd > 0 {
417        nd -= 1;
418        buf[len] = digits[nd];
419        len += 1;
420    }
421    buf[len] = b'%';
422    len += 1;
423    std::str::from_utf8(&buf[..len]).unwrap_or_default()
424}
425
426fn emit_symbol(w: &mut Writer, ch: char, class: AtomClass) {
427    let tag = symbol_tag(ch, class);
428    w.open(tag, &[]);
429    w.char_text(ch);
430    w.close(tag);
431}
432
433fn symbol_tag(ch: char, class: AtomClass) -> &'static str {
434    match class {
435        AtomClass::Ord if ch.is_ascii_digit() => "mn",
436        AtomClass::Ord => "mi",
437        AtomClass::Op
438        | AtomClass::Bin
439        | AtomClass::Rel
440        | AtomClass::Open
441        | AtomClass::Close
442        | AtomClass::Punct
443        | AtomClass::Inner => "mo",
444    }
445}
446
447fn emit_scripts(
448    w: &mut Writer,
449    base: Option<&Node>,
450    sub: Option<&Node>,
451    sup: Option<&Node>,
452    primes: &[Span],
453    style: Style,
454) {
455    let limits = scripts_as_limits(base, style);
456    let has_primes = !primes.is_empty();
457    let has_sub = sub.is_some();
458    let has_sup = sup.is_some() || has_primes;
459    if !has_sub && !has_sup {
460        match base {
461            Some(b) => emit_node(w, b, style),
462            None => {
463                w.open("mrow", &[]);
464                w.close("mrow");
465            }
466        }
467        return;
468    }
469    let tag = if limits {
470        match (has_sub, has_sup) {
471            (true, true) => "munderover",
472            (true, false) => "munder",
473            (false, true) => "mover",
474            (false, false) => "mrow",
475        }
476    } else {
477        match (has_sub, has_sup) {
478            (true, true) => "msubsup",
479            (true, false) => "msub",
480            (false, true) => "msup",
481            (false, false) => "mrow",
482        }
483    };
484    w.open(tag, &[]);
485    match base {
486        Some(b) => emit_node(w, b, style),
487        None => {
488            w.open("mrow", &[]);
489            w.close("mrow");
490        }
491    }
492    if has_sub {
493        if let Some(s) = sub {
494            emit_node(w, s, style);
495        }
496    }
497    if has_sup {
498        emit_superscript(w, sup, primes, style);
499    }
500    w.close(tag);
501}
502
503fn emit_superscript(w: &mut Writer, sup: Option<&Node>, primes: &[Span], style: Style) {
504    if primes.is_empty() {
505        if let Some(s) = sup {
506            emit_node(w, s, style);
507        }
508        return;
509    }
510    if sup.is_none() && primes.len() == 1 {
511        w.open("mo", &[]);
512        w.buf.push('′');
513        w.close("mo");
514        return;
515    }
516    w.open("mrow", &[]);
517    for _ in primes {
518        w.open("mo", &[]);
519        w.buf.push('′');
520        w.close("mo");
521    }
522    if let Some(s) = sup {
523        emit_node(w, s, style);
524    }
525    w.close("mrow");
526}
527
528#[inline(always)]
529fn scripts_as_limits(base: Option<&Node>, style: Style) -> bool {
530    let Some(node) = base else {
531        return false;
532    };
533    match &node.kind {
534        NodeKind::BigOp {
535            limits, integral, ..
536        } => match limits {
537            Limits::Limits => true,
538            Limits::NoLimits => false,
539            Limits::Default => !*integral && matches!(style, Style::Display),
540        },
541        NodeKind::OpName { limits, .. } => *limits && matches!(style, Style::Display),
542        _ => false,
543    }
544}
545
546fn emit_frac(
547    w: &mut Writer,
548    num: &Node,
549    den: &Node,
550    bar: bool,
551    delims: Option<(char, char)>,
552    forced_style: Option<Style>,
553    style: Style,
554) {
555    let wrap_style = forced_style;
556    if let Some(st) = wrap_style {
557        let ds = if matches!(st, Style::Display) {
558            "true"
559        } else {
560            "false"
561        };
562        w.open("mstyle", &[("displaystyle", ds)]);
563        emit_frac_body(w, num, den, bar, delims, style);
564        w.close("mstyle");
565    } else {
566        emit_frac_body(w, num, den, bar, delims, style);
567    }
568}
569
570fn emit_frac_body(
571    w: &mut Writer,
572    num: &Node,
573    den: &Node,
574    bar: bool,
575    delims: Option<(char, char)>,
576    style: Style,
577) {
578    if let Some((left, right)) = delims {
579        w.open("mrow", &[]);
580        emit_fence(w, left);
581        emit_mfrac(w, num, den, bar, style);
582        emit_fence(w, right);
583        w.close("mrow");
584    } else {
585        emit_mfrac(w, num, den, bar, style);
586    }
587}
588
589fn emit_mfrac(w: &mut Writer, num: &Node, den: &Node, bar: bool, style: Style) {
590    if bar {
591        w.open("mfrac", &[]);
592    } else {
593        w.open("mfrac", &[("linethickness", "0")]);
594    }
595    emit_node(w, num, style);
596    emit_node(w, den, style);
597    w.close("mfrac");
598}
599
600fn emit_fence(w: &mut Writer, ch: char) {
601    w.open("mo", &[("fence", "true"), ("stretchy", "true")]);
602    w.char_text(ch);
603    w.close("mo");
604}
605
606fn emit_radical(w: &mut Writer, index: Option<&Node>, radicand: &Node, style: Style) {
607    if let Some(ix) = index {
608        w.open("mroot", &[]);
609        emit_node(w, radicand, style);
610        emit_node(w, ix, style);
611        w.close("mroot");
612    } else {
613        w.open("msqrt", &[]);
614        emit_node(w, radicand, style);
615        w.close("msqrt");
616    }
617}
618
619fn emit_accent(w: &mut Writer, accent: AccentKind, base: &Node, style: Style) {
620    let tag = if accent.is_over() { "mover" } else { "munder" };
621    let stretchy = matches!(
622        accent,
623        AccentKind::WideHat
624            | AccentKind::WideTilde
625            | AccentKind::OverLine
626            | AccentKind::UnderLine
627            | AccentKind::OverBrace
628            | AccentKind::UnderBrace
629            | AccentKind::OverRightArrow
630            | AccentKind::OverLeftArrow
631    );
632    w.open(tag, &[]);
633    emit_node(w, base, style);
634    if stretchy {
635        w.open("mo", &[("stretchy", "true")]);
636    } else {
637        w.open("mo", &[]);
638    }
639    w.text(accent_char(accent));
640    w.close("mo");
641    w.close(tag);
642}
643
644#[inline(always)]
645fn accent_char(kind: AccentKind) -> &'static str {
646    match kind {
647        AccentKind::Hat | AccentKind::WideHat => "\u{02C6}",
648        AccentKind::Check => "\u{02C7}",
649        AccentKind::Tilde | AccentKind::WideTilde => "\u{02DC}",
650        AccentKind::Acute => "\u{00B4}",
651        AccentKind::Grave => "`",
652        AccentKind::Dot => "\u{02D9}",
653        AccentKind::Ddot => "\u{00A8}",
654        AccentKind::Breve => "\u{02D8}",
655        AccentKind::Bar => "\u{00AF}",
656        AccentKind::Vec | AccentKind::OverRightArrow => "\u{2192}",
657        AccentKind::Dddot => "\u{20DB}",
658        AccentKind::Ddddot => "\u{20DC}",
659        AccentKind::Ring => "\u{02DA}",
660        AccentKind::OverLine => "\u{203E}",
661        AccentKind::UnderLine => "_",
662        AccentKind::OverBrace => "\u{23DE}",
663        AccentKind::UnderBrace => "\u{23DF}",
664        AccentKind::OverLeftArrow => "\u{2190}",
665    }
666}
667
668fn emit_left_right(w: &mut Writer, left: &Delim, right: &Delim, body: &[Node], style: Style) {
669    w.open("mrow", &[]);
670    if let Some(ch) = left.ch {
671        emit_fence(w, ch);
672    }
673    emit_run(w, body, style, None, None);
674    if let Some(ch) = right.ch {
675        emit_fence(w, ch);
676    }
677    w.close("mrow");
678}
679
680fn emit_mtext_nodes(w: &mut Writer, body: &[Node]) {
681    w.open("mtext", &[]);
682    collect_text(w, body);
683    w.close("mtext");
684}
685
686fn collect_text(w: &mut Writer, body: &[Node]) {
687    for n in body {
688        match &n.kind {
689            NodeKind::TextRun { text, .. } => w.text(text),
690            NodeKind::Symbol { ch, .. } => w.char_text(*ch),
691            NodeKind::List(items) | NodeKind::Text { body: items } => collect_text(w, items),
692            NodeKind::TextStyled { body, .. } => collect_text(w, body),
693            NodeKind::Space(_) => w.buf.push(' '),
694            NodeKind::Tie => w.buf.push('\u{00A0}'),
695            _ => {}
696        }
697    }
698}
699
700fn emit_text_styled(w: &mut Writer, ts: TextStyle, body: &[Node]) {
701    let variant = match ts {
702        TextStyle::Bold => "bold",
703        TextStyle::Emph => "italic",
704        TextStyle::Underline => "normal",
705    };
706    w.open("mtext", &[("mathvariant", variant)]);
707    collect_text(w, body);
708    w.close("mtext");
709}
710
711fn math_font_variant(font: MathFont) -> &'static str {
712    match font {
713        MathFont::Blackboard => "double-struck",
714        MathFont::Calligraphic => "script",
715        MathFont::Roman => "normal",
716        MathFont::Bold => "bold",
717        MathFont::BoldItalic => "bold-italic",
718        MathFont::SansSerif => "sans-serif",
719        MathFont::Typewriter => "monospace",
720        MathFont::Italic => "italic",
721    }
722}
723
724fn emit_phantom(w: &mut Writer, kind: PhantomKind, body: &Node, style: Style) {
725    match kind {
726        PhantomKind::Full => {
727            w.open("mphantom", &[]);
728            emit_node(w, body, style);
729            w.close("mphantom");
730        }
731        PhantomKind::Horizontal => {
732            w.open("mpadded", &[("height", "0"), ("depth", "0")]);
733            w.open("mphantom", &[]);
734            emit_node(w, body, style);
735            w.close("mphantom");
736            w.close("mpadded");
737        }
738        PhantomKind::Vertical => {
739            w.open("mpadded", &[("width", "0")]);
740            w.open("mphantom", &[]);
741            emit_node(w, body, style);
742            w.close("mphantom");
743            w.close("mpadded");
744        }
745    }
746}
747
748fn emit_stack(w: &mut Writer, kind: StackKind, annotation: &Node, base: &Node, style: Style) {
749    let tag = match kind {
750        StackKind::Stackrel | StackKind::Overset => "mover",
751        StackKind::Underset => "munder",
752    };
753    w.open(tag, &[]);
754    emit_node(w, base, style);
755    emit_node(w, annotation, style);
756    w.close(tag);
757}
758
759fn emit_xarrow(w: &mut Writer, mapsto: bool, above: &Node, below: Option<&Node>, style: Style) {
760    let arrow = if mapsto { "\u{21A6}" } else { "\u{2192}" };
761    let tag = if below.is_some() {
762        "munderover"
763    } else {
764        "mover"
765    };
766    w.open(tag, &[]);
767    w.open("mo", &[("stretchy", "true")]);
768    w.text(arrow);
769    w.close("mo");
770    if let Some(b) = below {
771        emit_node(w, b, style);
772    }
773    emit_node(w, above, style);
774    w.close(tag);
775}
776
777fn emit_space(w: &mut Writer, kind: SpaceKind) {
778    let width = em_from_mu(kind.mu());
779    w.open("mspace", &[("width", &width)]);
780    w.close("mspace");
781}
782
783fn em_from_mu(mu: i32) -> String {
784    // width = mu/18 em, rounded to thousandths.
785    let sign = if mu < 0 { -1 } else { 1 };
786    let milli = if mu == 0 {
787        0
788    } else {
789        (mu * 1000 + 9 * sign) / 18
790    };
791    let mut s = String::new();
792    if milli < 0 {
793        s.push('-');
794    }
795    let abs = milli.unsigned_abs();
796    let whole = abs / 1000;
797    let frac = abs % 1000;
798    s.push_str(&whole.to_string());
799    if frac != 0 {
800        s.push('.');
801        if frac < 100 {
802            s.push('0');
803        }
804        if frac < 10 {
805            s.push('0');
806        }
807        s.push_str(&frac.to_string());
808        while s.ends_with('0') && s.contains('.') {
809            s.pop();
810        }
811        if s.ends_with('.') {
812            s.pop();
813        }
814    }
815    s.push_str("em");
816    s
817}
818
819fn emit_environment(
820    w: &mut Writer,
821    name: &str,
822    spec: Option<&str>,
823    rows: &[Vec<Node>],
824    style: Style,
825) {
826    let fences = env_fences(name);
827    let columnalign = env_columnalign(name, spec, column_count(rows));
828    if let Some((left, right)) = fences {
829        w.open("mrow", &[]);
830        if let Some(ch) = left {
831            emit_fence(w, ch);
832        }
833        emit_table(w, rows, columnalign.as_deref(), style);
834        if let Some(ch) = right {
835            emit_fence(w, ch);
836        }
837        w.close("mrow");
838    } else {
839        emit_table(w, rows, columnalign.as_deref(), style);
840    }
841}
842
843#[inline(always)]
844fn env_fences(name: &str) -> Option<(Option<char>, Option<char>)> {
845    match name {
846        "pmatrix" => Some((Some('('), Some(')'))),
847        "bmatrix" => Some((Some('['), Some(']'))),
848        "Bmatrix" => Some((Some('{'), Some('}'))),
849        "vmatrix" => Some((Some('|'), Some('|'))),
850        "Vmatrix" => Some((Some('\u{2016}'), Some('\u{2016}'))),
851        "cases" => Some((Some('{'), None)),
852        _ => None,
853    }
854}
855
856fn column_count(rows: &[Vec<Node>]) -> usize {
857    rows.iter().map(Vec::len).max().unwrap_or(0)
858}
859
860fn env_columnalign(name: &str, spec: Option<&str>, cols: usize) -> Option<String> {
861    if let Some(spec) = spec {
862        let mut parts = Vec::new();
863        for ch in spec.chars() {
864            match ch {
865                'l' => parts.push("left"),
866                'r' => parts.push("right"),
867                'c' => parts.push("center"),
868                _ => {}
869            }
870        }
871        if !parts.is_empty() {
872            return Some(parts.join(" "));
873        }
874    }
875    match name {
876        "align" | "align*" | "aligned" => {
877            if cols == 0 {
878                return None;
879            }
880            let mut parts = Vec::with_capacity(cols);
881            for i in 0..cols {
882                parts.push(if i % 2 == 0 { "right" } else { "left" });
883            }
884            Some(parts.join(" "))
885        }
886        "cases" => Some("left left".to_owned()),
887        _ => None,
888    }
889}
890
891fn emit_align_block(w: &mut Writer, lines: &[Node], style: Style) {
892    w.open("mtable", &[]);
893    for line in lines {
894        w.open("mtr", &[]);
895        w.open("mtd", &[]);
896        emit_node(w, line, style);
897        w.close("mtd");
898        w.close("mtr");
899    }
900    w.close("mtable");
901}
902
903fn emit_table(w: &mut Writer, rows: &[Vec<Node>], columnalign: Option<&str>, style: Style) {
904    if let Some(align) = columnalign {
905        w.open("mtable", &[("columnalign", align)]);
906    } else {
907        w.open("mtable", &[]);
908    }
909    let width = column_count(rows);
910    for row in rows {
911        w.open("mtr", &[]);
912        for i in 0..width {
913            w.open("mtd", &[]);
914            if let Some(cell) = row.get(i) {
915                emit_node(w, cell, style);
916            }
917            w.close("mtd");
918        }
919        w.close("mtr");
920    }
921    w.close("mtable");
922}
923
924fn emit_fragment(w: &mut Writer, kind: &FragmentKind) {
925    match kind {
926        FragmentKind::UnmatchedClose | FragmentKind::RedundantMathShift => {}
927        FragmentKind::StrayRight(delim) => {
928            if let Some(ch) = delim.ch {
929                emit_fence(w, ch);
930            }
931        }
932    }
933}
934
935fn check_well_formed(xml: &str) -> Result<(), String> {
936    let bytes = xml.as_bytes();
937    let mut i = 0;
938    let mut stack: Vec<(String, usize)> = Vec::new();
939    while i < bytes.len() {
940        if bytes[i] == b'<' {
941            let start = i;
942            i += 1;
943            if i >= bytes.len() {
944                return Err("truncated tag".to_owned());
945            }
946            if bytes[i] == b'/' {
947                i += 1;
948                let name = read_name(bytes, &mut i)?;
949                skip_ws(bytes, &mut i);
950                if bytes.get(i).copied() != Some(b'>') {
951                    return Err(format!("malformed close tag at {start}"));
952                }
953                i += 1;
954                match stack.pop() {
955                    Some((open, _)) if open == name => {}
956                    Some((open, at)) => {
957                        return Err(format!(
958                            "close </{name}> at {start} does not match <{open}> opened at {at}"
959                        ));
960                    }
961                    None => return Err(format!("unmatched close </{name}> at {start}")),
962                }
963            } else {
964                let name = read_name(bytes, &mut i)?;
965                read_attrs(bytes, &mut i)?;
966                if bytes.get(i).copied() == Some(b'/') {
967                    return Err(format!(
968                        "self-closing tag <{name}/> at {start} is forbidden"
969                    ));
970                }
971                if bytes.get(i).copied() != Some(b'>') {
972                    return Err(format!("unterminated open tag <{name}> at {start}"));
973                }
974                i += 1;
975                stack.push((name, start));
976            }
977        } else {
978            // Text: reject raw '<' (handled) and bare '&'.
979            if bytes[i] == b'&' {
980                i += 1;
981                consume_entity(bytes, &mut i)?;
982            } else {
983                i += 1;
984            }
985        }
986    }
987    if let Some((open, at)) = stack.last() {
988        return Err(format!("unclosed <{open}> opened at {at}"));
989    }
990    Ok(())
991}
992
993fn read_name(bytes: &[u8], i: &mut usize) -> Result<String, String> {
994    let start = *i;
995    if *i >= bytes.len() || !bytes[*i].is_ascii_alphabetic() {
996        return Err(format!("expected tag name at {start}"));
997    }
998    *i += 1;
999    while *i < bytes.len() && (bytes[*i].is_ascii_alphanumeric() || bytes[*i] == b'-') {
1000        *i += 1;
1001    }
1002    let name = core::str::from_utf8(&bytes[start..*i]).map_err(|_| "non-utf8 tag name")?;
1003    Ok(name.to_owned())
1004}
1005
1006fn skip_ws(bytes: &[u8], i: &mut usize) {
1007    while *i < bytes.len() && bytes[*i].is_ascii_whitespace() {
1008        *i += 1;
1009    }
1010}
1011
1012fn read_attrs(bytes: &[u8], i: &mut usize) -> Result<(), String> {
1013    loop {
1014        skip_ws(bytes, i);
1015        if *i >= bytes.len() {
1016            return Err("truncated attributes".to_owned());
1017        }
1018        match bytes[*i] {
1019            b'>' | b'/' => return Ok(()),
1020            b'a'..=b'z' | b'A'..=b'Z' => {
1021                let _ = read_name(bytes, i)?;
1022                skip_ws(bytes, i);
1023                if bytes.get(*i).copied() != Some(b'=') {
1024                    return Err("attribute missing '='".to_owned());
1025                }
1026                *i += 1;
1027                skip_ws(bytes, i);
1028                if bytes.get(*i).copied() != Some(b'"') {
1029                    return Err("attribute value must be double-quoted".to_owned());
1030                }
1031                *i += 1;
1032                while *i < bytes.len() && bytes[*i] != b'"' {
1033                    if bytes[*i] == b'&' {
1034                        *i += 1;
1035                        consume_entity(bytes, i)?;
1036                    } else if bytes[*i] == b'<' {
1037                        return Err("raw '<' in attribute".to_owned());
1038                    } else {
1039                        *i += 1;
1040                    }
1041                }
1042                if bytes.get(*i).copied() != Some(b'"') {
1043                    return Err("unterminated attribute value".to_owned());
1044                }
1045                *i += 1;
1046            }
1047            _ => return Err(format!("unexpected byte 0x{:02x} in tag", bytes[*i])),
1048        }
1049    }
1050}
1051
1052fn consume_entity(bytes: &[u8], i: &mut usize) -> Result<(), String> {
1053    let start = *i;
1054    if bytes.get(*i).copied() == Some(b'#') {
1055        *i += 1;
1056        let hex = bytes.get(*i).copied() == Some(b'x') || bytes.get(*i).copied() == Some(b'X');
1057        if hex {
1058            *i += 1;
1059        }
1060        let digit_start = *i;
1061        while *i < bytes.len() {
1062            let b = bytes[*i];
1063            let ok = if hex {
1064                b.is_ascii_hexdigit()
1065            } else {
1066                b.is_ascii_digit()
1067            };
1068            if !ok {
1069                break;
1070            }
1071            *i += 1;
1072        }
1073        if *i == digit_start {
1074            return Err("empty numeric entity".to_owned());
1075        }
1076    } else {
1077        while *i < bytes.len() && bytes[*i].is_ascii_alphabetic() {
1078            *i += 1;
1079        }
1080        if *i == start {
1081            return Err("bare '&'".to_owned());
1082        }
1083    }
1084    if bytes.get(*i).copied() != Some(b';') {
1085        return Err("entity missing ';'".to_owned());
1086    }
1087    *i += 1;
1088    Ok(())
1089}
1090
1091#[cfg(test)]
1092mod tests {
1093    use super::{PERCENT_BUF_LEN, em_from_mu, write_percent};
1094
1095    #[test]
1096    fn em_from_mu_thousandths() {
1097        assert_eq!(em_from_mu(3), "0.167em");
1098        assert_eq!(em_from_mu(18), "1em");
1099        assert_eq!(em_from_mu(-3), "-0.167em");
1100        assert_eq!(em_from_mu(0), "0em");
1101    }
1102
1103    /// The pre-R1 reference implementation: the old `percent_size` body
1104    /// (`format!`-based). `write_percent` must reproduce it byte-for-byte.
1105    fn percent_size_ref(factor: f64) -> String {
1106        let pct = (factor * 100.0).round();
1107        let n = if pct.is_finite() {
1108            pct.clamp(-10_000.0, 10_000.0) as i32
1109        } else {
1110            100
1111        };
1112        format!("{n}%")
1113    }
1114
1115    #[test]
1116    fn percent_digits_match_format_reference() {
1117        let mut buf = [0u8; PERCENT_BUF_LEN];
1118        // Dense sweep over the clamping range (factor 100 -> pct 10_000) and
1119        // beyond it on both sides; 0.0173 steps avoid landing only on
1120        // representable round numbers.
1121        let mut factors: Vec<f64> = Vec::new();
1122        let mut f = -300.0;
1123        while f <= 300.0 {
1124            factors.push(f);
1125            f += 0.0173;
1126        }
1127        factors.extend([
1128            -0.0,
1129            0.0,
1130            0.004_999,
1131            0.005,
1132            0.494_999,
1133            0.495,
1134            0.994_999,
1135            0.995,
1136            1.0,
1137            9.995,
1138            10.0,
1139            99.995,
1140            100.0,
1141            1e3,
1142            1e5,
1143            f64::NAN,
1144            f64::INFINITY,
1145            f64::NEG_INFINITY,
1146            f64::MIN,
1147            f64::MAX,
1148        ]);
1149        for f in factors {
1150            assert_eq!(
1151                write_percent(&mut buf, f),
1152                percent_size_ref(f),
1153                "factor {f}"
1154            );
1155        }
1156        // Exact clamp corners and the zero/ten-thousand boundaries.
1157        for f in [-100.0, 100.0, -100.0 - 1e-9, 100.0 + 1e-9, 0.0, 10.0, -10.0] {
1158            assert_eq!(
1159                write_percent(&mut buf, f),
1160                percent_size_ref(f),
1161                "clamp corner {f}"
1162            );
1163        }
1164        // Spot-check the literal byte shapes.
1165        assert_eq!(write_percent(&mut buf, 0.0), "0%");
1166        assert_eq!(write_percent(&mut buf, 0.5), "50%");
1167        assert_eq!(write_percent(&mut buf, 1.0), "100%");
1168        assert_eq!(write_percent(&mut buf, 2.074), "207%");
1169        assert_eq!(write_percent(&mut buf, 100.0), "10000%");
1170        assert_eq!(write_percent(&mut buf, -0.8), "-80%");
1171        assert_eq!(write_percent(&mut buf, f64::NAN), "100%");
1172    }
1173}