mathra 1.2.0

Equation converter: math LaTeX to a tree of meaning and accessible SVG, with locale rules (English and pt-BR).
Documentation
//! Tree of meaning: each node says what the thing is, not how it is drawn.
//! The form (comma or dot, "sen" or "sin", where limits go) is decided later,
//! by the locale and the style.

use serde::Serialize;

#[derive(Debug, Clone, PartialEq, Serialize)]
#[serde(tag = "type", rename_all = "kebab-case")]
pub enum Node {
    /// Number kept in canonical spelling, with a decimal point: "3.14".
    Number {
        value: String,
        /// Group thousands (`\num`). Plain numbers are not grouped.
        #[serde(skip_serializing_if = "std::ops::Not::not")]
        group: bool,
        /// L-NUM-3: thousands separator the author wrote (`1.000` in pt-BR), kept in the drawing.
        #[serde(skip_serializing_if = "Option::is_none")]
        thousands: Option<char>,
    },
    /// Single-letter variable or constant: "x".
    Identifier {
        name: String,
        #[serde(skip_serializing_if = "Variant::is_default")]
        variant: Variant,
    },
    /// Named function, stored by its canonical English name: "sin", "lim".
    Function {
        name: String,
        /// `lim`, `max`, `min`: limits below in display style.
        #[serde(skip_serializing_if = "std::ops::Not::not")]
        limits: bool,
    },
    /// Operator, relation, punctuation or delimiter.
    Operator {
        symbol: char,
        class: Class,
    },
    /// Fixed-size delimiter: `\big(`, `\Bigg]`. Size from 1 to 4.
    FixedDelimiter {
        symbol: char,
        class: Class,
        size: u8,
    },
    /// Sum, product, integral. `limits`: None lets the style decide.
    LargeOperator {
        symbol: char,
        #[serde(skip_serializing_if = "Option::is_none")]
        limits: Option<bool>,
    },
    /// Horizontal sequence.
    Row {
        children: Vec<Node>,
    },
    Fraction {
        num: Box<Node>,
        den: Box<Node>,
        #[serde(skip_serializing_if = "FractionStyle::is_default")]
        style: FractionStyle,
        /// `\binom` has no rule.
        #[serde(skip_serializing_if = "std::clone::Clone::clone")]
        rule: bool,
    },
    Root {
        radicand: Box<Node>,
        #[serde(skip_serializing_if = "Option::is_none")]
        index: Option<Box<Node>>,
    },
    /// Base with superscript and/or subscript. Does not yet tell powers from indices.
    Scripts {
        base: Box<Node>,
        #[serde(skip_serializing_if = "Option::is_none")]
        sup: Option<Box<Node>>,
        #[serde(skip_serializing_if = "Option::is_none")]
        sub: Option<Box<Node>>,
    },
    /// `\left( … \middle| … \right)`: delimiters that follow the height of the content.
    Delimited {
        #[serde(skip_serializing_if = "Option::is_none")]
        open: Option<char>,
        #[serde(skip_serializing_if = "Option::is_none")]
        close: Option<char>,
        parts: Vec<Node>,
        #[serde(skip_serializing_if = "Vec::is_empty")]
        middles: Vec<char>,
    },
    /// Matrices, cases, alignments and `array`.
    Table {
        shape: TableKind,
        rows: Vec<Vec<Node>>,
        #[serde(skip_serializing_if = "Vec::is_empty")]
        columns: Vec<char>,
        /// Vertical rules: column indices preceded by `|`.
        #[serde(skip_serializing_if = "Vec::is_empty")]
        verticals: Vec<usize>,
        /// Horizontal rules (`\hline`, `\cline{a-b}`) before a table row.
        #[serde(skip_serializing_if = "Vec::is_empty")]
        horizontals: Vec<TableRule>,
        /// Content of `@{…}` in place of the inter-column space: boundary index (0 = before the 1st column).
        #[serde(skip_serializing_if = "Vec::is_empty")]
        between: Vec<(usize, Node)>,
        /// Rows that are full-width running text (`\intertext`).
        #[serde(skip_serializing_if = "Vec::is_empty")]
        intertexts: Vec<usize>,
    },
    /// Accent over the base: hat, bar, vector, tilde, dot. `below`: under the base (`\underrightarrow`).
    Accent {
        base: Box<Node>,
        accent: char,
        stretchy: bool,
        #[serde(skip_serializing_if = "std::ops::Not::not")]
        below: bool,
    },
    /// `\overline`, `\underline`.
    Bar {
        base: Box<Node>,
        above: bool,
    },
    /// `\overbrace`, `\underbrace`, with an optional label. `bracket`: square (`\overbracket`).
    Brace {
        base: Box<Node>,
        above: bool,
        #[serde(skip_serializing_if = "Option::is_none")]
        label: Option<Box<Node>>,
        #[serde(skip_serializing_if = "std::ops::Not::not")]
        bracket: bool,
    },
    /// Running text inside the equation, with math snippets (`\text{if $x>0$}`).
    Text {
        parts: Vec<TextPart>,
    },
    /// Explicit space in em: `\,` `\quad`.
    Space {
        em: f32,
    },
    Canceled {
        base: Box<Node>,
        shape: Cancel,
    },
    Framed {
        base: Box<Node>,
        #[serde(skip_serializing_if = "Frame::is_default")]
        shape: Frame,
        /// `\colorbox`, `\fcolorbox`: filled behind the content.
        #[serde(skip_serializing_if = "Option::is_none")]
        background: Option<String>,
        /// `\fcolorbox`: color of the frame line.
        #[serde(skip_serializing_if = "Option::is_none")]
        border: Option<String>,
    },
    /// Arrow that stretches to fit its text: `\xrightarrow[below]{above}`.
    Arrow {
        symbol: char,
        #[serde(skip_serializing_if = "Option::is_none")]
        above: Option<Box<Node>>,
        #[serde(skip_serializing_if = "Option::is_none")]
        below: Option<Box<Node>>,
    },
    /// `\overset`, `\underset`, `\stackrel`: something above or below, at script size.
    Stacked {
        base: Box<Node>,
        #[serde(skip_serializing_if = "Option::is_none")]
        above: Option<Box<Node>>,
        #[serde(skip_serializing_if = "Option::is_none")]
        below: Option<Box<Node>>,
    },
    /// Takes the space of the base without drawing it (`\phantom`).
    Phantom {
        base: Box<Node>,
        width: bool,
        height: bool,
    },
    /// `\mathrel`, `\mathbin`, `\mathop`…: changes the atom class of the base.
    ForcedClass {
        class: Class,
        base: Box<Node>,
    },
    /// `\displaystyle` and friends: fixed style for the rest of the group. 0 display, 1 text, 2 script, 3 scriptscript.
    Style {
        level: u8,
        base: Box<Node>,
    },
    Colored {
        color: String,
        base: Box<Node>,
    },
    /// Quantity with a unit (`\SI`, `\qty`): value and unit in upright form.
    Quantity {
        #[serde(skip_serializing_if = "Option::is_none")]
        value: Option<String>,
        unit: Box<Node>,
    },
    /// Table cell spanning several columns (`\multicolumn`, `\hdotsfor`).
    Multicolumn {
        columns: usize,
        alignment: char,
        base: Box<Node>,
    },
    /// `\raisebox`: shifts the content vertically, in em.
    Raised {
        em: f32,
        base: Box<Node>,
    },
    /// `\scalebox`: changes the size of the content.
    Scaled {
        factor: f32,
        base: Box<Node>,
    },
    /// `\rule`: filled rectangle, sizes in em.
    Rule {
        width: f32,
        height: f32,
        raise: f32,
    },
    /// `\smash`: draws, but takes no height (and/or depth).
    Smashed {
        base: Box<Node>,
        height: bool,
        depth: bool,
    },
    /// `\mathllap`, `\mathclap`, `\mathrlap`: draws without taking width. Side: 'l', 'c' or 'r'.
    Overlapped {
        base: Box<Node>,
        side: char,
    },
    /// Vertical arrow of a commutative diagram (amscd's `@VVV`), with side labels.
    VerticalArrow {
        symbol: char,
        #[serde(skip_serializing_if = "Option::is_none")]
        left: Option<Box<Node>>,
        #[serde(skip_serializing_if = "Option::is_none")]
        right: Option<Box<Node>>,
    },
    /// Long division, Brazilian school layout. mathra computes the steps.
    LongDivision {
        dividend: String,
        divisor: String,
    },
    /// Column arithmetic: addition, subtraction or multiplication, computed by mathra.
    ColumnArithmetic {
        operation: char,
        terms: Vec<String>,
    },
}

#[derive(Debug, Clone, PartialEq, Serialize)]
#[serde(tag = "type", content = "value", rename_all = "kebab-case")]
pub enum TextPart {
    Text(String),
    Math(Node),
}

/// TeX atom classes (TeXbook, ch. 17). They decide the spacing between neighbors.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
#[serde(rename_all = "kebab-case")]
pub enum Class {
    Ord,
    Op,
    Bin,
    Rel,
    Open,
    Close,
    Punct,
    Inner,
}

#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize)]
#[serde(rename_all = "kebab-case")]
pub enum Variant {
    #[default]
    Italic,
    Upright,
    Bold,
    BoldItalic,
    DoubleStruck,
    Calligraphic,
    Fraktur,
    SansSerif,
    Monospace,
}

impl Variant {
    pub fn is_default(&self) -> bool {
        *self == Variant::Italic
    }
}

#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize)]
#[serde(rename_all = "kebab-case")]
pub enum FractionStyle {
    #[default]
    Auto,
    Display,
    Row,
}

impl FractionStyle {
    pub fn is_default(&self) -> bool {
        *self == FractionStyle::Auto
    }
}

#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
#[serde(rename_all = "kebab-case")]
pub enum Cancel {
    Bar,
    Backslash,
    X,
}

/// Horizontal table rule before row `row`, covering columns `from..=to` (0-based).
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
pub struct TableRule {
    pub row: usize,
    pub from: usize,
    pub to: usize,
}

#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize)]
#[serde(rename_all = "kebab-case")]
pub enum Frame {
    #[default]
    Rectangle,
    Circle,
    /// No line, only the padding (and the background, if any): `\colorbox`.
    Fill,
}

impl Frame {
    pub fn is_default(&self) -> bool {
        *self == Frame::Rectangle
    }
}

#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
#[serde(rename_all = "kebab-case")]
pub enum TableKind {
    Matrix,
    PMatrix,
    BMatrix,
    BraceMatrix,
    VMatrix,
    VVMatrix,
    SmallMatrix,
    Cases,
    /// mathtools `rcases`: the brace on the right.
    RCases,
    Aligned,
    Centered,
    Array,
    /// `\substack`, `subarray`: cramped rows, in the caller's style.
    Substack,
    /// amscd commutative diagram (`CD`).
    Diagram,
    /// `\bordermatrix`: the first row and first column are labels outside the parentheses.
    BorderMatrix,
}

impl Node {
    pub fn children(&self) -> &[Node] {
        match self {
            Node::Row { children } => children,
            _ => &[],
        }
    }

    pub fn to_json(&self) -> String {
        serde_json::to_string(self).expect("the tree always serializes")
    }

    pub fn row(children: Vec<Node>) -> Node {
        Node::Row { children }
    }

    /// A single node, without the surrounding Row when it has only one child.
    pub fn simplify(self) -> Node {
        match self {
            // recursive: {{2}} coming from a macro is also just the 2
            Node::Row { mut children } if children.len() == 1 => children.pop().unwrap().simplify(),
            other => other,
        }
    }
}