mathra 1.2.0

Equation converter: math LaTeX to a tree of meaning and accessible SVG, with locale rules (English and pt-BR).
Documentation
//! Readable linear text (close to UnicodeMath): goes into the SVG's selection layer and `--text`.

use crate::layout::classes;
use crate::locale::Locale;
use crate::tree::*;

pub fn linear(node: &Node, loc: Locale) -> String {
    let mut s = String::new();
    write_node(node, loc, &mut s);
    s.split_whitespace().collect::<Vec<_>>().join(" ")
}

fn alone(node: &Node) -> bool {
    match node {
        Node::Number { .. } | Node::Identifier { .. } | Node::Operator { .. } => true,
        Node::Row { children } => children.len() == 1 && alone(&children[0]),
        Node::Text { parts } => parts.len() == 1,
        _ => false,
    }
}

/// In parentheses when the piece has more than one atom.
fn group(node: &Node, loc: Locale, s: &mut String) {
    if alone(node) {
        write_node(node, loc, s);
    } else {
        s.push('(');
        write_node(node, loc, s);
        s.push(')');
    }
}

fn write_node(node: &Node, loc: Locale, s: &mut String) {
    match node {
        Node::Number { value, thousands, .. } => s.push_str(&super::written_number(value, *thousands, loc)),
        Node::Identifier { name, variant } => {
            let mapped: String = name.chars().map(|c| crate::layout::alphabet::character(c, *variant)).collect();
            s.push_str(if matches!(variant, Variant::Italic | Variant::Upright) { name } else { &mapped });
        }
        Node::Function { name, .. } => {
            s.push_str(&loc.function_name(name));
            s.push(' ');
        }
        Node::Operator { symbol, .. } | Node::FixedDelimiter { symbol, .. } => s.push(*symbol),
        Node::LargeOperator { symbol, .. } => s.push(*symbol),
        Node::Row { children } => {
            let cl = classes(children, loc, None, false);
            for (child, class) in children.iter().zip(cl) {
                match (child, class) {
                    (Node::Operator { symbol, .. }, Some(Class::Bin | Class::Rel)) => {
                        s.push(' ');
                        s.push(*symbol);
                        s.push(' ');
                    }
                    (Node::Operator { symbol, .. }, Some(Class::Punct)) => {
                        s.push(*symbol);
                        s.push(' ');
                    }
                    (other, _) => write_node(other, loc, s),
                }
            }
        }
        Node::Fraction { num, den, rule, .. } => {
            if *rule {
                group(num, loc, s);
                s.push('/');
                group(den, loc, s);
            } else {
                s.push('(');
                write_node(num, loc, s);
                s.push_str(" ¦ ");
                write_node(den, loc, s);
                s.push(')');
            }
        }
        Node::Root { radicand, index } => {
            match index.as_deref() {
                None => s.push('√'),
                Some(Node::Number { value, .. }) if value == "3" => s.push('∛'),
                Some(Node::Number { value, .. }) if value == "4" => s.push('∜'),
                Some(i) => {
                    s.push_str("√[");
                    write_node(i, loc, s);
                    s.push(']');
                }
            }
            group(radicand, loc, s);
        }
        Node::Scripts { base, sup, sub } => {
            // sin²x, log₂x: the scripts stick to the function name, and the space goes after them
            let function = if let Node::Function { name, .. } = base.as_ref() { Some(name) } else { None };
            match function {
                Some(name) => s.push_str(&loc.function_name(name)),
                None => write_node(base, loc, s),
            }
            if let Some(b) = sub {
                s.push('_');
                group(b, loc, s);
            }
            if let Some(p) = sup {
                let prime = |n: &Node| matches!(n, Node::Operator { symbol: '′' | '″' | '‴', .. });
                match p.as_ref() {
                    n if prime(n) => write_node(n, loc, s),
                    Node::Row { children } if children.first().is_some_and(prime) => {
                        let qty = children.iter().take_while(|f| prime(f)).count();
                        for f in &children[..qty] {
                            write_node(f, loc, s);
                        }
                        s.push('^');
                        group(&Node::row(children[qty..].to_vec()).simplify(), loc, s);
                    }
                    other => {
                        s.push('^');
                        group(other, loc, s);
                    }
                }
            }
            if function.is_some() {
                s.push(' ');
            }
        }
        Node::Delimited { open, close, parts, middles } => {
            if let Some(a) = open {
                s.push(*a);
            }
            for (i, p) in parts.iter().enumerate() {
                if i > 0 {
                    s.push(' ');
                    s.push(middles.get(i - 1).copied().unwrap_or('|'));
                    s.push(' ');
                }
                write_node(p, loc, s);
            }
            if let Some(f) = close {
                s.push(*f);
            }
        }
        Node::Table { shape: kind, rows, .. } => {
            let cell = |c: &Node| linear(c, loc);
            match kind {
                TableKind::Aligned | TableKind::Centered | TableKind::Cases | TableKind::RCases | TableKind::Array | TableKind::Substack | TableKind::Diagram => {
                    let join_rows = if *kind == TableKind::Aligned { " " } else { ", " };
                    let l: Vec<String> = rows.iter().map(|l| l.iter().map(cell).collect::<Vec<_>>().join(join_rows)).collect();
                    if *kind == TableKind::Cases {
                        s.push('{');
                    }
                    s.push_str(&l.join("; "));
                    if *kind == TableKind::RCases {
                        s.push('}');
                    }
                }
                _ => {
                    let l: Vec<String> = rows.iter().map(|l| format!("[{}]", l.iter().map(cell).collect::<Vec<_>>().join(", "))).collect();
                    let (a, f) = match kind {
                        TableKind::PMatrix => ("(", ")"),
                        TableKind::VMatrix => ("|", "|"),
                        _ => ("[", "]"),
                    };
                    s.push_str(a);
                    s.push_str(&l.join(", "));
                    s.push_str(f);
                }
            }
        }
        Node::Accent { base, accent, .. } => {
            group(base, loc, s);
            if matches!(*accent as u32, 0x0300..=0x036F | 0x20D0..=0x20FF) {
                s.push(*accent);
            }
        }
        Node::Bar { base, above } => {
            if *above {
                s.push('‾');
            }
            group(base, loc, s);
        }
        Node::Brace { base, label, .. } => {
            write_node(base, loc, s);
            if let Some(r) = label {
                s.push_str(" [");
                write_node(r, loc, s);
                s.push(']');
            }
        }
        Node::Text { parts } => {
            for p in parts {
                match p {
                    TextPart::Text(t) => s.push_str(&t.replace(['\u{E000}', '\u{E001}', '\u{E002}', '\u{E003}'], "")),
                    TextPart::Math(m) => write_node(m, loc, s),
                }
            }
        }
        Node::Space { em } => {
            if *em > 0.1 {
                s.push(' ');
            }
        }
        Node::Phantom { .. } => {}
        Node::Canceled { base, .. } | Node::Colored { base, .. } | Node::ForcedClass { base, .. } | Node::Style { base, .. } => write_node(base, loc, s),
        Node::Framed { base, shape, .. } => {
            if *shape == Frame::Circle {
                s.push('(');
                write_node(base, loc, s);
                s.push(')');
            } else {
                write_node(base, loc, s);
            }
        }
        Node::Quantity { unit, .. } => write_node(unit, loc, s),
        Node::Arrow { symbol, above, .. } => {
            s.push(' ');
            s.push(*symbol);
            if let Some(a) = above {
                s.push('[');
                write_node(a, loc, s);
                s.push(']');
            }
            s.push(' ');
        }
        Node::Stacked { base, .. }
        | Node::Multicolumn { base, .. }
        | Node::Raised { base, .. }
        | Node::Scaled { base, .. }
        | Node::Smashed { base, .. }
        | Node::Overlapped { base, .. } => write_node(base, loc, s),
        Node::Rule { .. } => {}
        Node::VerticalArrow { symbol, .. } => {
            s.push(' ');
            s.push(*symbol);
            s.push(' ');
        }
        Node::LongDivision { dividend, divisor } => {
            let (q, r) = match (dividend.parse::<u128>(), divisor.parse::<u128>()) {
                (Ok(a), Ok(b)) if b > 0 => (a / b, a % b),
                _ => (0, 0),
            };
            let remainder = loc.phrase("remainder");
            s.push_str(&format!("{dividend} ÷ {divisor} = {q} ({remainder} {r})"));
        }
        Node::ColumnArithmetic { operation, terms } => {
            let a: i128 = terms[0].parse().unwrap_or(0);
            let b: i128 = terms[1].parse().unwrap_or(0);
            let r = match operation {
                '+' => a + b,
                '−' => a - b,
                _ => a.saturating_mul(b),
            };
            s.push_str(&format!("{a} {operation} {b} = {r}"));
        }
    }
}