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/// Serialize `node` as a complete `<math>…</math>` fragment.
18///
19/// `display = true` sets `display="block"` (TeX display / `$$`); `false` sets
20/// `display="inline"` (`$…$`).
21#[must_use]
22pub fn to_mathml(node: &Node, display: bool) -> String {
23    let mut w = Writer::new();
24    let display_val = if display { "block" } else { "inline" };
25    w.open("math", &[("xmlns", MATHML_NS), ("display", display_val)]);
26    let style = if display { Style::Display } else { Style::Text };
27    match &node.kind {
28        NodeKind::List(items) => emit_run(&mut w, items, style, None, None),
29        _ => emit_node(&mut w, node, style),
30    }
31    w.close("math");
32    w.buf
33}
34
35/// Serialize `node` as a MathML element (no outer `<math>` wrapper).
36///
37/// A top-level [`NodeKind::List`] becomes a single `<mrow>`.
38#[must_use]
39pub fn to_mathml_element(node: &Node) -> String {
40    let mut w = Writer::new();
41    emit_node(&mut w, node, Style::Display);
42    w.buf
43}
44
45/// Std-only well-formedness check: balanced tags, quoted attributes, escaped
46/// text. Accepts the serializer's output contract (no self-closing tags).
47pub fn mathml_well_formed(xml: &str) -> Result<(), String> {
48    check_well_formed(xml)
49}
50
51struct Writer {
52    buf: String,
53}
54
55impl Writer {
56    fn new() -> Self {
57        Self { buf: String::new() }
58    }
59
60    fn open(&mut self, tag: &str, attrs: &[(&str, &str)]) {
61        self.buf.push('<');
62        self.buf.push_str(tag);
63        for &(name, value) in attrs {
64            self.buf.push(' ');
65            self.buf.push_str(name);
66            self.buf.push_str("=\"");
67            push_escaped(&mut self.buf, value, true);
68            self.buf.push('"');
69        }
70        self.buf.push('>');
71    }
72
73    fn close(&mut self, tag: &str) {
74        self.buf.push('<');
75        self.buf.push('/');
76        self.buf.push_str(tag);
77        self.buf.push('>');
78    }
79
80    fn text(&mut self, s: &str) {
81        push_escaped(&mut self.buf, s, false);
82    }
83
84    fn char_text(&mut self, ch: char) {
85        match ch {
86            '&' => self.buf.push_str("&amp;"),
87            '<' => self.buf.push_str("&lt;"),
88            '>' => self.buf.push_str("&gt;"),
89            _ => self.buf.push(ch),
90        }
91    }
92}
93
94fn push_escaped(buf: &mut String, s: &str, attr: bool) {
95    for ch in s.chars() {
96        match ch {
97            '&' => buf.push_str("&amp;"),
98            '<' => buf.push_str("&lt;"),
99            '>' => buf.push_str("&gt;"),
100            '"' if attr => buf.push_str("&quot;"),
101            _ => buf.push(ch),
102        }
103    }
104}
105
106fn emit_node(w: &mut Writer, node: &Node, style: Style) {
107    match &node.kind {
108        NodeKind::List(items) => {
109            w.open("mrow", &[]);
110            emit_run(w, items, style, None, None);
111            w.close("mrow");
112        }
113        NodeKind::Symbol { ch, class } => emit_symbol(w, *ch, *class),
114        NodeKind::BigOp { ch, .. } => {
115            w.open("mo", &[("movablelimits", "true")]);
116            w.char_text(*ch);
117            w.close("mo");
118        }
119        NodeKind::OpName { name, .. } => {
120            w.open("mi", &[("mathvariant", "normal")]);
121            w.text(name);
122            w.close("mi");
123        }
124        NodeKind::Scripts {
125            base,
126            sub,
127            sup,
128            primes,
129        } => emit_scripts(
130            w,
131            base.as_deref(),
132            sub.as_deref(),
133            sup.as_deref(),
134            primes,
135            style,
136        ),
137        NodeKind::Frac { num, den, spec } => {
138            emit_frac(w, num, den, spec.bar, spec.delims, spec.forced_style, style)
139        }
140        NodeKind::Radical { index, radicand } => emit_radical(w, index.as_deref(), radicand, style),
141        NodeKind::Accent { accent, base } => emit_accent(w, *accent, base, style),
142        NodeKind::LeftRight { left, right, body } => emit_left_right(w, left, right, body, style),
143        NodeKind::SizedDelim { delim, .. } => {
144            if let Some(ch) = delim.ch {
145                w.open("mo", &[]);
146                w.char_text(ch);
147                w.close("mo");
148            }
149        }
150        NodeKind::Text { body } => emit_mtext_nodes(w, body),
151        NodeKind::TextRun { text, .. } => {
152            w.open("mtext", &[]);
153            w.text(text);
154            w.close("mtext");
155        }
156        NodeKind::TextStyled { style: ts, body } => emit_text_styled(w, *ts, body),
157        NodeKind::MathIsland { body, display } => {
158            let inner_style = if *display {
159                Style::Display
160            } else {
161                Style::Text
162            };
163            w.open("mrow", &[]);
164            emit_run(w, body, inner_style, None, None);
165            w.close("mrow");
166        }
167        NodeKind::StyleChange(_)
168        | NodeKind::AlignChange(_)
169        | NodeKind::SizeChange(_)
170        | NodeKind::ColorChange(_)
171        | NodeKind::LineSpacing(_) => {
172            // Remainder markers only have meaning inside a list walk.
173        }
174        NodeKind::MathFont { font, body } => {
175            w.open("mstyle", &[("mathvariant", math_font_variant(*font))]);
176            emit_node(w, body, style);
177            w.close("mstyle");
178        }
179        NodeKind::Phantom { kind, body } => emit_phantom(w, *kind, body, style),
180        NodeKind::Stack {
181            kind,
182            annotation,
183            base,
184        } => emit_stack(w, *kind, annotation, base, style),
185        NodeKind::XArrow {
186            mapsto,
187            above,
188            below,
189        } => emit_xarrow(w, *mapsto, above, below.as_deref(), style),
190        NodeKind::Space(kind) => emit_space(w, *kind),
191        NodeKind::Tie => {
192            w.open("mtext", &[]);
193            w.buf.push('\u{00A0}');
194            w.close("mtext");
195        }
196        NodeKind::Linebreak => {
197            w.open("mspace", &[("linebreak", "newline")]);
198            w.close("mspace");
199        }
200        NodeKind::AlignTab => {}
201        NodeKind::AlignBlock { lines, .. } => emit_align_block(w, lines, style),
202        NodeKind::Environment { name, spec, rows } => {
203            emit_environment(w, name, spec.as_deref(), rows, style)
204        }
205        NodeKind::Fragment(kind) => emit_fragment(w, kind),
206    }
207}
208
209fn emit_run(w: &mut Writer, items: &[Node], style: Style, color: Option<&str>, size: Option<f64>) {
210    // Remainder markers (`\color`, `\displaystyle`, …) are siblings, not
211    // nested groups. Walking them recursively is O(markers) stack frames, so
212    // a long run of `\color{red}` would overflow. Fold style in a loop.
213    let mut items = items;
214    let mut style = style;
215    let mut color = color;
216    let mut size = size;
217    while !items.is_empty() {
218        let marker_at = items.iter().position(|n| is_remainder_marker(&n.kind));
219        match marker_at {
220            None => {
221                emit_styled_siblings(w, items, style, color, size);
222                return;
223            }
224            Some(0) => {
225                let Some((first, rest)) = items.split_first() else {
226                    return;
227                };
228                let (next_style, next_color, next_size) =
229                    apply_marker(&first.kind, style, color, size);
230                style = next_style;
231                color = next_color;
232                size = next_size;
233                items = rest;
234            }
235            Some(k) => {
236                emit_styled_siblings(w, &items[..k], style, color, size);
237                items = &items[k..];
238            }
239        }
240    }
241}
242
243fn is_remainder_marker(kind: &NodeKind) -> bool {
244    matches!(
245        kind,
246        NodeKind::StyleChange(_)
247            | NodeKind::AlignChange(_)
248            | NodeKind::SizeChange(_)
249            | NodeKind::ColorChange(_)
250            | NodeKind::LineSpacing(_)
251    )
252}
253
254fn apply_marker<'a>(
255    kind: &'a NodeKind,
256    style: Style,
257    color: Option<&'a str>,
258    size: Option<f64>,
259) -> (Style, Option<&'a str>, Option<f64>) {
260    match kind {
261        NodeKind::StyleChange(s) => (*s, color, size),
262        NodeKind::ColorChange(c) => (style, Some(c.as_str()), size),
263        NodeKind::SizeChange(f) => (style, color, Some(*f)),
264        NodeKind::AlignChange(_) | NodeKind::LineSpacing(_) => (style, color, size),
265        _ => (style, color, size),
266    }
267}
268
269fn emit_styled_siblings(
270    w: &mut Writer,
271    items: &[Node],
272    style: Style,
273    color: Option<&str>,
274    size: Option<f64>,
275) {
276    if items.is_empty() {
277        return;
278    }
279    let wrap = color.is_some() || size.is_some() || style_needs_mstyle(style);
280    if wrap {
281        let ds = if matches!(style, Style::Display) {
282            "true"
283        } else {
284            "false"
285        };
286        let sl = match style {
287            Style::Display | Style::Text => "0",
288            Style::Script => "1",
289            Style::ScriptScript => "2",
290        };
291        let size_owned = size.map(percent_size);
292        let mut attrs: Vec<(&str, &str)> = Vec::new();
293        if style_needs_mstyle(style) {
294            attrs.push(("displaystyle", ds));
295            attrs.push(("scriptlevel", sl));
296        }
297        if let Some(c) = color {
298            attrs.push(("mathcolor", c));
299        }
300        if let Some(ref s) = size_owned {
301            attrs.push(("mathsize", s));
302        }
303        w.open("mstyle", &attrs);
304        for n in items {
305            emit_node(w, n, style);
306        }
307        w.close("mstyle");
308    } else {
309        for n in items {
310            emit_node(w, n, style);
311        }
312    }
313}
314
315fn style_needs_mstyle(style: Style) -> bool {
316    !matches!(style, Style::Display | Style::Text)
317}
318
319fn percent_size(factor: f64) -> String {
320    let pct = (factor * 100.0).round();
321    let n = if pct.is_finite() {
322        pct.clamp(-10_000.0, 10_000.0) as i32
323    } else {
324        100
325    };
326    format!("{n}%")
327}
328
329fn emit_symbol(w: &mut Writer, ch: char, class: AtomClass) {
330    let tag = symbol_tag(ch, class);
331    w.open(tag, &[]);
332    w.char_text(ch);
333    w.close(tag);
334}
335
336fn symbol_tag(ch: char, class: AtomClass) -> &'static str {
337    match class {
338        AtomClass::Ord if ch.is_ascii_digit() => "mn",
339        AtomClass::Ord => "mi",
340        AtomClass::Op
341        | AtomClass::Bin
342        | AtomClass::Rel
343        | AtomClass::Open
344        | AtomClass::Close
345        | AtomClass::Punct
346        | AtomClass::Inner => "mo",
347    }
348}
349
350fn emit_scripts(
351    w: &mut Writer,
352    base: Option<&Node>,
353    sub: Option<&Node>,
354    sup: Option<&Node>,
355    primes: &[Span],
356    style: Style,
357) {
358    let limits = scripts_as_limits(base, style);
359    let has_primes = !primes.is_empty();
360    let has_sub = sub.is_some();
361    let has_sup = sup.is_some() || has_primes;
362    if !has_sub && !has_sup {
363        match base {
364            Some(b) => emit_node(w, b, style),
365            None => {
366                w.open("mrow", &[]);
367                w.close("mrow");
368            }
369        }
370        return;
371    }
372    let tag = if limits {
373        match (has_sub, has_sup) {
374            (true, true) => "munderover",
375            (true, false) => "munder",
376            (false, true) => "mover",
377            (false, false) => "mrow",
378        }
379    } else {
380        match (has_sub, has_sup) {
381            (true, true) => "msubsup",
382            (true, false) => "msub",
383            (false, true) => "msup",
384            (false, false) => "mrow",
385        }
386    };
387    w.open(tag, &[]);
388    match base {
389        Some(b) => emit_node(w, b, style),
390        None => {
391            w.open("mrow", &[]);
392            w.close("mrow");
393        }
394    }
395    if has_sub {
396        if let Some(s) = sub {
397            emit_node(w, s, style);
398        }
399    }
400    if has_sup {
401        emit_superscript(w, sup, primes, style);
402    }
403    w.close(tag);
404}
405
406fn emit_superscript(w: &mut Writer, sup: Option<&Node>, primes: &[Span], style: Style) {
407    if primes.is_empty() {
408        if let Some(s) = sup {
409            emit_node(w, s, style);
410        }
411        return;
412    }
413    if sup.is_none() && primes.len() == 1 {
414        w.open("mo", &[]);
415        w.buf.push('′');
416        w.close("mo");
417        return;
418    }
419    w.open("mrow", &[]);
420    for _ in primes {
421        w.open("mo", &[]);
422        w.buf.push('′');
423        w.close("mo");
424    }
425    if let Some(s) = sup {
426        emit_node(w, s, style);
427    }
428    w.close("mrow");
429}
430
431fn scripts_as_limits(base: Option<&Node>, style: Style) -> bool {
432    let Some(node) = base else {
433        return false;
434    };
435    match &node.kind {
436        NodeKind::BigOp {
437            limits, integral, ..
438        } => match limits {
439            Limits::Limits => true,
440            Limits::NoLimits => false,
441            Limits::Default => !*integral && matches!(style, Style::Display),
442        },
443        NodeKind::OpName { limits, .. } => *limits && matches!(style, Style::Display),
444        _ => false,
445    }
446}
447
448fn emit_frac(
449    w: &mut Writer,
450    num: &Node,
451    den: &Node,
452    bar: bool,
453    delims: Option<(char, char)>,
454    forced_style: Option<Style>,
455    style: Style,
456) {
457    let wrap_style = forced_style;
458    if let Some(st) = wrap_style {
459        let ds = if matches!(st, Style::Display) {
460            "true"
461        } else {
462            "false"
463        };
464        w.open("mstyle", &[("displaystyle", ds)]);
465        emit_frac_body(w, num, den, bar, delims, style);
466        w.close("mstyle");
467    } else {
468        emit_frac_body(w, num, den, bar, delims, style);
469    }
470}
471
472fn emit_frac_body(
473    w: &mut Writer,
474    num: &Node,
475    den: &Node,
476    bar: bool,
477    delims: Option<(char, char)>,
478    style: Style,
479) {
480    if let Some((left, right)) = delims {
481        w.open("mrow", &[]);
482        emit_fence(w, left);
483        emit_mfrac(w, num, den, bar, style);
484        emit_fence(w, right);
485        w.close("mrow");
486    } else {
487        emit_mfrac(w, num, den, bar, style);
488    }
489}
490
491fn emit_mfrac(w: &mut Writer, num: &Node, den: &Node, bar: bool, style: Style) {
492    if bar {
493        w.open("mfrac", &[]);
494    } else {
495        w.open("mfrac", &[("linethickness", "0")]);
496    }
497    emit_node(w, num, style);
498    emit_node(w, den, style);
499    w.close("mfrac");
500}
501
502fn emit_fence(w: &mut Writer, ch: char) {
503    w.open("mo", &[("fence", "true"), ("stretchy", "true")]);
504    w.char_text(ch);
505    w.close("mo");
506}
507
508fn emit_radical(w: &mut Writer, index: Option<&Node>, radicand: &Node, style: Style) {
509    if let Some(ix) = index {
510        w.open("mroot", &[]);
511        emit_node(w, radicand, style);
512        emit_node(w, ix, style);
513        w.close("mroot");
514    } else {
515        w.open("msqrt", &[]);
516        emit_node(w, radicand, style);
517        w.close("msqrt");
518    }
519}
520
521fn emit_accent(w: &mut Writer, accent: AccentKind, base: &Node, style: Style) {
522    let tag = if accent.is_over() { "mover" } else { "munder" };
523    let stretchy = matches!(
524        accent,
525        AccentKind::WideHat
526            | AccentKind::WideTilde
527            | AccentKind::OverLine
528            | AccentKind::UnderLine
529            | AccentKind::OverBrace
530            | AccentKind::UnderBrace
531            | AccentKind::OverRightArrow
532            | AccentKind::OverLeftArrow
533    );
534    w.open(tag, &[]);
535    emit_node(w, base, style);
536    if stretchy {
537        w.open("mo", &[("stretchy", "true")]);
538    } else {
539        w.open("mo", &[]);
540    }
541    w.text(accent_char(accent));
542    w.close("mo");
543    w.close(tag);
544}
545
546fn accent_char(kind: AccentKind) -> &'static str {
547    match kind {
548        AccentKind::Hat | AccentKind::WideHat => "\u{02C6}",
549        AccentKind::Check => "\u{02C7}",
550        AccentKind::Tilde | AccentKind::WideTilde => "\u{02DC}",
551        AccentKind::Acute => "\u{00B4}",
552        AccentKind::Grave => "`",
553        AccentKind::Dot => "\u{02D9}",
554        AccentKind::Ddot => "\u{00A8}",
555        AccentKind::Breve => "\u{02D8}",
556        AccentKind::Bar => "\u{00AF}",
557        AccentKind::Vec | AccentKind::OverRightArrow => "\u{2192}",
558        AccentKind::Dddot => "\u{20DB}",
559        AccentKind::Ddddot => "\u{20DC}",
560        AccentKind::Ring => "\u{02DA}",
561        AccentKind::OverLine => "\u{203E}",
562        AccentKind::UnderLine => "_",
563        AccentKind::OverBrace => "\u{23DE}",
564        AccentKind::UnderBrace => "\u{23DF}",
565        AccentKind::OverLeftArrow => "\u{2190}",
566    }
567}
568
569fn emit_left_right(w: &mut Writer, left: &Delim, right: &Delim, body: &[Node], style: Style) {
570    w.open("mrow", &[]);
571    if let Some(ch) = left.ch {
572        emit_fence(w, ch);
573    }
574    emit_run(w, body, style, None, None);
575    if let Some(ch) = right.ch {
576        emit_fence(w, ch);
577    }
578    w.close("mrow");
579}
580
581fn emit_mtext_nodes(w: &mut Writer, body: &[Node]) {
582    w.open("mtext", &[]);
583    collect_text(w, body);
584    w.close("mtext");
585}
586
587fn collect_text(w: &mut Writer, body: &[Node]) {
588    for n in body {
589        match &n.kind {
590            NodeKind::TextRun { text, .. } => w.text(text),
591            NodeKind::Symbol { ch, .. } => w.char_text(*ch),
592            NodeKind::List(items) | NodeKind::Text { body: items } => collect_text(w, items),
593            NodeKind::TextStyled { body, .. } => collect_text(w, body),
594            NodeKind::Space(_) => w.buf.push(' '),
595            NodeKind::Tie => w.buf.push('\u{00A0}'),
596            _ => {}
597        }
598    }
599}
600
601fn emit_text_styled(w: &mut Writer, ts: TextStyle, body: &[Node]) {
602    let variant = match ts {
603        TextStyle::Bold => "bold",
604        TextStyle::Emph => "italic",
605        TextStyle::Underline => "normal",
606    };
607    w.open("mtext", &[("mathvariant", variant)]);
608    collect_text(w, body);
609    w.close("mtext");
610}
611
612fn math_font_variant(font: MathFont) -> &'static str {
613    match font {
614        MathFont::Blackboard => "double-struck",
615        MathFont::Calligraphic => "script",
616        MathFont::Roman => "normal",
617        MathFont::Bold => "bold",
618        MathFont::BoldItalic => "bold-italic",
619        MathFont::SansSerif => "sans-serif",
620        MathFont::Typewriter => "monospace",
621        MathFont::Italic => "italic",
622    }
623}
624
625fn emit_phantom(w: &mut Writer, kind: PhantomKind, body: &Node, style: Style) {
626    match kind {
627        PhantomKind::Full => {
628            w.open("mphantom", &[]);
629            emit_node(w, body, style);
630            w.close("mphantom");
631        }
632        PhantomKind::Horizontal => {
633            w.open("mpadded", &[("height", "0"), ("depth", "0")]);
634            w.open("mphantom", &[]);
635            emit_node(w, body, style);
636            w.close("mphantom");
637            w.close("mpadded");
638        }
639        PhantomKind::Vertical => {
640            w.open("mpadded", &[("width", "0")]);
641            w.open("mphantom", &[]);
642            emit_node(w, body, style);
643            w.close("mphantom");
644            w.close("mpadded");
645        }
646    }
647}
648
649fn emit_stack(w: &mut Writer, kind: StackKind, annotation: &Node, base: &Node, style: Style) {
650    let tag = match kind {
651        StackKind::Stackrel | StackKind::Overset => "mover",
652        StackKind::Underset => "munder",
653    };
654    w.open(tag, &[]);
655    emit_node(w, base, style);
656    emit_node(w, annotation, style);
657    w.close(tag);
658}
659
660fn emit_xarrow(w: &mut Writer, mapsto: bool, above: &Node, below: Option<&Node>, style: Style) {
661    let arrow = if mapsto { "\u{21A6}" } else { "\u{2192}" };
662    let tag = if below.is_some() {
663        "munderover"
664    } else {
665        "mover"
666    };
667    w.open(tag, &[]);
668    w.open("mo", &[("stretchy", "true")]);
669    w.text(arrow);
670    w.close("mo");
671    if let Some(b) = below {
672        emit_node(w, b, style);
673    }
674    emit_node(w, above, style);
675    w.close(tag);
676}
677
678fn emit_space(w: &mut Writer, kind: SpaceKind) {
679    let width = em_from_mu(kind.mu());
680    w.open("mspace", &[("width", &width)]);
681    w.close("mspace");
682}
683
684fn em_from_mu(mu: i32) -> String {
685    // width = mu/18 em, rounded to thousandths.
686    let sign = if mu < 0 { -1 } else { 1 };
687    let milli = if mu == 0 {
688        0
689    } else {
690        (mu * 1000 + 9 * sign) / 18
691    };
692    let mut s = String::new();
693    if milli < 0 {
694        s.push('-');
695    }
696    let abs = milli.unsigned_abs();
697    let whole = abs / 1000;
698    let frac = abs % 1000;
699    s.push_str(&whole.to_string());
700    if frac != 0 {
701        s.push('.');
702        if frac < 100 {
703            s.push('0');
704        }
705        if frac < 10 {
706            s.push('0');
707        }
708        s.push_str(&frac.to_string());
709        while s.ends_with('0') && s.contains('.') {
710            s.pop();
711        }
712        if s.ends_with('.') {
713            s.pop();
714        }
715    }
716    s.push_str("em");
717    s
718}
719
720fn emit_environment(
721    w: &mut Writer,
722    name: &str,
723    spec: Option<&str>,
724    rows: &[Vec<Node>],
725    style: Style,
726) {
727    let fences = env_fences(name);
728    let columnalign = env_columnalign(name, spec, column_count(rows));
729    if let Some((left, right)) = fences {
730        w.open("mrow", &[]);
731        if let Some(ch) = left {
732            emit_fence(w, ch);
733        }
734        emit_table(w, rows, columnalign.as_deref(), style);
735        if let Some(ch) = right {
736            emit_fence(w, ch);
737        }
738        w.close("mrow");
739    } else {
740        emit_table(w, rows, columnalign.as_deref(), style);
741    }
742}
743
744fn env_fences(name: &str) -> Option<(Option<char>, Option<char>)> {
745    match name {
746        "pmatrix" => Some((Some('('), Some(')'))),
747        "bmatrix" => Some((Some('['), Some(']'))),
748        "Bmatrix" => Some((Some('{'), Some('}'))),
749        "vmatrix" => Some((Some('|'), Some('|'))),
750        "Vmatrix" => Some((Some('\u{2016}'), Some('\u{2016}'))),
751        "cases" => Some((Some('{'), None)),
752        _ => None,
753    }
754}
755
756fn column_count(rows: &[Vec<Node>]) -> usize {
757    rows.iter().map(Vec::len).max().unwrap_or(0)
758}
759
760fn env_columnalign(name: &str, spec: Option<&str>, cols: usize) -> Option<String> {
761    if let Some(spec) = spec {
762        let mut parts = Vec::new();
763        for ch in spec.chars() {
764            match ch {
765                'l' => parts.push("left"),
766                'r' => parts.push("right"),
767                'c' => parts.push("center"),
768                _ => {}
769            }
770        }
771        if !parts.is_empty() {
772            return Some(parts.join(" "));
773        }
774    }
775    match name {
776        "align" | "align*" | "aligned" => {
777            if cols == 0 {
778                return None;
779            }
780            let mut parts = Vec::with_capacity(cols);
781            for i in 0..cols {
782                parts.push(if i % 2 == 0 { "right" } else { "left" });
783            }
784            Some(parts.join(" "))
785        }
786        "cases" => Some("left left".to_owned()),
787        _ => None,
788    }
789}
790
791fn emit_align_block(w: &mut Writer, lines: &[Node], style: Style) {
792    w.open("mtable", &[]);
793    for line in lines {
794        w.open("mtr", &[]);
795        w.open("mtd", &[]);
796        emit_node(w, line, style);
797        w.close("mtd");
798        w.close("mtr");
799    }
800    w.close("mtable");
801}
802
803fn emit_table(w: &mut Writer, rows: &[Vec<Node>], columnalign: Option<&str>, style: Style) {
804    if let Some(align) = columnalign {
805        w.open("mtable", &[("columnalign", align)]);
806    } else {
807        w.open("mtable", &[]);
808    }
809    let width = column_count(rows);
810    for row in rows {
811        w.open("mtr", &[]);
812        for i in 0..width {
813            w.open("mtd", &[]);
814            if let Some(cell) = row.get(i) {
815                emit_node(w, cell, style);
816            }
817            w.close("mtd");
818        }
819        w.close("mtr");
820    }
821    w.close("mtable");
822}
823
824fn emit_fragment(w: &mut Writer, kind: &FragmentKind) {
825    match kind {
826        FragmentKind::UnmatchedClose | FragmentKind::RedundantMathShift => {}
827        FragmentKind::StrayRight(delim) => {
828            if let Some(ch) = delim.ch {
829                emit_fence(w, ch);
830            }
831        }
832    }
833}
834
835fn check_well_formed(xml: &str) -> Result<(), String> {
836    let bytes = xml.as_bytes();
837    let mut i = 0;
838    let mut stack: Vec<(String, usize)> = Vec::new();
839    while i < bytes.len() {
840        if bytes[i] == b'<' {
841            let start = i;
842            i += 1;
843            if i >= bytes.len() {
844                return Err("truncated tag".to_owned());
845            }
846            if bytes[i] == b'/' {
847                i += 1;
848                let name = read_name(bytes, &mut i)?;
849                skip_ws(bytes, &mut i);
850                if bytes.get(i).copied() != Some(b'>') {
851                    return Err(format!("malformed close tag at {start}"));
852                }
853                i += 1;
854                match stack.pop() {
855                    Some((open, _)) if open == name => {}
856                    Some((open, at)) => {
857                        return Err(format!(
858                            "close </{name}> at {start} does not match <{open}> opened at {at}"
859                        ));
860                    }
861                    None => return Err(format!("unmatched close </{name}> at {start}")),
862                }
863            } else {
864                let name = read_name(bytes, &mut i)?;
865                read_attrs(bytes, &mut i)?;
866                if bytes.get(i).copied() == Some(b'/') {
867                    return Err(format!(
868                        "self-closing tag <{name}/> at {start} is forbidden"
869                    ));
870                }
871                if bytes.get(i).copied() != Some(b'>') {
872                    return Err(format!("unterminated open tag <{name}> at {start}"));
873                }
874                i += 1;
875                stack.push((name, start));
876            }
877        } else {
878            // Text: reject raw '<' (handled) and bare '&'.
879            if bytes[i] == b'&' {
880                i += 1;
881                consume_entity(bytes, &mut i)?;
882            } else {
883                i += 1;
884            }
885        }
886    }
887    if let Some((open, at)) = stack.last() {
888        return Err(format!("unclosed <{open}> opened at {at}"));
889    }
890    Ok(())
891}
892
893fn read_name(bytes: &[u8], i: &mut usize) -> Result<String, String> {
894    let start = *i;
895    if *i >= bytes.len() || !bytes[*i].is_ascii_alphabetic() {
896        return Err(format!("expected tag name at {start}"));
897    }
898    *i += 1;
899    while *i < bytes.len() && (bytes[*i].is_ascii_alphanumeric() || bytes[*i] == b'-') {
900        *i += 1;
901    }
902    let name = core::str::from_utf8(&bytes[start..*i]).map_err(|_| "non-utf8 tag name")?;
903    Ok(name.to_owned())
904}
905
906fn skip_ws(bytes: &[u8], i: &mut usize) {
907    while *i < bytes.len() && bytes[*i].is_ascii_whitespace() {
908        *i += 1;
909    }
910}
911
912fn read_attrs(bytes: &[u8], i: &mut usize) -> Result<(), String> {
913    loop {
914        skip_ws(bytes, i);
915        if *i >= bytes.len() {
916            return Err("truncated attributes".to_owned());
917        }
918        match bytes[*i] {
919            b'>' | b'/' => return Ok(()),
920            b'a'..=b'z' | b'A'..=b'Z' => {
921                let _ = read_name(bytes, i)?;
922                skip_ws(bytes, i);
923                if bytes.get(*i).copied() != Some(b'=') {
924                    return Err("attribute missing '='".to_owned());
925                }
926                *i += 1;
927                skip_ws(bytes, i);
928                if bytes.get(*i).copied() != Some(b'"') {
929                    return Err("attribute value must be double-quoted".to_owned());
930                }
931                *i += 1;
932                while *i < bytes.len() && bytes[*i] != b'"' {
933                    if bytes[*i] == b'&' {
934                        *i += 1;
935                        consume_entity(bytes, i)?;
936                    } else if bytes[*i] == b'<' {
937                        return Err("raw '<' in attribute".to_owned());
938                    } else {
939                        *i += 1;
940                    }
941                }
942                if bytes.get(*i).copied() != Some(b'"') {
943                    return Err("unterminated attribute value".to_owned());
944                }
945                *i += 1;
946            }
947            _ => return Err(format!("unexpected byte 0x{:02x} in tag", bytes[*i])),
948        }
949    }
950}
951
952fn consume_entity(bytes: &[u8], i: &mut usize) -> Result<(), String> {
953    let start = *i;
954    if bytes.get(*i).copied() == Some(b'#') {
955        *i += 1;
956        let hex = bytes.get(*i).copied() == Some(b'x') || bytes.get(*i).copied() == Some(b'X');
957        if hex {
958            *i += 1;
959        }
960        let digit_start = *i;
961        while *i < bytes.len() {
962            let b = bytes[*i];
963            let ok = if hex {
964                b.is_ascii_hexdigit()
965            } else {
966                b.is_ascii_digit()
967            };
968            if !ok {
969                break;
970            }
971            *i += 1;
972        }
973        if *i == digit_start {
974            return Err("empty numeric entity".to_owned());
975        }
976    } else {
977        while *i < bytes.len() && bytes[*i].is_ascii_alphabetic() {
978            *i += 1;
979        }
980        if *i == start {
981            return Err("bare '&'".to_owned());
982        }
983    }
984    if bytes.get(*i).copied() != Some(b';') {
985        return Err("entity missing ';'".to_owned());
986    }
987    *i += 1;
988    Ok(())
989}
990
991#[cfg(test)]
992mod tests {
993    use super::em_from_mu;
994
995    #[test]
996    fn em_from_mu_thousandths() {
997        assert_eq!(em_from_mu(3), "0.167em");
998        assert_eq!(em_from_mu(18), "1em");
999        assert_eq!(em_from_mu(-3), "-0.167em");
1000        assert_eq!(em_from_mu(0), "0em");
1001    }
1002}