diff --git a/ATTRIBUTION.md b/ATTRIBUTION.md
index 817cbff2a..e9120eb91 100644
--- a/ATTRIBUTION.md
+++ b/ATTRIBUTION.md
@@ -29,6 +29,8 @@ author, source and license. Library code and original assets are under the repos
| `crates/crw/src/tables.rs`, `crates/crw/clean-room/logs/table_sel*.json` | craft-crw contributors (derived in LightCraft, 2026-10, by known-plaintext alignment of the sample files' entropy streams; `crates/crw/clean-room/02-crw-format.md`) | this repository | MIT OR Apache-2.0 |
| `crates/crw/clean-room/logs/private-corpus.tsv` (file names, sizes and SHA-256 of the maintainer's own EOS D60 files; no image content) | craft-crw contributors (LightCraft derivation record) | this repository | MIT OR Apache-2.0 |
| `crates/prc/src/acof/table.rs` (the rows of the PRC `Double` prefix-code table, reformatted as Rust tuples) | Kristof Ralovich (prc-rs); the table is defined by ISO 14739-1 | [ralovich/prc-rs@287b671](https://github.com/ralovich/prc-rs/blob/287b671bb5b31b05efb5ec1b98f7c359b9e4dd7d/src/double.rs), through [PdfCraft `crates/prc`](https://github.com/storytold/pdfcraft/tree/80e26e674d1aa33078e83c8139875047f3289eb6/crates/prc) | MIT (`licenses/prc-rs/LICENSE.txt`; the notice is also in the file header) |
+| `crates/math/tests/corpus/equations.tsv`, `crates/math/tests/corpus/latex.tsv` (unmodified) | shuff57 (equaSHion) | [storytold/craft-libs#29](https://github.com/storytold/craft-libs/pull/29) at `7a6ef6b9`, `corpus/` | MIT OR Apache-2.0 |
+| `crates/math/tests/corpus/omml/*.xml` (unmodified) | WordCraft and DeckCraft contributors, collected by shuff57 (equaSHion) | [storytold/craft-libs#29](https://github.com/storytold/craft-libs/pull/29) at `7a6ef6b9`, `corpus/omml/` | MIT OR Apache-2.0 |
Original assets are covered by the repository licence.
diff --git a/Cargo.lock b/Cargo.lock
index 022df2744..6c238afdf 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -1348,6 +1348,16 @@ dependencies = [
"thiserror 2.0.21",
]
+[[package]]
+name = "craft-math"
+version = "0.1.0"
+dependencies = [
+ "craft-xml",
+ "proptest",
+ "serde",
+ "serde_json",
+]
+
[[package]]
name = "craft-media"
version = "0.1.0"
diff --git a/Cargo.toml b/Cargo.toml
index 9f82f1a6d..12cb3b7f1 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -68,6 +68,7 @@ craft-assuan = { path = "crates/assuan" }
craft-kra = { path = "crates/kra" }
craft-audio = { path = "crates/audio" }
# No default features, so craft-audio's `encode` builds craft-media without `isobmff`.
+craft-math = { path = "crates/math" }
craft-media = { path = "crates/media", default-features = false }
craft-media-testkit = { path = "crates/media-testkit" }
craft-midi = { path = "crates/midi" }
diff --git a/README.md b/README.md
index 3a1f14b8d..6a05d9dd4 100644
--- a/README.md
+++ b/README.md
@@ -205,6 +205,7 @@ craft- = { git = "https://github.com/storytold/craft-libs", rev = "`), modelled on
+//! the Office Math object model (ECMA-376 Part 1 §22.1), and [`Math`], an equation with its kept
+//! source OMML.
+//! - [`math_linear`]: the linear format (UnicodeMath, the plain text Word shows for an equation),
+//! read and written ([`math::parse_linear`], [`math::to_linear`]).
+//! - [`math_latex`]: LaTeX math, read and written ([`math_latex::parse_latex`],
+//! [`math_latex::to_latex`]), with the named symbols of [`math_symbols`].
+//! - [`omml`]: Office Math Markup, read ([`omml::read_omath`], [`omml::read_str`]) and written
+//! ([`omml::write_equation`], [`omml::write_str`]).
+//! - [`mathml`]: presentation MathML, written ([`mathml::to_mathml`]) and read
+//! ([`mathml::parse`], [`mathml::read`], [`mathml::to_tree`]).
+//! - [`math_tree`]: the arguments of a node and a walk over every run.
+//! - [`symbol_fonts`]: text in symbol fonts (Symbol, Wingdings…) as Unicode, as OMML runs and
+//! Word text need it.
+//! - [`Rgb`]: the colour of a run.
+//!
+//! Every reader is bounded (nesting [`math::MAX_DEPTH`], node budgets) and never panics on any
+//! input.
+//!
+//! ```
+//! use craft_math::{math, math_latex, mathml, omml};
+//!
+//! let tree = math::parse_linear("x=(-b±√(b^2-4ac))/2a");
+//! assert_eq!(math::to_linear(&tree), "x=(-b±√(b^2-4ac))/2a");
+//! assert!(math_latex::to_latex(&tree).contains("\\frac"));
+//!
+//! let eq = math::Math { nodes: tree.clone(), ..Default::default() };
+//! let xml = omml::write_str("", false, &eq, None);
+//! assert_eq!(omml::read_str(&xml).map(|m| m.nodes), Some(tree.clone()));
+//!
+//! let ml = mathml::to_mathml(&tree, false, "");
+//! assert_eq!(mathml::parse(&ml).as_ref().map(mathml::to_tree), Some(tree));
+//! ```
+
+#[cfg(doctest)]
+#[doc = include_str!("../README.md")]
+struct ReadmeExamples;
+
+pub mod math;
+pub mod math_latex;
+pub mod math_linear;
+pub mod math_symbols;
+pub mod math_tree;
+pub mod mathml;
+pub mod omml;
+mod rgb;
+pub mod symbol_fonts;
+
+pub use math::{Arg, MNode, MRun, Math, MathProps};
+pub use rgb::Rgb;
diff --git a/crates/math/src/math.rs b/crates/math/src/math.rs
new file mode 100644
index 000000000..dc09cd164
--- /dev/null
+++ b/crates/math/src/math.rs
@@ -0,0 +1,1138 @@
+//! Equations: the Office Math object model (OMML, ECMA-376 Part 1 §22.1) as a tree, its linear
+//! format (UnicodeMath, the plain text Word shows for an equation) and a parser for that format.
+//!
+//! An equation is a list of [`MNode`]s (an *argument*); structures hold arguments of their own
+//! (a fraction's numerator and denominator, a radical's degree and base…). Layout reads the tree;
+//! DOCX import builds it from OMML and keeps the source XML so a saved file carries the equation
+//! exactly as Word wrote it.
+
+use serde::{Deserialize, Serialize};
+
+use crate::rgb::Rgb;
+
+/// A sequence of math nodes (OMML's `m:e`, `m:num`, `m:sub`…).
+pub type Arg = Vec;
+
+/// Deepest structure nesting kept (parsers stop here; layout draws nothing deeper).
+pub const MAX_DEPTH: usize = 64;
+
+/// Most matrix columns kept (writers pad every row to the widest, so this bounds a row's cost).
+pub const MAX_MATRIX_COLS: usize = 64;
+
+/// Most matrix rows kept.
+pub const MAX_MATRIX_ROWS: usize = 1000;
+
+/// An equation's structure, plus what's needed to write it back.
+#[derive(Clone, Debug, PartialEq, Default, Serialize, Deserialize)]
+pub struct Math {
+ #[serde(default, skip_serializing_if = "Vec::is_empty")]
+ pub nodes: Arg,
+ /// The source `m:oMath` element (DOCX import), written back unchanged on save. Empty for
+ /// equations made here.
+ #[serde(default, skip_serializing_if = "String::is_empty")]
+ pub omml: String,
+ /// Justification of a display equation.
+ #[serde(default, skip_serializing_if = "is_default")]
+ pub jc: MathJc,
+ /// Shown as its linear-format text (Equation › Convert › Linear) instead of built up.
+ #[serde(default, skip_serializing_if = "is_default")]
+ pub linear: bool,
+}
+
+fn is_default(v: &T) -> bool {
+ *v == T::default()
+}
+
+impl Math {
+ pub fn is_empty(&self) -> bool {
+ self.nodes.is_empty()
+ }
+
+ /// Rough heap bytes a clone copies: every node, its argument lists and its run text.
+ /// Iterative, so it costs at most the size of the structure.
+ pub fn heap_bytes(&self) -> usize {
+ let mut n = self.omml.len();
+ let mut todo: Vec<&MNode> = self.nodes.iter().collect();
+ while let Some(node) = todo.pop() {
+ n = n.saturating_add(std::mem::size_of::());
+ if let MNode::Run(r) = node {
+ n = n.saturating_add(r.text.len()).saturating_add(r.font.as_ref().map_or(0, String::len));
+ }
+ each_arg(node, &mut |a| {
+ n = n.saturating_add(std::mem::size_of::());
+ todo.extend(a);
+ });
+ }
+ n
+ }
+}
+
+/// Display equation justification (`m:oMathParaPr/m:jc`).
+#[derive(Clone, Copy, Debug, PartialEq, Eq, Default, Serialize, Deserialize)]
+#[serde(rename_all = "camelCase")]
+pub enum MathJc {
+ #[default]
+ CenterGroup,
+ Center,
+ Left,
+ Right,
+}
+
+/// Where a display equation too wide for its line breaks at a binary operator (`m:brkBin`).
+#[derive(Clone, Copy, Debug, PartialEq, Eq, Default, Serialize, Deserialize)]
+#[serde(rename_all = "camelCase")]
+pub enum BrkBin {
+ /// The operator starts the next line.
+ #[default]
+ Before,
+ /// The operator ends the line.
+ After,
+ /// The operator ends the line and starts the next one again.
+ Repeat,
+}
+
+/// How a minus repeats across a break when [`BrkBin::Repeat`] (`m:brkBinSub`): the operator
+/// ending the line, then the one starting the next.
+#[derive(Clone, Copy, Debug, PartialEq, Eq, Default, Serialize, Deserialize)]
+#[serde(rename_all = "camelCase")]
+pub enum BrkBinSub {
+ /// `--`
+ #[default]
+ MinusMinus,
+ /// `-+`
+ MinusPlus,
+ /// `+-`
+ PlusMinus,
+}
+
+/// Document-wide equation options (`m:mathPr` in the settings part) that layout uses: how long
+/// display equations wrap.
+#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
+#[serde(default, rename_all = "camelCase")]
+pub struct MathProps {
+ pub brk_bin: BrkBin,
+ pub brk_bin_sub: BrkBinSub,
+ /// Indent of a wrapped equation's continuation lines, points (`m:wrapIndent`).
+ pub wrap_indent: f32,
+ /// Continuation lines align right instead (`m:wrapRight`).
+ pub wrap_right: bool,
+}
+
+/// `m:wrapIndent`'s default: 1440 twips, one inch.
+pub const DEFAULT_WRAP_INDENT: f32 = 72.0;
+
+impl Default for MathProps {
+ fn default() -> Self {
+ MathProps { brk_bin: BrkBin::Before, brk_bin_sub: BrkBinSub::MinusMinus, wrap_indent: DEFAULT_WRAP_INDENT, wrap_right: false }
+ }
+}
+
+/// Math run style (`m:sty`): plain, bold, italic, bold italic.
+#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
+#[serde(rename_all = "camelCase")]
+pub enum MSty {
+ Plain,
+ Bold,
+ Italic,
+ BoldItalic,
+}
+
+/// Math alphabet (`m:scr`).
+#[derive(Clone, Copy, Debug, PartialEq, Eq, Default, Serialize, Deserialize)]
+#[serde(rename_all = "camelCase")]
+pub enum MScr {
+ #[default]
+ Roman,
+ Script,
+ Fraktur,
+ DoubleStruck,
+ SansSerif,
+ Monospace,
+}
+
+/// A run of math text (`m:r`).
+#[derive(Clone, Debug, PartialEq, Default, Serialize, Deserialize)]
+pub struct MRun {
+ pub text: String,
+ /// `None`: math default (letters italic, the rest upright).
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub sty: Option,
+ #[serde(default, skip_serializing_if = "is_default")]
+ pub scr: MScr,
+ /// Normal (non-math) text.
+ #[serde(default, skip_serializing_if = "is_default")]
+ pub nor: bool,
+ /// Literal: no automatic operator handling.
+ #[serde(default, skip_serializing_if = "is_default")]
+ pub lit: bool,
+ /// Font size, points.
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub size: Option,
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub color: Option,
+ /// Font for normal text.
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub font: Option,
+ /// A manual line break before this run in a display equation (`m:brk`): `Some(0)` wraps as
+ /// usual, `Some(n)` aligns the new line at the `n`th operator of the first line (`m:alnAt`).
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub brk: Option,
+}
+
+impl MRun {
+ pub fn new(text: impl Into) -> MRun {
+ MRun { text: text.into(), ..Default::default() }
+ }
+ pub fn plain(text: impl Into) -> MRun {
+ MRun { text: text.into(), sty: Some(MSty::Plain), ..Default::default() }
+ }
+}
+
+#[derive(Clone, Copy, Debug, PartialEq, Eq, Default, Serialize, Deserialize)]
+#[serde(rename_all = "camelCase")]
+pub enum FracKind {
+ /// Stacked with a rule.
+ #[default]
+ Bar,
+ /// Skewed (num ⁄ den).
+ Skewed,
+ /// Linear (num/den).
+ Linear,
+ /// Stacked without a rule.
+ NoBar,
+}
+
+#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
+#[serde(rename_all = "camelCase")]
+pub enum ScriptKind {
+ Sup,
+ Sub,
+ SubSup,
+ /// Pre-scripts (`m:sPre`).
+ Pre,
+}
+
+/// Where an n-ary operator's limits go.
+#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
+#[serde(rename_all = "camelCase")]
+pub enum LimLoc {
+ /// Under and over the operator.
+ UndOvr,
+ /// As sub- and superscripts.
+ SubSup,
+}
+
+/// Column justification in a matrix.
+#[derive(Clone, Copy, Debug, PartialEq, Eq, Default, Serialize, Deserialize)]
+#[serde(rename_all = "camelCase")]
+pub enum ColJc {
+ #[default]
+ Center,
+ Left,
+ Right,
+}
+
+/// A math node.
+#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
+#[serde(tag = "t", rename_all = "camelCase")]
+pub enum MNode {
+ Run(MRun),
+ /// `m:f`.
+ Frac {
+ #[serde(default)]
+ kind: FracKind,
+ num: Arg,
+ den: Arg,
+ },
+ /// `m:sSup`, `m:sSub`, `m:sSubSup`, `m:sPre`.
+ Script {
+ kind: ScriptKind,
+ base: Arg,
+ #[serde(default)]
+ sub: Arg,
+ #[serde(default)]
+ sup: Arg,
+ },
+ /// `m:rad`.
+ Rad {
+ #[serde(default)]
+ deg: Arg,
+ #[serde(default)]
+ deg_hide: bool,
+ e: Arg,
+ },
+ /// `m:nary`: ∑ ∫ ∏ … with limits and an operand.
+ Nary {
+ chr: char,
+ /// `None`: Word's default (integrals take sub/sup limits, the rest under/over).
+ #[serde(default)]
+ lim_loc: Option,
+ #[serde(default)]
+ grow: bool,
+ #[serde(default)]
+ sub_hide: bool,
+ #[serde(default)]
+ sup_hide: bool,
+ #[serde(default)]
+ sub: Arg,
+ #[serde(default)]
+ sup: Arg,
+ e: Arg,
+ },
+ /// `m:d`: delimiters around elements separated by `sep`.
+ Delim {
+ /// `None` = no delimiter.
+ beg: Option,
+ end: Option,
+ #[serde(default)]
+ sep: Option,
+ /// Delimiters grow to the content (Word's default).
+ #[serde(default = "yes")]
+ grow: bool,
+ /// Grow to match the content exactly rather than centred on the math axis.
+ #[serde(default)]
+ shp_match: bool,
+ elems: Vec,
+ },
+ /// `m:func`: function name applied to an argument (sin x).
+ Func {
+ name: Arg,
+ e: Arg,
+ },
+ /// `m:limLow` / `m:limUpp`.
+ Lim {
+ upper: bool,
+ e: Arg,
+ lim: Arg,
+ },
+ /// `m:acc`: accent over the base.
+ Acc {
+ chr: char,
+ e: Arg,
+ },
+ /// `m:bar`: overbar or underbar.
+ Bar {
+ top: bool,
+ e: Arg,
+ },
+ /// `m:borderBox`. `hide`: top, bottom, left, right. `strike`: horizontal, vertical,
+ /// bottom-left→top-right, top-left→bottom-right.
+ BorderBox {
+ #[serde(default)]
+ hide: [bool; 4],
+ #[serde(default)]
+ strike: [bool; 4],
+ e: Arg,
+ },
+ /// `m:box`.
+ Boxed {
+ e: Arg,
+ },
+ /// `m:groupChr`: a stretched character (brace, arrow) under or over the base.
+ GroupChr {
+ chr: char,
+ top: bool,
+ e: Arg,
+ },
+ /// `m:eqArr`: rows aligned at `&`.
+ EqArr {
+ rows: Vec,
+ },
+ /// `m:m`.
+ Matrix {
+ rows: Vec>,
+ #[serde(default)]
+ col_jc: Vec,
+ },
+ /// `m:phant`.
+ Phant {
+ #[serde(default = "yes")]
+ show: bool,
+ #[serde(default)]
+ zero_wid: bool,
+ #[serde(default)]
+ zero_asc: bool,
+ #[serde(default)]
+ zero_desc: bool,
+ e: Arg,
+ },
+}
+
+fn yes() -> bool {
+ true
+}
+
+/// A row holds an equation-number mark (`#`) in a top-level run.
+pub fn has_number_mark(row: &[MNode]) -> bool {
+ row.iter().any(|n| matches!(n, MNode::Run(r) if !r.lit && !r.nor && r.text.contains('#')))
+}
+
+/// Integral signs (their limits default to sub/sup positions).
+pub fn is_integral(c: char) -> bool {
+ matches!(c, '∫' | '∬' | '∭' | '∮' | '∯' | '∰' | '∱' | '∲' | '∳' | '⨌')
+}
+
+/// Characters that take n-ary operator form in the linear format.
+pub fn is_nary_char(c: char) -> bool {
+ is_integral(c) || matches!(c, '∑' | '∏' | '∐' | '⋃' | '⋂' | '⋁' | '⋀' | '⨁' | '⨂' | '⨀' | '⨄' | '⨆')
+}
+
+/// Mathematical alphanumeric for `c` in a style and alphabet (Unicode block U+1D400).
+pub fn math_alnum(c: char, bold: bool, italic: bool, scr: MScr) -> char {
+ let off = |base: u32, k: u32| char::from_u32(base + k).unwrap_or(c);
+ let upper = c.is_ascii_uppercase();
+ let lower = c.is_ascii_lowercase();
+ if upper || lower {
+ let k = if upper { c as u32 - 'A' as u32 } else { c as u32 - 'a' as u32 };
+ // Letters that live in the Letterlike Symbols block.
+ let hole = |table: &[(char, char)]| table.iter().find(|(f, _)| *f == c).map(|(_, t)| *t);
+ let (base_u, base_l) = match (scr, bold, italic) {
+ (MScr::Roman, false, false) => return c,
+ (MScr::Roman, true, false) => (0x1D400, 0x1D41A),
+ (MScr::Roman, false, true) => {
+ if c == 'h' {
+ return '\u{210E}';
+ }
+ (0x1D434, 0x1D44E)
+ }
+ (MScr::Roman, true, true) => (0x1D468, 0x1D482),
+ (MScr::Script, false, _) => {
+ if let Some(t) = hole(&[
+ ('B', 'ℬ'),
+ ('E', 'ℰ'),
+ ('F', 'ℱ'),
+ ('H', 'ℋ'),
+ ('I', 'ℐ'),
+ ('L', 'ℒ'),
+ ('M', 'ℳ'),
+ ('R', 'ℛ'),
+ ('e', 'ℯ'),
+ ('g', 'ℊ'),
+ ('o', 'ℴ'),
+ ]) {
+ return t;
+ }
+ (0x1D49C, 0x1D4B6)
+ }
+ (MScr::Script, true, _) => (0x1D4D0, 0x1D4EA),
+ (MScr::Fraktur, false, _) => {
+ if let Some(t) = hole(&[('C', 'ℭ'), ('H', 'ℌ'), ('I', 'ℑ'), ('R', 'ℜ'), ('Z', 'ℨ')]) {
+ return t;
+ }
+ (0x1D504, 0x1D51E)
+ }
+ (MScr::Fraktur, true, _) => (0x1D56C, 0x1D586),
+ (MScr::DoubleStruck, _, _) => {
+ if let Some(t) = hole(&[('C', 'ℂ'), ('H', 'ℍ'), ('N', 'ℕ'), ('P', 'ℙ'), ('Q', 'ℚ'), ('R', 'ℝ'), ('Z', 'ℤ')]) {
+ return t;
+ }
+ (0x1D538, 0x1D552)
+ }
+ (MScr::SansSerif, false, false) => (0x1D5A0, 0x1D5BA),
+ (MScr::SansSerif, true, false) => (0x1D5D4, 0x1D5EE),
+ (MScr::SansSerif, false, true) => (0x1D608, 0x1D622),
+ (MScr::SansSerif, true, true) => (0x1D63C, 0x1D656),
+ (MScr::Monospace, _, _) => (0x1D670, 0x1D68A),
+ };
+ return off(if upper { base_u } else { base_l }, k);
+ }
+ if c.is_ascii_digit() {
+ let k = c as u32 - '0' as u32;
+ return match (scr, bold) {
+ (MScr::Roman, true) => off(0x1D7CE, k),
+ (MScr::DoubleStruck, _) => off(0x1D7D8, k),
+ (MScr::SansSerif, false) => off(0x1D7E2, k),
+ (MScr::SansSerif, true) => off(0x1D7EC, k),
+ (MScr::Monospace, _) => off(0x1D7F6, k),
+ _ => c,
+ };
+ }
+ // Greek (roman alphabet only).
+ if scr == MScr::Roman && (bold || italic) {
+ let (cap, small) = match (bold, italic) {
+ (true, false) => (0x1D6A8, 0x1D6C2),
+ (false, true) => (0x1D6E2, 0x1D6FC),
+ _ => (0x1D71C, 0x1D736),
+ };
+ let u = c as u32;
+ if (0x391..=0x3A9).contains(&u) && u != 0x3A2 {
+ return off(cap, u - 0x391);
+ }
+ if (0x3B1..=0x3C9).contains(&u) {
+ return off(small, u - 0x3B1);
+ }
+ let extra = match c {
+ '∂' => Some(25),
+ 'ϵ' => Some(26),
+ 'ϑ' => Some(27),
+ 'ϰ' => Some(28),
+ 'ϕ' => Some(29),
+ 'ϱ' => Some(30),
+ 'ϖ' => Some(31),
+ _ => None,
+ };
+ if let Some(e) = extra
+ && italic
+ {
+ return off(small, e);
+ }
+ }
+ c
+}
+
+// ---------------------------------------------------------------------------------------------
+// Linear format out.
+
+/// The equation as linear-format text (`x=(-b±√(b^2-4ac))/2a`).
+pub fn to_linear(nodes: &[MNode]) -> String {
+ let mut s = String::new();
+ lin_arg(nodes, &mut s, 0);
+ s
+}
+
+fn lin_arg(a: &[MNode], s: &mut String, depth: usize) {
+ if depth > MAX_DEPTH {
+ return;
+ }
+ for (i, n) in a.iter().enumerate() {
+ lin_node(n, s, depth + 1);
+ // A bare script or limit ends at a space: a letter, digit or period after it is its own item.
+ if matches!(n, MNode::Script { kind, .. } if *kind != ScriptKind::Pre) || matches!(n, MNode::Lim { .. }) {
+ let ends_bare = s.chars().next_back().is_some_and(|c| c.is_alphanumeric() || c == '.');
+ if ends_bare && a.get(i + 1).and_then(first_lin_char).is_some_and(|c| c.is_alphanumeric() || c == '.') {
+ s.push(' ');
+ }
+ }
+ }
+}
+
+/// The first character `n` is written with, when that is cheap to tell (letters and digits).
+fn first_lin_char(n: &MNode) -> Option {
+ match n {
+ MNode::Run(r) => r.text.chars().next(),
+ MNode::Func { name, .. } => name.first().and_then(first_lin_char),
+ MNode::Script { kind, base, .. } if *kind != ScriptKind::Pre => base.first().and_then(first_lin_char),
+ MNode::Lim { e, .. } => e.first().and_then(first_lin_char),
+ MNode::Frac { num, .. } => num.first().and_then(first_lin_char),
+ _ => None,
+ }
+}
+
+/// An argument as an operand: bare when it's a single simple atom, parenthesised otherwise.
+fn lin_operand(a: &[MNode], s: &mut String, depth: usize) {
+ let mut inner = String::new();
+ lin_arg(a, &mut inner, depth);
+ let simple = match a {
+ [MNode::Run(r)] => {
+ let mut cs = r.text.chars();
+ match (cs.next(), cs.next()) {
+ (Some(c), None) => !"+-−=/^_ ".contains(c),
+ _ => r.text.chars().all(|c| c.is_alphanumeric() || c == '.'),
+ }
+ }
+ [MNode::Delim { .. }] => true,
+ _ => false,
+ };
+ if simple && !inner.is_empty() {
+ s.push_str(&inner);
+ } else {
+ s.push('(');
+ s.push_str(&inner);
+ s.push(')');
+ }
+}
+
+/// A script or limit operand. The parser reads a run of letters and digits up to the next space or
+/// operator, so `V_dd` is written bare as Word's Linear format shows it (`V_(dd)` reads back the same).
+fn lin_script(a: &[MNode], s: &mut String, depth: usize) {
+ // Bare only when the parser reads the whole run back, in the same style.
+ if let [MNode::Run(r)] = a
+ && r.text.chars().nth(1).is_some()
+ {
+ let upright_name = r.sty == Some(MSty::Plain) && crate::math_linear::FUNCTION_NAMES.contains(&r.text.as_str());
+ let number = r.text.starts_with(|c: char| c.is_ascii_digit()) && r.text.chars().all(|c| c.is_alphanumeric() || c == '.');
+ let word = r.text.starts_with(char::is_alphabetic) && r.text.chars().all(char::is_alphanumeric);
+ let upright_other = r.sty == Some(MSty::Plain) && !upright_name;
+ if upright_other || (!number && !word) {
+ s.push('(');
+ s.push_str(&r.text);
+ s.push(')');
+ return;
+ }
+ }
+ lin_operand(a, s, depth);
+}
+
+fn lin_node(n: &MNode, s: &mut String, depth: usize) {
+ match n {
+ MNode::Run(r) => s.push_str(&r.text),
+ MNode::Frac { kind, num, den } => {
+ lin_operand(num, s, depth);
+ s.push(match kind {
+ FracKind::Skewed => '⁄',
+ FracKind::Linear => '∕',
+ FracKind::NoBar => '¦',
+ FracKind::Bar => '/',
+ });
+ lin_operand(den, s, depth);
+ }
+ MNode::Script { kind, base, sub, sup } => {
+ if *kind == ScriptKind::Pre {
+ s.push('_');
+ lin_script(sub, s, depth);
+ s.push('^');
+ lin_script(sup, s, depth);
+ s.push('▒');
+ lin_operand(base, s, depth);
+ return;
+ }
+ lin_operand(base, s, depth);
+ if matches!(kind, ScriptKind::Sub | ScriptKind::SubSup) {
+ s.push('_');
+ lin_script(sub, s, depth);
+ }
+ if matches!(kind, ScriptKind::Sup | ScriptKind::SubSup) {
+ s.push('^');
+ lin_script(sup, s, depth);
+ }
+ }
+ MNode::Rad { deg, deg_hide, e } => {
+ if *deg_hide || deg.is_empty() {
+ s.push('√');
+ lin_operand(e, s, depth);
+ } else {
+ s.push_str("√(");
+ lin_arg(deg, s, depth);
+ s.push('&');
+ lin_arg(e, s, depth);
+ s.push(')');
+ }
+ }
+ MNode::Nary { chr, sub_hide, sup_hide, sub, sup, e, .. } => {
+ s.push(*chr);
+ if !sub_hide && !sub.is_empty() {
+ s.push('_');
+ lin_script(sub, s, depth);
+ }
+ if !sup_hide && !sup.is_empty() {
+ s.push('^');
+ lin_script(sup, s, depth);
+ }
+ s.push('▒');
+ lin_operand(e, s, depth);
+ }
+ MNode::Delim { beg, end, elems, .. } => {
+ // A standard pair writes as itself; anything else uses explicit `├X … Y┤`.
+ let pair = matches!((beg, end), (Some(b), Some(e)) if crate::math_linear::closer(*b) == Some(*e));
+ let invisible = beg.is_none() && end.is_none();
+ if pair {
+ s.extend(*beg);
+ } else if invisible {
+ s.push('〖');
+ } else {
+ s.push('├');
+ s.extend(*beg);
+ }
+ for (i, e) in elems.iter().enumerate() {
+ if i > 0 {
+ s.push('│');
+ }
+ lin_arg(e, s, depth);
+ }
+ if pair {
+ s.extend(*end);
+ } else if invisible {
+ s.push('〗');
+ } else {
+ s.extend(*end);
+ s.push('┤');
+ }
+ }
+ MNode::Func { name, e } => {
+ lin_arg(name, s, depth);
+ s.push('\u{2061}');
+ lin_operand(e, s, depth);
+ }
+ MNode::Lim { upper, e, lim } => {
+ lin_operand(e, s, depth);
+ s.push(if *upper { '┴' } else { '┬' });
+ lin_script(lim, s, depth);
+ }
+ MNode::Acc { chr, e } => {
+ lin_operand(e, s, depth);
+ s.push(*chr);
+ }
+ MNode::Bar { top, e } => {
+ s.push(if *top { '¯' } else { '▁' });
+ lin_operand(e, s, depth);
+ }
+ MNode::BorderBox { e, .. } => {
+ s.push('▭');
+ lin_operand(e, s, depth);
+ }
+ MNode::Boxed { e } => {
+ s.push('□');
+ lin_operand(e, s, depth);
+ }
+ MNode::GroupChr { chr, e, .. } => {
+ s.push(*chr);
+ lin_operand(e, s, depth);
+ }
+ // A numbered equation (`E=mc^2#(1)`) is written as its one row.
+ MNode::EqArr { rows } if rows.len() == 1 && rows.first().is_some_and(|r| has_number_mark(r)) => {
+ if let Some(r) = rows.first() {
+ lin_arg(r, s, depth);
+ }
+ }
+ MNode::EqArr { rows } => {
+ s.push_str("█(");
+ for (i, r) in rows.iter().enumerate() {
+ if i > 0 {
+ s.push('@');
+ }
+ lin_arg(r, s, depth);
+ }
+ s.push(')');
+ }
+ MNode::Matrix { rows, .. } => {
+ s.push_str("■(");
+ for (i, r) in rows.iter().enumerate() {
+ if i > 0 {
+ s.push('@');
+ }
+ for (j, c) in r.iter().enumerate() {
+ if j > 0 {
+ s.push('&');
+ }
+ lin_arg(c, s, depth);
+ }
+ }
+ s.push(')');
+ }
+ MNode::Phant { e, .. } => {
+ s.push('⟡');
+ lin_operand(e, s, depth);
+ }
+ }
+}
+
+// ---------------------------------------------------------------------------------------------
+// Linear format in.
+
+/// Parse linear-format text into nodes ([`crate::math_linear`]). Never fails: what isn't
+/// understood stays as text.
+pub fn parse_linear(s: &str) -> Arg {
+ crate::math_linear::parse(s)
+}
+
+pub use crate::math_linear::FUNCTION_NAMES;
+
+/// Spacing class of a math character (TeX's atom classes).
+#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
+pub enum MClass {
+ #[default]
+ Ord,
+ Op,
+ Bin,
+ Rel,
+ Open,
+ Close,
+ Punct,
+ Inner,
+}
+
+/// The spacing class of a character in an equation.
+pub fn math_class(c: char) -> MClass {
+ match c {
+ '+' | '−' | '-' | '±' | '∓' | '×' | '÷' | '·' | '∙' | '∗' | '*' | '∘' | '⊕' | '⊖' | '⊗' | '⊘' | '⊙' | '⊚' | '⊛' | '∪' | '∩' | '∧' | '∨'
+ | '∖' | '⋅' | '⋆' | '⊎' | '⊓' | '⊔' | '⋄' | '⊞' | '⊟' | '⊠' | '⊡' | '⋉' | '⋊' | '⋋' | '⋌' | '⋎' | '⋏' | '⋒' | '⋓' | '∔' | '∸' | '≀' | '⨿'
+ | '⊼' | '⊻' | '⊽' => MClass::Bin,
+ '=' | '<' | '>' | '≤' | '≥' | '≠' | '≈' | '≡' | '≢' | '∼' | '≃' | '≅' | '≐' | '∝' | '≪' | '≫' | '≺' | '≻' | '≼' | '≽' | '⊂' | '⊃' | '⊆'
+ | '⊇' | '⊄' | '⊅' | '⊈' | '⊉' | '⊊' | '⊋' | '∈' | '∉' | '∋' | '∌' | '⊥' | '∥' | '∦' | '∣' | '∤' | '≔' | '≕' | '≝' | '≜' | '≞' | '≟' | '→'
+ | '←' | '↔' | '⇒' | '⇐' | '⇔' | '↦' | '⟼' | '⟶' | '⟵' | '⟷' | '⟹' | '⟸' | '⟺' | '↑' | '↓' | '↕' | '⇑' | '⇓' | '⇕' | '↗' | '↘' | '↙' | '↖'
+ | '⊢' | '⊣' | '⊨' | '⊩' | '⊪' | '⊬' | '⊭' | '⊮' | '⊯' | '≲' | '≳' | '≍' | '⊏' | '⊐' | '⊑' | '⊒' | '⋢' | '⋣' | '≶' | '≷' | '≮' | '≯' | '≰'
+ | '≱' | '≦' | '≧' | '⩽' | '⩾' | ':' | '∶' | '∷' | '∺' | '∻' | '≗' | '≙' | '≚' | '≑' | '≒' | '≓' | '≖' | '⇌' | '⇋' | '⇄' | '⇆' | '⇇' | '⇉'
+ | '⇈' | '⇊' | '↪' | '↩' | '↼' | '↽' | '⇀' | '⇁' | '↿' | '↾' | '⇃' | '⇂' | '↶' | '↷' | '↺' | '↻' | '⊸' | '↜' | '↝' | '↞' | '↠' | '↢' | '↣'
+ | '↫' | '↬' | '↭' | '⇚' | '⇛' | '↰' | '↱' | '⇝' | '≁' | '≄' | '≉' | '≇' | '≭' | '≨' | '≩' | '⊀' | '⊁' | '⋠' | '⋡' | '⋦' | '⋧' | '⋨' | '⋩'
+ | '⋪' | '⋫' | '⋬' | '⋭' | '⋈' | '∴' | '∵' | '≏' | '≎' | '⋘' | '⋙' | '⊲' | '⊳' | '⊴' | '⊵' | '⋐' | '⋑' => {
+ MClass::Rel
+ }
+ '(' | '[' | '{' | '⟨' | '⌈' | '⌊' | '⟦' | '〈' => MClass::Open,
+ ')' | ']' | '}' | '⟩' | '⌉' | '⌋' | '⟧' | '〉' | '!' => MClass::Close,
+ ',' | ';' => MClass::Punct,
+ c if is_nary_char(c) => MClass::Op,
+ _ => MClass::Ord,
+ }
+}
+
+/// Merge adjacent runs with the same formatting; recurse into every structure.
+pub fn merge_runs(a: Arg) -> Arg {
+ let mut out: Arg = Vec::with_capacity(a.len());
+ for n in a {
+ let n = match n {
+ MNode::Run(r) => {
+ if let Some(MNode::Run(prev)) = out.last_mut()
+ && prev.sty == r.sty
+ && prev.scr == r.scr
+ && prev.nor == r.nor
+ && prev.lit == r.lit
+ && prev.size == r.size
+ && prev.color == r.color
+ && prev.font == r.font
+ && r.brk.is_none()
+ {
+ prev.text.push_str(&r.text);
+ continue;
+ }
+ MNode::Run(r)
+ }
+ other => crate::math_linear::map_args(other, &merge_runs),
+ };
+ out.push(n);
+ }
+ out
+}
+
+/// Deepest node (a leaf counts 1) the parsers wrap further. Half of [`MAX_DEPTH`]: serde_json
+/// stops reading at 128 nested containers and a node costs two (object and array).
+pub const WRAP_DEPTH: usize = MAX_DEPTH / 2;
+
+/// Calls `f` with every argument of `n`.
+fn each_arg<'a>(n: &'a MNode, f: &mut impl FnMut(&'a [MNode])) {
+ match n {
+ MNode::Run(_) => {}
+ MNode::Frac { num, den, .. } => {
+ f(num);
+ f(den);
+ }
+ MNode::Script { base, sub, sup, .. } => {
+ f(base);
+ f(sub);
+ f(sup);
+ }
+ MNode::Rad { deg, e, .. } => {
+ f(deg);
+ f(e);
+ }
+ MNode::Nary { sub, sup, e, .. } => {
+ f(sub);
+ f(sup);
+ f(e);
+ }
+ MNode::Delim { elems, .. } => elems.iter().for_each(|a| f(a)),
+ MNode::Func { name, e } => {
+ f(name);
+ f(e);
+ }
+ MNode::Lim { e, lim, .. } => {
+ f(e);
+ f(lim);
+ }
+ MNode::Acc { e, .. }
+ | MNode::Bar { e, .. }
+ | MNode::BorderBox { e, .. }
+ | MNode::Boxed { e }
+ | MNode::GroupChr { e, .. }
+ | MNode::Phant { e, .. } => f(e),
+ MNode::EqArr { rows } => rows.iter().for_each(|a| f(a)),
+ MNode::Matrix { rows, .. } => rows.iter().flatten().for_each(|a| f(a)),
+ }
+}
+
+/// `n` is already nested too deeply to be wrapped in another structure. The parsers use this so
+/// `x^1^1^1…` stays shallow (a left-nested tree 10 000 deep overflows the stack in `Clone`,
+/// `Drop`, `PartialEq` and serde). Iterative, stops at the first too-deep path; costs at most the
+/// size of `n`, which the parsers' input caps bound.
+pub fn too_deep(n: &MNode) -> bool {
+ let mut stack: Vec<(&MNode, usize)> = vec![(n, 1)];
+ while let Some((n, d)) = stack.pop() {
+ if d >= WRAP_DEPTH {
+ return true;
+ }
+ each_arg(n, &mut |a| stack.extend(a.iter().map(|c| (c, d + 1))));
+ }
+ false
+}
+
+/// Any of `a` is too deep to be wrapped ([`too_deep`]).
+pub fn any_too_deep(a: &[MNode]) -> bool {
+ a.iter().any(too_deep)
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ fn run(t: &str) -> MNode {
+ MNode::Run(MRun::new(t))
+ }
+
+ #[test]
+ fn quadratic_formula_parses_into_structures() {
+ let n = parse_linear("x=(-b±√(b^2-4ac))/2a");
+ assert_eq!(n.len(), 2, "{n:#?}");
+ assert_eq!(n[0], run("x="));
+ let MNode::Frac { num, den, .. } = &n[1] else { panic!("{n:#?}") };
+ assert_eq!(den, &vec![run("2a")]);
+ assert!(num.iter().any(|m| matches!(m, MNode::Rad { .. })), "{num:#?}");
+ let MNode::Rad { e, .. } = num.iter().find(|m| matches!(m, MNode::Rad { .. })).unwrap() else { panic!() };
+ assert!(matches!(e.first(), Some(MNode::Script { kind: ScriptKind::Sup, .. })), "{e:#?}");
+ }
+
+ #[test]
+ fn scripts_and_nary() {
+ let n = parse_linear("∑_(i=1)^n▒i^2");
+ let MNode::Nary { chr, sub, sup, e, .. } = &n[0] else { panic!("{n:#?}") };
+ assert_eq!(*chr, '∑');
+ assert_eq!(sub, &vec![run("i=1")]);
+ assert_eq!(sup, &vec![run("n")]);
+ assert!(matches!(e.first(), Some(MNode::Script { .. })));
+ let n = parse_linear("x_i^2");
+ assert!(matches!(&n[0], MNode::Script { kind: ScriptKind::SubSup, .. }), "{n:#?}");
+ }
+
+ #[test]
+ fn functions_and_matrices() {
+ let n = parse_linear("sin x");
+ assert!(matches!(&n[0], MNode::Func { .. }), "{n:#?}");
+ let n = parse_linear("■(a&b@c&d)");
+ let MNode::Matrix { rows, .. } = &n[0] else { panic!("{n:#?}") };
+ assert_eq!(rows.len(), 2);
+ assert_eq!(rows[0].len(), 2);
+ }
+
+ #[test]
+ fn linear_round_trip_keeps_structure() {
+ for s in [
+ "x=(-b±√(b^2-4ac))/2a",
+ "∑_(i=1)^n▒i",
+ "a/b+c",
+ "E=mc^2",
+ "√(3&x)",
+ "├]a[┤",
+ "{█(x@y)┤",
+ "|x|+‖v‖",
+ "⏞(a+b)┴k",
+ "_1^n▒Y",
+ "sin^(−1)x",
+ "lim┬(n→∞)a_n",
+ "A⃗+x̂",
+ "▭(a)+¯(b)+▁(c)",
+ "(a│b)",
+ "E=mc^2#(1)",
+ ] {
+ let n = parse_linear(s);
+ let back = parse_linear(&to_linear(&n));
+ assert_eq!(n, back, "{s} → {}", to_linear(&n));
+ }
+ }
+
+ //T1
+ /// Deepest nesting of `a` (a leaf counts 1), without recursion.
+ fn depth_of(a: &[MNode]) -> usize {
+ let mut best = 0;
+ let mut stack: Vec<(&MNode, usize)> = a.iter().map(|n| (n, 1)).collect();
+ while let Some((n, d)) = stack.pop() {
+ best = best.max(d);
+ each_arg(n, &mut |a| stack.extend(a.iter().map(|c| (c, d + 1))));
+ }
+ best
+ }
+
+ /// Run `f` on a thread with a stack far below the 8 MB of a main thread (#1456).
+ fn on_small_stack(f: impl FnOnce() + Send + 'static) {
+ std::thread::Builder::new().stack_size(512 * 1024).spawn(f).unwrap().join().unwrap();
+ }
+
+ #[test]
+ fn deeply_chained_input_stays_shallow() {
+ on_small_stack(|| {
+ let cases: Vec<(&str, Arg)> = vec![
+ ("x^1^1…", parse_linear(&format!("x{}", "^1".repeat(9999)))),
+ ("a/a/a…", parse_linear(&format!("a{}", "/a".repeat(9999)))),
+ ("accents", parse_linear(&format!("x{}", "\u{302}".repeat(9999)))),
+ ("limits", parse_linear(&format!("x{}", "┬a".repeat(9000)))),
+ ("(a b)^1^1…", parse_linear(&format!("(a b){}", "^1".repeat(9000)))),
+ ("latex x^1^1…", crate::math_latex::parse_latex(&format!("x{}", "^1".repeat(9999)))),
+ ("latex \\sum…", crate::math_latex::parse_latex(&"\\sum".repeat(5000))),
+ ("latex \\sin x^1…", crate::math_latex::parse_latex(&format!("\\sin x{}", "^1".repeat(9000)))),
+ ];
+ for (name, n) in cases {
+ assert!(!n.is_empty(), "{name}");
+ assert!(depth_of(&n) <= 70, "{name}: depth {}", depth_of(&n));
+ let copy = n.clone();
+ assert_eq!(copy, n, "{name}");
+ let _ = to_linear(&n);
+ let _ = crate::math_latex::to_latex(&n);
+ let json = serde_json::to_string(&n).unwrap();
+ let back: Arg = serde_json::from_str(&json).unwrap();
+ assert_eq!(back, n, "{name}");
+ }
+ });
+ }
+
+ #[test]
+ fn ordinary_nesting_is_still_built() {
+ // Well below the cap: scripts, fractions and operators still wrap.
+ let n = parse_linear(&format!("x{}", "^1".repeat(10)));
+ assert_eq!(depth_of(&n), 11, "{n:#?}");
+ let n = crate::math_latex::parse_latex(r"\sum_{i}\sum_{j}a_{ij}");
+ assert!(matches!(&n[0], MNode::Nary { e, .. } if matches!(e.first(), Some(MNode::Nary { .. }))), "{n:#?}");
+ }
+
+ //T2
+ #[test]
+ fn latex_text_escapes_what_latex_reads_as_markup() {
+ for text in ["a}b\\c", "{x}", "50% of #1", "a_b&c$d~e^f", "\\", "\\alpha", "\\{"] {
+ let n = vec![MNode::Run(MRun { text: text.into(), nor: true, sty: Some(MSty::Plain), ..Default::default() })];
+ let tex = crate::math_latex::to_latex(&n);
+ assert_eq!(crate::math_latex::parse_latex(&tex), n, "{text:?} → {tex}");
+ }
+ let n = vec![MNode::Run(MRun::new("a\\b"))];
+ assert_eq!(crate::math_latex::parse_latex(&crate::math_latex::to_latex(&n)), n);
+ }
+
+ //T3
+ #[test]
+ fn latex_runs_merge_inside_every_structure() {
+ use crate::math_latex::parse_latex;
+ for (tex, lin) in [(r"\overline{ab}", "¯(ab)"), (r"\underline{ab}", "▁(ab)"), (r"\boxed{ab}", "▭(ab)"), (r"\phantom{ab}", "⟡(ab)")] {
+ assert_eq!(parse_latex(tex), parse_linear(lin), "{tex}");
+ }
+ let n = parse_latex(r"\begin{matrix}a b&c d\\e f&g\end{matrix}");
+ let MNode::Matrix { rows, .. } = &n[0] else { panic!("{n:#?}") };
+ assert!(rows.iter().flatten().all(|c| c.len() == 1), "{rows:#?}");
+ let n = parse_latex(r"\hat{ab}\underbrace{ab}\underset{ab}{cd}");
+ let runs = |a: &Arg| a.iter().filter(|n| matches!(n, MNode::Run(_))).count();
+ assert!(matches!(&n[0], MNode::Acc { e, .. } if runs(e) == 1), "{n:#?}");
+ assert!(matches!(&n[1], MNode::GroupChr { e, .. } if runs(e) == 1), "{n:#?}");
+ assert!(matches!(&n[2], MNode::Lim { e, lim, .. } if runs(e) == 1 && runs(lim) == 1), "{n:#?}");
+ }
+
+ #[test]
+ fn multi_letter_scripts_round_trip() {
+ // Word for Mac 16.113.4 (#1609): a script is the whole run up to a space or operator, and Word's
+ // Linear format shows it bare (`V_dd`); the parenthesised form reads back identically.
+ let script = |kind, sub: &str, sup: &str| MNode::Script { kind, base: vec![run("V")], sub: parse_linear(sub), sup: parse_linear(sup) };
+ for n in [
+ vec![script(ScriptKind::Sub, "dd", "")],
+ vec![script(ScriptKind::Sup, "", "ab")],
+ vec![script(ScriptKind::SubSup, "in", "2")],
+ vec![MNode::Nary {
+ chr: '∑',
+ lim_loc: None,
+ grow: false,
+ sub_hide: false,
+ sup_hide: false,
+ sub: vec![run("ij")],
+ sup: vec![run("n")],
+ e: vec![run("x")],
+ }],
+ vec![MNode::Lim { upper: false, e: vec![run("⟶")], lim: vec![run("yields")] }],
+ ] {
+ assert_eq!(parse_linear(&to_linear(&n)), n, "{}", to_linear(&n));
+ }
+ // Numbers and single letters stay bare.
+ assert_eq!(to_linear(&[script(ScriptKind::SubSup, "10", "2.5")]), "V_10^2.5");
+ assert_eq!(to_linear(&[script(ScriptKind::Sub, "d", "")]), "V_d");
+ assert_eq!(to_linear(&[script(ScriptKind::Sub, "dd", "")]), "V_dd");
+ assert_eq!(to_linear(&[script(ScriptKind::Sup, "", "ab")]), "V^ab");
+ }
+
+ #[test]
+ fn multi_letter_script_operands_follow_word() {
+ // #1609, Word for Mac 16.113.4: `V_dd` and `V_(dd)` build up the same; `dd` is all subscript.
+ let sub_dd = MNode::Script { kind: ScriptKind::Sub, base: vec![run("V")], sub: vec![run("dd")], sup: vec![] };
+ assert_eq!(parse_linear("V_dd"), vec![sub_dd.clone()]);
+ assert_eq!(parse_linear("V_(dd)"), vec![sub_dd]);
+ let sup_ab = MNode::Script { kind: ScriptKind::Sup, base: vec![run("x")], sub: vec![], sup: vec![run("ab")] };
+ assert_eq!(parse_linear("x^ab"), vec![sup_ab.clone()]);
+ assert_eq!(parse_linear("x^(ab)"), vec![sup_ab]);
+ // Delimiters still end the operand.
+ let both = MNode::Script { kind: ScriptKind::SubSup, base: vec![run("x")], sub: vec![run("i")], sup: vec![run("2")] };
+ assert_eq!(parse_linear("x_i^2"), vec![both]);
+ let a_i = MNode::Script { kind: ScriptKind::Sub, base: vec![run("a")], sub: vec![run("i")], sup: vec![] };
+ assert_eq!(parse_linear("a_i b"), vec![a_i, run("b")]);
+ let both2 = MNode::Script { kind: ScriptKind::SubSup, base: vec![run("x")], sub: vec![run("ij")], sup: vec![run("kl")] };
+ assert_eq!(parse_linear("x_ij^kl"), vec![both2]);
+ // Round trip is Word's bare form.
+ assert_eq!(to_linear(&parse_linear("V_(dd)")), "V_dd");
+ }
+
+ #[test]
+ fn a_bare_script_does_not_absorb_what_follows() {
+ // #1609 review: `x_i` then `y` must not read back as x with subscript `iy`.
+ let sc =
+ |kind, base: &str, sub: &str, sup: &str| MNode::Script { kind, base: vec![run(base)], sub: parse_linear(sub), sup: parse_linear(sup) };
+ let sin_x = MNode::Func { name: vec![MNode::Run(MRun::plain("sin"))], e: vec![run("x")] };
+ let lim = MNode::Lim { upper: false, e: vec![run("a")], lim: vec![run("n")] };
+ for n in [
+ vec![sc(ScriptKind::Sub, "x", "i", ""), run("y")],
+ vec![sc(ScriptKind::Sub, "a", "i", ""), sc(ScriptKind::Sub, "b", "j", "")],
+ vec![sc(ScriptKind::Sup, "x", "", "2"), run("y")],
+ vec![sc(ScriptKind::Sup, "x", "", "2"), run("3")],
+ vec![sc(ScriptKind::Sub, "x", "i", ""), sin_x],
+ vec![lim.clone(), run("y")],
+ vec![sc(ScriptKind::Sub, "x", "ab", ""), run("y")],
+ vec![sc(ScriptKind::Sub, "x", "i", ""), run(".5")],
+ ] {
+ let lin = to_linear(&n);
+ assert_eq!(parse_linear(&lin), n, "{lin}");
+ }
+ assert_eq!(to_linear(&[sc(ScriptKind::Sub, "x", "i", ""), run("y")]), "x_i y");
+ // Nothing extra when the script already ends in a bracket or a symbol follows.
+ assert_eq!(to_linear(&[sc(ScriptKind::Sub, "x", "i", ""), run("+")]), "x_i+");
+ }
+
+ #[test]
+ fn a_function_name_in_a_script_stays_upright() {
+ // Word for Mac (#1609): `x_max` and `x_(max)` both give an upright function name.
+ let up = vec![MNode::Script { kind: ScriptKind::Sub, base: vec![run("x")], sub: vec![MNode::Run(MRun::plain("max"))], sup: vec![] }];
+ assert_eq!(parse_linear("x_max"), up);
+ assert_eq!(parse_linear("x_(max)"), up);
+ assert_eq!(parse_linear(&to_linear(&up)), up, "{}", to_linear(&up));
+ // Other upright text is not a function name: it keeps its parentheses.
+ let other = vec![MNode::Script { kind: ScriptKind::Sub, base: vec![run("x")], sub: vec![MNode::Run(MRun::plain("ab"))], sup: vec![] }];
+ assert_eq!(to_linear(&other), "x_(ab)");
+ }
+
+ #[test]
+ fn digits_then_letters_continue_a_script() {
+ // Word for Mac (#1609): a script runs to a space or operator, so `x_2a` is subscript "2a".
+ let sub = |t: &str| vec![MNode::Script { kind: ScriptKind::Sub, base: vec![run("x")], sub: vec![run(t)], sup: vec![] }];
+ assert_eq!(parse_linear("x_2a"), sub("2a"));
+ assert_eq!(parse_linear("x_a2"), sub("a2"));
+ assert_eq!(parse_linear("x_αβ"), sub("αβ"));
+ assert_eq!(parse_linear("x_10"), sub("10"));
+ assert_eq!(parse_linear("x_2.5"), sub("2.5"));
+ assert_eq!(to_linear(&sub("2a")), "x_2a");
+ assert_eq!(parse_linear(&to_linear(&sub("2a"))), sub("2a"));
+ }
+
+ #[test]
+ fn hostile_linear_never_panics() {
+ for s in ["", "/", "^", "_", "√", "∑", "((((", "))))", "■(", "█(@@&&", "a/", "/b", "^^^__", "√(&)", "∫_^▒", "lim_", "sin"] {
+ let _ = to_linear(&parse_linear(s));
+ }
+ let deep = "(".repeat(5000) + &")".repeat(5000);
+ let _ = parse_linear(&deep);
+ let deep = "√".repeat(3000);
+ let _ = parse_linear(&deep);
+ }
+
+ #[test]
+ fn a_long_run_of_letters_parses_in_linear_time() {
+ // Each letter that doesn't start a function name used to rescan (and copy) the rest of
+ // the run, so a 20,000-letter equation did 200 million steps. (No wall-clock assert: a
+ // loaded runner would fail it; a regression shows as a slow test.)
+ for _ in 0..5 {
+ let e = parse_linear(&"x".repeat(30_000));
+ assert!(matches!(e.as_slice(), [MNode::Run(r)] if r.text.len() == 20_000), "{:?}", e.len());
+ }
+ // Function names are still found at the start of a run, and only as whole runs.
+ assert!(matches!(parse_linear("sin x").as_slice(), [MNode::Func { .. }]));
+ assert!(matches!(parse_linear("arcsin x").as_slice(), [MNode::Func { .. }]));
+ assert!(matches!(parse_linear("sinx").as_slice(), [MNode::Run(_)]));
+ assert!(matches!(parse_linear("arcsinhx").as_slice(), [MNode::Run(_)]));
+ }
+}
diff --git a/crates/math/src/math_latex.rs b/crates/math/src/math_latex.rs
new file mode 100644
index 000000000..5f442f437
--- /dev/null
+++ b/crates/math/src/math_latex.rs
@@ -0,0 +1,1292 @@
+//! LaTeX ↔ math tree (Word's LaTeX equation input and conversion).
+//!
+//! Input covers the commands people type in equations: `\frac`, `\sqrt[n]{}`, scripts, big
+//! operators with limits, `\left…\middle…\right`, matrix and `cases`/`aligned` environments,
+//! accents, `\overbrace`/`\underbrace`, `\text`/`\mathrm`/`\mathbf`/`\mathbb`…, function names,
+//! spacing and every named symbol in [`crate::math_symbols`]. Unknown commands stay as text.
+
+use crate::math::{
+ Arg, ColJc, FracKind, MAX_DEPTH, MNode, MRun, MScr, MSty, ScriptKind, WRAP_DEPTH, any_too_deep, is_nary_char, math_class, merge_runs, too_deep,
+};
+use crate::math_linear::{FUNCTION_NAMES, map_args};
+use crate::math_symbols::{name_of, symbol};
+
+#[derive(Clone, Debug, PartialEq)]
+enum Tok {
+ Cmd(String),
+ Open,
+ Close,
+ Sup,
+ Sub,
+ Amp,
+ Row,
+ Ch(char),
+}
+
+/// Commands whose `{…}` argument is text, where spaces are part of the content.
+const TEXT_COMMANDS: &[&str] = &["text", "textrm", "mbox", "hbox", "textnormal", "textbf", "textit", "emph", "textsf"];
+
+fn tokenize(s: &str) -> Vec {
+ let cs: Vec = s.chars().take(20_000).collect();
+ let mut out = Vec::new();
+ let mut i = 0;
+ // A text command was just read: its `{` starts text, where spaces count (#448).
+ let mut text_next = false;
+ // Brace depth inside a text argument.
+ let mut text_depth: Option = None;
+ while let Some(&c) = cs.get(i) {
+ i += 1;
+ if text_depth.is_some() && matches!(c, ' ' | '\t' | '\n' | '\r') {
+ // A run of white space is one space, as in LaTeX.
+ if out.last() != Some(&Tok::Ch(' ')) {
+ out.push(Tok::Ch(' '));
+ }
+ continue;
+ }
+ let was_text_next = std::mem::take(&mut text_next);
+ match c {
+ '\\' => {
+ let start = i;
+ while cs.get(i).is_some_and(char::is_ascii_alphabetic) {
+ i += 1;
+ }
+ if i == start {
+ match cs.get(i) {
+ Some('\\') => {
+ i += 1;
+ out.push(Tok::Row);
+ }
+ Some(&c) => {
+ i += 1;
+ out.push(Tok::Cmd(c.to_string()));
+ }
+ None => {}
+ }
+ } else {
+ let name: String = cs.get(start..i).unwrap_or(&[]).iter().collect();
+ text_next = text_depth.is_none() && TEXT_COMMANDS.contains(&name.as_str());
+ out.push(Tok::Cmd(name));
+ }
+ }
+ '{' => {
+ text_depth = match text_depth {
+ Some(d) => Some(d.saturating_add(1)),
+ None if was_text_next => Some(0),
+ None => None,
+ };
+ out.push(Tok::Open);
+ }
+ '}' => {
+ text_depth = match text_depth {
+ Some(0) | None => None,
+ Some(d) => Some(d.saturating_sub(1)),
+ };
+ out.push(Tok::Close);
+ }
+ '^' => out.push(Tok::Sup),
+ '_' => out.push(Tok::Sub),
+ '&' => out.push(Tok::Amp),
+ // White space between a text command and its `{` doesn't end the wait.
+ ' ' | '\t' | '\n' | '\r' => text_next = was_text_next,
+ '~' => out.push(Tok::Ch('\u{A0}')),
+ c => out.push(Tok::Ch(c)),
+ }
+ }
+ out
+}
+
+/// Parse LaTeX math into nodes. Never fails.
+pub fn parse_latex(s: &str) -> Arg {
+ let mut p = Lp { t: tokenize(s), i: 0, depth: 0 };
+ let mut out = Vec::new();
+ while p.i < p.t.len() {
+ out.extend(p.seq(false));
+ // A stray `}` or `&`/`\\` at the top level.
+ match p.t.get(p.i) {
+ Some(Tok::Amp) => out.push(run("&")),
+ Some(Tok::Row) => {}
+ Some(Tok::Close) => {}
+ _ => {}
+ }
+ p.i += 1;
+ }
+ let out = merge_runs(out);
+ if out.iter().any(|n| matches!(n, MNode::Run(r) if !r.nor && r.text.contains('#'))) {
+ return vec![MNode::EqArr { rows: vec![out] }];
+ }
+ out
+}
+
+fn run(t: &str) -> MNode {
+ MNode::Run(MRun::new(t))
+}
+
+struct Lp {
+ t: Vec,
+ i: usize,
+ depth: usize,
+}
+
+impl Lp {
+ fn peek(&self) -> Option<&Tok> {
+ self.t.get(self.i)
+ }
+
+ /// A sequence up to `}` (in a group), `&`, `\\`, `\right`, `\middle` or `\end`.
+ fn seq(&mut self, in_group: bool) -> Arg {
+ self.depth += 1;
+ let mut out: Arg = Vec::new();
+ if self.depth > MAX_DEPTH {
+ self.i = self.t.len();
+ self.depth -= 1;
+ return out;
+ }
+ while let Some(tok) = self.peek().cloned() {
+ match tok {
+ Tok::Close if in_group => break,
+ Tok::Close | Tok::Amp | Tok::Row => break,
+ Tok::Cmd(ref c) if matches!(c.as_str(), "right" | "middle" | "end") => break,
+ Tok::Sup | Tok::Sub => {
+ self.i += 1;
+ let s = self.arg();
+ if out.last().is_some_and(too_deep) {
+ // Too deep to wrap (`x^1^1^1…`): the operator is dropped, the script kept.
+ out.extend(s);
+ } else {
+ let base = out.pop();
+ out.push(script(base, tok == Tok::Sup, s));
+ }
+ }
+ Tok::Cmd(ref c) if c == "limits" || c == "nolimits" || c == "displaystyle" || c == "textstyle" => {
+ self.i += 1;
+ if c == "nolimits"
+ && let Some(MNode::Nary { lim_loc, .. }) = out.last_mut()
+ {
+ *lim_loc = Some(crate::math::LimLoc::SubSup);
+ }
+ if c == "limits"
+ && let Some(MNode::Nary { lim_loc, .. }) = out.last_mut()
+ {
+ *lim_loc = Some(crate::math::LimLoc::UndOvr);
+ }
+ }
+ _ => {
+ let a = self.atom();
+ out.extend(a);
+ }
+ }
+ }
+ self.depth -= 1;
+ // Big operators take the following term as their operand.
+ nary_operands(out, self.depth)
+ }
+
+ /// A command argument: a `{group}` or one token.
+ fn arg(&mut self) -> Arg {
+ match self.peek() {
+ Some(Tok::Open) => {
+ self.i += 1;
+ let g = self.seq(true);
+ if self.peek() == Some(&Tok::Close) {
+ self.i += 1;
+ }
+ g
+ }
+ Some(_) => self.atom(),
+ None => Vec::new(),
+ }
+ }
+
+ /// `[optional]` argument.
+ fn opt(&mut self) -> Option {
+ if self.peek() != Some(&Tok::Ch('[')) {
+ return None;
+ }
+ self.i += 1;
+ let mut toks = Vec::new();
+ let mut level = 0i32;
+ while let Some(t) = self.peek().cloned() {
+ self.i += 1;
+ match t {
+ Tok::Ch(']') if level == 0 => break,
+ Tok::Open => level += 1,
+ Tok::Close => level -= 1,
+ _ => {}
+ }
+ toks.push(t);
+ }
+ let mut p = Lp { t: toks, i: 0, depth: self.depth };
+ Some(p.seq(false))
+ }
+
+ /// The text of a `{…}` argument, verbatim.
+ fn raw_arg(&mut self) -> String {
+ let mut s = String::new();
+ if self.peek() != Some(&Tok::Open) {
+ if let Some(Tok::Ch(c)) = self.peek().cloned() {
+ self.i += 1;
+ s.push(c);
+ }
+ return s;
+ }
+ self.i += 1;
+ let mut level = 0i32;
+ // The previous token was a control word (`\backslash{}`: the empty braces end its name).
+ let mut after_word = false;
+ while let Some(t) = self.peek().cloned() {
+ self.i += 1;
+ let was_word = std::mem::replace(&mut after_word, matches!(&t, Tok::Cmd(c) if c.chars().all(|c| c.is_ascii_alphabetic())));
+ match t {
+ Tok::Close if level == 0 => break,
+ Tok::Open if was_word && self.peek() == Some(&Tok::Close) => self.i += 1,
+ Tok::Open => {
+ level += 1;
+ s.push('{');
+ }
+ Tok::Close => {
+ level -= 1;
+ s.push('}');
+ }
+ Tok::Ch(c) => s.push(c),
+ Tok::Cmd(c) => {
+ if c.chars().all(|c| c.is_ascii_alphabetic()) {
+ match symbol(&c) {
+ Some(sym) => s.push(sym),
+ None => s.push_str(&c),
+ }
+ } else {
+ s.push_str(&c);
+ }
+ if c == " " {
+ s.push(' ');
+ }
+ }
+ Tok::Sup => s.push('^'),
+ Tok::Sub => s.push('_'),
+ Tok::Amp => s.push('&'),
+ Tok::Row => s.push('\n'),
+ }
+ }
+ s
+ }
+
+ /// A delimiter after `\left`, `\right`, `\middle`, `\big…`.
+ fn delim(&mut self) -> Option {
+ let t = self.peek().cloned()?;
+ self.i += 1;
+ match t {
+ Tok::Ch('.') => None,
+ Tok::Ch(c) => Some(c),
+ Tok::Cmd(c) => match c.as_str() {
+ "{" => Some('{'),
+ "}" => Some('}'),
+ "|" => Some('‖'),
+ other => symbol(other),
+ },
+ _ => None,
+ }
+ }
+
+ fn atom(&mut self) -> Arg {
+ if self.depth > MAX_DEPTH {
+ self.i = self.t.len();
+ return Vec::new();
+ }
+ self.depth += 1;
+ let out = self.atom_inner();
+ self.depth -= 1;
+ out
+ }
+
+ fn atom_inner(&mut self) -> Arg {
+ let Some(t) = self.peek().cloned() else { return Vec::new() };
+ self.i += 1;
+ match t {
+ Tok::Open => {
+ let g = self.seq(true);
+ if self.peek() == Some(&Tok::Close) {
+ self.i += 1;
+ }
+ g
+ }
+ Tok::Ch(c) => vec![run(&c.to_string())],
+ Tok::Close | Tok::Amp | Tok::Row | Tok::Sup | Tok::Sub => Vec::new(),
+ Tok::Cmd(c) => self.command(&c),
+ }
+ }
+
+ fn command(&mut self, c: &str) -> Arg {
+ let styled = |a: Arg, sty: Option, scr: MScr, nor: bool| -> Arg { restyle(a, sty, scr, nor) };
+ let one = |n: MNode| vec![n];
+ match c {
+ "frac" | "dfrac" | "tfrac" | "cfrac" => {
+ let num = self.arg();
+ let den = self.arg();
+ one(MNode::Frac { kind: FracKind::Bar, num, den })
+ }
+ "genfrac" => {
+ let beg = delim_arg(&self.arg());
+ let end = delim_arg(&self.arg());
+ let thickness = self.arg();
+ let _style = self.arg();
+ let num = self.arg();
+ let den = self.arg();
+ let kind = if is_zero_length(&thickness) { FracKind::NoBar } else { FracKind::Bar };
+ let frac = MNode::Frac { kind, num, den };
+ if beg.is_none() && end.is_none() {
+ one(frac)
+ } else {
+ one(MNode::Delim { beg, end, sep: None, grow: true, shp_match: false, elems: vec![vec![frac]] })
+ }
+ }
+ "binom" | "dbinom" | "tbinom" => {
+ let n = self.arg();
+ let k = self.arg();
+ one(MNode::Delim {
+ beg: Some('('),
+ end: Some(')'),
+ sep: None,
+ grow: true,
+ shp_match: false,
+ elems: vec![vec![MNode::Frac { kind: FracKind::NoBar, num: n, den: k }]],
+ })
+ }
+ "sqrt" => {
+ let deg = self.opt().unwrap_or_default();
+ let e = self.arg();
+ one(MNode::Rad { deg_hide: deg.is_empty(), deg, e })
+ }
+ "left" => {
+ let beg = self.delim();
+ let mut elems = vec![self.seq(false)];
+ let mut sep = None;
+ let mut end = None;
+ while let Some(Tok::Cmd(k)) = self.peek().cloned() {
+ self.i += 1;
+ if k == "middle" {
+ sep = self.delim().or(Some('|'));
+ elems.push(self.seq(false));
+ } else {
+ if k == "right" {
+ end = self.delim();
+ }
+ break;
+ }
+ }
+ one(MNode::Delim { beg, end, sep, grow: true, shp_match: false, elems })
+ }
+ "bigl" | "Bigl" | "biggl" | "Biggl" | "bigr" | "Bigr" | "biggr" | "Biggr" | "big" | "Big" | "bigg" | "Bigg" => {
+ self.delim().map(|d| vec![run(&d.to_string())]).unwrap_or_default()
+ }
+ "begin" => {
+ let env = self.raw_arg();
+ self.environment(env.trim())
+ }
+ "text" | "textrm" | "mbox" | "hbox" | "textnormal" => {
+ let t = self.raw_arg();
+ one(MNode::Run(MRun { text: t, nor: true, sty: Some(MSty::Plain), ..Default::default() }))
+ }
+ "textbf" => {
+ let t = self.raw_arg();
+ one(MNode::Run(MRun { text: t, nor: true, sty: Some(MSty::Bold), ..Default::default() }))
+ }
+ "textit" | "emph" => {
+ let t = self.raw_arg();
+ one(MNode::Run(MRun { text: t, nor: true, sty: Some(MSty::Italic), ..Default::default() }))
+ }
+ "textsf" => {
+ let t = self.raw_arg();
+ one(MNode::Run(MRun { text: t, nor: true, sty: Some(MSty::Plain), scr: MScr::SansSerif, ..Default::default() }))
+ }
+ // MathLive's absolute value, upright constants and differential.
+ "abs" => {
+ let e = self.arg();
+ one(MNode::Delim { beg: Some('|'), end: Some('|'), sep: None, grow: true, shp_match: false, elems: vec![e] })
+ }
+ "differentialD" => vec![run("d")],
+ "exponentialE" => vec![MNode::Run(MRun::plain("e"))],
+ "imaginaryI" => vec![MNode::Run(MRun::plain("i"))],
+ "operatorname" => {
+ let starred = self.peek() == Some(&Tok::Ch('*'));
+ if starred {
+ self.i += 1;
+ }
+ let t = self.raw_arg();
+ let name = vec![MNode::Run(MRun::plain(t))];
+ if starred && self.peek() == Some(&Tok::Sub) {
+ self.i += 1;
+ let lim = self.arg();
+ vec![MNode::Lim { upper: false, e: name, lim }]
+ } else {
+ self.apply(name)
+ }
+ }
+ "tag" => {
+ let starred = self.peek() == Some(&Tok::Ch('*'));
+ if starred {
+ self.i += 1;
+ }
+ let t = self.raw_arg();
+ let num_str = if starred { format!("#{t}") } else { format!("# ({t})") };
+ let mut r = MRun::new(num_str);
+ r.lit = false;
+ vec![MNode::Run(r)]
+ }
+ "mathrm" | "rm" => {
+ let a = self.arg();
+ styled(a, Some(MSty::Plain), MScr::Roman, false)
+ }
+ "mathbf" | "bf" => {
+ let a = self.arg();
+ styled(a, Some(MSty::Bold), MScr::Roman, false)
+ }
+ "mathnormal" => self.arg(),
+ "mathit" => {
+ let a = self.arg();
+ styled(a, Some(MSty::Italic), MScr::Roman, false)
+ }
+ "boldsymbol" | "bm" => {
+ let a = self.arg();
+ styled(a, Some(MSty::BoldItalic), MScr::Roman, false)
+ }
+ "mathbb" => {
+ let a = self.arg();
+ styled(a, None, MScr::DoubleStruck, false)
+ }
+ "mathcal" | "mathscr" => {
+ let a = self.arg();
+ styled(a, None, MScr::Script, false)
+ }
+ "mathfrak" => {
+ let a = self.arg();
+ styled(a, None, MScr::Fraktur, false)
+ }
+ "mathsf" => {
+ let a = self.arg();
+ styled(a, None, MScr::SansSerif, false)
+ }
+ "mathtt" => {
+ let a = self.arg();
+ styled(a, None, MScr::Monospace, false)
+ }
+ "overline" => one(MNode::Bar { top: true, e: self.arg() }),
+ "underline" => one(MNode::Bar { top: false, e: self.arg() }),
+ "boxed" | "fbox" => one(MNode::BorderBox { hide: [false; 4], strike: [false; 4], e: self.arg() }),
+ "phantom" => one(MNode::Phant { show: false, zero_wid: false, zero_asc: false, zero_desc: false, e: self.arg() }),
+ "overbrace" | "underbrace" | "overparen" | "underparen" | "overbracket" | "underbracket" => {
+ let top = c.starts_with("over");
+ let chr = match c {
+ "overbrace" => '⏞',
+ "underbrace" => '⏟',
+ "overparen" => '⏜',
+ "underparen" => '⏝',
+ "overbracket" => '⎴',
+ _ => '⎵',
+ };
+ let e = self.arg();
+ let g = MNode::GroupChr { chr, top, e };
+ let want = if top { Tok::Sup } else { Tok::Sub };
+ if self.peek() == Some(&want) {
+ self.i += 1;
+ let lim = self.arg();
+ one(MNode::Lim { upper: top, e: vec![g], lim })
+ } else {
+ one(g)
+ }
+ }
+ "underleftarrow" | "underrightarrow" | "underleftrightarrow" => {
+ let chr = match c {
+ "underleftarrow" => '←',
+ "underrightarrow" => '→',
+ _ => '↔',
+ };
+ one(MNode::GroupChr { chr, top: false, e: self.arg() })
+ }
+ "stackrel" | "overset" => {
+ let over = self.arg();
+ let base = self.arg();
+ one(MNode::Lim { upper: true, e: base, lim: over })
+ }
+ "underset" => {
+ let under = self.arg();
+ let base = self.arg();
+ one(MNode::Lim { upper: false, e: base, lim: under })
+ }
+ "hat" | "widehat" | "tilde" | "widetilde" | "dot" | "ddot" | "dddot" | "check" | "breve" | "acute" | "grave" | "vec" | "bar"
+ | "overrightarrow" | "overleftarrow" | "overleftrightarrow" | "mathring" => {
+ let chr = match c {
+ "hat" | "widehat" => '\u{302}',
+ "tilde" | "widetilde" => '\u{303}',
+ "dot" => '\u{307}',
+ "ddot" => '\u{308}',
+ "dddot" => '\u{20DB}',
+ "check" => '\u{30C}',
+ "breve" => '\u{306}',
+ "acute" => '\u{301}',
+ "grave" => '\u{300}',
+ "bar" => '\u{305}',
+ "overleftarrow" => '\u{20D6}',
+ "overleftrightarrow" => '\u{20E1}',
+ "mathring" => '\u{30A}',
+ _ => '\u{20D7}',
+ };
+ one(MNode::Acc { chr, e: self.arg() })
+ }
+ "," | "thinspace" => vec![run("\u{2009}")],
+ ":" | ">" | "medspace" => vec![run("\u{205F}")],
+ ";" | "thickspace" => vec![run("\u{2005}")],
+ "!" | "negthinspace" => Vec::new(),
+ " " => vec![run(" ")],
+ "qquad" => vec![run("\u{2003}\u{2003}")],
+ "{" | "}" | "%" | "$" | "#" | "&" | "_" => {
+ // Escaped characters (a literal `#` must not number the equation).
+ let mut r = MRun::new(c);
+ r.lit = c == "#";
+ vec![MNode::Run(r)]
+ }
+ "|" => vec![run("‖")],
+ name if FUNCTION_NAMES.contains(&name) => {
+ let plain = vec![MNode::Run(MRun::plain(name))];
+ let limits = matches!(name, "lim" | "liminf" | "limsup" | "max" | "min" | "sup" | "inf" | "det" | "gcd" | "Pr");
+ let named = if limits && self.peek() == Some(&Tok::Sub) {
+ self.i += 1;
+ let lim = self.arg();
+ vec![MNode::Lim { upper: false, e: plain, lim }]
+ } else if matches!(self.peek(), Some(Tok::Sup) | Some(Tok::Sub)) {
+ let sup = self.peek() == Some(&Tok::Sup);
+ self.i += 1;
+ let s = self.arg();
+ vec![script(Some(MNode::Run(MRun::plain(name))), sup, s)]
+ } else {
+ plain
+ };
+ self.apply(named)
+ }
+ name => match symbol(name) {
+ Some(ch) if is_nary_char(ch) => vec![MNode::Nary {
+ chr: ch,
+ lim_loc: None,
+ grow: false,
+ sub_hide: true,
+ sup_hide: true,
+ sub: Vec::new(),
+ sup: Vec::new(),
+ e: Vec::new(),
+ }],
+ Some(ch) => vec![run(&ch.to_string())],
+ None => vec![run(&format!("\\{name}"))],
+ },
+ }
+ }
+
+ /// A function name applied to the next atom (with its scripts).
+ fn apply(&mut self, name: Arg) -> Arg {
+ let mut e = self.atom();
+ while let Some(t @ (Tok::Sup | Tok::Sub)) = self.peek().cloned() {
+ self.i += 1;
+ let s = self.arg();
+ if any_too_deep(&e) {
+ e.extend(s);
+ continue;
+ }
+ let base = if e.len() == 1 { e.pop() } else { Some(group(std::mem::take(&mut e))) };
+ e.push(script(base, t == Tok::Sup, s));
+ }
+ if e.is_empty() {
+ return name;
+ }
+ vec![MNode::Func { name, e }]
+ }
+
+ fn environment(&mut self, env: &str) -> Arg {
+ let base = env.trim_end_matches('*');
+ let mut col_jc = Vec::new();
+ if base == "array" || base == "tabular" {
+ let _ = self.opt();
+ let spec = self.raw_arg();
+ for ch in spec.chars() {
+ match ch {
+ 'l' | 'L' => col_jc.push(ColJc::Left),
+ 'r' | 'R' => col_jc.push(ColJc::Right),
+ 'c' | 'C' => col_jc.push(ColJc::Center),
+ _ => {}
+ }
+ }
+ } else if base == "aligned" {
+ let _ = self.opt();
+ }
+ // Rows of `&`-separated cells up to `\end{…}`.
+ let mut rows: Vec> = vec![vec![]];
+ loop {
+ let cell = self.seq(false);
+ if let Some(r) = rows.last_mut() {
+ r.push(cell);
+ }
+ match self.peek().cloned() {
+ Some(Tok::Amp) => self.i += 1,
+ Some(Tok::Row) => {
+ self.i += 1;
+ rows.push(vec![]);
+ }
+ Some(Tok::Cmd(k)) if k == "end" => {
+ self.i += 1;
+ let _ = self.raw_arg();
+ break;
+ }
+ Some(Tok::Close) => self.i += 1,
+ Some(_) => self.i += 1,
+ None => break,
+ }
+ }
+ // A trailing `\\` leaves an empty last row.
+ if rows.len() > 1 && rows.last().is_some_and(|r| r.iter().all(|c| c.is_empty())) {
+ rows.pop();
+ }
+ let delims = match base {
+ "pmatrix" => Some((Some('('), Some(')'))),
+ "bmatrix" => Some((Some('['), Some(']'))),
+ "Bmatrix" => Some((Some('{'), Some('}'))),
+ "vmatrix" => Some((Some('|'), Some('|'))),
+ "Vmatrix" => Some((Some('‖'), Some('‖'))),
+ _ => None,
+ };
+ let wrap = |inner: MNode, beg: Option, end: Option| MNode::Delim {
+ beg,
+ end,
+ sep: None,
+ grow: true,
+ shp_match: false,
+ elems: vec![vec![inner]],
+ };
+ match base {
+ "matrix" | "pmatrix" | "bmatrix" | "Bmatrix" | "vmatrix" | "Vmatrix" | "smallmatrix" | "array" | "tabular" => {
+ let m = MNode::Matrix { rows, col_jc };
+ match delims {
+ Some((b, e)) => vec![wrap(m, b, e)],
+ None => vec![m],
+ }
+ }
+ "cases" | "dcases" => {
+ let eq = MNode::EqArr { rows: rows.into_iter().map(join_cells).collect() };
+ vec![wrap(eq, Some('{'), None)]
+ }
+ "rcases" => {
+ let eq = MNode::EqArr { rows: rows.into_iter().map(join_cells).collect() };
+ vec![wrap(eq, None, Some('}'))]
+ }
+ _ => vec![MNode::EqArr { rows: rows.into_iter().map(join_cells).collect() }],
+ }
+ }
+}
+
+/// Cells of an aligned row joined with `&` alignment marks.
+fn join_cells(cells: Vec) -> Arg {
+ let mut out = Vec::new();
+ for (i, c) in cells.into_iter().enumerate() {
+ if i > 0 {
+ out.push(run("&"));
+ }
+ out.extend(c);
+ }
+ merge_runs(out)
+}
+
+fn group(a: Arg) -> MNode {
+ MNode::Delim { beg: None, end: None, sep: None, grow: true, shp_match: false, elems: vec![a] }
+}
+
+fn script(base: Option, sup: bool, s: Arg) -> MNode {
+ match base {
+ Some(MNode::Nary { chr, lim_loc, grow, sub_hide, sup_hide, sub, sup: sp, e }) if e.is_empty() => {
+ if sup {
+ MNode::Nary { chr, lim_loc, grow, sub_hide, sup_hide: false, sub, sup: s, e }
+ } else {
+ MNode::Nary { chr, lim_loc, grow, sub_hide: false, sup_hide, sub: s, sup: sp, e }
+ }
+ }
+ Some(MNode::Script { kind: ScriptKind::Sub, base, sub, .. }) if sup => MNode::Script { kind: ScriptKind::SubSup, base, sub, sup: s },
+ Some(MNode::Script { kind: ScriptKind::Sup, base, sup: sp, .. }) if !sup => MNode::Script { kind: ScriptKind::SubSup, base, sub: s, sup: sp },
+ b => {
+ let base = b.map(|b| vec![b]).unwrap_or_default();
+ if sup {
+ MNode::Script { kind: ScriptKind::Sup, base, sub: Vec::new(), sup: s }
+ } else {
+ MNode::Script { kind: ScriptKind::Sub, base, sub: s, sup: Vec::new() }
+ }
+ }
+ }
+}
+
+/// Big operators without an operand take the atoms after them up to a relation or binary operator.
+/// `depth` is how deep the operators already sit; past [`WRAP_DEPTH`] they stay without operand
+/// (`\sum\sum\sum…` must not nest once per operator).
+fn nary_operands(a: Arg, depth: usize) -> Arg {
+ let mut out: Arg = Vec::with_capacity(a.len());
+ let mut it = a.into_iter().peekable();
+ while let Some(n) = it.next() {
+ match n {
+ MNode::Nary { chr, lim_loc, grow, sub_hide, sup_hide, sub, sup, e } if e.is_empty() && depth < WRAP_DEPTH => {
+ let mut body = Vec::new();
+ while let Some(next) = it.peek() {
+ let stop = match next {
+ MNode::Run(r) => r.text.chars().next().is_some_and(|c| {
+ matches!(math_class(c), crate::math::MClass::Rel | crate::math::MClass::Bin | crate::math::MClass::Punct) || c == '&'
+ }),
+ _ => false,
+ };
+ if stop {
+ break;
+ }
+ if let Some(x) = it.next() {
+ body.push(x);
+ }
+ }
+ let body = merge_runs(nary_operands(body, depth + 1));
+ if any_too_deep(&body) {
+ // No room to wrap the operand: it follows the operator.
+ out.push(MNode::Nary { chr, lim_loc, grow, sub_hide, sup_hide, sub, sup, e });
+ out.extend(body);
+ } else {
+ out.push(MNode::Nary { chr, lim_loc, grow, sub_hide, sup_hide, sub, sup, e: body });
+ }
+ }
+ other => out.push(other),
+ }
+ }
+ out
+}
+
+/// Set a style on every run inside.
+fn restyle(a: Arg, sty: Option, scr: MScr, nor: bool) -> Arg {
+ a.into_iter()
+ .map(|n| match n {
+ MNode::Run(mut r) => {
+ if sty.is_some() {
+ r.sty = sty;
+ }
+ if scr != MScr::Roman {
+ r.scr = scr;
+ }
+ r.nor |= nor;
+ MNode::Run(r)
+ }
+ other => map_args(other, &|a| restyle(a, sty, scr, nor)),
+ })
+ .collect()
+}
+
+// ---------------------------------------------------------------------------------------------
+// Out.
+
+/// The equation as LaTeX.
+pub fn to_latex(nodes: &[MNode]) -> String {
+ let mut s = String::new();
+ arg(nodes, &mut s, 0);
+ s.trim().to_string()
+}
+
+fn arg(a: &[MNode], s: &mut String, depth: usize) {
+ if depth > MAX_DEPTH {
+ return;
+ }
+ for n in a {
+ node(n, s, depth + 1);
+ }
+}
+
+fn braced(a: &[MNode], s: &mut String, depth: usize) {
+ s.push('{');
+ arg(a, s, depth);
+ s.push('}');
+}
+
+fn chars(text: &str, s: &mut String) {
+ for c in text.chars() {
+ match c {
+ '{' | '}' | '%' | '$' | '#' | '&' | '_' => {
+ s.push('\\');
+ s.push(c);
+ }
+ '\\' => s.push_str("\\backslash "),
+ '−' => s.push('-'),
+ c if c.is_ascii() => s.push(c),
+ c => match name_of(c) {
+ Some(n) if !matches!(n, "sqrt" | "matrix" | "of" | "above" | "below" | "box" | "rect") => {
+ s.push('\\');
+ s.push_str(n);
+ s.push(' ');
+ }
+ _ => s.push(c),
+ },
+ }
+ }
+}
+
+/// Text inside `\text{…}`: only what LaTeX would read as markup is escaped.
+fn text_chars(text: &str, s: &mut String) {
+ for c in text.chars() {
+ match c {
+ '{' | '}' | '%' | '$' | '#' | '&' | '_' | '~' => {
+ s.push('\\');
+ s.push(c);
+ }
+ // `{}` ends the command name without adding a space (spaces count in text).
+ '\\' => s.push_str("\\backslash{}"),
+ c => s.push(c),
+ }
+ }
+}
+
+/// The delimiter of a `\genfrac` argument: one character, or none for `{}` and anything else.
+fn delim_arg(a: &[MNode]) -> Option {
+ let [MNode::Run(r)] = a else { return None };
+ let mut cs = r.text.chars();
+ match (cs.next(), cs.next()) {
+ (Some(c), None) if c != '.' => Some(c),
+ _ => None,
+ }
+}
+
+/// Whether a `\genfrac` thickness is a zero length such as `0pt`, `0.0pt` or `0 em`. An empty
+/// thickness means the default rule and anything that is not a plain number and unit is not zero.
+fn is_zero_length(a: &[MNode]) -> bool {
+ let mut text = String::new();
+ for n in a {
+ let MNode::Run(r) = n else { return false };
+ text.extend(r.text.chars().filter(|c| !c.is_whitespace() && !matches!(c, '\u{2009}' | '\u{205F}' | '\u{2005}')));
+ }
+ let number = text.trim_end_matches(|c: char| c.is_ascii_alphabetic());
+ let unit = &text[number.len()..];
+ !number.is_empty()
+ && matches!(unit, "pt" | "em" | "ex" | "mm" | "cm" | "in" | "bp" | "pc" | "sp" | "mu")
+ && number.parse::().is_ok_and(|v| v == 0.0)
+}
+
+fn delim_tex(c: Option) -> String {
+ match c {
+ None => ".".into(),
+ Some('{') => "\\{".into(),
+ Some('}') => "\\}".into(),
+ Some('‖') => "\\|".into(),
+ Some(c) if c.is_ascii() => c.to_string(),
+ Some(c) => name_of(c).map(|n| format!("\\{n} ")).unwrap_or_else(|| c.to_string()),
+ }
+}
+
+fn group_chr_tex(chr: char, top: bool) -> &'static str {
+ match chr {
+ '⏜' => "\\overparen",
+ '⏝' => "\\underparen",
+ '⎴' => "\\overbracket",
+ '⎵' => "\\underbracket",
+ _ if top => "\\overbrace",
+ _ => "\\underbrace",
+ }
+}
+
+fn node(node: &MNode, output: &mut String, depth: usize) {
+ match node {
+ MNode::Run(run) => run_node(run, output),
+ MNode::Frac { kind, num, den } => fraction_node(*kind, num, den, output, depth),
+ MNode::Script { kind, base, sub, sup } => script_node(*kind, base, sub, sup, output, depth),
+ MNode::Rad { deg, deg_hide, e } => radical_node(deg, *deg_hide, e, output, depth),
+ MNode::Nary { .. } => nary_node(node, output, depth),
+ MNode::Delim { beg, end, sep, elems, .. } => delimiter_node(*beg, *end, *sep, elems, output, depth),
+ MNode::Func { name, e } => {
+ arg(name, output, depth);
+ braced(e, output, depth);
+ }
+ MNode::Lim { upper, e, lim } => limit_node(*upper, e, lim, output, depth),
+ MNode::Acc { chr, e } => accent_node(*chr, e, output, depth),
+ MNode::Bar { top, e } => {
+ output.push_str(if *top { "\\overline" } else { "\\underline" });
+ braced(e, output, depth);
+ }
+ MNode::BorderBox { e, .. } => {
+ output.push_str("\\boxed");
+ braced(e, output, depth);
+ }
+ MNode::Boxed { e } => braced(e, output, depth),
+ MNode::GroupChr { chr, top, e } => {
+ output.push_str(group_chr_tex(*chr, *top));
+ braced(e, output, depth);
+ }
+ MNode::EqArr { rows } => equation_array_node(rows, output, depth),
+ MNode::Matrix { rows, .. } => matrix_node(rows, output, depth),
+ MNode::Phant { e, .. } => {
+ output.push_str("\\phantom");
+ braced(e, output, depth);
+ }
+ }
+}
+
+fn run_node(run: &MRun, output: &mut String) {
+ let (open, close) = if run.nor {
+ ("\\text{", "}")
+ } else {
+ match (run.sty, run.scr) {
+ (_, MScr::DoubleStruck) => ("\\mathbb{", "}"),
+ (_, MScr::Script) => ("\\mathcal{", "}"),
+ (_, MScr::Fraktur) => ("\\mathfrak{", "}"),
+ (_, MScr::SansSerif) => ("\\mathsf{", "}"),
+ (_, MScr::Monospace) => ("\\mathtt{", "}"),
+ (Some(MSty::Bold), _) => ("\\mathbf{", "}"),
+ (Some(MSty::BoldItalic), _) => ("\\boldsymbol{", "}"),
+ (Some(MSty::Plain), _) if run.text.chars().any(char::is_alphabetic) => {
+ if FUNCTION_NAMES.contains(&run.text.as_str()) {
+ output.push('\\');
+ output.push_str(&run.text);
+ output.push(' ');
+ return;
+ }
+ ("\\mathrm{", "}")
+ }
+ _ => ("", ""),
+ }
+ };
+ output.push_str(open);
+ if run.nor {
+ text_chars(&run.text, output)
+ } else {
+ chars(&run.text, output)
+ }
+ output.push_str(close);
+}
+
+fn fraction_node(kind: FracKind, numerator: &[MNode], denominator: &[MNode], output: &mut String, depth: usize) {
+ match kind {
+ FracKind::NoBar => {
+ output.push_str("\\genfrac{}{}{0pt}{}");
+ braced(numerator, output, depth);
+ braced(denominator, output, depth);
+ }
+ FracKind::Linear | FracKind::Skewed => {
+ braced(numerator, output, depth);
+ output.push('/');
+ braced(denominator, output, depth);
+ }
+ FracKind::Bar => {
+ output.push_str("\\frac");
+ braced(numerator, output, depth);
+ braced(denominator, output, depth);
+ }
+ }
+}
+
+fn script_node(kind: ScriptKind, base: &[MNode], sub: &[MNode], sup: &[MNode], output: &mut String, depth: usize) {
+ if kind == ScriptKind::Pre {
+ output.push_str("{}_");
+ braced(sub, output, depth);
+ output.push('^');
+ braced(sup, output, depth);
+ braced(base, output, depth);
+ return;
+ }
+ braced(base, output, depth);
+ if matches!(kind, ScriptKind::Sub | ScriptKind::SubSup) {
+ output.push('_');
+ braced(sub, output, depth);
+ }
+ if matches!(kind, ScriptKind::Sup | ScriptKind::SubSup) {
+ output.push('^');
+ braced(sup, output, depth);
+ }
+}
+
+fn radical_node(degree: &[MNode], degree_hidden: bool, expression: &[MNode], output: &mut String, depth: usize) {
+ output.push_str("\\sqrt");
+ if !degree_hidden && !degree.is_empty() {
+ output.push('[');
+ arg(degree, output, depth);
+ output.push(']');
+ }
+ braced(expression, output, depth);
+}
+
+fn nary_node(node: &MNode, output: &mut String, depth: usize) {
+ let MNode::Nary { chr, lim_loc, sub_hide, sup_hide, sub, sup, e, .. } = node else { return };
+ match name_of(*chr) {
+ Some(name) => {
+ output.push('\\');
+ output.push_str(name);
+ }
+ None => output.push(*chr),
+ }
+ match lim_loc {
+ Some(crate::math::LimLoc::UndOvr) => output.push_str("\\limits"),
+ Some(crate::math::LimLoc::SubSup) if !crate::math::is_integral(*chr) => output.push_str("\\nolimits"),
+ _ => {}
+ }
+ if !sub_hide && !sub.is_empty() {
+ output.push('_');
+ braced(sub, output, depth);
+ }
+ if !sup_hide && !sup.is_empty() {
+ output.push('^');
+ braced(sup, output, depth);
+ }
+ output.push(' ');
+ braced(e, output, depth);
+}
+
+fn delimiter_node(begin: Option, end: Option, separator: Option, elements: &[Arg], output: &mut String, depth: usize) {
+ output.push_str("\\left");
+ output.push_str(&delim_tex(begin));
+ let middle = separator.map(|c| format!("\\middle{}", delim_tex(Some(c)))).unwrap_or_else(|| "\\middle|".to_string());
+ for (index, element) in elements.iter().enumerate() {
+ if index > 0 {
+ output.push_str(&middle);
+ }
+ arg(element, output, depth);
+ }
+ output.push_str("\\right");
+ output.push_str(&delim_tex(end));
+}
+
+fn limit_node(upper: bool, expression: &[MNode], limit: &[MNode], output: &mut String, depth: usize) {
+ let simple = matches!(expression, [MNode::Run(run)] if run.sty == Some(MSty::Plain) && FUNCTION_NAMES.contains(&run.text.as_str()));
+ if simple && !upper {
+ arg(expression, output, depth);
+ output.push('_');
+ braced(limit, output, depth);
+ } else if let [MNode::GroupChr { chr, top, e: inner }] = expression {
+ output.push_str(group_chr_tex(*chr, *top));
+ braced(inner, output, depth);
+ output.push(if upper { '^' } else { '_' });
+ braced(limit, output, depth);
+ } else {
+ output.push_str(if upper { "\\overset" } else { "\\underset" });
+ braced(limit, output, depth);
+ braced(expression, output, depth);
+ }
+}
+
+fn accent_node(character: char, expression: &[MNode], output: &mut String, depth: usize) {
+ let command = match character {
+ '\u{302}' => "hat",
+ '\u{303}' => "tilde",
+ '\u{307}' => "dot",
+ '\u{308}' => "ddot",
+ '\u{20DB}' => "dddot",
+ '\u{30C}' => "check",
+ '\u{306}' => "breve",
+ '\u{301}' => "acute",
+ '\u{300}' => "grave",
+ '\u{305}' | '\u{AF}' => "bar",
+ '\u{20D6}' => "overleftarrow",
+ '\u{20E1}' => "overleftrightarrow",
+ _ => "vec",
+ };
+ output.push('\\');
+ output.push_str(command);
+ braced(expression, output, depth);
+}
+
+fn equation_array_node(rows: &[Arg], output: &mut String, depth: usize) {
+ if rows.len() == 1
+ && let Some(row) = rows.first()
+ && crate::math::has_number_mark(row)
+ {
+ arg(row, output, depth);
+ return;
+ }
+ output.push_str("\\begin{aligned}");
+ for (index, row) in rows.iter().enumerate() {
+ if index > 0 {
+ output.push_str("\\\\");
+ }
+ for child_node in row {
+ match child_node {
+ MNode::Run(run) if !run.lit && run.text.contains('&') => {
+ for (index, part) in run.text.split('&').enumerate() {
+ if index > 0 {
+ output.push('&');
+ }
+ let mut piece = run.clone();
+ piece.text = part.to_string();
+ run_node(&piece, output);
+ }
+ }
+ other => node(other, output, depth + 1),
+ }
+ }
+ }
+ output.push_str("\\end{aligned}");
+}
+
+fn matrix_node(rows: &[Vec], output: &mut String, depth: usize) {
+ output.push_str("\\begin{matrix}");
+ for (row_index, row) in rows.iter().enumerate() {
+ if row_index > 0 {
+ output.push_str("\\\\");
+ }
+ for (column, cell) in row.iter().enumerate() {
+ if column > 0 {
+ output.push('&');
+ }
+ arg(cell, output, depth);
+ }
+ }
+ output.push_str("\\end{matrix}");
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use crate::math::{LimLoc, MathJc};
+
+ #[test]
+ fn latex_structures() {
+ let n = parse_latex(r"x=\frac{-b\pm\sqrt{b^2-4ac}}{2a}");
+ assert_eq!(crate::math::to_linear(&n), "x=(-b±√(b^2-4ac))/2a");
+ let n = parse_latex(r"\sum_{i=1}^{n} i^2 = \frac{n(n+1)(2n+1)}{6}");
+ let MNode::Nary { chr, sub, sup, e, .. } = &n[0] else { panic!("{n:#?}") };
+ assert_eq!((*chr, sub.len(), sup.len()), ('∑', 1, 1));
+ assert!(matches!(e.first(), Some(MNode::Script { .. })), "{e:#?}");
+ let n = parse_latex(r"\begin{pmatrix} a & b \\ c & d \end{pmatrix}");
+ assert!(
+ matches!(&n[0], MNode::Delim { beg: Some('('), elems, .. } if matches!(elems[0][0], MNode::Matrix { ref rows, .. } if rows.len() == 2))
+ );
+ let n = parse_latex(r"f(x)=\begin{cases} x & x\ge 0\\ -x & x<0\end{cases}");
+ assert!(n.iter().any(|m| matches!(m, MNode::Delim { beg: Some('{'), end: None, .. })), "{n:#?}");
+ let n = parse_latex(r"\lim_{n\to\infty}\left(1+\frac{1}{n}\right)^n");
+ assert!(matches!(&n[0], MNode::Func { .. }), "{n:#?}");
+ let n = parse_latex(r"\mathbb{R}\hat{x}\text{ if }\overbrace{a+b}^{k}");
+ assert!(matches!(&n[0], MNode::Run(r) if r.scr == MScr::DoubleStruck));
+ let _ = (LimLoc::UndOvr, MathJc::Center);
+ }
+
+ #[test]
+ fn latex_binom_round_trip() {
+ let a = parse_latex(r"\binom{n}{k}");
+ let l = to_latex(&a);
+ assert_eq!(parse_latex(&l), a, "exported as {l}");
+ let nobar = |n: &str, k: &str| MNode::Frac { kind: FracKind::NoBar, num: parse_latex(n), den: parse_latex(k) };
+ let b = parse_latex(r"\genfrac{}{}{0pt}{}{n}{k}");
+ assert_eq!(b, vec![nobar("n", "k")]);
+ assert_eq!(parse_latex(&to_latex(&b)), b);
+ // Other spellings of a zero thickness.
+ for z in ["0 pt", "0.0pt", r"0\,pt", "0em"] {
+ let c = parse_latex(&format!(r"\genfrac{{}}{{}}{{{z}}}{{}}{{n}}{{k}}"));
+ assert_eq!(c, vec![nobar("n", "k")], "thickness {z}");
+ }
+ // A non-zero or default thickness keeps the bar.
+ for t in ["1pt", "0.4pt", ""] {
+ let c = parse_latex(&format!(r"\genfrac{{}}{{}}{{{t}}}{{}}{{n}}{{k}}"));
+ let bar = MNode::Frac { kind: FracKind::Bar, num: parse_latex("n"), den: parse_latex("k") };
+ assert_eq!(c, vec![bar], "thickness {t:?}");
+ }
+ // Delimiters are kept.
+ let d = parse_latex(r"\genfrac(){0pt}{}{n}{k}");
+ assert_eq!(d, a);
+ let e = parse_latex(r"\genfrac[]{0pt}{}{n}{k}");
+ assert!(matches!(&e[..], [MNode::Delim { beg: Some('['), end: Some(']'), .. }]), "{e:#?}");
+ assert_eq!(parse_latex(&to_latex(&e)), e);
+ }
+
+ #[test]
+ fn latex_round_trip() {
+ for lin in ["x=(-b±√(b^2-4ac))/2a", "∑_(i=1)^n▒i^2", "√(3&x)", "a⃗+x̂", "■(a&b@c&d)", "sinx+cosy", "e^(-x^2)"] {
+ let n = crate::math::parse_linear(lin);
+ let tex = to_latex(&n);
+ let back = parse_latex(&tex);
+ assert_eq!(crate::math::to_linear(&back), crate::math::to_linear(&n), "{lin} → {tex}");
+ }
+ }
+
+ #[test]
+ fn group_character_latex_roundtrip() {
+ for cmd in ["\\overparen{a+b}", "\\underparen{a+b}", "\\overbracket{x}", "\\underbracket{x}"] {
+ let n = parse_latex(cmd);
+ let tex = to_latex(&n);
+ assert_eq!(tex, cmd);
+ }
+ }
+
+ #[test]
+ fn hostile_latex_never_panics() {
+ for s in ["", "\\", "{", "}", "^", "_", "\\frac", "\\sqrt[", "\\left(", "\\right)", "\\begin{matrix", "&&\\\\", "{{{{", "\\end{x}", "a^^b__c"]
+ {
+ let _ = to_latex(&parse_latex(s));
+ }
+ let deep = "{".repeat(5000);
+ let _ = parse_latex(&deep);
+ let deep = "\\frac{".repeat(3000);
+ let _ = parse_latex(&deep);
+ let deep = "\\sqrt".repeat(5000);
+ let _ = parse_latex(&deep);
+ let deep = "\\hat".repeat(5000) + "x";
+ let _ = parse_latex(&deep);
+ }
+
+ #[test]
+ fn array_column_specs_and_aligned_optional_args() {
+ let n = parse_latex(r"\begin{array}{lcr} a & b & c \\ d & e & f \end{array}");
+ let MNode::Matrix { rows, col_jc } = &n[0] else { panic!("{n:#?}") };
+ assert_eq!(col_jc, &[ColJc::Left, ColJc::Center, ColJc::Right]);
+ assert_eq!(rows.len(), 2);
+ assert_eq!(crate::math::to_linear(&rows[0][0]), "a");
+ assert_eq!(crate::math::to_linear(&rows[0][1]), "b");
+ assert_eq!(crate::math::to_linear(&rows[0][2]), "c");
+
+ let n = parse_latex(r"\begin{aligned}[t] x &= 1 \\ y &= 2 \end{aligned}");
+ assert!(matches!(&n[0], MNode::EqArr { rows } if rows.len() == 2));
+ }
+
+ #[test]
+ fn operatorname_starred_and_tag() {
+ let n = parse_latex(r"\operatorname*{argmin}_{x} f(x)");
+ assert!(matches!(&n[0], MNode::Lim { upper: false, .. }));
+
+ let n = parse_latex(r"E=mc^2 \tag{1}");
+ let MNode::EqArr { rows } = &n[0] else { panic!("{n:#?}") };
+ assert!(crate::math::has_number_mark(&rows[0]));
+
+ let n = parse_latex(r"E=mc^2 \tag*{(A)}");
+ let MNode::EqArr { rows } = &n[0] else { panic!("{n:#?}") };
+ assert!(crate::math::has_number_mark(&rows[0]));
+ }
+
+ #[test]
+ fn delim_preserves_custom_separator() {
+ let delim = MNode::Delim {
+ beg: Some('{'),
+ end: Some('}'),
+ sep: Some('‖'),
+ grow: true,
+ shp_match: false,
+ elems: vec![vec![MNode::Run(MRun::new("x"))], vec![MNode::Run(MRun::new("y"))]],
+ };
+ let tex = to_latex(&[delim]);
+ assert!(tex.contains(r"\middle\|"), "Expected \\middle\\|, got: {tex}");
+ }
+
+ /// Commands MathLive and typed input use, from equaSHion (storytold/craft-libs#29).
+ #[test]
+ fn mathlive_and_other_spellings() {
+ let lin = |src: &str| crate::math::to_linear(&parse_latex(src));
+ assert_eq!(lin(r"\abs{x-1}"), lin(r"\left|x-1\right|"));
+ assert_eq!(lin(r"\lvert x\rvert+\lVert v\rVert"), "|x|+‖v‖");
+ assert_eq!(lin(r"a\nparallel b \backprime"), "a∦b‵");
+ assert_eq!(lin(r"\infinity\cross 2"), "∞×2");
+ assert_eq!(lin(r"\mathnormal{x}"), "x");
+ let runs = parse_latex(r"\exponentialE^{\imaginaryI\pi}\differentialD x");
+ assert!(
+ matches!(&runs[0], MNode::Script { base, .. } if matches!(&base[0], MNode::Run(r) if r.text == "e" && r.sty == Some(MSty::Plain))),
+ "{runs:?}"
+ );
+ for (src, sty, scr) in [
+ (r"\emph{so it is}", MSty::Italic, MScr::Roman),
+ (r"\textsf{so it is}", MSty::Plain, MScr::SansSerif),
+ (r"\hbox{so it is}", MSty::Plain, MScr::Roman),
+ ] {
+ let a = parse_latex(src);
+ assert!(matches!(a.as_slice(), [MNode::Run(r)] if r.text == "so it is" && r.nor && r.sty == Some(sty) && r.scr == scr), "{src}: {a:?}");
+ }
+ for (src, want) in [(r"\underrightarrow{AB}", '→'), (r"\underleftarrow{AB}", '←'), (r"\underleftrightarrow{AB}", '↔')] {
+ assert!(matches!(parse_latex(src).as_slice(), [MNode::GroupChr { chr, top: false, .. }] if *chr == want), "{src}");
+ }
+ // The writer keeps its own names.
+ assert_eq!(to_latex(&parse_latex(r"\infinity")), to_latex(&parse_latex(r"\infty")));
+ }
+}
diff --git a/crates/math/src/math_linear.rs b/crates/math/src/math_linear.rs
new file mode 100644
index 000000000..39a25ab5d
--- /dev/null
+++ b/crates/math/src/math_linear.rs
@@ -0,0 +1,762 @@
+//! The linear format (UnicodeMath, the plain text Word builds equations from) → math tree.
+//!
+//! Grammar, informally: an expression is a sequence of atoms and operators. Atoms are characters,
+//! bracket groups (`(…)`, `[…]`, `{…}`, `|…|`, `〖…〗` invisible, `├…┤` one-sided, `│`
+//! separators), prefix structures (`√x`, `∑_a^b▒x`, `█(…)`, `■(…)`, `▭x`, `□x`, `¯x`, `▁x`,
+//! `⏞x`, `⏟x`, `⟡x`), function names (`sin x`, `log_2 x`, `lim┬(n→∞) x`) and placeholders (`⬚`).
+//! Postfix operators apply to the atom before them: `^` `_` scripts, combining accents, `┬` `┴`
+//! limits. `/` `⁄` `∕` `¦` take the operands on either side (an operand is a run of atoms up to
+//! an operator or space; a parenthesised operand loses its parentheses). `^`/`_` with nothing
+//! before them are pre-scripts. A top-level `#` numbers the equation (`E=mc^2#(1)`).
+
+use crate::math::{Arg, FracKind, MAX_DEPTH, MClass, MNode, MRun, ScriptKind, any_too_deep, is_nary_char, math_class, merge_runs, too_deep};
+
+/// Function names set upright and applied to the following operand.
+pub const FUNCTION_NAMES: &[&str] = &[
+ "arccos", "arccot", "arccsc", "arcsec", "arcsin", "arctan", "arg", "cos", "cosh", "cot", "coth", "csc", "csch", "deg", "det", "dim", "exp",
+ "gcd", "hom", "inf", "ker", "lg", "lim", "liminf", "limsup", "ln", "log", "max", "min", "mod", "Pr", "sec", "sech", "sin", "sinh", "sup", "tan",
+ "tanh",
+];
+
+/// Length of the longest of [`FUNCTION_NAMES`].
+const MAX_NAME: usize = {
+ let (mut max, mut i) = (0, 0);
+ while i < FUNCTION_NAMES.len() {
+ if FUNCTION_NAMES[i].len() > max {
+ max = FUNCTION_NAMES[i].len();
+ }
+ i += 1;
+ }
+ max
+};
+
+/// Function names whose subscripts go underneath (`lim_(n→∞)`).
+const LIMIT_NAMES: &[&str] = &["lim", "liminf", "limsup", "max", "min", "sup", "inf", "det", "gcd", "Pr"];
+
+/// The empty-argument placeholder.
+pub const PLACEHOLDER: char = '⬚';
+
+/// Parse linear-format text. Never fails: what isn't understood stays as text.
+pub fn parse(s: &str) -> Arg {
+ let mut p = P { c: s.chars().take(20_000).collect(), i: 0, depth: 0, no_limits: 0, steps: 0 };
+ let mut out = Vec::new();
+ // Stray closers at the top level are text.
+ while p.i < p.c.len() {
+ out.extend(p.expr(&[]));
+ if let Some(c) = p.peek() {
+ p.i += 1;
+ out.push(text(c));
+ }
+ }
+ let mut out = clean(out);
+ // `expr#(n)`: a numbered equation.
+ if out.iter().any(|n| matches!(n, MNode::Run(r) if r.text.contains('#'))) {
+ out = vec![MNode::EqArr { rows: vec![out] }];
+ }
+ out
+}
+
+fn text(c: char) -> MNode {
+ MNode::Run(MRun::new(c.to_string()))
+}
+
+/// Closing bracket for an opening one.
+pub fn closer(c: char) -> Option {
+ Some(match c {
+ '(' => ')',
+ '[' => ']',
+ '{' => '}',
+ '⟨' => '⟩',
+ '〈' => '〉',
+ '⌈' => '⌉',
+ '⌊' => '⌋',
+ '⟦' => '⟧',
+ '〖' => '〗',
+ _ => return None,
+ })
+}
+
+fn is_closer(c: char) -> bool {
+ matches!(c, ')' | ']' | '}' | '⟩' | '〉' | '⌉' | '⌋' | '⟧' | '〗' | '┤')
+}
+
+/// Prefix characters that make a structure from the operand after them.
+fn is_prefix(c: char) -> bool {
+ matches!(c, '√' | '∛' | '∜' | '█' | '■' | '▭' | '□' | '¯' | '▁' | '⟡' | '⏞' | '⏟' | '⏜' | '⏝' | '⎴' | '⎵') || is_nary_char(c)
+}
+
+/// Combining marks used as accents.
+pub fn is_combining(c: char) -> bool {
+ matches!(c as u32, 0x300..=0x36F | 0x20D0..=0x20FF)
+}
+
+/// Characters that end an operand.
+fn is_boundary(c: char) -> bool {
+ matches!(c, ' ' | '&' | '@' | '#' | '▒' | '│' | '/' | '⁄' | '∕' | '¦' | '\u{2061}')
+ || is_closer(c)
+ || matches!(math_class(c), MClass::Bin | MClass::Rel | MClass::Punct)
+}
+
+struct P {
+ c: Vec,
+ i: usize,
+ depth: usize,
+ /// Inside a brace's operand: limits are left for the brace.
+ no_limits: usize,
+ /// Work done (bounds hostile input).
+ steps: usize,
+}
+
+const MAX_STEPS: usize = 400_000;
+
+impl P {
+ fn peek(&self) -> Option {
+ self.c.get(self.i).copied()
+ }
+ fn peek_at(&self, k: usize) -> Option {
+ self.c.get(self.i + k).copied()
+ }
+ fn tired(&mut self) -> bool {
+ self.steps += 1;
+ self.steps > MAX_STEPS || self.depth > MAX_DEPTH
+ }
+ /// The rest of the input as text (when too deep or too long).
+ fn rest(&mut self) -> Arg {
+ let t: String = self.c.get(self.i..).unwrap_or(&[]).iter().collect();
+ self.i = self.c.len();
+ if t.is_empty() { Vec::new() } else { vec![MNode::Run(MRun::new(t))] }
+ }
+
+ /// A sequence up to one of `stops` (not consumed) or the end.
+ fn expr(&mut self, stops: &[char]) -> Arg {
+ self.depth += 1;
+ let mut out: Arg = Vec::new();
+ if self.tired() {
+ self.depth -= 1;
+ return self.rest();
+ }
+ while let Some(c) = self.peek() {
+ if stops.contains(&c) || (is_closer(c) && !stops.is_empty()) {
+ break;
+ }
+ if self.tired() {
+ out.extend(self.rest());
+ break;
+ }
+ match c {
+ '/' | '⁄' | '∕' | '¦' => {
+ self.i += 1;
+ let start = operand_start(&out);
+ // A numerator that is already deep stays as it is: `/` is text then.
+ if out.get(start..).is_some_and(any_too_deep) {
+ out.push(text(c));
+ continue;
+ }
+ let num = strip_parens(out.drain(start..).collect());
+ let den = self.operand(stops);
+ let kind = match c {
+ '⁄' => FracKind::Skewed,
+ '∕' => FracKind::Linear,
+ '¦' => FracKind::NoBar,
+ _ => FracKind::Bar,
+ };
+ out.push(MNode::Frac { kind, num, den });
+ }
+ '^' | '_' => {
+ let pre = match out.last() {
+ None => true,
+ Some(MNode::Run(r)) => r.text == " " || r.text.chars().all(|c| is_boundary(c) && c != '#'),
+ _ => false,
+ };
+ if pre {
+ out.push(self.prescript(stops));
+ } else {
+ self.i += 1;
+ let s = self.script_operand(stops);
+ if out.last().is_some_and(too_deep) {
+ out.push(text(c));
+ out.extend(s);
+ } else {
+ let base = out.pop();
+ out.push(attach_script(base, c == '^', s));
+ }
+ }
+ }
+ '┬' | '┴' => {
+ self.i += 1;
+ let lim = self.script_operand(stops);
+ if out.last().is_some_and(too_deep) {
+ out.push(text(c));
+ out.extend(lim);
+ } else {
+ let e = out.pop().map(|n| vec![n]).unwrap_or_default();
+ out.push(MNode::Lim { upper: c == '┴', e: strip_parens(e), lim });
+ }
+ }
+ c if is_combining(c) => {
+ self.i += 1;
+ if out.last().is_some_and(too_deep) {
+ out.push(text(c));
+ } else {
+ let e = out.pop().map(|n| strip_parens(vec![n])).unwrap_or_default();
+ out.push(MNode::Acc { chr: c, e });
+ }
+ }
+ '▒' | '\u{2061}' => self.i += 1,
+ ' ' => {
+ self.i += 1;
+ out.push(text(' '));
+ }
+ _ => {
+ let atom = self.atom(stops);
+ out.extend(atom);
+ }
+ }
+ }
+ self.depth -= 1;
+ out
+ }
+
+ /// One atom (a character, a bracket group, a structure or a function application).
+ fn atom(&mut self, stops: &[char]) -> Arg {
+ let Some(c) = self.peek() else { return Vec::new() };
+ if let Some(close) = closer(c) {
+ self.i += 1;
+ return vec![self.group(Some(c), &[close])];
+ }
+ if c == '├' {
+ self.i += 1;
+ if let Some(d) = self.explicit_group() {
+ return vec![d];
+ }
+ return vec![self.group(None, &[')', ']', '}', '⟩', '⌉', '⌋', '|', '‖'])];
+ }
+ if (c == '|' || c == '‖') && self.c.get(self.i + 1..).is_some_and(|r| r.contains(&c)) {
+ self.i += 1;
+ return vec![self.group(Some(c), &[c])];
+ }
+ if is_prefix(c) {
+ self.i += 1;
+ return vec![self.prefix(c, stops)];
+ }
+ if c.is_ascii_alphabetic() {
+ return self.word(stops);
+ }
+ self.i += 1;
+ vec![text(c)]
+ }
+
+ /// A bracket group after its opening character; `closers` end it.
+ fn group(&mut self, open: Option, closers: &[char]) -> MNode {
+ let mut stops: Vec = closers.to_vec();
+ stops.extend(['┤', '│']);
+ let mut elems = Vec::new();
+ let mut end = None;
+ loop {
+ let e = self.expr(&stops);
+ elems.push(e);
+ match self.peek() {
+ Some('│') => self.i += 1,
+ Some('┤') => {
+ self.i += 1;
+ break;
+ }
+ Some(c) if closers.contains(&c) => {
+ self.i += 1;
+ end = Some(c);
+ break;
+ }
+ // Another closer: the group ends unclosed there.
+ _ => break,
+ }
+ if self.tired() {
+ break;
+ }
+ }
+ let beg = open.filter(|c| *c != '〖');
+ let end = end.filter(|c| *c != '〗');
+ let sep = if elems.len() > 1 { Some('|') } else { None };
+ MNode::Delim { beg, end, sep, grow: true, shp_match: false, elems }
+ }
+
+ /// `├X … Y┤`: brackets given explicitly (any bracket characters, either side optional), the
+ /// cursor just after `├`. `None` (cursor unchanged) when there's no matching `┤`.
+ fn explicit_group(&mut self) -> Option {
+ let mut level = 1usize;
+ let mut k = self.i;
+ while let Some(c) = self.c.get(k) {
+ match c {
+ '├' => level += 1,
+ '┤' => {
+ level -= 1;
+ if level == 0 {
+ break;
+ }
+ }
+ _ => {}
+ }
+ k += 1;
+ }
+ if level != 0 {
+ return None;
+ }
+ let bracket = |c: &char| matches!(c, '(' | ')' | '[' | ']' | '{' | '}' | '⟨' | '⟩' | '⌈' | '⌉' | '⌊' | '⌋' | '|' | '‖' | '⟦' | '⟧');
+ let mut inner: Vec = self.c.get(self.i..k).unwrap_or(&[]).to_vec();
+ self.i = k + 1;
+ let beg = if inner.first().is_some_and(bracket) { Some(inner.remove(0)) } else { None };
+ // A closing bracket at the end that pairs with an opening one inside is content.
+ let paired = |inner: &[char]| -> bool {
+ let Some((last, body)) = inner.split_last() else { return false };
+ let opener = match last {
+ ')' => '(',
+ ']' => '[',
+ '}' => '{',
+ '⟩' => '⟨',
+ '⌉' => '⌈',
+ '⌋' => '⌊',
+ '⟧' => '⟦',
+ _ => return false,
+ };
+ body.iter().filter(|c| **c == opener).count() > body.iter().filter(|c| *c == last).count()
+ };
+ let end = if !inner.is_empty() && inner.last().is_some_and(bracket) && !paired(&inner) { inner.pop() } else { None };
+ let mut sub = P { c: inner, i: 0, depth: self.depth + 1, no_limits: 0, steps: self.steps };
+ let mut elems = vec![Vec::new()];
+ while sub.i < sub.c.len() {
+ if let Some(e) = elems.last_mut() {
+ e.extend(sub.expr(&['│']));
+ }
+ match sub.peek() {
+ Some('│') => {
+ sub.i += 1;
+ elems.push(Vec::new());
+ }
+ Some(c) => {
+ sub.i += 1;
+ if let Some(e) = elems.last_mut() {
+ e.push(text(c));
+ }
+ }
+ None => {}
+ }
+ if sub.tired() {
+ break;
+ }
+ }
+ self.steps = sub.steps;
+ let sep = if elems.len() > 1 { Some('|') } else { None };
+ Some(MNode::Delim { beg, end, sep, grow: true, shp_match: false, elems })
+ }
+
+ /// A prefix structure after its character.
+ fn prefix(&mut self, c: char, stops: &[char]) -> MNode {
+ match c {
+ '√'..='∜' => {
+ let mut deg = match c {
+ '∛' => vec![text('3')],
+ '∜' => vec![text('4')],
+ _ => Vec::new(),
+ };
+ let mut e = self.operand(stops);
+ if let Some(k) = e.iter().position(|n| matches!(n, MNode::Run(r) if r.text == "&")) {
+ let rest = e.split_off(k + 1);
+ e.pop();
+ deg = e;
+ e = rest;
+ }
+ MNode::Rad { deg_hide: deg.is_empty(), deg, e }
+ }
+ '█' | '■' => {
+ if self.peek() == Some('(') {
+ self.i += 1;
+ let inner = self.expr(&[')']);
+ if self.peek() == Some(')') {
+ self.i += 1;
+ }
+ grid(c == '■', inner)
+ } else {
+ text(c)
+ }
+ }
+ '▭' => MNode::BorderBox { hide: [false; 4], strike: [false; 4], e: self.operand(stops) },
+ '□' => MNode::Boxed { e: self.operand(stops) },
+ '¯' => MNode::Bar { top: true, e: self.operand(stops) },
+ '▁' => MNode::Bar { top: false, e: self.operand(stops) },
+ '⟡' => MNode::Phant { show: false, zero_wid: false, zero_asc: false, zero_desc: false, e: self.operand(stops) },
+ // A label (`┴`/`┬`) after the operand belongs to the whole brace.
+ '⏞' | '⏜' | '⎴' | '⏟' | '⏝' | '⎵' => {
+ self.no_limits += 1;
+ let e = self.operand(stops);
+ self.no_limits -= 1;
+ MNode::GroupChr { chr: c, top: matches!(c, '⏞' | '⏜' | '⎴'), e }
+ }
+ _ => {
+ // N-ary operator: limits, then the operand.
+ let (mut sub, mut sup) = (Vec::new(), Vec::new());
+ let (mut has_sub, mut has_sup) = (false, false);
+ loop {
+ match self.peek() {
+ Some('_') => {
+ self.i += 1;
+ sub = self.script_operand(stops);
+ has_sub = true;
+ }
+ Some('^') => {
+ self.i += 1;
+ sup = self.script_operand(stops);
+ has_sup = true;
+ }
+ _ => break,
+ }
+ }
+ if self.peek() == Some('▒') {
+ self.i += 1;
+ }
+ let e = if self.peek().is_some_and(|c| c == ' ') { Vec::new() } else { self.operand(stops) };
+ MNode::Nary { chr: c, lim_loc: None, grow: false, sub_hide: !has_sub, sup_hide: !has_sup, sub, sup, e }
+ }
+ }
+ }
+
+ /// Letters: a function application when they spell a function name, else one letter.
+ fn word(&mut self, stops: &[char]) -> Arg {
+ let start = self.i;
+ let mut end = start;
+ // Look no further than the longest name (and one past it, to see the run is longer): a
+ // scan to the end of every run made a long run of letters quadratic (CWE-407).
+ while end - start <= MAX_NAME && self.c.get(end).is_some_and(char::is_ascii_alphabetic) {
+ end += 1;
+ }
+ let word: String = if end - start > MAX_NAME { String::new() } else { self.c.get(start..end).unwrap_or(&[]).iter().collect() };
+ if !FUNCTION_NAMES.contains(&word.as_str()) {
+ self.i = start + 1;
+ return vec![text(self.c.get(start).copied().unwrap_or(' '))];
+ }
+ self.i = end;
+ let plain = vec![MNode::Run(MRun::plain(word.clone()))];
+ // Limits and scripts on the name.
+ let name = match self.peek() {
+ Some('┬') | Some('┴') => {
+ let upper = self.peek() == Some('┴');
+ self.i += 1;
+ let lim = self.script_operand(stops);
+ vec![MNode::Lim { upper, e: plain, lim }]
+ }
+ Some('_') if LIMIT_NAMES.contains(&word.as_str()) => {
+ self.i += 1;
+ let lim = self.script_operand(stops);
+ vec![MNode::Lim { upper: false, e: plain, lim }]
+ }
+ Some(c @ ('^' | '_')) => {
+ self.i += 1;
+ let s = self.script_operand(stops);
+ let mut n = attach_script(Some(MNode::Run(MRun::plain(word.clone()))), c == '^', s);
+ if let Some(c2 @ ('^' | '_')) = self.peek() {
+ self.i += 1;
+ let s2 = self.script_operand(stops);
+ n = attach_script(Some(n), c2 == '^', s2);
+ }
+ vec![n]
+ }
+ _ => plain,
+ };
+ // The operand, after an optional space or function-application mark.
+ if matches!(self.peek(), Some(' ') | Some('\u{2061}') | Some('▒')) {
+ self.i += 1;
+ }
+ let next = self.peek();
+ let applies = next.is_some_and(|c| !stops.contains(&c) && !is_closer(c) && (!is_boundary(c) || c == '(' || matches!(c, '−' | '-' | '+')));
+ if !applies {
+ return name;
+ }
+ let e = self.operand(stops);
+ if e.is_empty() {
+ return name;
+ }
+ vec![MNode::Func { name, e }]
+ }
+
+ /// Pre-scripts (`_a^b x`): the scripts, then the base.
+ fn prescript(&mut self, stops: &[char]) -> MNode {
+ let (mut sub, mut sup) = (Vec::new(), Vec::new());
+ while let Some(c @ ('^' | '_')) = self.peek() {
+ self.i += 1;
+ let s = self.script_operand(stops);
+ if c == '^' {
+ sup = s;
+ } else {
+ sub = s;
+ }
+ }
+ if self.peek() == Some('▒') {
+ self.i += 1;
+ }
+ let base = self.operand(stops);
+ MNode::Script { kind: ScriptKind::Pre, base, sub, sup }
+ }
+
+ /// An operand: a parenthesised group (parentheses dropped) or a run of atoms with their
+ /// postfix scripts, accents and limits, up to an operator or space.
+ fn operand(&mut self, stops: &[char]) -> Arg {
+ if self.tired() {
+ return self.rest();
+ }
+ self.depth += 1;
+ let mut out: Arg = Vec::new();
+ if self.peek() == Some('(') {
+ self.i += 1;
+ let g = self.group(Some('('), &[')']);
+ let inner = match g {
+ MNode::Delim { elems, end: Some(')'), .. } if elems.len() == 1 => elems.into_iter().next().unwrap_or_default(),
+ other => vec![other],
+ };
+ out = inner;
+ self.postfix(&mut out, stops, true);
+ } else if self.peek() == Some('〖') {
+ self.i += 1;
+ let g = self.group(Some('〖'), &['〗']);
+ out = match g {
+ MNode::Delim { elems, .. } if elems.len() == 1 => elems.into_iter().next().unwrap_or_default(),
+ other => vec![other],
+ };
+ self.postfix(&mut out, stops, true);
+ } else {
+ // A signed operand (`e^-x`, `−b`).
+ if let Some(c @ ('−' | '-' | '+' | '±' | '∓')) = self.peek()
+ && self.peek_at(1).is_some_and(|n| !is_boundary(n))
+ {
+ self.i += 1;
+ out.push(text(c));
+ }
+ while let Some(c) = self.peek() {
+ if stops.contains(&c) || is_boundary(c) || self.tired() {
+ break;
+ }
+ let a = self.atom(stops);
+ if a.is_empty() {
+ break;
+ }
+ out.extend(a);
+ self.postfix(&mut out, stops, false);
+ }
+ if out.is_empty()
+ && let Some(c) = self.peek()
+ && c != ' '
+ && !stops.contains(&c)
+ && !is_closer(c)
+ && !matches!(c, '/' | '⁄' | '∕' | '¦' | '&' | '@' | '#' | '│' | '▒')
+ {
+ // A lone operator as operand.
+ self.i += 1;
+ out.push(text(c));
+ }
+ }
+ self.depth -= 1;
+ out
+ }
+
+ /// Postfix operators on the last atom of `out` (`whole`: on all of `out` as one group).
+ fn postfix(&mut self, out: &mut Arg, stops: &[char], whole: bool) {
+ while let Some(c) = self.peek() {
+ if self.tired() {
+ break;
+ }
+ let base = |out: &mut Arg| -> Option {
+ if whole && out.len() != 1 {
+ if out.is_empty() {
+ return None;
+ }
+ return Some(MNode::Delim { beg: None, end: None, sep: None, grow: true, shp_match: false, elems: vec![std::mem::take(out)] });
+ }
+ out.pop()
+ };
+ if !(matches!(c, '^' | '_') || matches!(c, '┬' | '┴') && self.no_limits == 0 || is_combining(c)) {
+ break;
+ }
+ // The base is already deep: the operator is text (see `too_deep`).
+ let deep = if whole { any_too_deep(out) } else { out.last().is_some_and(too_deep) };
+ self.i += 1;
+ if deep {
+ let s = if matches!(c, '^' | '_' | '┬' | '┴') { self.script_operand(stops) } else { Vec::new() };
+ out.push(text(c));
+ out.extend(s);
+ continue;
+ }
+ match c {
+ '^' | '_' => {
+ let s = self.script_operand(stops);
+ let b = base(out);
+ out.push(attach_script(b, c == '^', s));
+ }
+ '┬' | '┴' => {
+ let lim = self.script_operand(stops);
+ let b = base(out);
+ out.push(MNode::Lim { upper: c == '┴', e: b.map(|n| vec![n]).unwrap_or_default(), lim });
+ }
+ _ => {
+ let b = base(out);
+ out.push(MNode::Acc { chr: c, e: b.map(|n| strip_parens(vec![n])).unwrap_or_default() });
+ }
+ }
+ }
+ }
+
+ /// A script or limit: a parenthesised group, a structure, a number, a signed operand, a run of
+ /// letters (and digits) or one other character.
+ fn script_operand(&mut self, stops: &[char]) -> Arg {
+ if self.tired() {
+ return self.rest();
+ }
+ self.depth += 1;
+ let out = match self.peek() {
+ Some('(') => {
+ self.i += 1;
+ match self.group(Some('('), &[')']) {
+ MNode::Delim { elems, end: Some(')'), .. } if elems.len() == 1 => elems.into_iter().next().unwrap_or_default(),
+ other => vec![other],
+ }
+ }
+ Some('〖') => {
+ self.i += 1;
+ match self.group(Some('〖'), &['〗']) {
+ MNode::Delim { elems, .. } if elems.len() == 1 => elems.into_iter().next().unwrap_or_default(),
+ other => vec![other],
+ }
+ }
+ Some(c) if is_prefix(c) || closer(c).is_some() => self.atom(stops),
+ Some(c) if c.is_ascii_digit() => {
+ let start = self.i;
+ while self.peek().is_some_and(|k| k.is_alphanumeric() || k == '.') {
+ self.i += 1;
+ }
+ let t: String = self.c.get(start..self.i).unwrap_or(&[]).iter().collect();
+ vec![MNode::Run(MRun::new(t))]
+ }
+ Some(c @ ('−' | '-' | '+' | '±' | '∓')) if self.peek_at(1).is_some_and(|n| !is_boundary(n)) => {
+ self.i += 1;
+ let mut v = vec![text(c)];
+ v.extend(self.script_operand(stops));
+ v
+ }
+ Some(c) if c.is_alphabetic() => {
+ // Word (#1609): the whole run of letters and digits up to a space or operator, so
+ // `V_dd` is V with a subscript `dd`, exactly like `V_(dd)`.
+ let start = self.i;
+ while self.peek().is_some_and(char::is_alphanumeric) {
+ self.i += 1;
+ }
+ let t: String = self.c.get(start..self.i).unwrap_or(&[]).iter().collect();
+ // A known function name stays upright, as Word builds it.
+ vec![MNode::Run(if FUNCTION_NAMES.contains(&t.as_str()) { MRun::plain(t) } else { MRun::new(t) })]
+ }
+ Some(c) if !stops.contains(&c) && c != ' ' && !is_closer(c) => {
+ self.i += 1;
+ vec![text(c)]
+ }
+ _ => Vec::new(),
+ };
+ self.depth -= 1;
+ out
+ }
+}
+
+/// Where the trailing operand of `out` starts (after the last operator or space).
+fn operand_start(out: &[MNode]) -> usize {
+ let mut k = out.len();
+ while k > 0 {
+ match out.get(k - 1) {
+ Some(MNode::Run(r)) if r.text.chars().all(is_boundary) => break,
+ _ => k -= 1,
+ }
+ }
+ k
+}
+
+/// A single parenthesised group used as an operand loses its parentheses.
+pub fn strip_parens(mut a: Arg) -> Arg {
+ if a.len() == 1
+ && let Some(MNode::Delim { beg: Some('('), end: Some(')'), elems, .. } | MNode::Delim { beg: None, end: None, elems, .. }) = a.first_mut()
+ && elems.len() == 1
+ {
+ return elems.pop().unwrap_or_default();
+ }
+ a
+}
+
+fn attach_script(base: Option, sup: bool, s: Arg) -> MNode {
+ match base {
+ Some(MNode::Script { kind: ScriptKind::Sub, base, sub, .. }) if sup => MNode::Script { kind: ScriptKind::SubSup, base, sub, sup: s },
+ Some(MNode::Script { kind: ScriptKind::Sup, base, sup: sp, .. }) if !sup => MNode::Script { kind: ScriptKind::SubSup, base, sub: s, sup: sp },
+ b => {
+ let base = b.map(|b| vec![b]).unwrap_or_default();
+ if sup {
+ MNode::Script { kind: ScriptKind::Sup, base, sub: Vec::new(), sup: s }
+ } else {
+ MNode::Script { kind: ScriptKind::Sub, base, sub: s, sup: Vec::new() }
+ }
+ }
+ }
+}
+
+/// `█(a@b)` equation array / `■(a&b@c&d)` matrix from the parsed inside.
+fn grid(matrix: bool, inner: Arg) -> MNode {
+ let mut rows: Vec> = vec![vec![Vec::new()]];
+ for n in inner {
+ match &n {
+ MNode::Run(r) if r.text == "@" => rows.push(vec![Vec::new()]),
+ MNode::Run(r) if r.text == "&" && matrix => {
+ if let Some(row) = rows.last_mut() {
+ row.push(Vec::new());
+ }
+ }
+ _ => {
+ if let Some(cell) = rows.last_mut().and_then(|r| r.last_mut()) {
+ cell.push(n);
+ }
+ }
+ }
+ }
+ if matrix {
+ MNode::Matrix { rows, col_jc: Vec::new() }
+ } else {
+ MNode::EqArr { rows: rows.into_iter().map(|r| r.into_iter().flatten().collect()).collect() }
+ }
+}
+
+/// Drop placeholders and build-up spaces, merge runs; recursively.
+fn clean(a: Arg) -> Arg {
+ let mut out = Vec::with_capacity(a.len());
+ for n in a {
+ match n {
+ MNode::Run(r) if r.text == " " || r.text.chars().all(|c| c == PLACEHOLDER) => {}
+ other => out.push(map_args(other, &clean)),
+ }
+ }
+ merge_runs(out)
+}
+
+/// Apply `f` to every argument of a node.
+pub fn map_args(n: MNode, f: &dyn Fn(Arg) -> Arg) -> MNode {
+ match n {
+ MNode::Run(r) => MNode::Run(r),
+ MNode::Frac { kind, num, den } => MNode::Frac { kind, num: f(num), den: f(den) },
+ MNode::Script { kind, base, sub, sup } => MNode::Script { kind, base: f(base), sub: f(sub), sup: f(sup) },
+ MNode::Rad { deg, deg_hide, e } => MNode::Rad { deg: f(deg), deg_hide, e: f(e) },
+ MNode::Nary { chr, lim_loc, grow, sub_hide, sup_hide, sub, sup, e } => {
+ MNode::Nary { chr, lim_loc, grow, sub_hide, sup_hide, sub: f(sub), sup: f(sup), e: f(e) }
+ }
+ MNode::Delim { beg, end, sep, grow, shp_match, elems } => {
+ MNode::Delim { beg, end, sep, grow, shp_match, elems: elems.into_iter().map(f).collect() }
+ }
+ MNode::Func { name, e } => MNode::Func { name: f(name), e: f(e) },
+ MNode::Lim { upper, e, lim } => MNode::Lim { upper, e: f(e), lim: f(lim) },
+ MNode::Acc { chr, e } => MNode::Acc { chr, e: f(e) },
+ MNode::Bar { top, e } => MNode::Bar { top, e: f(e) },
+ MNode::BorderBox { hide, strike, e } => MNode::BorderBox { hide, strike, e: f(e) },
+ MNode::Boxed { e } => MNode::Boxed { e: f(e) },
+ MNode::GroupChr { chr, top, e } => MNode::GroupChr { chr, top, e: f(e) },
+ MNode::EqArr { rows } => MNode::EqArr { rows: rows.into_iter().map(f).collect() },
+ MNode::Matrix { rows, col_jc } => MNode::Matrix { rows: rows.into_iter().map(|r| r.into_iter().map(f).collect()).collect(), col_jc },
+ MNode::Phant { show, zero_wid, zero_asc, zero_desc, e } => MNode::Phant { show, zero_wid, zero_asc, zero_desc, e: f(e) },
+ }
+}
diff --git a/crates/math/src/math_symbols.rs b/crates/math/src/math_symbols.rs
new file mode 100644
index 000000000..279ffd2ca
--- /dev/null
+++ b/crates/math/src/math_symbols.rs
@@ -0,0 +1,399 @@
+//! Named math characters: Math AutoCorrect while typing in an equation (`\alpha` + space → α)
+//! and LaTeX conversion. Names are the usual TeX names plus the linear-format operators
+//! (`\sqrt` → √, `\above` → ┴, `\matrix` → ■…).
+
+/// (name without the backslash, character). When a character has several names the first one
+/// listed is the one LaTeX output uses.
+pub const SYMBOLS: &[(&str, char)] = &[
+ // Greek.
+ ("alpha", 'α'),
+ ("beta", 'β'),
+ ("gamma", 'γ'),
+ ("delta", 'δ'),
+ ("epsilon", 'ϵ'),
+ ("varepsilon", 'ε'),
+ ("zeta", 'ζ'),
+ ("eta", 'η'),
+ ("theta", 'θ'),
+ ("vartheta", 'ϑ'),
+ ("iota", 'ι'),
+ ("kappa", 'κ'),
+ ("varkappa", 'ϰ'),
+ ("lambda", 'λ'),
+ ("mu", 'μ'),
+ ("nu", 'ν'),
+ ("xi", 'ξ'),
+ ("omicron", 'ο'),
+ ("pi", 'π'),
+ ("varpi", 'ϖ'),
+ ("rho", 'ρ'),
+ ("varrho", 'ϱ'),
+ ("sigma", 'σ'),
+ ("varsigma", 'ς'),
+ ("tau", 'τ'),
+ ("upsilon", 'υ'),
+ ("phi", 'ϕ'),
+ ("varphi", 'φ'),
+ ("chi", 'χ'),
+ ("psi", 'ψ'),
+ ("omega", 'ω'),
+ ("Alpha", 'Α'),
+ ("Beta", 'Β'),
+ ("Gamma", 'Γ'),
+ ("Delta", 'Δ'),
+ ("Epsilon", 'Ε'),
+ ("Zeta", 'Ζ'),
+ ("Eta", 'Η'),
+ ("Theta", 'Θ'),
+ ("Iota", 'Ι'),
+ ("Kappa", 'Κ'),
+ ("Lambda", 'Λ'),
+ ("Mu", 'Μ'),
+ ("Nu", 'Ν'),
+ ("Xi", 'Ξ'),
+ ("Omicron", 'Ο'),
+ ("Pi", 'Π'),
+ ("Rho", 'Ρ'),
+ ("Sigma", 'Σ'),
+ ("Tau", 'Τ'),
+ ("Upsilon", 'Υ'),
+ ("Phi", 'Φ'),
+ ("Chi", 'Χ'),
+ ("Psi", 'Ψ'),
+ ("Omega", 'Ω'),
+ ("digamma", 'ϝ'),
+ // Binary operators.
+ ("pm", '±'),
+ ("mp", '∓'),
+ ("times", '×'),
+ ("div", '÷'),
+ ("cdot", '⋅'),
+ ("bullet", '∙'),
+ ("ast", '∗'),
+ ("star", '⋆'),
+ ("circ", '∘'),
+ ("oplus", '⊕'),
+ ("ominus", '⊖'),
+ ("otimes", '⊗'),
+ ("oslash", '⊘'),
+ ("odot", '⊙'),
+ ("cup", '∪'),
+ ("cap", '∩'),
+ ("uplus", '⊎'),
+ ("sqcup", '⊔'),
+ ("sqcap", '⊓'),
+ ("vee", '∨'),
+ ("wedge", '∧'),
+ ("setminus", '∖'),
+ ("backslash", '\\'),
+ ("wr", '≀'),
+ ("diamond", '⋄'),
+ ("dagger", '†'),
+ ("ddagger", '‡'),
+ ("amalg", '⨿'),
+ ("dotplus", '∔'),
+ ("ltimes", '⋉'),
+ ("rtimes", '⋊'),
+ // Relations.
+ ("le", '≤'),
+ ("leq", '≤'),
+ ("ge", '≥'),
+ ("geq", '≥'),
+ ("ne", '≠'),
+ ("neq", '≠'),
+ ("approx", '≈'),
+ ("equiv", '≡'),
+ ("sim", '∼'),
+ ("simeq", '≃'),
+ ("cong", '≅'),
+ ("propto", '∝'),
+ ("ll", '≪'),
+ ("gg", '≫'),
+ ("prec", '≺'),
+ ("succ", '≻'),
+ ("preceq", '≼'),
+ ("succeq", '≽'),
+ ("subset", '⊂'),
+ ("supset", '⊃'),
+ ("subseteq", '⊆'),
+ ("supseteq", '⊇'),
+ ("sqsubseteq", '⊑'),
+ ("sqsupseteq", '⊒'),
+ ("in", '∈'),
+ ("ni", '∋'),
+ ("notin", '∉'),
+ ("perp", '⊥'),
+ ("parallel", '∥'),
+ ("mid", '∣'),
+ ("nmid", '∤'),
+ ("asymp", '≍'),
+ ("doteq", '≐'),
+ ("models", '⊨'),
+ ("vdash", '⊢'),
+ ("dashv", '⊣'),
+ ("bowtie", '⋈'),
+ ("coloneq", '≔'),
+ ("defeq", '≝'),
+ ("triangleq", '≜'),
+ ("nless", '≮'),
+ ("ngtr", '≯'),
+ ("nleq", '≰'),
+ ("ngeq", '≱'),
+ ("nsim", '≁'),
+ ("ncong", '≇'),
+ ("nequiv", '≢'),
+ ("nsubset", '⊄'),
+ ("nsupset", '⊅'),
+ ("nsubseteq", '⊈'),
+ ("nsupseteq", '⊉'),
+ ("subsetneq", '⊊'),
+ ("supsetneq", '⊋'),
+ ("lesssim", '≲'),
+ ("gtrsim", '≳'),
+ ("therefore", '∴'),
+ ("because", '∵'),
+ // Arrows.
+ ("rightarrow", '→'),
+ ("to", '→'),
+ ("leftarrow", '←'),
+ ("gets", '←'),
+ ("leftrightarrow", '↔'),
+ ("uparrow", '↑'),
+ ("downarrow", '↓'),
+ ("updownarrow", '↕'),
+ ("Rightarrow", '⇒'),
+ ("Leftarrow", '⇐'),
+ ("Leftrightarrow", '⇔'),
+ ("Uparrow", '⇑'),
+ ("Downarrow", '⇓'),
+ ("Updownarrow", '⇕'),
+ ("longrightarrow", '⟶'),
+ ("longleftarrow", '⟵'),
+ ("longleftrightarrow", '⟷'),
+ ("Longrightarrow", '⟹'),
+ ("Longleftarrow", '⟸'),
+ ("Longleftrightarrow", '⟺'),
+ ("implies", '⟹'),
+ ("iff", '⟺'),
+ ("mapsto", '↦'),
+ ("longmapsto", '⟼'),
+ ("nearrow", '↗'),
+ ("searrow", '↘'),
+ ("swarrow", '↙'),
+ ("nwarrow", '↖'),
+ ("hookleftarrow", '↩'),
+ ("hookrightarrow", '↪'),
+ ("leftharpoonup", '↼'),
+ ("leftharpoondown", '↽'),
+ ("rightharpoonup", '⇀'),
+ ("rightharpoondown", '⇁'),
+ ("rightleftharpoons", '⇌'),
+ ("leftrightharpoons", '⇋'),
+ ("circlearrowleft", '↺'),
+ ("circlearrowright", '↻'),
+ // Big operators.
+ ("sum", '∑'),
+ ("prod", '∏'),
+ ("coprod", '∐'),
+ ("int", '∫'),
+ ("iint", '∬'),
+ ("iiint", '∭'),
+ ("oint", '∮'),
+ ("oiint", '∯'),
+ ("oiiint", '∰'),
+ ("bigcup", '⋃'),
+ ("bigcap", '⋂'),
+ ("bigvee", '⋁'),
+ ("bigwedge", '⋀'),
+ ("bigoplus", '⨁'),
+ ("bigotimes", '⨂'),
+ ("bigodot", '⨀'),
+ ("biguplus", '⨄'),
+ ("bigsqcup", '⨆'),
+ // Brackets.
+ ("langle", '⟨'),
+ ("rangle", '⟩'),
+ ("lceil", '⌈'),
+ ("rceil", '⌉'),
+ ("lfloor", '⌊'),
+ ("rfloor", '⌋'),
+ ("lbrack", '['),
+ ("rbrack", ']'),
+ ("lbrace", '{'),
+ ("rbrace", '}'),
+ ("Vert", '‖'),
+ ("norm", '‖'),
+ ("vert", '|'),
+ ("lbbrack", '⟦'),
+ ("rbbrack", '⟧'),
+ // Miscellaneous.
+ ("infty", '∞'),
+ ("partial", '∂'),
+ ("nabla", '∇'),
+ ("forall", '∀'),
+ ("exists", '∃'),
+ ("nexists", '∄'),
+ ("neg", '¬'),
+ ("lnot", '¬'),
+ ("emptyset", '∅'),
+ ("varnothing", '∅'),
+ ("hbar", 'ℏ'),
+ ("ell", 'ℓ'),
+ ("Re", 'ℜ'),
+ ("Im", 'ℑ'),
+ ("wp", '℘'),
+ ("aleph", 'ℵ'),
+ ("beth", 'ℶ'),
+ ("gimel", 'ℷ'),
+ ("daleth", 'ℸ'),
+ ("degree", '°'),
+ ("angle", '∠'),
+ ("measuredangle", '∡'),
+ ("sphericalangle", '∢'),
+ ("rightangle", '∟'),
+ ("triangle", '△'),
+ ("square", '□'),
+ ("blacksquare", '■'),
+ ("qed", '∎'),
+ ("top", '⊤'),
+ ("bot", '⊥'),
+ ("cdots", '⋯'),
+ ("ldots", '…'),
+ ("dots", '…'),
+ ("vdots", '⋮'),
+ ("ddots", '⋱'),
+ ("adots", '⋰'),
+ ("prime", '′'),
+ ("dprime", '″'),
+ ("trprime", '‴'),
+ ("dd", 'ⅆ'),
+ ("Dd", 'ⅅ'),
+ ("ee", 'ⅇ'),
+ ("ii", 'ⅈ'),
+ ("jj", 'ⅉ'),
+ ("imath", 'ı'),
+ ("jmath", 'ȷ'),
+ ("complement", '∁'),
+ ("Angstrom", 'Å'),
+ ("mho", '℧'),
+ ("eth", 'ð'),
+ ("flat", '♭'),
+ ("natural", '♮'),
+ ("sharp", '♯'),
+ ("clubsuit", '♣'),
+ ("diamondsuit", '♢'),
+ ("heartsuit", '♡'),
+ ("spadesuit", '♠'),
+ // Spaces.
+ ("quad", '\u{2003}'),
+ ("emsp", '\u{2003}'),
+ ("ensp", '\u{2002}'),
+ ("thicksp", '\u{2005}'),
+ ("medsp", '\u{205F}'),
+ ("thinsp", '\u{2009}'),
+ ("hairsp", '\u{200A}'),
+ ("nbsp", '\u{A0}'),
+ ("zwsp", '\u{200B}'),
+ // Linear-format operators (build-up).
+ ("sqrt", '√'),
+ ("cbrt", '∛'),
+ ("qdrt", '∜'),
+ ("matrix", '■'),
+ ("eqarray", '█'),
+ ("above", '┴'),
+ ("below", '┬'),
+ ("of", '▒'),
+ ("naryand", '▒'),
+ ("funcapply", '\u{2061}'),
+ ("box", '□'),
+ ("rect", '▭'),
+ ("overbar", '¯'),
+ ("underbar", '▁'),
+ ("phantom", '⟡'),
+ ("overbrace", '⏞'),
+ ("underbrace", '⏟'),
+ ("overparen", '⏜'),
+ ("underparen", '⏝'),
+ ("atop", '¦'),
+ ("over", '/'),
+ ("ldiv", '∕'),
+ ("sdiv", '⁄'),
+ ("begin", '〖'),
+ ("end", '〗'),
+ ("left", '├'),
+ ("right", '┤'),
+ ("eqno", '#'),
+ ("placeholder", '⬚'),
+ // Accents (combining, typed after the base).
+ ("hat", '\u{302}'),
+ ("check", '\u{30C}'),
+ ("tilde", '\u{303}'),
+ ("acute", '\u{301}'),
+ ("grave", '\u{300}'),
+ ("dot", '\u{307}'),
+ ("ddot", '\u{308}'),
+ ("dddot", '\u{20DB}'),
+ ("breve", '\u{306}'),
+ ("bar", '\u{305}'),
+ ("Bar", '\u{33F}'),
+ ("vec", '\u{20D7}'),
+ ("tvec", '\u{20E1}'),
+ ("lhvec", '\u{20D0}'),
+ ("hvec", '\u{20D1}'),
+ // Other spellings (after the names the writer uses, which come first).
+ ("nparallel", '∦'),
+ ("backprime", '‵'),
+ ("lvert", '|'),
+ ("rvert", '|'),
+ ("lVert", '‖'),
+ ("rVert", '‖'),
+ ("infinity", '∞'),
+ ("cross", '×'),
+];
+
+/// The character for a name (`alpha` → α), including `doubleR`, `scriptL`, `frakturg`.
+pub fn symbol(name: &str) -> Option {
+ if let Some((_, c)) = SYMBOLS.iter().find(|(n, _)| *n == name) {
+ return Some(*c);
+ }
+ for (prefix, scr) in [("double", crate::math::MScr::DoubleStruck), ("script", crate::math::MScr::Script), ("fraktur", crate::math::MScr::Fraktur)]
+ {
+ if let Some(rest) = name.strip_prefix(prefix) {
+ let mut cs = rest.chars();
+ if let (Some(c), None) = (cs.next(), cs.next())
+ && c.is_ascii_alphanumeric()
+ {
+ let m = crate::math::math_alnum(c, false, false, scr);
+ if m != c {
+ return Some(m);
+ }
+ }
+ }
+ }
+ None
+}
+
+/// The (first) name of a character.
+pub fn name_of(c: char) -> Option<&'static str> {
+ SYMBOLS.iter().find(|(_, s)| *s == c).map(|(n, _)| *n)
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn names_resolve() {
+ assert_eq!(symbol("alpha"), Some('α'));
+ assert_eq!(symbol("doubleR"), Some('ℝ'));
+ assert_eq!(symbol("scriptL"), Some('ℒ'));
+ assert_eq!(symbol("nonesuch"), None);
+ assert_eq!(name_of('≤'), Some("le"));
+ // No name is listed twice.
+ let mut names: Vec<&str> = SYMBOLS.iter().map(|(n, _)| *n).collect();
+ names.sort_unstable();
+ let before = names.len();
+ names.dedup();
+ assert_eq!(before, names.len());
+ }
+}
diff --git a/crates/math/src/math_tree.rs b/crates/math/src/math_tree.rs
new file mode 100644
index 000000000..45d2c603a
--- /dev/null
+++ b/crates/math/src/math_tree.rs
@@ -0,0 +1,104 @@
+//! Structural access to an equation tree: the arguments of a node, and every run in a tree.
+
+use crate::math::{Arg, MAX_DEPTH, MNode, MRun};
+
+/// Number of child arguments of a node.
+pub fn child_count(n: &MNode) -> usize {
+ match n {
+ MNode::Run(_) => 0,
+ MNode::Frac { .. } | MNode::Func { .. } | MNode::Lim { .. } => 2,
+ MNode::Rad { .. } => 2,
+ MNode::Script { .. } | MNode::Nary { .. } => 3,
+ MNode::Delim { elems, .. } => elems.len(),
+ MNode::EqArr { rows } => rows.len(),
+ MNode::Matrix { rows, .. } => rows.iter().map(|r| r.len()).sum(),
+ MNode::Acc { .. } | MNode::Bar { .. } | MNode::BorderBox { .. } | MNode::Boxed { .. } | MNode::GroupChr { .. } | MNode::Phant { .. } => 1,
+ }
+}
+
+/// Child argument `i` of a node (see [`child_count`] for the numbering: fraction num 0 / den 1;
+/// script base 0 / sub 1 / sup 2; radical degree 0 / base 1; n-ary sub 0 / sup 1 / operand 2;
+/// function name 0 / argument 1; limit base 0 / limit 1; matrices row by row).
+pub fn child(n: &MNode, i: usize) -> Option<&Arg> {
+ match (n, i) {
+ (MNode::Frac { num, .. }, 0) => Some(num),
+ (MNode::Frac { den, .. }, 1) => Some(den),
+ (MNode::Script { base, .. }, 0) => Some(base),
+ (MNode::Script { sub, .. }, 1) => Some(sub),
+ (MNode::Script { sup, .. }, 2) => Some(sup),
+ (MNode::Rad { deg, .. }, 0) => Some(deg),
+ (MNode::Rad { e, .. }, 1) => Some(e),
+ (MNode::Nary { sub, .. }, 0) => Some(sub),
+ (MNode::Nary { sup, .. }, 1) => Some(sup),
+ (MNode::Nary { e, .. }, 2) => Some(e),
+ (MNode::Delim { elems, .. }, i) => elems.get(i),
+ (MNode::Func { name, .. }, 0) => Some(name),
+ (MNode::Func { e, .. }, 1) => Some(e),
+ (MNode::Lim { e, .. }, 0) => Some(e),
+ (MNode::Lim { lim, .. }, 1) => Some(lim),
+ (
+ MNode::Acc { e, .. }
+ | MNode::Bar { e, .. }
+ | MNode::BorderBox { e, .. }
+ | MNode::Boxed { e }
+ | MNode::GroupChr { e, .. }
+ | MNode::Phant { e, .. },
+ 0,
+ ) => Some(e),
+ (MNode::EqArr { rows }, i) => rows.get(i),
+ (MNode::Matrix { rows, .. }, i) => rows.iter().flatten().nth(i),
+ _ => None,
+ }
+}
+
+pub fn child_mut(n: &mut MNode, i: usize) -> Option<&mut Arg> {
+ match (n, i) {
+ (MNode::Frac { num, .. }, 0) => Some(num),
+ (MNode::Frac { den, .. }, 1) => Some(den),
+ (MNode::Script { base, .. }, 0) => Some(base),
+ (MNode::Script { sub, .. }, 1) => Some(sub),
+ (MNode::Script { sup, .. }, 2) => Some(sup),
+ (MNode::Rad { deg, .. }, 0) => Some(deg),
+ (MNode::Rad { e, .. }, 1) => Some(e),
+ (MNode::Nary { sub, .. }, 0) => Some(sub),
+ (MNode::Nary { sup, .. }, 1) => Some(sup),
+ (MNode::Nary { e, .. }, 2) => Some(e),
+ (MNode::Delim { elems, .. }, i) => elems.get_mut(i),
+ (MNode::Func { name, .. }, 0) => Some(name),
+ (MNode::Func { e, .. }, 1) => Some(e),
+ (MNode::Lim { e, .. }, 0) => Some(e),
+ (MNode::Lim { lim, .. }, 1) => Some(lim),
+ (
+ MNode::Acc { e, .. }
+ | MNode::Bar { e, .. }
+ | MNode::BorderBox { e, .. }
+ | MNode::Boxed { e }
+ | MNode::GroupChr { e, .. }
+ | MNode::Phant { e, .. },
+ 0,
+ ) => Some(e),
+ (MNode::EqArr { rows }, i) => rows.get_mut(i),
+ (MNode::Matrix { rows, .. }, i) => rows.iter_mut().flatten().nth(i),
+ _ => None,
+ }
+}
+
+/// Call `f` on every run in the tree.
+pub fn each_run_mut(root: &mut Arg, f: &mut dyn FnMut(&mut MRun)) {
+ fn go(a: &mut Arg, f: &mut dyn FnMut(&mut MRun), depth: usize) {
+ if depth > MAX_DEPTH {
+ return;
+ }
+ for n in a.iter_mut() {
+ if let MNode::Run(r) = n {
+ f(r);
+ }
+ for c in 0..child_count(n) {
+ if let Some(ch) = child_mut(n, c) {
+ go(ch, f, depth + 1);
+ }
+ }
+ }
+ }
+ go(root, f, 0);
+}
diff --git a/crates/math/src/mathml.rs b/crates/math/src/mathml.rs
new file mode 100644
index 000000000..9dc2433ac
--- /dev/null
+++ b/crates/math/src/mathml.rs
@@ -0,0 +1,1080 @@
+//! Equations as MathML (W3C MathML Core / Presentation MathML): written into HTML export and the
+//! HTML a copy puts on the system clipboard, so programs that read MathML can get a formatted
+//! equation instead of its linear text (#816); for a copy of one equation alone, the text of the
+//! platform's MathML clipboard formats, which Word reads first when it pastes math (#1297;
+//! pasting into Word and LibreOffice not yet verified); and read back from pasted or opened HTML.
+//!
+//! The writer walks the equation tree ([`MNode`]) and wraps it in `` with the linear
+//! format as an `application/x-unicodemath` annotation, which renderers ignore and WordCraft reads
+//! back exactly. The reader takes that annotation when present, else a TeX annotation, else
+//! builds the tree from the presentation elements (fractions, scripts, radicals, under/over
+//! limits, tables, fences); both bound their nesting and size.
+
+use crate::math::{
+ Arg, ColJc, FracKind, LimLoc, MAX_DEPTH, MClass, MNode, MRun, MScr, MSty, ScriptKind, is_integral, is_nary_char, math_alnum, math_class,
+ to_linear,
+};
+use crate::math_linear::FUNCTION_NAMES;
+
+/// Escape text for HTML or XML content and attribute values.
+fn esc(s: &str) -> String {
+ let mut o = String::with_capacity(s.len() + 8);
+ for c in s.chars() {
+ match c {
+ '&' => o.push_str("&"),
+ '<' => o.push_str("<"),
+ '>' => o.push_str(">"),
+ '"' => o.push_str("""),
+ '\u{000C}' | '\u{000E}' => {}
+ _ => o.push(c),
+ }
+ }
+ o
+}
+
+/// The MathML namespace.
+pub const NS: &str = "http://www.w3.org/1998/Math/MathML";
+/// The annotation encoding carrying the linear format (UnicodeMath).
+pub const UNICODEMATH: &str = "application/x-unicodemath";
+
+// ---------------------------------------------------------------------------------------------
+// Writer
+
+/// An equation as a `
")), vec![(linear.to_string(), display)], "{xml}");
+ }
+ }
+
+ #[test]
+ fn foreign_mathml_is_read_from_its_elements() {
+ let cases = [
+ ("y=2xs", "y=2x_s"),
+ ("ab+1", "a/(b+1)"),
+ ("x", "√x"),
+ ("∑k=1nk", "∑_(k=1)^n▒k"),
+ ("ex", "e^x"),
+ // A TeX annotation is used when the presentation is empty.
+ ("\\frac{1}{2}", "1/2"),
+ ];
+ for (html, want) in cases {
+ let got = pasted(html);
+ assert_eq!(got.first().map(|g| g.0.as_str()), Some(want), "{html}");
+ }
+ assert!(pasted("x").first().is_some_and(|g| g.1));
+ // An unclosed element ends with the input.
+ assert!(!pasted("x").is_empty());
+ // Hostile nesting is bounded.
+ let deep = format!("{}x{}", "".repeat(5000), "".repeat(5000));
+ assert!(pasted(&deep).len() <= 1);
+ }
+
+ #[test]
+ fn math_over_the_node_cap_keeps_the_first_elements() {
+ let xml = format!("{}z", "y".repeat(MAX_NODES * 2));
+ let el = parse(&xml).unwrap();
+ assert_eq!(el.children.len(), MAX_NODES);
+ assert!(read(&el).is_some_and(|(l, _)| !l.contains('z')));
+ // Not XML, or no math element: nothing.
+ assert!(parse("").is_none() && parse("no math
").is_none() && parse("\u{0}<<").is_none());
+ }
+
+ #[test]
+ fn function_names_apply_without_the_invisible_operator() {
+ let cases = [
+ ("sinx", "sin\u{2061}x"),
+ ("logab", "log\u{2061}ab"),
+ ("cos\u{2061}x", "cos\u{2061}x"),
+ ("abx", "abx"),
+ ];
+ for (html, want) in cases {
+ assert_eq!(pasted(html).first().map(|g| g.0.as_str()), Some(want), "{html}");
+ }
+ let tree = to_tree(&El {
+ name: "math".into(),
+ attrs: vec![],
+ children: vec![
+ Item::El(El { name: "mi".into(), attrs: vec![], children: vec![Item::Text("sin".into())] }),
+ Item::El(El { name: "mi".into(), attrs: vec![], children: vec![Item::Text("x".into())] }),
+ ],
+ });
+ assert!(matches!(tree.as_slice(), [MNode::Func { .. }]), "{tree:?}");
+ }
+
+ #[test]
+ fn one_column_tables_are_column_vectors_unless_marked() {
+ let tree = |html: &str| {
+ let linear = pasted(html).first().map(|g| g.0.clone()).unwrap_or_default();
+ crate::math::parse_linear(&linear)
+ };
+ let vector = tree("ab");
+ assert!(matches!(vector.as_slice(), [MNode::Matrix { rows, .. }] if rows.len() == 2), "{vector:?}");
+ for marked in [
+ "ab",
+ "a=1b",
+ ] {
+ let t = tree(marked);
+ assert!(matches!(t.as_slice(), [MNode::EqArr { rows }] if rows.len() == 2), "{marked}: {t:?}");
+ }
+ // Our own equation arrays read back as equation arrays from their elements alone.
+ let ml = to_mathml(&[MNode::EqArr { rows: vec![parse_linear("x=1"), parse_linear("y=2")] }], false, "");
+ let ml = ml.replace("application/x-unicodemath", "x-ignored");
+ let t = tree(&ml);
+ assert!(matches!(t.as_slice(), [MNode::EqArr { .. }]), "{ml}: {t:?}");
+ }
+
+ #[test]
+ fn mmultiscripts_and_borderbox_round_trip() {
+ let html = "X12";
+ let p = pasted(html);
+ assert!(!p.is_empty(), "should parse mmultiscripts with pre and post scripts");
+
+ let html_box = "x";
+ let p_box = pasted(html_box);
+ assert!(!p_box.is_empty(), "should parse menclose");
+
+ let html_tbl = "ab";
+ let p_tbl = pasted(html_tbl);
+ assert!(!p_tbl.is_empty(), "should parse mtable with columnalign");
+
+ let html_pad = "z";
+ let p_pad = pasted(html_pad);
+ assert!(!p_pad.is_empty(), "should parse mpadded phantom");
+ }
+
+ #[test]
+ fn deep_nesting_is_bounded() {
+ let mut n = parse_linear("x");
+ for _ in 0..500 {
+ n = vec![MNode::Frac { kind: FracKind::Bar, num: n, den: parse_linear("y") }];
+ }
+ let m = to_mathml(&n, false, "z");
+ assert!(m.ends_with(""));
+ }
+
+ #[test]
+ fn test_mfrac_linethickness_none_and_menclose_notations() {
+ let el_frac = El {
+ name: "mfrac".into(),
+ attrs: vec![("linethickness".into(), "none".into())],
+ children: vec![
+ Item::El(El { name: "mi".into(), attrs: vec![], children: vec![Item::Text("n".into())] }),
+ Item::El(El { name: "mi".into(), attrs: vec![], children: vec![Item::Text("k".into())] }),
+ ],
+ };
+ let mut budget = MAX_NODES;
+ let tree_frac = elem(&el_frac, 0, &mut budget);
+ assert!(matches!(tree_frac.as_slice(), [MNode::Frac { kind: FracKind::NoBar, .. }]), "{tree_frac:?}");
+
+ let el_strike = El {
+ name: "menclose".into(),
+ attrs: vec![("notation".into(), "horizontalstrike".into())],
+ children: vec![Item::El(El { name: "mi".into(), attrs: vec![], children: vec![Item::Text("x".into())] })],
+ };
+ let mut budget = MAX_NODES;
+ let tree_strike = elem(&el_strike, 0, &mut budget);
+ assert!(matches!(tree_strike.as_slice(), [MNode::BorderBox { strike: [true, false, false, false], .. }]), "{tree_strike:?}");
+
+ let el_cross = El {
+ name: "menclose".into(),
+ attrs: vec![("notation".into(), "cross".into())],
+ children: vec![Item::El(El { name: "mi".into(), attrs: vec![], children: vec![Item::Text("x".into())] })],
+ };
+ let mut budget = MAX_NODES;
+ let tree_cross = elem(&el_cross, 0, &mut budget);
+ assert!(matches!(tree_cross.as_slice(), [MNode::BorderBox { strike: [false, false, true, true], .. }]), "{tree_cross:?}");
+
+ let el_rad = El {
+ name: "menclose".into(),
+ attrs: vec![("notation".into(), "radical".into())],
+ children: vec![Item::El(El { name: "mi".into(), attrs: vec![], children: vec![Item::Text("x".into())] })],
+ };
+ let mut budget = MAX_NODES;
+ let tree_rad = elem(&el_rad, 0, &mut budget);
+ assert!(matches!(tree_rad.as_slice(), [MNode::Rad { deg_hide: true, .. }]), "{tree_rad:?}");
+ }
+}
diff --git a/crates/math/src/omml.rs b/crates/math/src/omml.rs
new file mode 100644
index 000000000..99024ec8b
--- /dev/null
+++ b/crates/math/src/omml.rs
@@ -0,0 +1,232 @@
+//! Office Math Markup (OMML, ECMA-376 Part 1 §22.1) read into and written from the equation tree
+//! ([`crate::math`]).
+//!
+//! The reader takes an `m:oMath` element of a [`craft_xml`] tree read with the canonical
+//! prefixes of [`NAMESPACES`] (`m:` for math, `w:` for WordprocessingML, `mc:` for markup
+//! compatibility), whatever prefixes the file used; [`parse`] does that for a string. It keeps
+//! the source XML in [`Math::omml`], and the writer writes that back unchanged while the
+//! equation's colour is the same, so a saved file carries the equation exactly as Word wrote it.
+//! The writer otherwise writes the tree: `m:oMath`, inside `m:oMathPara` for a display equation.
+
+use craft_xml::{Canonical, El, Options};
+
+use crate::math::{Math, MathJc};
+use crate::rgb::Rgb;
+
+mod read;
+mod write;
+
+pub use read::{lift_color, para_jc, read_math_pr, read_omath};
+pub use write::{math_pr, resolve_numbers, write_equation};
+
+/// The OMML namespace (transitional), and the strict one.
+pub const MATH_NS: &str = "http://schemas.openxmlformats.org/officeDocument/2006/math";
+pub const MATH_NS_STRICT: &str = "http://purl.oclc.org/ooxml/officeDocument/math";
+/// The WordprocessingML namespace OMML runs borrow (`w:rPr`, `w:t`, `w:sym`), transitional and
+/// strict.
+pub const W_NS: &str = "http://schemas.openxmlformats.org/wordprocessingml/2006/main";
+pub const W_NS_STRICT: &str = "http://purl.oclc.org/ooxml/wordprocessingml/main";
+/// Markup compatibility (`mc:AlternateContent`).
+pub const MC_NS: &str = "http://schemas.openxmlformats.org/markup-compatibility/2006";
+
+/// The canonical prefixes the reader expects, for [`craft_xml::Canonical`].
+pub const NAMESPACES: &[(&str, &str)] = &[("m", MATH_NS), ("m", MATH_NS_STRICT), ("w", W_NS), ("w", W_NS_STRICT), ("mc", MC_NS)];
+
+/// Most elements [`parse`] reads from one string.
+pub const MAX_ELEMENTS: usize = 250_000;
+
+/// OMML text read into a tree with the canonical prefixes of [`NAMESPACES`]: the first
+/// `m:oMathPara` or `m:oMath` element in it (the root or any descendant). `None` when there is
+/// none, or the text is not XML. Text kept by the reader ([`Math::omml`]) declares no
+/// namespaces; its prefixes are already the canonical ones and are read as written.
+pub fn parse(xml: &str) -> Option {
+ let canonical = Canonical::new(NAMESPACES);
+ let limits = craft_xml::Limits { max_nodes: MAX_ELEMENTS.saturating_mul(2), ..craft_xml::Limits::DEFAULT };
+ let first = |root: El| -> Option {
+ if matches!(root.name.as_str(), "m:oMath" | "m:oMathPara") {
+ return Some(root);
+ }
+ root.find("m:oMathPara").or_else(|| root.find("m:oMath")).cloned()
+ };
+ let read = Options::canonical(&canonical).with_limits(limits).parse(xml.as_bytes()).ok().and_then(first);
+ read.or_else(|| parse_kept(xml).and_then(first))
+}
+
+/// Kept OMML (canonical prefixes, no declarations) read as written.
+fn parse_kept(xml: &str) -> Option {
+ let limits = craft_xml::Limits { max_nodes: MAX_ELEMENTS.saturating_mul(2), ..craft_xml::Limits::DEFAULT };
+ Options { ns_decls: false, ..Options::default() }.with_limits(limits).parse(xml.as_bytes()).ok()
+}
+
+/// Is `xml` (kept OMML) well-formed enough to write back as it is?
+fn is_well_formed(xml: &str) -> bool {
+ xml.starts_with(" Option {
+ let e = parse(xml)?;
+ if e.name == "m:oMathPara" {
+ let jc = para_jc(&e);
+ return e.find("m:oMath").map(|m| read_omath(m, jc));
+ }
+ Some(read_omath(&e, MathJc::default()))
+}
+
+/// An equation as OMML text ([`write_equation`] into a string, no XML declaration).
+pub fn write_str(linear: &str, display: bool, math: &Math, color: Option) -> String {
+ let mut w = W::default();
+ write_equation(&mut w, linear, display, math, color);
+ w.s
+}
+
+/// OMML on/off property of WordprocessingML (`w:b`, `w:i`): absent `w:val` = on.
+fn on_off(e: &El) -> bool {
+ match e.attr_or_plain("w:val") {
+ None => true,
+ Some(v) => !matches!(v.trim(), "0" | "false" | "off" | "False" | "FALSE"),
+ }
+}
+
+/// Largest absolute length accepted, points (200 inches).
+const MAX_LEN_PT: f32 = 14_400.0;
+
+/// A twips value (`m:wrapIndent`), with or without an ISO 29500 universal measure (`1in`,
+/// `2.5cm`, `12pt`), in points.
+fn measure_twips(s: Option<&str>) -> Option {
+ let t = s?.trim();
+ if t.is_empty() || t.len() > 40 {
+ return None;
+ }
+ let bytes = t.as_bytes();
+ let mut split = t.len();
+ let mut in_exp = false;
+ for (i, &b) in bytes.iter().enumerate() {
+ if b.is_ascii_digit() || b == b'.' {
+ in_exp = false;
+ continue;
+ }
+ if (b == b'-' || b == b'+') && (i == 0 || in_exp) {
+ in_exp = false;
+ continue;
+ }
+ if b == b'e' || b == b'E' {
+ let rest = bytes.get(i + 1..).unwrap_or_default();
+ let after_sign = match rest.first() {
+ Some(&s) if s == b'+' || s == b'-' => rest.get(1..).unwrap_or_default(),
+ _ => rest,
+ };
+ if after_sign.first().is_some_and(u8::is_ascii_digit) {
+ in_exp = true;
+ continue;
+ }
+ }
+ split = i;
+ break;
+ }
+ let (num, unit) = t.split_at(split);
+ let v: f64 = num.parse().ok()?;
+ if !v.is_finite() {
+ return None;
+ }
+ let pt = match unit.trim().to_ascii_lowercase().as_str() {
+ "" => v / 20.0,
+ "pt" => v,
+ "in" => v * 72.0,
+ "cm" => v * 72.0 / 2.54,
+ "mm" => v * 72.0 / 25.4,
+ "pc" | "pi" => v * 12.0,
+ "px" => v * 0.75,
+ "emu" => v / 12_700.0,
+ _ => return None,
+ };
+ let pt = pt as f32;
+ pt.is_finite().then_some(pt.clamp(-MAX_LEN_PT, MAX_LEN_PT))
+}
+
+/// Points as a twips attribute value.
+fn twips(pt: f32) -> String {
+ let pt = pt.clamp(-MAX_LEN_PT, MAX_LEN_PT);
+ let pt = if pt.is_finite() { pt } else { 0.0 };
+ ((pt * 20.0).round() as i64).to_string()
+}
+
+fn xml_char_ok(c: char) -> bool {
+ matches!(c, '\t' | '\n' | '\r' | '\u{20}'..='\u{D7FF}' | '\u{E000}'..='\u{FFFD}' | '\u{10000}'..='\u{10FFFF}')
+}
+
+/// Escape text for element content or attribute values (drops characters XML can't carry).
+fn esc(s: &str) -> String {
+ let mut o = String::with_capacity(s.len());
+ for c in s.chars() {
+ match c {
+ '&' => o.push_str("&"),
+ '<' => o.push_str("<"),
+ '>' => o.push_str(">"),
+ '"' => o.push_str("""),
+ '\'' => o.push_str("'"),
+ '\t' => o.push_str(" "),
+ '\n' => o.push_str("
"),
+ '\r' => o.push_str("
"),
+ c if xml_char_ok(c) => o.push(c),
+ _ => {}
+ }
+ }
+ o
+}
+
+/// A string-building XML writer, as WordprocessingML writers write: names as given, values
+/// escaped.
+#[derive(Default)]
+pub struct W {
+ pub s: String,
+}
+
+impl W {
+ fn attrs(&mut self, attrs: &[(&str, &str)]) {
+ for (k, v) in attrs {
+ self.s.push(' ');
+ self.s.push_str(k);
+ self.s.push_str("=\"");
+ self.s.push_str(&esc(v));
+ self.s.push('"');
+ }
+ }
+ pub fn open(&mut self, name: &str, attrs: &[(&str, &str)]) {
+ self.s.push('<');
+ self.s.push_str(name);
+ self.attrs(attrs);
+ self.s.push('>');
+ }
+ pub fn empty(&mut self, name: &str, attrs: &[(&str, &str)]) {
+ self.s.push('<');
+ self.s.push_str(name);
+ self.attrs(attrs);
+ self.s.push_str("/>");
+ }
+ pub fn close(&mut self, name: &str) {
+ self.s.push_str("");
+ self.s.push_str(name);
+ self.s.push('>');
+ }
+ pub fn text(&mut self, t: &str) {
+ self.s.push_str(&esc(t));
+ }
+ /// `text`.
+ pub fn leaf(&mut self, name: &str, attrs: &[(&str, &str)], t: &str) {
+ self.open(name, attrs);
+ self.text(t);
+ self.close(name);
+ }
+ /// ``.
+ pub fn val(&mut self, name: &str, v: &str) {
+ self.empty(name, &[("w:val", v)]);
+ }
+ pub fn raw(&mut self, s: &str) {
+ self.s.push_str(s);
+ }
+}
+
+#[cfg(test)]
+mod tests;
diff --git a/crates/math/src/omml/read.rs b/crates/math/src/omml/read.rs
new file mode 100644
index 000000000..2f01f6fb4
--- /dev/null
+++ b/crates/math/src/omml/read.rs
@@ -0,0 +1,359 @@
+//! OMML into the tree.
+
+use craft_xml::El;
+
+use super::{measure_twips, on_off};
+use crate::math::{
+ Arg, BrkBin, BrkBinSub, ColJc, FracKind, LimLoc, MAX_DEPTH, MNode, MRun, MScr, MSty, Math, MathJc, MathProps, ScriptKind, merge_runs,
+};
+use crate::rgb::Rgb;
+use crate::symbol_fonts;
+
+/// An `m:oMath` element as an equation (structure plus its source XML).
+pub fn read_omath(e: &El, jc: MathJc) -> Math {
+ Math { nodes: merge_runs(arg(e, 0)), omml: e.to_xml(), jc, linear: false }
+}
+
+/// A colour every math run shares, taken off the runs: it is the equation's own colour, as
+/// [`write_equation`] spreads it (#1467). The source OMML
+/// is kept: the writer replaces it only when the colour changes.
+pub fn lift_color(math: &mut Math) -> Option {
+ let (mut shared, mut uniform) = (None, true);
+ crate::math_tree::each_run_mut(&mut math.nodes, &mut |r| match shared {
+ None => shared = Some(r.color),
+ Some(c) => uniform &= c == r.color,
+ });
+ let c = shared.flatten().filter(|_| uniform)?;
+ crate::math_tree::each_run_mut(&mut math.nodes, &mut |r| r.color = None);
+ Some(c)
+}
+
+/// `m:oMathParaPr/m:jc`.
+pub fn para_jc(para: &El) -> MathJc {
+ match para.child("m:oMathParaPr").and_then(|p| p.child("m:jc")).and_then(|j| j.attr_or_plain("m:val")) {
+ Some("left") => MathJc::Left,
+ Some("right") => MathJc::Right,
+ Some("center") => MathJc::Center,
+ _ => MathJc::CenterGroup,
+ }
+}
+
+/// The settings part's `m:mathPr`: how display equations wrap.
+pub fn read_math_pr(e: &El) -> MathProps {
+ let mut m = MathProps::default();
+ let pr = Some(e);
+ m.brk_bin = match val(pr, "m:brkBin") {
+ Some("after") => BrkBin::After,
+ Some("repeat") => BrkBin::Repeat,
+ _ => BrkBin::Before,
+ };
+ m.brk_bin_sub = match val(pr, "m:brkBinSub") {
+ Some("-+") => BrkBinSub::MinusPlus,
+ Some("+-") => BrkBinSub::PlusMinus,
+ _ => BrkBinSub::MinusMinus,
+ };
+ if let Some(v) = e.child("m:wrapIndent").and_then(|c| measure_twips(c.attr_or_plain("m:val"))).filter(|v| v.is_finite() && *v >= 0.0) {
+ m.wrap_indent = v.min(1584.0);
+ }
+ m.wrap_right = prop(pr, "m:wrapRight", false);
+ m
+}
+
+/// OMML on/off property: absent `m:val` = on.
+fn on(e: &El) -> bool {
+ match e.attr_or_plain("m:val") {
+ None => true,
+ Some(v) => !matches!(v.trim(), "0" | "false" | "off" | "False" | "FALSE"),
+ }
+}
+
+/// An on/off property `name` in the properties element `pr` (absent = `default`).
+fn prop(pr: Option<&El>, name: &str, default: bool) -> bool {
+ pr.and_then(|p| p.child(name)).map(on).unwrap_or(default)
+}
+
+/// A character property (`m:chr`, `m:begChr`…): `None` when absent, `Some(None)` when empty.
+fn chr(pr: Option<&El>, name: &str) -> Option