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)", "sin⁡x+cos⁡y", "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 `` element. `linear` (the linear format) goes in an annotation for an +/// exact round trip; empty = taken from the tree. +pub fn to_mathml(nodes: &[MNode], display: bool, linear: &str) -> String { + let mut o = String::new(); + o.push_str(&format!(""); + arg(nodes, &mut o, 0, false); + o.push_str(""); + let linear = if linear.is_empty() { to_linear(nodes) } else { linear.to_string() }; + if !linear.is_empty() { + o.push_str(&format!("{}", esc(&linear))); + } + o.push_str(""); + o +} + +/// An equation known by its linear format, as a `` element. +pub fn linear_to_mathml(linear: &str, display: bool) -> String { + to_mathml(&crate::math::parse_linear(linear), display, linear) +} + +/// An argument as a row of elements. `eq_arr`: a row of an equation array, whose `&` marks are +/// alignment points, not text. +fn arg(a: &[MNode], o: &mut String, depth: usize, eq_arr: bool) { + if depth > MAX_DEPTH { + return; + } + for n in a { + node(n, o, depth + 1, eq_arr); + } +} + +/// An argument as one element (`` around several). +fn group(a: &[MNode], o: &mut String, depth: usize) { + o.push_str(""); + arg(a, o, depth, false); + o.push_str(""); +} + +fn mo(c: char, attrs: &str, o: &mut String) { + o.push_str(&format!("{}", esc(&c.to_string()))); +} + +fn run(r: &MRun, o: &mut String, eq_arr: bool) { + let color = r.color.map(|c| format!(" mathcolor=\"#{}\"", c.hex())).unwrap_or_default(); + if r.nor { + if !r.text.is_empty() { + o.push_str(&format!("{}", esc(&r.text))); + } + return; + } + let bold = matches!(r.sty, Some(MSty::Bold | MSty::BoldItalic)); + let italic = match r.sty { + None => r.scr == MScr::Roman, + Some(s) => matches!(s, MSty::Italic | MSty::BoldItalic), + }; + let cs: Vec = r.text.chars().collect(); + let mut i = 0; + while let Some(&c) = cs.get(i) { + if c.is_ascii_digit() || (c == '.' && cs.get(i + 1).is_some_and(char::is_ascii_digit)) { + let mut num = String::new(); + while let Some(&d) = + cs.get(i).filter(|d| d.is_ascii_digit() || **d == '.' || **d == ',' && cs.get(i + 1).is_some_and(char::is_ascii_digit)) + { + num.push(d); + i += 1; + } + let num: String = if bold { num.chars().map(|d| math_alnum(d, true, false, MScr::Roman)).collect() } else { num }; + o.push_str(&format!("{}", esc(&num))); + continue; + } + i += 1; + if c.is_whitespace() || (eq_arr && c == '&') { + continue; + } + if c.is_alphabetic() { + if bold || r.scr != MScr::Roman { + let m = math_alnum(c, bold, italic, r.scr); + let plain = if m == c && !italic { " mathvariant=\"normal\"" } else { "" }; + o.push_str(&format!("{}", esc(&m.to_string()))); + } else if italic { + o.push_str(&format!("{}", esc(&c.to_string()))); + } else { + o.push_str(&format!("{}", esc(&c.to_string()))); + } + } else if math_class(c) == MClass::Ord && !c.is_ascii_punctuation() && c != '\u{2061}' { + // ∞, ∂, ∅, ℏ…: identifiers, not operators. + o.push_str(&format!("{}", esc(&c.to_string()))); + } else { + mo(c, &color, o); + } + } +} + +/// A function name (`sin`, `lim`): one upright identifier when it's plain text. +fn func_name(name: &[MNode], o: &mut String, depth: usize) { + match name { + [MNode::Run(r)] if !r.nor && r.text.chars().all(char::is_alphabetic) && !r.text.is_empty() => { + o.push_str(&format!("{}", esc(&r.text))); + } + _ => group(name, o, depth), + } +} + +fn fraction(kind: FracKind, num: &[MNode], den: &[MNode], o: &mut String, depth: usize) { + match kind { + FracKind::Bar | FracKind::NoBar => { + o.push_str(if kind == FracKind::NoBar { "" } else { "" }); + group(num, o, depth); + group(den, o, depth); + o.push_str(""); + } + FracKind::Skewed | FracKind::Linear => { + o.push_str(""); + group(num, o, depth); + mo(if kind == FracKind::Skewed { '⁄' } else { '/' }, "", o); + group(den, o, depth); + o.push_str(""); + } + } +} + +fn script(kind: ScriptKind, base: &[MNode], sub: &[MNode], sup: &[MNode], o: &mut String, depth: usize) { + match kind { + ScriptKind::Sup => { + o.push_str(""); + group(base, o, depth); + group(sup, o, depth); + o.push_str(""); + } + ScriptKind::Sub => { + o.push_str(""); + group(base, o, depth); + group(sub, o, depth); + o.push_str(""); + } + ScriptKind::SubSup => { + o.push_str(""); + group(base, o, depth); + group(sub, o, depth); + group(sup, o, depth); + o.push_str(""); + } + ScriptKind::Pre => { + o.push_str(""); + group(base, o, depth); + o.push_str(""); + if sub.is_empty() { + o.push_str(""); + } else { + group(sub, o, depth); + } + if sup.is_empty() { + o.push_str(""); + } else { + group(sup, o, depth); + } + o.push_str(""); + } + } +} + +fn write_nary(n: &MNode, o: &mut String, depth: usize) { + let MNode::Nary { chr, lim_loc, sub_hide, sup_hide, sub, sup, e, .. } = n else { return }; + let under_over = match lim_loc { + Some(l) => *l == LimLoc::UndOvr, + None => !is_integral(*chr), + }; + let has_sub = !*sub_hide && !sub.is_empty(); + let has_sup = !*sup_hide && !sup.is_empty(); + let tag = match (under_over, has_sub, has_sup) { + (_, false, false) => "", + (true, true, true) => "munderover", + (true, true, false) => "munder", + (true, false, true) => "mover", + (false, true, true) => "msubsup", + (false, true, false) => "msub", + (false, false, true) => "msup", + }; + o.push_str(""); + if !tag.is_empty() { + o.push_str(&format!("<{tag}>")); + } + mo(*chr, " largeop=\"true\" movablelimits=\"false\"", o); + if has_sub { + group(sub, o, depth); + } + if has_sup { + group(sup, o, depth); + } + if !tag.is_empty() { + o.push_str(&format!("")); + } + group(e, o, depth); + o.push_str(""); +} + +fn delimiters(n: &MNode, o: &mut String, depth: usize) { + let MNode::Delim { beg, end, sep, grow, elems, .. } = n else { return }; + let stretchy = if *grow { "" } else { " stretchy=\"false\"" }; + o.push_str(""); + if let Some(begin) = beg { + mo(*begin, &format!(" fence=\"true\" form=\"prefix\"{stretchy}"), o); + } + for (i, element) in elems.iter().enumerate() { + if i > 0 { + mo(sep.unwrap_or('|'), &format!(" separator=\"true\"{stretchy}"), o); + } + group(element, o, depth); + } + if let Some(close) = end { + mo(*close, &format!(" fence=\"true\" form=\"postfix\"{stretchy}"), o); + } + o.push_str(""); +} + +fn node(n: &MNode, o: &mut String, depth: usize, eq_arr: bool) { + match n { + MNode::Run(r) => run(r, o, eq_arr), + MNode::Frac { kind, num, den } => fraction(*kind, num, den, o, depth), + MNode::Script { kind, base, sub, sup } => script(*kind, base, sub, sup, o, depth), + MNode::Rad { deg, deg_hide, e } => { + if *deg_hide || deg.is_empty() { + o.push_str(""); + arg(e, o, depth, false); + o.push_str(""); + } else { + o.push_str(""); + group(e, o, depth); + group(deg, o, depth); + o.push_str(""); + } + } + MNode::Nary { .. } => write_nary(n, o, depth), + MNode::Delim { .. } => delimiters(n, o, depth), + MNode::Func { name, e } => { + o.push_str(""); + func_name(name, o, depth); + mo('\u{2061}', "", o); + group(e, o, depth); + o.push_str(""); + } + MNode::Lim { upper, e, lim } => { + let tag = if *upper { "mover" } else { "munder" }; + o.push_str(&format!("<{tag}>")); + func_name(e, o, depth); + group(lim, o, depth); + o.push_str(&format!("")); + } + MNode::Acc { chr, e } => { + o.push_str(""); + group(e, o, depth); + mo(spacing_accent(*chr), "", o); + o.push_str(""); + } + MNode::Bar { top, e } => { + if *top { + o.push_str(""); + group(e, o, depth); + mo('\u{203E}', "", o); + o.push_str(""); + } else { + o.push_str(""); + group(e, o, depth); + mo('_', "", o); + o.push_str(""); + } + } + MNode::BorderBox { hide, strike, e } => { + let mut notation: Vec<&str> = Vec::new(); + for (h, name) in hide.iter().zip(["top", "bottom", "left", "right"]) { + if !h { + notation.push(name); + } + } + if notation.len() == 4 { + notation = vec!["box"]; + } + for (s, name) in strike.iter().zip(["horizontalstrike", "verticalstrike", "updiagonalstrike", "downdiagonalstrike"]) { + if *s { + notation.push(name); + } + } + o.push_str(&format!("", notation.join(" "))); + arg(e, o, depth, false); + o.push_str(""); + } + MNode::Boxed { e } => group(e, o, depth), + MNode::GroupChr { chr, top, e } => { + let tag = if *top { "mover" } else { "munder" }; + o.push_str(&format!("<{tag}>")); + group(e, o, depth); + mo(*chr, " stretchy=\"true\"", o); + o.push_str(&format!("")); + } + MNode::EqArr { rows } => { + // `displaystyle` marks an equation array; a bare one-column table reads as a matrix. + o.push_str(""); + for r in rows { + o.push_str(""); + arg(r, o, depth, true); + o.push_str(""); + } + o.push_str(""); + } + MNode::Matrix { rows, col_jc } => { + o.push_str(""); + for r in rows { + o.push_str(""); + for (k, cell) in r.iter().enumerate() { + match col_jc.get(k) { + Some(ColJc::Left) => o.push_str(""), + Some(ColJc::Right) => o.push_str(""), + _ => o.push_str(""), + } + arg(cell, o, depth, false); + o.push_str(""); + } + o.push_str(""); + } + o.push_str(""); + } + MNode::Phant { show, zero_wid, zero_asc, zero_desc, e } => { + let mut pad = String::new(); + for (z, a) in [(zero_wid, "width"), (zero_asc, "height"), (zero_desc, "depth")] { + if *z { + pad.push_str(&format!(" {a}=\"0\"")); + } + } + if !pad.is_empty() { + o.push_str(&format!("")); + } + o.push_str(if *show { "" } else { "" }); + arg(e, o, depth, false); + o.push_str(if *show { "" } else { "" }); + if !pad.is_empty() { + o.push_str(""); + } + } + } +} + +/// Combining accents (how equations keep them) and the spacing characters MathML puts over the +/// base, both ways. +const ACCENTS: &[(char, char)] = &[ + ('\u{0302}', '^'), + ('\u{0303}', '~'), + ('\u{0304}', '¯'), + ('\u{0305}', '\u{203E}'), + ('\u{0306}', '˘'), + ('\u{0307}', '˙'), + ('\u{0308}', '¨'), + ('\u{030C}', 'ˇ'), + ('\u{0301}', '´'), + ('\u{0300}', '`'), + ('\u{030A}', '˚'), + ('\u{20D7}', '→'), + ('\u{20D6}', '←'), + ('\u{20E1}', '↔'), + ('\u{20D1}', '⇀'), + ('\u{20D0}', '↼'), +]; + +fn spacing_accent(c: char) -> char { + ACCENTS.iter().find(|(comb, _)| *comb == c).map(|(_, s)| *s).unwrap_or(c) +} + +fn combining_accent(c: char) -> Option { + let c = match c { + 'ˆ' => '^', + '˜' => '~', + '‾' => '\u{203E}', + '\u{20D7}' | '\u{0302}' | '\u{0303}' | '\u{0307}' | '\u{0308}' => return Some(c), + c => c, + }; + ACCENTS.iter().find(|(_, s)| *s == c).map(|(comb, _)| *comb) +} + +// --------------------------------------------------------------------------------------------- +// Reader + +/// Most elements read from one `` element. +pub const MAX_NODES: usize = 20_000; + +/// A parsed MathML element (namespace prefixes dropped, names lowercase). +#[derive(Clone, Debug, Default, PartialEq)] +pub struct El { + pub name: String, + pub attrs: Vec<(String, String)>, + pub children: Vec, +} + +#[derive(Clone, Debug, PartialEq)] +pub enum Item { + El(El), + Text(String), +} + +impl El { + fn attr(&self, name: &str) -> Option<&str> { + self.attrs.iter().find(|(k, _)| k == name).map(|(_, v)| v.as_str()) + } + fn els(&self) -> impl Iterator { + self.children.iter().filter_map(|c| match c { + Item::El(e) => Some(e), + Item::Text(_) => None, + }) + } + /// The text inside, all descendants (depth-limited). + pub fn text(&self) -> String { + let mut s = String::new(); + text_into(self, &mut s, 0); + s + } +} + +fn text_into(e: &El, s: &mut String, depth: usize) { + if depth > MAX_DEPTH { + return; + } + for c in &e.children { + match c { + Item::Text(t) => s.push_str(t), + Item::El(c) => text_into(c, s, depth + 1), + } + } +} + +/// The first `` element of XML text (the root or a descendant, any prefix), as an [`El`]: +/// for MathML read from an XML file or a clipboard format. At most [`MAX_NODES`] elements are +/// kept; `None` when there is no `math` element or the text is not XML. +pub fn parse(xml: &str) -> Option { + let limits = craft_xml::Limits { max_nodes: MAX_NODES.saturating_mul(4), max_depth: MAX_DEPTH.saturating_mul(2), ..craft_xml::Limits::DEFAULT }; + let (root, _) = craft_xml::Options { ns_decls: false, ..craft_xml::Options::default() }.with_limits(limits).parse_streaming_partial( + xml.as_bytes(), + &[], + |_| Ok::<(), craft_xml::Error>(()), + ); + let root = root?; + let math = if local(&root.name).eq_ignore_ascii_case("math") { &root } else { root.find(craft_xml::Local("math"))? }; + let mut budget = MAX_NODES; + Some(from_xml(math, 0, &mut budget)) +} + +fn from_xml(e: &craft_xml::El, depth: usize, budget: &mut usize) -> El { + let mut out = El { name: e.name.to_ascii_lowercase(), attrs: e.attrs.clone(), children: Vec::new() }; + if depth > MAX_DEPTH { + return out; + } + for n in &e.children { + match n { + craft_xml::Node::El(c) if *budget > 0 => { + *budget -= 1; + out.children.push(Item::El(from_xml(c, depth + 1, budget))); + } + craft_xml::Node::Text(t) => out.children.push(Item::Text(t.clone())), + _ => {} + } + } + out +} + +/// The element name without a namespace prefix (`m:math`, `mml:mfrac`). +pub fn local(name: &str) -> &str { + name.rsplit(':').next().unwrap_or(name) +} + +/// The linear format of a `` element, and whether it's a display equation. `None` when it +/// holds nothing. +pub fn read(math: &El) -> Option<(String, bool)> { + let display = + math.attr("display").is_some_and(|d| d.eq_ignore_ascii_case("block")) || math.attr("mode").is_some_and(|d| d.eq_ignore_ascii_case("display")); + let linear = linear_of(math); + let linear: String = linear.chars().filter(|c| !c.is_control()).collect(); + let linear = linear.trim().to_string(); + (!linear.is_empty()).then_some((linear, display)) +} + +fn linear_of(math: &El) -> String { + // Our own copy carries the linear format exactly. + if let Some(sem) = math.els().find(|e| local(&e.name) == "semantics") { + let ann = |enc: &dyn Fn(&str) -> bool| { + sem.els() + .filter(|e| local(&e.name) == "annotation") + .find(|e| e.attr("encoding").is_some_and(|v| enc(&v.to_ascii_lowercase()))) + .map(El::text) + .filter(|t| !t.trim().is_empty()) + }; + if let Some(t) = ann(&|v| v == UNICODEMATH) { + return t; + } + let tree = to_tree(math); + if !tree.is_empty() { + return to_linear(&tree); + } + if let Some(t) = ann(&|v| v.contains("tex")) { + return to_linear(&crate::math_latex::parse_latex(&t)); + } + return String::new(); + } + to_linear(&to_tree(math)) +} + +/// A MathML element's children as an equation tree. +pub fn to_tree(math: &El) -> Arg { + let mut budget = MAX_NODES; + crate::math::merge_runs(children(math, 0, &mut budget)) +} + +fn children(e: &El, depth: usize, budget: &mut usize) -> Arg { + let els: Vec<&El> = e.els().collect(); + seq(&els, depth, budget) +} + +/// A sequence of elements; an n-ary operator without an operand takes the next element. +fn seq(els: &[&El], depth: usize, budget: &mut usize) -> Arg { + let mut out: Arg = Vec::new(); + let mut i = 0; + while let Some(el) = els.get(i) { + i += 1; + // A function name (`sin`) applies to the next element even without U+2061, + // so it doesn't merge with it into `sinx`. Before an operator (`(`, U+2061) the runs + // merge and the linear format finds the function itself. + if local(&el.name) == "mi" + && let Some(next) = els.get(i).filter(|n| local(&n.name) != "mo") + { + let t = el.text(); + let t = t.trim(); + if FUNCTION_NAMES.contains(&t) && *budget > 0 { + *budget -= 1; + i += 1; + out.push(MNode::Func { name: vec![MNode::Run(MRun::plain(t))], e: elem(next, depth + 1, budget) }); + continue; + } + } + let mut a = elem(el, depth, budget); + if let [MNode::Nary { e, .. }] = a.as_mut_slice() + && e.is_empty() + && let Some(next) = els.get(i) + { + i += 1; + *e = elem(next, depth, budget); + } + out.extend(a); + } + out +} + +/// The `k`th child element as an argument. +fn nth(e: &El, k: usize, depth: usize, budget: &mut usize) -> Arg { + e.els().nth(k).map(|c| elem(c, depth, budget)).unwrap_or_default() +} + +/// A lone operator character (`∑`). +fn lone_char(a: &[MNode]) -> Option { + match a { + [MNode::Run(r)] => { + let mut cs = r.text.trim().chars(); + let c = cs.next()?; + cs.next().is_none().then_some(c) + } + _ => None, + } +} + +/// A plain word (`lim`, `max`). +fn word(a: &[MNode]) -> bool { + matches!(a, [MNode::Run(r)] if r.text.chars().count() > 1 && r.text.chars().all(char::is_alphabetic)) +} + +fn elem(e: &El, depth: usize, budget: &mut usize) -> Arg { + if depth > MAX_DEPTH || *budget == 0 { + return Vec::new(); + } + *budget -= 1; + let d = depth + 1; + match local(&e.name) { + "mi" | "mn" | "mo" | "ms" => { + let t = e.text(); + let t = t.trim(); + if t.is_empty() { + return Vec::new(); + } + let mut r = MRun::new(t); + if local(&e.name) == "mi" && e.attr("mathvariant") == Some("normal") && t.chars().count() == 1 { + r.sty = Some(MSty::Plain); + } + vec![MNode::Run(r)] + } + "mtext" => { + let t = e.text(); + if t.trim().is_empty() { + return Vec::new(); + } + vec![MNode::Run(MRun { text: t, nor: true, ..Default::default() })] + } + "mfrac" => { + let thin = e.attr("linethickness").is_some_and(|t| { + let s = t.trim(); + s.eq_ignore_ascii_case("none") || s.trim_end_matches(|c: char| c.is_alphabetic() || c == '%').trim() == "0" + }); + let kind = if e.attr("bevelled").is_some_and(|b| b.eq_ignore_ascii_case("true")) { + FracKind::Skewed + } else if thin { + FracKind::NoBar + } else { + FracKind::Bar + }; + vec![MNode::Frac { kind, num: nth(e, 0, d, budget), den: nth(e, 1, d, budget) }] + } + "msqrt" => vec![MNode::Rad { deg: Vec::new(), deg_hide: true, e: children(e, d, budget) }], + "mroot" => vec![MNode::Rad { deg: nth(e, 1, d, budget), deg_hide: false, e: nth(e, 0, d, budget) }], + "msup" | "msub" | "msubsup" => { + let name = local(&e.name); + let base = nth(e, 0, d, budget); + let (sub, sup) = match name { + "msup" => (Vec::new(), nth(e, 1, d, budget)), + "msub" => (nth(e, 1, d, budget), Vec::new()), + _ => (nth(e, 1, d, budget), nth(e, 2, d, budget)), + }; + if let Some(c) = lone_char(&base).filter(|c| is_nary_char(*c)) { + return vec![nary(c, LimLoc::SubSup, sub, sup)]; + } + let kind = match name { + "msup" => ScriptKind::Sup, + "msub" => ScriptKind::Sub, + _ => ScriptKind::SubSup, + }; + vec![MNode::Script { kind, base, sub, sup }] + } + "munder" | "mover" | "munderover" => { + let name = local(&e.name); + let base = nth(e, 0, d, budget); + let (under, over) = match name { + "munder" => (nth(e, 1, d, budget), Vec::new()), + "mover" => (Vec::new(), nth(e, 1, d, budget)), + _ => (nth(e, 1, d, budget), nth(e, 2, d, budget)), + }; + if let Some(c) = lone_char(&base).filter(|c| is_nary_char(*c)) { + return vec![nary(c, LimLoc::UndOvr, under, over)]; + } + if name == "munderover" { + let inner = MNode::Lim { upper: false, e: base, lim: under }; + return vec![MNode::Lim { upper: true, e: vec![inner], lim: over }]; + } + let upper = name == "mover"; + let mark = if upper { lone_char(&over) } else { lone_char(&under) }; + match mark { + Some('\u{203E}' | '¯' | '‾') if upper => vec![MNode::Bar { top: true, e: base }], + Some('_' | '\u{203E}' | '¯') if !upper => vec![MNode::Bar { top: false, e: base }], + Some(c @ ('⏞' | '⏟' | '⏜' | '⏝' | '⎴' | '⎵' | '←' | '→' | '↔' | '⇐' | '⇒' | '⇔')) if !word(&base) => { + vec![MNode::GroupChr { chr: c, top: upper, e: base }] + } + Some(c) if upper && combining_accent(c).is_some() => vec![MNode::Acc { chr: combining_accent(c).unwrap_or(c), e: base }], + _ => vec![MNode::Lim { upper, e: base, lim: if upper { over } else { under } }], + } + } + "mmultiscripts" => { + let els: Vec<&El> = e.els().collect(); + let base = els.first().map(|b| elem(b, d, budget)).unwrap_or_default(); + let pre_at = els.iter().position(|c| local(&c.name) == "mprescripts"); + let script = + |k: usize, budget: &mut usize| els.get(k).filter(|c| local(&c.name) != "none").map(|c| elem(c, d, budget)).unwrap_or_default(); + match pre_at { + Some(p) => { + let sub = script(p + 1, budget); + let sup = script(p + 2, budget); + let pre_node = MNode::Script { kind: ScriptKind::Pre, base, sub, sup }; + if p > 1 { + let post_sub = script(1, budget); + let post_sup = script(2, budget); + let kind = match (post_sub.is_empty(), post_sup.is_empty()) { + (true, true) => return vec![pre_node], + (false, true) => ScriptKind::Sub, + (true, false) => ScriptKind::Sup, + (false, false) => ScriptKind::SubSup, + }; + vec![MNode::Script { kind, base: vec![pre_node], sub: post_sub, sup: post_sup }] + } else { + vec![pre_node] + } + } + None => { + let sub = script(1, budget); + let sup = script(2, budget); + let kind = match (sub.is_empty(), sup.is_empty()) { + (true, true) => return base, + (false, true) => ScriptKind::Sub, + (true, false) => ScriptKind::Sup, + (false, false) => ScriptKind::SubSup, + }; + vec![MNode::Script { kind, base, sub, sup }] + } + } + } + "mfenced" => { + let first = |a: &str, dflt: char| e.attr(a).map(|v| v.trim().chars().next()).unwrap_or(Some(dflt)); + let beg = first("open", '('); + let end = first("close", ')'); + let sep = first("separators", ','); + let elems: Vec = e.els().map(|c| elem(c, d, budget)).collect(); + vec![MNode::Delim { beg, end, sep, grow: true, shp_match: false, elems }] + } + "mtable" => { + let mtr_list: Vec<&El> = e.els().filter(|r| matches!(local(&r.name), "mtr" | "mlabeledtr")).take(256).collect(); + let rows: Vec> = + mtr_list.iter().map(|r| r.els().filter(|c| local(&c.name) == "mtd").take(256).map(|c| children(c, d, budget)).collect()).collect(); + if rows.is_empty() { + return Vec::new(); + } + // One column is an equation array only when marked as one (`displaystyle`, alignment + // marks, labelled rows, as TeX's align and our own writer give); else a column vector. + let marked = + e.attr("displaystyle").is_some_and(|v| v.trim() == "true") || e.els().any(|r| local(&r.name) == "mlabeledtr") || has_align_mark(e, 0); + if marked && rows.iter().all(|r| r.len() <= 1) { + return vec![MNode::EqArr { rows: rows.into_iter().map(|mut r| r.pop().unwrap_or_default()).collect() }]; + } + let mut col_jc: Vec = e + .attr("columnalign") + .map(|ca| { + ca.split_whitespace() + .map(|a| match a { + "left" => ColJc::Left, + "right" => ColJc::Right, + _ => ColJc::Center, + }) + .collect() + }) + .unwrap_or_default(); + if col_jc.is_empty() + && let Some(first_row) = mtr_list.first() + { + let cell_aligns: Vec = first_row + .els() + .filter(|c| local(&c.name) == "mtd") + .map(|c| match c.attr("columnalign") { + Some("left") => ColJc::Left, + Some("right") => ColJc::Right, + _ => ColJc::Center, + }) + .collect(); + if cell_aligns.iter().any(|a| *a != ColJc::Center) { + col_jc = cell_aligns; + } + } + vec![MNode::Matrix { rows, col_jc }] + } + "mpadded" => { + let zero_wid = e.attr("width").is_some_and(|w| w.trim() == "0"); + let zero_asc = e.attr("height").is_some_and(|h| h.trim() == "0"); + let zero_desc = e.attr("depth").is_some_and(|d| d.trim() == "0"); + let ch = children(e, d, budget); + if zero_wid || zero_asc || zero_desc { + if let [MNode::Phant { show, e, .. }] = ch.as_slice() { + vec![MNode::Phant { show: *show, zero_wid, zero_asc, zero_desc, e: e.clone() }] + } else { + vec![MNode::Phant { show: true, zero_wid, zero_asc, zero_desc, e: ch }] + } + } else { + ch + } + } + "mphantom" => vec![MNode::Phant { show: false, zero_wid: false, zero_asc: false, zero_desc: false, e: children(e, d, budget) }], + "menclose" => { + let n = e.attr("notation").unwrap_or("longdiv"); + let has = |k: &str| n.split_whitespace().any(|w| w.eq_ignore_ascii_case(k)); + if has("radical") { + return vec![MNode::Rad { deg: Vec::new(), deg_hide: true, e: children(e, d, budget) }]; + } + // An actuarial symbol is the top and right edges. + let actuarial = has("actuarial"); + let is_box = has("box") || has("roundedbox") || has("circle"); + let (top, right) = (has("top") || actuarial, has("right") || actuarial); + let has_border = is_box || top || has("bottom") || has("left") || right; + let strike = + [has("horizontalstrike"), has("verticalstrike"), has("updiagonalstrike") || has("cross"), has("downdiagonalstrike") || has("cross")]; + if has_border || strike.iter().any(|&s| s) { + let hide = if is_box { + [false; 4] + } else if has_border { + [!top, !has("bottom"), !has("left"), !right] + } else { + [true; 4] + }; + return vec![MNode::BorderBox { hide, strike, e: children(e, d, budget) }]; + } + children(e, d, budget) + } + // The first child of `semantics` is the presentation; annotations aren't content. + "semantics" => e.els().next().map(|c| elem(c, d, budget)).unwrap_or_default(), + "annotation" | "annotation-xml" | "mspace" | "none" | "mprescripts" | "maligngroup" | "malignmark" => Vec::new(), + _ => children(e, d, budget), + } +} + +/// Whether `e` holds a `maligngroup` or `malignmark` near the top (in a cell or a row inside it). +fn has_align_mark(e: &El, depth: usize) -> bool { + depth <= 4 && e.els().any(|c| matches!(local(&c.name), "maligngroup" | "malignmark") || has_align_mark(c, depth + 1)) +} + +fn nary(chr: char, lim_loc: LimLoc, sub: Arg, sup: Arg) -> MNode { + let lim_loc = Some(lim_loc).filter(|l| *l != if is_integral(chr) { LimLoc::SubSup } else { LimLoc::UndOvr }); + MNode::Nary { chr, lim_loc, grow: false, sub_hide: sub.is_empty(), sup_hide: sup.is_empty(), sub, sup, e: Vec::new() } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::math::parse_linear; + + fn ml(linear: &str) -> String { + to_mathml(&parse_linear(linear), false, "") + } + + #[test] + fn runs_split_into_identifiers_numbers_and_operators() { + let m = ml("y=2x"); + assert!(m.starts_with(""), "{m}"); + assert!(m.contains("y=2x"), "{m}"); + assert!(m.contains("y=2x"), "{m}"); + assert!(to_mathml(&parse_linear("x"), true, "").contains("display=\"block\"")); + } + + #[test] + fn structures_become_their_elements() { + for (linear, want) in [ + ("a/b", "ab"), + ("x^2", "x2"), + ("x_s", "xs"), + ("x_i^2", ""), + ("√x", "x"), + ("√(3&x)", "x3"), + ("∑_(i=1)^n▒i", "∑"), + ("∫_0^1▒x", "∫"), + ("(a+b)", "("), + ("sin⁡x", "sin\u{2061}"), + ("■(a&b@c&d)", "ab"), + ] { + let m = ml(linear); + assert!(m.contains(want), "{linear}: {m}"); + } + let acc = to_mathml(&[MNode::Acc { chr: '\u{0302}', e: parse_linear("a") }], false, ""); + assert!(acc.contains("a^"), "{acc}"); + let bar = to_mathml(&[MNode::Bar { top: true, e: parse_linear("a") }], false, ""); + assert!(bar.contains("\u{203E}"), "{bar}"); + let lim = to_mathml(&[MNode::Lim { upper: false, e: parse_linear("lim"), lim: parse_linear("n→∞") }], false, ""); + assert!(lim.contains("limn→∞"), "{lim}"); + let bb = to_mathml(&[MNode::BorderBox { hide: [false; 4], strike: [false; 4], e: parse_linear("a") }], false, ""); + assert!(bb.contains(""), "{bb}"); + let gc = to_mathml(&[MNode::GroupChr { chr: '⏟', top: false, e: parse_linear("a") }], false, ""); + assert!(gc.contains("a⏟"), "{gc}"); + let pre = to_mathml(&[MNode::Script { kind: ScriptKind::Pre, base: parse_linear("C"), sub: parse_linear("n"), sup: vec![] }], false, ""); + assert!(pre.contains("") && pre.contains(""), "{pre}"); + let eq = to_mathml(&[MNode::EqArr { rows: vec![parse_linear("x&=1"), parse_linear("y&=2")] }], false, ""); + assert!(eq.contains("x=1") && !eq.contains("&"), "{eq}"); + let ph = to_mathml(&[MNode::Phant { show: false, zero_wid: true, zero_asc: false, zero_desc: false, e: parse_linear("a") }], false, ""); + assert!(ph.contains(""), "{ph}"); + let styled = to_mathml( + &[ + MNode::Run(MRun { text: "R".into(), scr: MScr::DoubleStruck, ..Default::default() }), + MNode::Run(MRun::plain("d")), + MNode::Run(MRun { text: "speed".into(), nor: true, ..Default::default() }), + ], + false, + "", + ); + assert!( + styled.contains("ℝ") && styled.contains("d") && styled.contains("speed"), + "{styled}" + ); + // Text is escaped. + assert!(ml("a<")); + } + + fn pasted(xml: &str) -> Vec<(String, bool)> { + parse(xml).and_then(|m| read(&m)).into_iter().collect() + } + + #[test] + fn written_mathml_reads_back_exactly() { + for (linear, display) in [("y=2x_s", false), ("x=(-b±√(b^2-4ac))/2a", true), ("∑_(k=1)^n▒k", false), ("■(a&b@c&d)", false)] { + let xml = linear_to_mathml(linear, display); + assert!(xml.starts_with("Before {xml} after

")), 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("'); + } + 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> { + let c = pr?.child(name)?; + match c.attr_or_plain("m:val") { + Some(v) => Some(v.chars().next()), + None => Some(None), + } +} + +fn val<'a>(pr: Option<&'a El>, name: &str) -> Option<&'a str> { + pr.and_then(|p| p.child(name)).and_then(|c| c.attr_or_plain("m:val")) +} + +/// The argument held by child `name` of `e`. +fn sub_arg(e: &El, name: &str, depth: usize) -> Arg { + e.child(name).map(|c| arg(c, depth + 1)).unwrap_or_default() +} + +/// Read the math content of an element (`m:oMath`, `m:e`, `m:num`…). +fn arg(e: &El, depth: usize) -> Arg { + let mut out = Vec::new(); + if depth > MAX_DEPTH { + return out; + } + for k in e.elements() { + node(k, depth, &mut out); + } + out +} + +fn node(k: &El, depth: usize, out: &mut Arg) { + let d = depth + 1; + match k.name.as_str() { + "m:r" => { + if let Some(r) = run(k) { + out.push(MNode::Run(r)); + } + } + // Word puts ordinary runs in math zones for normal text in some files. + "w:r" => { + let text = run_text(k); + if !text.is_empty() { + let mut r = MRun { text, nor: true, sty: Some(MSty::Plain), ..Default::default() }; + wrpr(k.child("w:rPr"), &mut r); + out.push(MNode::Run(r)); + } + } + "m:f" => { + let pr = k.child("m:fPr"); + let kind = match val(pr, "m:type") { + Some("skw") => FracKind::Skewed, + Some("lin") => FracKind::Linear, + Some("noBar") => FracKind::NoBar, + _ => FracKind::Bar, + }; + out.push(MNode::Frac { kind, num: sub_arg(k, "m:num", d), den: sub_arg(k, "m:den", d) }); + } + "m:sSup" => out.push(MNode::Script { kind: ScriptKind::Sup, base: sub_arg(k, "m:e", d), sub: Vec::new(), sup: sub_arg(k, "m:sup", d) }), + "m:sSub" => out.push(MNode::Script { kind: ScriptKind::Sub, base: sub_arg(k, "m:e", d), sub: sub_arg(k, "m:sub", d), sup: Vec::new() }), + "m:sSubSup" => { + out.push(MNode::Script { kind: ScriptKind::SubSup, base: sub_arg(k, "m:e", d), sub: sub_arg(k, "m:sub", d), sup: sub_arg(k, "m:sup", d) }) + } + "m:sPre" => { + out.push(MNode::Script { kind: ScriptKind::Pre, base: sub_arg(k, "m:e", d), sub: sub_arg(k, "m:sub", d), sup: sub_arg(k, "m:sup", d) }) + } + "m:rad" => { + let pr = k.child("m:radPr"); + out.push(MNode::Rad { deg: sub_arg(k, "m:deg", d), deg_hide: prop(pr, "m:degHide", false), e: sub_arg(k, "m:e", d) }); + } + "m:nary" => { + let pr = k.child("m:naryPr"); + let chr = chr(pr, "m:chr").map(|c| c.unwrap_or('∫')).unwrap_or('∫'); + let lim_loc = match val(pr, "m:limLoc") { + Some("undOvr") => Some(LimLoc::UndOvr), + Some("subSup") => Some(LimLoc::SubSup), + _ => None, + }; + out.push(MNode::Nary { + chr, + lim_loc, + grow: prop(pr, "m:grow", false), + sub_hide: prop(pr, "m:subHide", false), + sup_hide: prop(pr, "m:supHide", false), + sub: sub_arg(k, "m:sub", d), + sup: sub_arg(k, "m:sup", d), + e: sub_arg(k, "m:e", d), + }); + } + "m:d" => { + let pr = k.child("m:dPr"); + let beg = chr(pr, "m:begChr").unwrap_or(Some('(')); + let end = chr(pr, "m:endChr").unwrap_or(Some(')')); + let sep = chr(pr, "m:sepChr").unwrap_or(Some('|')); + let elems: Vec = k.children("m:e").map(|e| arg(e, d)).collect(); + out.push(MNode::Delim { + beg, + end, + sep, + grow: prop(pr, "m:grow", true), + shp_match: val(pr, "m:shp") == Some("match"), + elems: if elems.is_empty() { vec![Vec::new()] } else { elems }, + }); + } + "m:func" => out.push(MNode::Func { name: sub_arg(k, "m:fName", d), e: sub_arg(k, "m:e", d) }), + "m:limLow" => out.push(MNode::Lim { upper: false, e: sub_arg(k, "m:e", d), lim: sub_arg(k, "m:lim", d) }), + "m:limUpp" => out.push(MNode::Lim { upper: true, e: sub_arg(k, "m:e", d), lim: sub_arg(k, "m:lim", d) }), + "m:acc" => { + let c = chr(k.child("m:accPr"), "m:chr").map(|c| c.unwrap_or('\u{302}')).unwrap_or('\u{302}'); + out.push(MNode::Acc { chr: c, e: sub_arg(k, "m:e", d) }); + } + "m:bar" => out.push(MNode::Bar { top: val(k.child("m:barPr"), "m:pos") == Some("top"), e: sub_arg(k, "m:e", d) }), + "m:borderBox" => { + let pr = k.child("m:borderBoxPr"); + out.push(MNode::BorderBox { + hide: [prop(pr, "m:hideTop", false), prop(pr, "m:hideBot", false), prop(pr, "m:hideLeft", false), prop(pr, "m:hideRight", false)], + strike: [ + prop(pr, "m:strikeH", false), + prop(pr, "m:strikeV", false), + prop(pr, "m:strikeBLTR", false), + prop(pr, "m:strikeTLBR", false), + ], + e: sub_arg(k, "m:e", d), + }); + } + "m:box" => out.push(MNode::Boxed { e: sub_arg(k, "m:e", d) }), + "m:groupChr" => { + let pr = k.child("m:groupChrPr"); + let c = chr(pr, "m:chr").map(|c| c.unwrap_or('\u{23DF}')).unwrap_or('\u{23DF}'); + out.push(MNode::GroupChr { chr: c, top: val(pr, "m:pos") == Some("top"), e: sub_arg(k, "m:e", d) }); + } + "m:eqArr" => out.push(MNode::EqArr { rows: k.children("m:e").map(|e| arg(e, d)).collect() }), + "m:m" => { + let mut col_jc = Vec::new(); + if let Some(mcs) = k.child("m:mPr").and_then(|p| p.child("m:mcs")) { + for mc in mcs.children("m:mc") { + let pr = mc.child("m:mcPr"); + let jc = match val(pr, "m:mcJc") { + Some("left") => ColJc::Left, + Some("right") => ColJc::Right, + _ => ColJc::Center, + }; + let count = val(pr, "m:count").and_then(|c| c.trim().parse::().ok()).unwrap_or(1).clamp(1, 64); + for _ in 0..count { + if col_jc.len() < 64 { + col_jc.push(jc); + } + } + } + } + let rows = k.children("m:mr").take(1000).map(|r| r.children("m:e").take(64).map(|e| arg(e, d)).collect()).collect(); + out.push(MNode::Matrix { rows, col_jc }); + } + "m:phant" => { + let pr = k.child("m:phantPr"); + out.push(MNode::Phant { + show: prop(pr, "m:show", true), + zero_wid: prop(pr, "m:zeroWid", false), + zero_asc: prop(pr, "m:zeroAsc", false), + zero_desc: prop(pr, "m:zeroDesc", false), + e: sub_arg(k, "m:e", d), + }); + } + // Tracked insertions show; deletions don't. Hyperlinks, simple fields and direction + // wrappers (EG_PContentMath, §22.1.2.77) keep their runs (#1273). + "w:ins" | "w:moveTo" | "w:smartTag" | "w:customXml" | "w:hyperlink" | "w:fldSimple" | "w:dir" | "w:bdo" | "m:oMath" if depth <= MAX_DEPTH => { + for c in k.elements() { + node(c, d, out); + } + } + // Markup compatibility: the math choice (we read math), else the fallback. + "mc:AlternateContent" if depth <= MAX_DEPTH => { + if let Some(c) = k.child("mc:Choice").or_else(|| k.child("mc:Fallback")) { + for c in c.elements() { + node(c, d, out); + } + } + } + "w:sdt" => { + if let Some(c) = k.child("w:sdtContent") { + for c in c.elements() { + node(c, d, out); + } + } + } + _ => {} + } +} + +/// `m:t` (and `w:t`) text of a run, with symbol characters (`w:sym`) and special hyphens. +/// Text in a symbol font (Symbol, Wingdings…) becomes its Unicode equivalent: the math font has +/// no glyphs at those private-use codes (#1273). +fn run_text(r: &El) -> String { + let font = r + .child("w:rPr") + .and_then(|p| p.child("w:rFonts")) + .and_then(|f| f.attr_or_plain("w:ascii").or_else(|| f.attr_or_plain("w:hAnsi"))) + .unwrap_or(""); + let mut s = String::new(); + for c in r.elements() { + match c.name.as_str() { + "m:t" | "w:t" => s.push_str(&symbol_fonts::map_text(font, &c.text())), + "w:tab" => s.push('\t'), + "w:noBreakHyphen" => s.push('\u{2011}'), + "w:sym" => { + let ch = c + .attr_or_plain("w:char") + .and_then(|h| u32::from_str_radix(h.trim(), 16).ok()) + .and_then(char::from_u32) + .filter(|c| *c as u32 >= 0x20); + if let Some(ch) = ch { + let f = c.attr_or_plain("w:font").unwrap_or(font); + s.push(symbol_fonts::symbol_char(f, ch).unwrap_or(ch)); + } + } + _ => {} + } + } + s +} + +fn run(r: &El) -> Option { + let text = run_text(r); + if text.is_empty() { + return None; + } + let mut out = MRun { text, ..Default::default() }; + if let Some(pr) = r.child("m:rPr") { + out.sty = match pr.child("m:sty").and_then(|s| s.attr_or_plain("m:val")) { + Some("p") => Some(MSty::Plain), + Some("b") => Some(MSty::Bold), + Some("i") => Some(MSty::Italic), + Some("bi") => Some(MSty::BoldItalic), + _ => None, + }; + out.scr = match pr.child("m:scr").and_then(|s| s.attr_or_plain("m:val")) { + Some("script") => MScr::Script, + Some("fraktur") => MScr::Fraktur, + Some("double-struck") => MScr::DoubleStruck, + Some("sans-serif") => MScr::SansSerif, + Some("monospace") => MScr::Monospace, + _ => MScr::Roman, + }; + out.nor = pr.child("m:nor").is_some_and(on); + out.lit = pr.child("m:lit").is_some_and(on); + if let Some(b) = pr.child("m:brk") { + let aln = b.attr_or_plain("m:alnAt").and_then(|v| v.trim().parse::().ok()).unwrap_or(0); + out.brk = Some(aln.min(255) as u8); + } + } + wrpr(r.child("w:rPr"), &mut out); + Some(out) +} + +/// The run formatting a math run takes from `w:rPr`: size, colour, normal-text font and weight. +fn wrpr(pr: Option<&El>, out: &mut MRun) { + let Some(pr) = pr else { return }; + if let Some(sz) = pr.child("w:sz").and_then(|s| s.attr_or_plain("w:val")).and_then(|v| v.trim().parse::().ok()) { + let pt = sz / 2.0; + if pt.is_finite() && (1.0..=1638.0).contains(&pt) { + out.size = Some(pt); + } + } + if let Some(c) = pr.child("w:color").and_then(|c| c.attr_or_plain("w:val")).and_then(Rgb::parse) { + out.color = Some(c); + } + if out.nor { + // Symbol-font text was mapped to Unicode: it takes the text font. + if let Some(f) = pr + .child("w:rFonts") + .and_then(|f| f.attr_or_plain("w:ascii").or_else(|| f.attr_or_plain("w:hAnsi"))) + .filter(|f| !symbol_fonts::is_symbol_font(f)) + { + out.font = Some(f.to_string()); + } + let bold = pr.child("w:b").is_some_and(on_off); + let italic = pr.child("w:i").is_some_and(on_off); + if bold || italic { + out.sty = Some(match (bold, italic) { + (true, true) => MSty::BoldItalic, + (true, false) => MSty::Bold, + _ => MSty::Italic, + }); + } + } +} diff --git a/crates/math/src/omml/tests.rs b/crates/math/src/omml/tests.rs new file mode 100644 index 000000000..863bacf4f --- /dev/null +++ b/crates/math/src/omml/tests.rs @@ -0,0 +1,191 @@ +use super::*; +use crate::math::*; + +fn omath(inner: &str) -> El { + let x = format!( + r#"{inner}"# + ); + parse(&x).unwrap() +} + +#[test] +fn reads_fraction_radical_and_scripts() { + let e = omath( + r#"x=-b±b2-4ac2a"#, + ); + let m = read_omath(&e, MathJc::CenterGroup); + assert_eq!(m.nodes.len(), 2); + let MNode::Frac { num, den, kind } = &m.nodes[1] else { panic!("{:#?}", m.nodes) }; + assert_eq!(*kind, FracKind::Bar); + assert_eq!(den, &vec![MNode::Run(MRun::new("2a"))]); + let MNode::Rad { deg_hide, e, .. } = &num[1] else { panic!() }; + assert!(*deg_hide); + assert!(matches!(e[0], MNode::Script { kind: ScriptKind::Sup, .. })); + assert_eq!(crate::math::to_linear(&m.nodes), "x=(-b±√(b^2-4ac))/2a"); + assert!(m.omml.starts_with("")); +} + +#[test] +fn reads_nary_delims_matrix_and_defaults() { + let e = omath( + r#"iiab"#, + ); + let m = read_omath(&e, MathJc::CenterGroup); + let MNode::Nary { chr, lim_loc, .. } = &m.nodes[0] else { panic!() }; + assert_eq!((*chr, *lim_loc), ('∑', Some(LimLoc::UndOvr))); + let MNode::Delim { beg, end, sep, elems, .. } = &m.nodes[1] else { panic!() }; + assert_eq!((*beg, *end, *sep, elems.len()), (Some('['), None, Some('|'), 2)); + assert!(matches!(m.nodes[2], MNode::Nary { chr: '∫', .. })); + assert!(matches!(&m.nodes[3], MNode::Matrix { rows, .. } if rows.len() == 1 && rows[0].len() == 2)); +} + +/// Every run's text in the tree, in reading order. +fn texts(m: &mut Math) -> Vec { + let mut out = Vec::new(); + crate::math_tree::each_run_mut(&mut m.nodes, &mut |r| out.push(r.text.clone())); + out +} + +fn t(s: &str) -> String { + format!("{s}") +} + +/// #1273: one equation using every Office Math element of ECMA-376 §22.1 (each argument +/// holding a distinct marker) keeps every argument's text: nothing is silently dropped. +#[test] +fn every_omml_element_keeps_its_content() { + let parts = [ + format!("{}", t("A1")), + format!("{}", t("B1")), + format!("{}", t("C1")), + format!("{}", t("D1")), + format!("{}{}", t("E1"), t("E2")), + format!("{}{}", t("F1"), t("F2")), + format!("{}{}", t("G1"), t("G2")), + format!("{}{}", t("H1"), t("H2")), + format!("{}", t("I1")), + format!("{}{}", t("J1"), t("J2")), + format!("{}{}", t("K1"), t("K2")), + format!("{}{}{}", t("L1"), t("L2"), t("L3")), + format!( + "{}{}{}", + t("M1"), + t("M2"), + t("M3") + ), + format!("{}", t("N1")), + format!("{}{}", t("O1"), t("O2")), + format!("{}{}{}", t("P1"), t("P2"), t("P3")), + format!("{}{}", t("Q1"), t("Q2")), + format!("{}{}{}", t("R1"), t("R2"), t("R3")), + format!("{}{}", t("S1"), t("S2")), + // Run-level wrappers allowed in math (EG_PContentMath): hyperlinks, simple fields, + // tracked insertions, content controls, markup compatibility. + format!("{}", t("T1")), + format!("{}", t("T2")), + format!("{}", t("T3")), + format!("{}", t("T4")), + format!( + "{}picture", + t("T5") + ), + "T6".to_string(), + ]; + let x = format!( + r#"{}"#, + parts.concat() + ); + let e = parse(&x).unwrap(); + let mut m = read_omath(&e, MathJc::CenterGroup); + let got = texts(&mut m).concat(); + let markers = [ + "A1", "B1", "C1", "D1", "E1", "E2", "F1", "F2", "G1", "G2", "H1", "H2", "I1", "J1", "J2", "K1", "K2", "L1", "L2", "L3", "M1", "M2", "M3", + "N1", "O1", "O2", "P1", "P2", "P3", "Q1", "Q2", "R1", "R2", "R3", "S1", "S2", "T1", "T2", "T3", "T4", "T5", "T6", + ]; + for k in markers { + assert!(got.contains(k), "{k} dropped from {got}"); + } + assert!(!got.contains("picture"), "the math choice wins over the fallback: {got}"); +} + +/// #1273: deleted text stays out; symbol characters and symbol-font text become Unicode. +#[test] +fn symbols_and_deletions_in_math() { + let e = omath( + r#"xgone"#, + ); + let mut m = read_omath(&e, MathJc::CenterGroup); + let got = texts(&mut m).concat(); + assert_eq!(got, "xαβ\u{2011}γ"); + let mut font = None; + crate::math_tree::each_run_mut(&mut m.nodes, &mut |r| { + if r.nor { + font = Some(r.font.clone()); + } + }); + assert_eq!(font, Some(None), "mapped symbol text takes the text font"); +} + +/// Hostile nesting through the wrappers is bounded (no stack overflow, no panic). +#[test] +fn deeply_nested_wrappers_are_bounded() { + // Deeper than the math limit, within the XML parser's own depth limit. + let n = 120; + let inner = format!("{}{}{}", "".repeat(n), t("deep"), "".repeat(n)); + let e = omath(&inner); + let _ = read_omath(&e, MathJc::CenterGroup); + let inner = format!( + "{}{}{}", + "".repeat(n), + t("deep"), + "".repeat(n) + ); + let e = omath(&inner); + let _ = read_omath(&e, MathJc::CenterGroup); +} + +#[test] +fn text_entry_points_find_the_equation_and_refuse_junk() { + let ns = r#"xmlns:m="http://schemas.openxmlformats.org/officeDocument/2006/math""#; + // Any prefix, inside other markup, as a display paragraph with its justification. + let x = r#"

x

"#; + let m = read_str(x).unwrap(); + assert_eq!((crate::math::to_linear(&m.nodes).as_str(), m.jc), ("x", MathJc::Left)); + // The strict OOXML namespace. + let strict = r#"y"#; + assert_eq!(read_str(strict).map(|m| crate::math::to_linear(&m.nodes)).as_deref(), Some("y")); + // Kept OMML, prefixes without declarations. + assert_eq!(read_str("z").map(|m| crate::math::to_linear(&m.nodes)).as_deref(), Some("z")); + for junk in ["", "

", "not xml", "").is_none() && read_str("").is_none()); + // More elements than the reader keeps: refused, not read in part. + let wide = format!("{}", "".repeat(MAX_ELEMENTS * 2 + 1)); + assert!(parse(&wide).is_none()); + let fits = format!("{}", "a".repeat(1000)); + assert!(parse(&fits).is_some()); + // Deep nesting is bounded. + let deep = format!("{}x{}", "".repeat(5000), "".repeat(5000)); + let _ = read_str(&deep); +} + +#[test] +fn kept_omml_is_written_back_only_when_well_formed() { + let m = read_str(r#"x"#).unwrap(); + assert_eq!(write_str("", false, &m, None), m.omml); + let broken = Math { omml: "".into(), ..m.clone() }; + let out = write_str("", false, &broken, None); + assert!(out.starts_with("") && out.ends_with("") && out.contains(">x"), "{out}"); + // A colour that differs from the kept one rewrites the equation from the tree, with the colour. + let red = write_str("", false, &m, Some(Rgb(255, 0, 0))); + assert!(red.contains(r#""#), "{red}"); +} + +proptest::proptest! { + #[test] + fn any_text_reads_without_panicking(s in "[<>/=\"a-z:m ]{0,120}") { + let _ = read_str(&s); + let _ = crate::mathml::parse(&s).map(|e| crate::mathml::to_tree(&e)); + } +} diff --git a/crates/math/src/omml/write.rs b/crates/math/src/omml/write.rs new file mode 100644 index 000000000..9f09aab67 --- /dev/null +++ b/crates/math/src/omml/write.rs @@ -0,0 +1,480 @@ +//! The tree as OMML. + +use super::read::{lift_color, read_omath}; +use super::{W, is_well_formed, parse_kept, twips}; +use crate::math::{ + Arg, BrkBin, BrkBinSub, ColJc, FracKind, LimLoc, MAX_DEPTH, MAX_MATRIX_COLS, MAX_MATRIX_ROWS, MNode, MRun, MScr, MSty, Math, MathJc, MathProps, + ScriptKind, parse_linear, +}; +use crate::rgb::Rgb; + +/// Write an equation: `m:oMath`, inside `m:oMathPara` for a display equation. The source OMML +/// is written back unchanged when the equation came from a file. `color` (the equation's run +/// colour) goes on every math run without its own: OMML has no equation-level colour (#1467). +pub fn write_equation(w: &mut W, linear: &str, display: bool, math: &Math, color: Option) { + let tinted; + let math = if color == omml_color(&math.omml) { + math + } else { + // The kept source XML has the old colour baked in: write from the nodes instead. + let mut m = math.clone(); + if m.nodes.is_empty() { + m.nodes = parse_linear(linear); + } + if let Some(c) = color { + crate::math_tree::each_run_mut(&mut m.nodes, &mut |r| { + r.color.get_or_insert(c); + }); + } + m.omml.clear(); + tinted = m; + &tinted + }; + if display { + w.open("m:oMathPara", &[]); + let jc = match math.jc { + MathJc::CenterGroup => None, + MathJc::Center => Some("center"), + MathJc::Left => Some("left"), + MathJc::Right => Some("right"), + }; + if let Some(jc) = jc { + w.open("m:oMathParaPr", &[]); + w.empty("m:jc", &[("m:val", jc)]); + w.close("m:oMathParaPr"); + } + } + if !math.omml.is_empty() && math.omml.starts_with(" Option { + if !display { + return None; + } + let [MNode::EqArr { rows }] = math.nodes.as_slice() else { return None }; + let mut out = rows.clone(); + let mut changed = false; + for row in out.iter_mut() { + if !crate::math::has_number_mark(row) { + continue; + } + *counter = counter.saturating_add(1); + // Nothing after the `#`: an automatic number. + let mut after = false; + let mut empty = true; + for n in row.iter() { + match n { + MNode::Run(r) if !after => { + if let Some(k) = r.text.find('#') { + after = true; + if !r.text.get(k + 1..).unwrap_or("").is_empty() { + empty = false; + } + } + } + _ if after => empty = false, + _ => {} + } + } + if empty { + let n = vec![MNode::Run(MRun::new(counter.to_string()))]; + row.push(MNode::Delim { beg: Some('('), end: Some(')'), sep: None, grow: true, shp_match: false, elems: vec![n] }); + changed = true; + } + } + changed.then(|| Math { nodes: vec![MNode::EqArr { rows: out }], omml: String::new(), ..math.clone() }) +} + +/// The equation colour the kept source OMML carries, as the reader lifts it. +fn omml_color(omml: &str) -> Option { + if omml.is_empty() { + return None; + } + let root = parse_kept(omml)?; + let e = if root.name == "m:oMath" { &root } else { root.find("m:oMath")? }; + lift_color(&mut read_omath(e, MathJc::default())) +} + +/// The settings part's `m:mathPr` (CT_MathPr order; `m:wrapIndent` and `m:wrapRight` are a choice). +pub fn math_pr(w: &mut W, m: &MathProps) { + w.open("m:mathPr", &[]); + let bin = match m.brk_bin { + BrkBin::Before => "before", + BrkBin::After => "after", + BrkBin::Repeat => "repeat", + }; + val(w, "m:brkBin", bin); + let sub = match m.brk_bin_sub { + BrkBinSub::MinusMinus => "--", + BrkBinSub::MinusPlus => "-+", + BrkBinSub::PlusMinus => "+-", + }; + val(w, "m:brkBinSub", sub); + if m.wrap_right { + w.empty("m:wrapRight", &[]); + } else { + let indent = if m.wrap_indent.is_finite() { m.wrap_indent.clamp(0.0, 1584.0) } else { crate::math::DEFAULT_WRAP_INDENT }; + val(w, "m:wrapIndent", &twips(indent)); + } + w.close("m:mathPr"); +} + +fn val(w: &mut W, name: &str, v: &str) { + w.empty(name, &[("m:val", v)]); +} + +fn arg(w: &mut W, name: &str, a: &[MNode], depth: usize) { + if a.is_empty() { + w.empty(name, &[]); + return; + } + w.open(name, &[]); + arg_inner(w, a, depth + 1); + w.close(name); +} + +fn arg_inner(w: &mut W, a: &[MNode], depth: usize) { + if depth > MAX_DEPTH { + return; + } + for n in a { + node(w, n, depth); + } +} + +fn ctrl(w: &mut W) { + w.open("m:ctrlPr", &[]); + w.open("w:rPr", &[]); + w.empty("w:rFonts", &[("w:ascii", "Cambria Math"), ("w:hAnsi", "Cambria Math")]); + w.empty("w:i", &[]); + w.close("w:rPr"); + w.close("m:ctrlPr"); +} + +fn char_prop(w: &mut W, name: &str, c: Option) { + let s = c.map(|c| c.to_string()).unwrap_or_default(); + val(w, name, &s); +} + +fn write_matrix(w: &mut W, n: &MNode, depth: usize) { + let MNode::Matrix { rows, col_jc } = n else { return }; + // Every row is padded to the widest one, so a ragged matrix is clamped to the reader's + // caps rather than written as rows × widest cells (#3643). + let rows = rows.get(..rows.len().min(MAX_MATRIX_ROWS)).unwrap_or_default(); + let cols = rows.iter().map(|r| r.len()).max().unwrap_or(0).clamp(1, MAX_MATRIX_COLS); + w.open("m:m", &[]); + w.open("m:mPr", &[]); + w.open("m:mcs", &[]); + for c in 0..cols { + let jc = match col_jc.get(c).copied().unwrap_or_default() { + ColJc::Center => "center", + ColJc::Left => "left", + ColJc::Right => "right", + }; + w.open("m:mc", &[]); + w.open("m:mcPr", &[]); + val(w, "m:count", "1"); + val(w, "m:mcJc", jc); + w.close("m:mcPr"); + w.close("m:mc"); + } + w.close("m:mcs"); + ctrl(w); + w.close("m:mPr"); + for row in rows { + w.open("m:mr", &[]); + for c in 0..cols { + arg(w, "m:e", row.get(c).map(|v| v.as_slice()).unwrap_or(&[]), depth); + } + w.close("m:mr"); + } + w.close("m:m"); +} + +fn write_nary(w: &mut W, n: &MNode, depth: usize) { + let MNode::Nary { chr, lim_loc, grow, sub_hide, sup_hide, sub, sup, e } = n else { return }; + w.open("m:nary", &[]); + w.open("m:naryPr", &[]); + if *chr != '∫' { + char_prop(w, "m:chr", Some(*chr)); + } + match lim_loc { + Some(LimLoc::UndOvr) => val(w, "m:limLoc", "undOvr"), + Some(LimLoc::SubSup) => val(w, "m:limLoc", "subSup"), + None => {} + } + if *grow { + val(w, "m:grow", "1"); + } + if *sub_hide { + val(w, "m:subHide", "1"); + } + if *sup_hide { + val(w, "m:supHide", "1"); + } + ctrl(w); + w.close("m:naryPr"); + arg(w, "m:sub", sub, depth); + arg(w, "m:sup", sup, depth); + arg(w, "m:e", e, depth); + w.close("m:nary"); +} + +fn node(w: &mut W, n: &MNode, depth: usize) { + match n { + MNode::Run(r) => run(w, r), + MNode::Frac { kind, num, den } => { + w.open("m:f", &[]); + w.open("m:fPr", &[]); + match kind { + FracKind::Bar => {} + FracKind::Skewed => val(w, "m:type", "skw"), + FracKind::Linear => val(w, "m:type", "lin"), + FracKind::NoBar => val(w, "m:type", "noBar"), + } + ctrl(w); + w.close("m:fPr"); + arg(w, "m:num", num, depth); + arg(w, "m:den", den, depth); + w.close("m:f"); + } + MNode::Script { kind, base, sub, sup } => { + let (el, pr) = match kind { + ScriptKind::Sup => ("m:sSup", "m:sSupPr"), + ScriptKind::Sub => ("m:sSub", "m:sSubPr"), + ScriptKind::SubSup => ("m:sSubSup", "m:sSubSupPr"), + ScriptKind::Pre => ("m:sPre", "m:sPrePr"), + }; + w.open(el, &[]); + w.open(pr, &[]); + ctrl(w); + w.close(pr); + if *kind == ScriptKind::Pre { + arg(w, "m:sub", sub, depth); + arg(w, "m:sup", sup, depth); + arg(w, "m:e", base, depth); + } else { + arg(w, "m:e", base, depth); + if matches!(kind, ScriptKind::Sub | ScriptKind::SubSup) { + arg(w, "m:sub", sub, depth); + } + if matches!(kind, ScriptKind::Sup | ScriptKind::SubSup) { + arg(w, "m:sup", sup, depth); + } + } + w.close(el); + } + MNode::Rad { deg, deg_hide, e } => { + w.open("m:rad", &[]); + w.open("m:radPr", &[]); + if *deg_hide { + val(w, "m:degHide", "1"); + } + ctrl(w); + w.close("m:radPr"); + arg(w, "m:deg", deg, depth); + arg(w, "m:e", e, depth); + w.close("m:rad"); + } + MNode::Nary { .. } => write_nary(w, n, depth), + MNode::Delim { beg, end, sep, grow, shp_match, elems } => { + w.open("m:d", &[]); + w.open("m:dPr", &[]); + if *beg != Some('(') { + char_prop(w, "m:begChr", *beg); + } + if *sep != Some('|') { + char_prop(w, "m:sepChr", *sep); + } + if *end != Some(')') { + char_prop(w, "m:endChr", *end); + } + if !grow { + val(w, "m:grow", "0"); + } + if *shp_match { + val(w, "m:shp", "match"); + } + ctrl(w); + w.close("m:dPr"); + for e in elems { + arg(w, "m:e", e, depth); + } + w.close("m:d"); + } + MNode::Func { name, e } => { + w.open("m:func", &[]); + w.open("m:funcPr", &[]); + ctrl(w); + w.close("m:funcPr"); + arg(w, "m:fName", name, depth); + arg(w, "m:e", e, depth); + w.close("m:func"); + } + MNode::Lim { upper, e, lim } => { + let (el, pr) = if *upper { ("m:limUpp", "m:limUppPr") } else { ("m:limLow", "m:limLowPr") }; + w.open(el, &[]); + w.open(pr, &[]); + ctrl(w); + w.close(pr); + arg(w, "m:e", e, depth); + arg(w, "m:lim", lim, depth); + w.close(el); + } + MNode::Acc { chr, e } => { + w.open("m:acc", &[]); + w.open("m:accPr", &[]); + char_prop(w, "m:chr", Some(*chr)); + ctrl(w); + w.close("m:accPr"); + arg(w, "m:e", e, depth); + w.close("m:acc"); + } + MNode::Bar { top, e } => { + w.open("m:bar", &[]); + w.open("m:barPr", &[]); + val(w, "m:pos", if *top { "top" } else { "bot" }); + ctrl(w); + w.close("m:barPr"); + arg(w, "m:e", e, depth); + w.close("m:bar"); + } + MNode::BorderBox { hide, strike, e } => { + w.open("m:borderBox", &[]); + w.open("m:borderBoxPr", &[]); + for (on, name) in hide.iter().zip(["m:hideTop", "m:hideBot", "m:hideLeft", "m:hideRight"]) { + if *on { + val(w, name, "1"); + } + } + for (on, name) in strike.iter().zip(["m:strikeH", "m:strikeV", "m:strikeBLTR", "m:strikeTLBR"]) { + if *on { + val(w, name, "1"); + } + } + ctrl(w); + w.close("m:borderBoxPr"); + arg(w, "m:e", e, depth); + w.close("m:borderBox"); + } + MNode::Boxed { e } => { + w.open("m:box", &[]); + w.open("m:boxPr", &[]); + ctrl(w); + w.close("m:boxPr"); + arg(w, "m:e", e, depth); + w.close("m:box"); + } + MNode::GroupChr { chr, top, e } => { + w.open("m:groupChr", &[]); + w.open("m:groupChrPr", &[]); + char_prop(w, "m:chr", Some(*chr)); + val(w, "m:pos", if *top { "top" } else { "bot" }); + val(w, "m:vertJc", if *top { "bot" } else { "top" }); + ctrl(w); + w.close("m:groupChrPr"); + arg(w, "m:e", e, depth); + w.close("m:groupChr"); + } + MNode::EqArr { rows } => { + w.open("m:eqArr", &[]); + w.open("m:eqArrPr", &[]); + // Numbered rows (`#`) stretch across the line so the number reaches the margin. + if rows.iter().any(|r| crate::math::has_number_mark(r)) { + val(w, "m:maxDist", "1"); + } + ctrl(w); + w.close("m:eqArrPr"); + for r in rows { + arg(w, "m:e", r, depth); + } + w.close("m:eqArr"); + } + MNode::Matrix { .. } => write_matrix(w, n, depth), + MNode::Phant { show, zero_wid, zero_asc, zero_desc, e } => { + w.open("m:phant", &[]); + w.open("m:phantPr", &[]); + val(w, "m:show", if *show { "1" } else { "0" }); + for (on, name) in [(*zero_wid, "m:zeroWid"), (*zero_asc, "m:zeroAsc"), (*zero_desc, "m:zeroDesc")] { + if on { + val(w, name, "1"); + } + } + ctrl(w); + w.close("m:phantPr"); + arg(w, "m:e", e, depth); + w.close("m:phant"); + } + } +} + +fn run(w: &mut W, r: &MRun) { + w.open("m:r", &[]); + let sty = r.sty.map(|s| match s { + MSty::Plain => "p", + MSty::Bold => "b", + MSty::Italic => "i", + MSty::BoldItalic => "bi", + }); + let scr = match r.scr { + MScr::Roman => None, + MScr::Script => Some("script"), + MScr::Fraktur => Some("fraktur"), + MScr::DoubleStruck => Some("double-struck"), + MScr::SansSerif => Some("sans-serif"), + MScr::Monospace => Some("monospace"), + }; + if sty.is_some() || scr.is_some() || r.nor || r.lit || r.brk.is_some() { + w.open("m:rPr", &[]); + if r.lit { + w.empty("m:lit", &[]); + } + if r.nor { + w.empty("m:nor", &[]); + } + if let Some(s) = scr { + val(w, "m:scr", s); + } + if let Some(s) = sty { + val(w, "m:sty", s); + } + match r.brk { + Some(0) => w.empty("m:brk", &[]), + Some(n) => w.empty("m:brk", &[("m:alnAt", &n.to_string())]), + None => {} + } + w.close("m:rPr"); + } + w.open("w:rPr", &[]); + let font = r.font.as_deref().filter(|_| r.nor).unwrap_or("Cambria Math"); + w.empty("w:rFonts", &[("w:ascii", font), ("w:hAnsi", font)]); + if let Some(c) = r.color { + w.val("w:color", &c.hex()); + } + if let Some(sz) = r.size { + let hp = (sz * 2.0).round().clamp(2.0, 3276.0) as i64; + w.val("w:sz", &hp.to_string()); + w.val("w:szCs", &hp.to_string()); + } + w.close("w:rPr"); + w.leaf("m:t", &[("xml:space", "preserve")], &r.text); + w.close("m:r"); +} diff --git a/crates/math/src/rgb.rs b/crates/math/src/rgb.rs new file mode 100644 index 000000000..f624cda15 --- /dev/null +++ b/crates/math/src/rgb.rs @@ -0,0 +1,173 @@ +//! An sRGB colour, the one type of colour every Craft document model and this crate share. + +use serde::{Deserialize, Serialize}; + +/// An sRGB colour. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Default)] +pub struct Rgb(pub u8, pub u8, pub u8); + +impl<'de> Deserialize<'de> for Rgb { + /// Reads what it writes (`[r, g, b]`) and also a string [`Rgb::parse`] reads, so the colours in + /// `CharProps` / `ParaProps` take the `RRGGBB` that `para.borders` and `table.shading` take (#865). + fn deserialize>(d: D) -> Result { + use serde::de::{Error, SeqAccess, Unexpected, Visitor}; + struct V; + impl<'de> Visitor<'de> for V { + type Value = Rgb; + fn expecting(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result { + f.write_str("`[r, g, b]`, `RRGGBB` or `#RRGGBB`") + } + fn visit_str(self, s: &str) -> Result { + Rgb::parse(s).ok_or_else(|| E::invalid_value(Unexpected::Str(s), &self)) + } + fn visit_seq>(self, mut a: A) -> Result { + let mut c = [0u8; 3]; + for (i, x) in c.iter_mut().enumerate() { + *x = a.next_element()?.ok_or_else(|| A::Error::invalid_length(i, &self))?; + } + if a.next_element::()?.is_some() { + return Err(A::Error::invalid_length(4, &self)); + } + Ok(Rgb(c[0], c[1], c[2])) + } + } + d.deserialize_any(V) + } +} + +impl Rgb { + pub const BLACK: Rgb = Rgb(0, 0, 0); + pub const WHITE: Rgb = Rgb(255, 255, 255); + /// `RRGGBB` (no `#`). + pub fn hex(self) -> String { + format!("{:02X}{:02X}{:02X}", self.0, self.1, self.2) + } + /// Parse `RRGGBB` or `#RRGGBB`. + pub fn parse(s: &str) -> Option { + let s = s.trim(); + let s = s.strip_prefix('#').unwrap_or(s); + if s.len() != 6 || !s.bytes().all(|b| b.is_ascii_hexdigit()) { + return None; + } + let p = |i: usize| s.get(i..i + 2).and_then(|h| u8::from_str_radix(h, 16).ok()); + Some(Rgb(p(0)?, p(2)?, p(4)?)) + } + /// This colour with the DrawingML colour modifier `name` (`lumMod`, `lumOff`, `tint`, + /// `shade`, `satMod`, ECMA-376 §20.1.2.3) at `v` (1.0 = 100%); other names leave it + /// unchanged. + pub fn modified(self, name: &str, v: f64) -> Rgb { + match name { + "lumMod" => adjust_lum(self, |l| l * v), + "lumOff" => adjust_lum(self, |l| l + v), + "tint" => mix(self, 255.0, v), + "shade" => mix(self, 0.0, v), + "satMod" => { + let (h, sat, l) = to_hsl(self); + from_hsl(h, (sat * v).clamp(0.0, 1.0), l) + } + _ => self, + } + } + /// The colour with hue `h` (0..1 of a turn), saturation `s` and lightness `l` (0..1). + pub fn from_hsl(h: f64, s: f64, l: f64) -> Rgb { + from_hsl(h, s, l) + } + /// This colour with its HSL luminance changed by `f`. + pub fn with_lum(self, f: impl Fn(f64) -> f64) -> Rgb { + adjust_lum(self, f) + } + pub fn luma(self) -> f32 { + 0.299 * self.0 as f32 + 0.587 * self.1 as f32 + 0.114 * self.2 as f32 + } +} + +/// `tint` (towards `to` = 255) and `shade` (towards 0): `v` is the share of the original colour kept. +fn mix(c: Rgb, to: f64, v: f64) -> Rgb { + let f = |x: u8| (x as f64 * v + to * (1.0 - v)).round().clamp(0.0, 255.0) as u8; + Rgb(f(c.0), f(c.1), f(c.2)) +} + +/// `c` with its HSL luminance changed by `f`. +fn adjust_lum(c: Rgb, f: impl Fn(f64) -> f64) -> Rgb { + let (h, s, l) = to_hsl(c); + from_hsl(h, s, f(l).clamp(0.0, 1.0)) +} + +fn to_hsl(c: Rgb) -> (f64, f64, f64) { + let (r, g, b) = (c.0 as f64 / 255.0, c.1 as f64 / 255.0, c.2 as f64 / 255.0); + let (max, min) = (r.max(g).max(b), r.min(g).min(b)); + let l = (max + min) / 2.0; + if max == min { + return (0.0, 0.0, l); + } + let d = max - min; + let s = if l > 0.5 { d / (2.0 - max - min) } else { d / (max + min) }; + let h = if max == r { + (g - b) / d + if g < b { 6.0 } else { 0.0 } + } else if max == g { + (b - r) / d + 2.0 + } else { + (r - g) / d + 4.0 + }; + (h / 6.0, s, l) +} + +fn from_hsl(h: f64, s: f64, l: f64) -> Rgb { + let byte = |v: f64| (v * 255.0).round().clamp(0.0, 255.0) as u8; + if s == 0.0 { + return Rgb(byte(l), byte(l), byte(l)); + } + let q = if l < 0.5 { l * (1.0 + s) } else { l + s - l * s }; + let p = 2.0 * l - q; + let channel = |t: f64| { + let t = t.rem_euclid(1.0); + let v = if t < 1.0 / 6.0 { + p + (q - p) * 6.0 * t + } else if t < 0.5 { + q + } else if t < 2.0 / 3.0 { + p + (q - p) * (2.0 / 3.0 - t) * 6.0 + } else { + p + }; + byte(v) + }; + Rgb(channel(h + 1.0 / 3.0), channel(h), channel(h - 1.0 / 3.0)) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn rgb_hex() { + assert_eq!(Rgb(255, 0, 16).hex(), "FF0010"); + assert_eq!(Rgb::parse("#FF0010"), Some(Rgb(255, 0, 16))); + assert_eq!(Rgb::parse("zzzzzz"), None); + assert_eq!(Rgb::parse("é1234"), None); + } + + #[test] + fn rgb_parse_rejects_non_hex() { + assert_eq!(Rgb::parse("+1+2+3"), None); + assert_eq!(Rgb::parse("-1-2-3"), None); + assert_eq!(Rgb::parse("##ff0000"), None); + assert_eq!(Rgb::parse("ff00 0"), None); + assert_eq!(Rgb::parse("#ff0000"), Some(Rgb(255, 0, 0))); + assert_eq!(Rgb::parse("ff0000"), Some(Rgb(255, 0, 0))); + } + + /// #865: an [`Rgb`] reads `RRGGBB` / `#RRGGBB` as well as the `[r, g, b]` it still writes. + #[test] + fn rgb_reads_hex_and_keeps_its_serialized_form() { + let read = |j: &str| serde_json::from_str::(j); + let ink = Rgb(0x1B, 0x16, 0x11); + assert_eq!(serde_json::to_string(&ink).unwrap(), "[27,22,17]", "written as before"); + for j in ["[27,22,17]", r#""1B1611""#, r##""#1b1611""##] { + assert_eq!(read(j).unwrap(), ink, "{j}"); + } + for bad in ["[27,22]", "[27,22,17,0]", "[27,22,300]", "[-1,0,0]", r#""auto""#, r#""1B161""#, r#""""#, "7", "null", r#"{"r":1}"#] { + assert!(read(bad).is_err(), "{bad}"); + } + } +} diff --git a/crates/math/src/symbol_fonts.rs b/crates/math/src/symbol_fonts.rs new file mode 100644 index 000000000..1d5175540 --- /dev/null +++ b/crates/math/src/symbol_fonts.rs @@ -0,0 +1,316 @@ +//! Word's symbol fonts — Symbol, Wingdings, Wingdings 2, Wingdings 3 and Webdings — mapped to +//! standard Unicode characters, so text set in them shows the intended symbols when the font +//! isn't installed (they are proprietary and never bundled) and exports as real characters. +//! +//! Word stores a symbol-font character either as the private-use code U+F000 + the font's own +//! 8-bit code (U+F020–U+F0FF, what Insert › Symbol and `w:sym` write) or as the plain 8-bit code +//! (U+0020–U+00FF) in a run whose font is the symbol font. Both map the same way. The stored text +//! never changes: the mapping is for display, layout and plain-text output only. +//! +//! Sources (Unicode's own published data; no font file was read): +//! - Symbol: the Adobe Symbol encoding table published by Unicode, +//! (codes it maps to the +//! private use area are left out; 0x6D maps to GREEK SMALL LETTER MU). +//! - Wingdings, Wingdings 2, Wingdings 3, Webdings: the correspondence tables in "Status of +//! encoding of Wingdings and Webdings Symbols", Unicode L2/12-368 (WG2 N4384, M. Suignard, +//! 2012), the frozen repertoire of the Unicode 7.0 encoding (earlier drafts L2/11-052, +//! L2/11-344), checked against the Unicode Character Database names. The one character not +//! encoded (Wingdings 0xFF) has no entry. +//! +//! Tables are indexed by code − 0x20; 0 = no Unicode equivalent. + +/// The symbol table for a single family name, by lowercase family name. +fn single_table(family: &str) -> Option<&'static [u32; 224]> { + let f = family.trim(); + if f.eq_ignore_ascii_case("symbol") || f.eq_ignore_ascii_case("symbol (pcl6)") { + Some(&SYMBOL) + } else if f.eq_ignore_ascii_case("wingdings") { + Some(&WINGDINGS) + } else if f.eq_ignore_ascii_case("wingdings 2") { + Some(&WINGDINGS_2) + } else if f.eq_ignore_ascii_case("wingdings 3") { + Some(&WINGDINGS_3) + } else if f.eq_ignore_ascii_case("webdings") { + Some(&WEBDINGS) + } else { + None + } +} + +/// The symbol fonts this module knows, by lowercase family name. +fn table(family: &str) -> Option<&'static [u32; 224]> { + if family.contains(';') { family.split(';').map(str::trim).filter(|s| !s.is_empty()).find_map(single_table) } else { single_table(family) } +} + +/// Whether `family` is one of Word's symbol fonts (Symbol, Wingdings, Wingdings 2, Wingdings 3, +/// Webdings), whose characters [`symbol_char`] maps. +pub fn is_symbol_font(family: &str) -> bool { + table(family).is_some() +} + +/// The standard Unicode character for `c` in a run set in the symbol font `family`: `c` is the +/// font's code stored as U+F020–U+F0FF or as U+0020–U+00FF (except the soft hyphen U+00AD). +/// `None` for other fonts, other characters and codes with no Unicode equivalent. +pub fn symbol_char(family: &str, c: char) -> Option { + let t = table(family)?; + let u = u32::from(c); + let code = match u { + 0xF020..=0xF0FF => u - 0xF000, + // A soft hyphen stays a soft hyphen (Word stores `w:softHyphen` as U+00AD). + 0xAD => return None, + 0x20..=0xFF => u, + _ => return None, + }; + let i = usize::try_from(code.checked_sub(0x20)?).ok()?; + t.get(i).copied().filter(|v| *v != 0).and_then(char::from_u32) +} + +/// `text` (in a run set in `family`) with every symbol-font character replaced by its Unicode +/// equivalent; unchanged for other fonts. +pub fn map_text(family: &str, text: &str) -> String { + if !is_symbol_font(family) { + return text.to_string(); + } + text.chars().map(|c| symbol_char(family, c).unwrap_or(c)).collect() +} + +const SYMBOL: [u32; 224] = [ + 0x0020, 0x0021, 0x2200, 0x0023, 0x2203, 0x0025, 0x0026, 0x220B, // 0x20 + 0x0028, 0x0029, 0x2217, 0x002B, 0x002C, 0x2212, 0x002E, 0x002F, // 0x28 + 0x0030, 0x0031, 0x0032, 0x0033, 0x0034, 0x0035, 0x0036, 0x0037, // 0x30 + 0x0038, 0x0039, 0x003A, 0x003B, 0x003C, 0x003D, 0x003E, 0x003F, // 0x38 + 0x2245, 0x0391, 0x0392, 0x03A7, 0x0394, 0x0395, 0x03A6, 0x0393, // 0x40 + 0x0397, 0x0399, 0x03D1, 0x039A, 0x039B, 0x039C, 0x039D, 0x039F, // 0x48 + 0x03A0, 0x0398, 0x03A1, 0x03A3, 0x03A4, 0x03A5, 0x03C2, 0x03A9, // 0x50 + 0x039E, 0x03A8, 0x0396, 0x005B, 0x2234, 0x005D, 0x22A5, 0x005F, // 0x58 + 0x0000, 0x03B1, 0x03B2, 0x03C7, 0x03B4, 0x03B5, 0x03C6, 0x03B3, // 0x60 + 0x03B7, 0x03B9, 0x03D5, 0x03BA, 0x03BB, 0x03BC, 0x03BD, 0x03BF, // 0x68 + 0x03C0, 0x03B8, 0x03C1, 0x03C3, 0x03C4, 0x03C5, 0x03D6, 0x03C9, // 0x70 + 0x03BE, 0x03C8, 0x03B6, 0x007B, 0x007C, 0x007D, 0x223C, 0x0000, // 0x78 + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, // 0x80 + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, // 0x88 + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, // 0x90 + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, // 0x98 + 0x20AC, 0x03D2, 0x2032, 0x2264, 0x2044, 0x221E, 0x0192, 0x2663, // 0xA0 + 0x2666, 0x2665, 0x2660, 0x2194, 0x2190, 0x2191, 0x2192, 0x2193, // 0xA8 + 0x00B0, 0x00B1, 0x2033, 0x2265, 0x00D7, 0x221D, 0x2202, 0x2022, // 0xB0 + 0x00F7, 0x2260, 0x2261, 0x2248, 0x2026, 0x0000, 0x0000, 0x21B5, // 0xB8 + 0x2135, 0x2111, 0x211C, 0x2118, 0x2297, 0x2295, 0x2205, 0x2229, // 0xC0 + 0x222A, 0x2283, 0x2287, 0x2284, 0x2282, 0x2286, 0x2208, 0x2209, // 0xC8 + 0x2220, 0x2207, 0x0000, 0x0000, 0x0000, 0x220F, 0x221A, 0x22C5, // 0xD0 + 0x00AC, 0x2227, 0x2228, 0x21D4, 0x21D0, 0x21D1, 0x21D2, 0x21D3, // 0xD8 + 0x25CA, 0x2329, 0x0000, 0x0000, 0x0000, 0x2211, 0x0000, 0x0000, // 0xE0 + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, // 0xE8 + 0x0000, 0x232A, 0x222B, 0x2320, 0x0000, 0x2321, 0x0000, 0x0000, // 0xF0 + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, // 0xF8 +]; + +const WEBDINGS: [u32; 224] = [ + 0x0020, 0x1F577, 0x1F578, 0x1F572, 0x1F576, 0x1F3C6, 0x1F396, 0x1F587, // 0x20 + 0x1F5E8, 0x1F5E9, 0x1F5F0, 0x1F5F1, 0x1F336, 0x1F397, 0x1F67E, 0x1F67C, // 0x28 + 0x1F5D5, 0x1F5D6, 0x1F5D7, 0x23F4, 0x23F5, 0x23F6, 0x23F7, 0x23EA, // 0x30 + 0x23E9, 0x23EE, 0x23ED, 0x23F8, 0x23F9, 0x23FA, 0x1F5DA, 0x1F5F3, // 0x38 + 0x1F6E0, 0x1F3D7, 0x1F3D8, 0x1F3D9, 0x1F3DA, 0x1F3DC, 0x1F3ED, 0x1F3DB, // 0x40 + 0x1F3E0, 0x1F3D6, 0x1F3DD, 0x1F6E3, 0x1F50D, 0x1F3D4, 0x1F441, 0x1F442, // 0x48 + 0x1F3DE, 0x1F3D5, 0x1F6E4, 0x1F3DF, 0x1F6F3, 0x1F56C, 0x1F56B, 0x1F568, // 0x50 + 0x1F508, 0x1F394, 0x1F395, 0x1F5EC, 0x1F67D, 0x1F5ED, 0x1F5EA, 0x1F5EB, // 0x58 + 0x2B94, 0x2714, 0x1F6B2, 0x2B1C, 0x1F6E1, 0x1F4E6, 0x1F6F1, 0x2B1B, // 0x60 + 0x1F691, 0x1F6C8, 0x1F6E9, 0x1F6F0, 0x1F7C8, 0x1F574, 0x2B24, 0x1F6E5, // 0x68 + 0x1F694, 0x1F5D8, 0x1F5D9, 0x2753, 0x1F6F2, 0x1F687, 0x1F68D, 0x26F3, // 0x70 + 0x29B8, 0x2296, 0x1F6AD, 0x1F5EE, 0x23D0, 0x1F5EF, 0x1F5F2, 0x0000, // 0x78 + 0x1F6B9, 0x1F6BA, 0x1F6C9, 0x1F6CA, 0x1F6BC, 0x1F47D, 0x1F3CB, 0x26F7, // 0x80 + 0x1F3C2, 0x1F3CC, 0x1F3CA, 0x1F3C4, 0x1F3CD, 0x1F3CE, 0x1F698, 0x1F5E0, // 0x88 + 0x1F6E2, 0x1F4B0, 0x1F3F7, 0x1F4B3, 0x1F46A, 0x1F5E1, 0x1F5E2, 0x1F5E3, // 0x90 + 0x272F, 0x1F584, 0x1F585, 0x1F583, 0x1F586, 0x1F5B9, 0x1F5BA, 0x1F5BB, // 0x98 + 0x1F575, 0x1F570, 0x1F5BD, 0x1F5BE, 0x1F4CB, 0x1F5D2, 0x1F5D3, 0x1F56E, // 0xA0 + 0x1F4DA, 0x1F5DE, 0x1F5DF, 0x1F5C3, 0x1F5C2, 0x1F5BC, 0x1F3AD, 0x1F39C, // 0xA8 + 0x1F398, 0x1F399, 0x1F3A7, 0x1F4BF, 0x1F39E, 0x1F4F7, 0x1F39F, 0x1F3AC, // 0xB0 + 0x1F4FD, 0x1F4F9, 0x1F4FE, 0x1F4FB, 0x1F39A, 0x1F39B, 0x1F4FA, 0x1F4BB, // 0xB8 + 0x1F5A5, 0x1F5A6, 0x1F5A7, 0x1F579, 0x1F3AE, 0x1F57B, 0x1F57C, 0x1F4DF, // 0xC0 + 0x1F581, 0x1F580, 0x1F5A8, 0x1F5A9, 0x1F5BF, 0x1F5AA, 0x1F5DC, 0x1F512, // 0xC8 + 0x1F513, 0x1F5DD, 0x1F4E5, 0x1F4E4, 0x1F573, 0x1F323, 0x1F324, 0x1F325, // 0xD0 + 0x1F326, 0x2601, 0x1F328, 0x1F327, 0x1F329, 0x1F32A, 0x1F32C, 0x1F32B, // 0xD8 + 0x1F31C, 0x1F321, 0x1F6CB, 0x1F6CF, 0x1F37D, 0x1F378, 0x1F6CE, 0x1F6CD, // 0xE0 + 0x24C5, 0x267F, 0x1F6C6, 0x1F588, 0x1F393, 0x1F5E4, 0x1F5E5, 0x1F5E6, // 0xE8 + 0x1F5E7, 0x1F6EA, 0x1F43F, 0x1F426, 0x1F41F, 0x1F415, 0x1F408, 0x1F66C, // 0xF0 + 0x1F66E, 0x1F66D, 0x1F66F, 0x1F5FA, 0x1F30D, 0x1F30F, 0x1F30E, 0x1F54A, // 0xF8 +]; + +const WINGDINGS: [u32; 224] = [ + 0x0020, 0x1F589, 0x2702, 0x2701, 0x1F453, 0x1F56D, 0x1F56E, 0x1F56F, // 0x20 + 0x1F57F, 0x2706, 0x1F582, 0x1F583, 0x1F4EA, 0x1F4EB, 0x1F4EC, 0x1F4ED, // 0x28 + 0x1F5C0, 0x1F5C1, 0x1F5CE, 0x1F5CF, 0x1F5D0, 0x1F5C4, 0x23F3, 0x1F5AE, // 0x30 + 0x1F5B0, 0x1F5B2, 0x1F5B3, 0x1F5B4, 0x1F5AB, 0x1F5AC, 0x2707, 0x270D, // 0x38 + 0x1F58E, 0x270C, 0x1F58F, 0x1F44D, 0x1F44E, 0x261C, 0x261E, 0x261D, // 0x40 + 0x1F597, 0x1F590, 0x263A, 0x1F610, 0x2639, 0x1F4A3, 0x1F571, 0x1F3F3, // 0x48 + 0x1F3F1, 0x2708, 0x263C, 0x1F322, 0x2744, 0x1F546, 0x271E, 0x1F548, // 0x50 + 0x2720, 0x2721, 0x262A, 0x262F, 0x1F549, 0x2638, 0x2648, 0x2649, // 0x58 + 0x264A, 0x264B, 0x264C, 0x264D, 0x264E, 0x264F, 0x2650, 0x2651, // 0x60 + 0x2652, 0x2653, 0x1F670, 0x1F675, 0x26AB, 0x1F53E, 0x25FC, 0x1F78F, // 0x68 + 0x1F790, 0x2751, 0x2752, 0x1F79F, 0x29EB, 0x25C6, 0x2756, 0x1F799, // 0x70 + 0x2327, 0x2BB9, 0x2318, 0x1F3F5, 0x1F3F6, 0x1F676, 0x1F677, 0x0000, // 0x78 + 0x1F10B, 0x2780, 0x2781, 0x2782, 0x2783, 0x2784, 0x2785, 0x2786, // 0x80 + 0x2787, 0x2788, 0x2789, 0x1F10C, 0x278A, 0x278B, 0x278C, 0x278D, // 0x88 + 0x278E, 0x278F, 0x2790, 0x2791, 0x2792, 0x2793, 0x1F662, 0x1F660, // 0x90 + 0x1F661, 0x1F663, 0x1F65E, 0x1F65C, 0x1F65D, 0x1F65F, 0x2219, 0x2022, // 0x98 + 0x2B1D, 0x2B58, 0x1F786, 0x1F788, 0x1F78A, 0x1F78B, 0x1F53F, 0x25AA, // 0xA0 + 0x1F78E, 0x1F7C1, 0x1F7C5, 0x2605, 0x1F7CB, 0x1F7CF, 0x1F7D3, 0x1F7D1, // 0xA8 + 0x2BD0, 0x2316, 0x2BCE, 0x2BCF, 0x2BD1, 0x272A, 0x2730, 0x1F550, // 0xB0 + 0x1F551, 0x1F552, 0x1F553, 0x1F554, 0x1F555, 0x1F556, 0x1F557, 0x1F558, // 0xB8 + 0x1F559, 0x1F55A, 0x1F55B, 0x2BB0, 0x2BB1, 0x2BB2, 0x2BB3, 0x2BB4, // 0xC0 + 0x2BB5, 0x2BB6, 0x2BB7, 0x1F66A, 0x1F66B, 0x1F655, 0x1F654, 0x1F657, // 0xC8 + 0x1F656, 0x1F650, 0x1F651, 0x1F652, 0x1F653, 0x232B, 0x2326, 0x2B98, // 0xD0 + 0x2B9A, 0x2B99, 0x2B9B, 0x2B88, 0x2B8A, 0x2B89, 0x2B8B, 0x1F868, // 0xD8 + 0x1F86A, 0x1F869, 0x1F86B, 0x1F86C, 0x1F86D, 0x1F86F, 0x1F86E, 0x1F878, // 0xE0 + 0x1F87A, 0x1F879, 0x1F87B, 0x1F87C, 0x1F87D, 0x1F87F, 0x1F87E, 0x21E6, // 0xE8 + 0x21E8, 0x21E7, 0x21E9, 0x2B04, 0x21F3, 0x2B01, 0x2B00, 0x2B03, // 0xF0 + 0x2B02, 0x1F8AC, 0x1F8AD, 0x1F5F6, 0x2713, 0x1F5F7, 0x1F5F9, 0x0000, // 0xF8 +]; + +const WINGDINGS_2: [u32; 224] = [ + 0x0020, 0x1F58A, 0x1F58B, 0x1F58C, 0x1F58D, 0x2704, 0x2700, 0x1F57E, // 0x20 + 0x1F57D, 0x1F5C5, 0x1F5C6, 0x1F5C7, 0x1F5C8, 0x1F5C9, 0x1F5CA, 0x1F5CB, // 0x28 + 0x1F5CC, 0x1F5CD, 0x1F4CB, 0x1F5D1, 0x1F5D4, 0x1F5B5, 0x1F5B6, 0x1F5B7, // 0x30 + 0x1F5B8, 0x1F5AD, 0x1F5AF, 0x1F5B1, 0x1F592, 0x1F593, 0x1F598, 0x1F599, // 0x38 + 0x1F59A, 0x1F59B, 0x1F448, 0x1F449, 0x1F59C, 0x1F59D, 0x1F59E, 0x1F59F, // 0x40 + 0x1F5A0, 0x1F5A1, 0x1F446, 0x1F447, 0x1F5A2, 0x1F5A3, 0x1F591, 0x1F5F4, // 0x48 + 0x1F5F8, 0x1F5F5, 0x2611, 0x2BBD, 0x2612, 0x2BBE, 0x2BBF, 0x1F6C7, // 0x50 + 0x29B8, 0x1F671, 0x1F674, 0x1F672, 0x1F673, 0x203D, 0x1F679, 0x1F67A, // 0x58 + 0x1F67B, 0x1F666, 0x1F664, 0x1F665, 0x1F667, 0x1F65A, 0x1F658, 0x1F659, // 0x60 + 0x1F65B, 0x24EA, 0x2460, 0x2461, 0x2462, 0x2463, 0x2464, 0x2465, // 0x68 + 0x2466, 0x2467, 0x2468, 0x2469, 0x24FF, 0x2776, 0x2777, 0x2778, // 0x70 + 0x2779, 0x277A, 0x277B, 0x277C, 0x277D, 0x277E, 0x277F, 0x0000, // 0x78 + 0x2609, 0x1F315, 0x263D, 0x263E, 0x2E3F, 0x271D, 0x1F547, 0x1F55C, // 0x80 + 0x1F55D, 0x1F55E, 0x1F55F, 0x1F560, 0x1F561, 0x1F562, 0x1F563, 0x1F564, // 0x88 + 0x1F565, 0x1F566, 0x1F567, 0x1F668, 0x1F669, 0x22C5, 0x1F784, 0x2981, // 0x90 + 0x25CF, 0x25CB, 0x1F785, 0x1F787, 0x1F789, 0x2299, 0x29BF, 0x1F78C, // 0x98 + 0x1F78D, 0x25FE, 0x25A0, 0x25A1, 0x1F791, 0x1F792, 0x1F793, 0x1F794, // 0xA0 + 0x25A3, 0x1F795, 0x1F796, 0x1F797, 0x1F798, 0x2B29, 0x2B25, 0x25C7, // 0xA8 + 0x1F79A, 0x25C8, 0x1F79B, 0x1F79C, 0x1F79D, 0x1F79E, 0x2B2A, 0x2B27, // 0xB0 + 0x25CA, 0x1F7A0, 0x25D6, 0x25D7, 0x2BCA, 0x2BCB, 0x2BC0, 0x2BC1, // 0xB8 + 0x2B1F, 0x2BC2, 0x2B23, 0x2B22, 0x2BC3, 0x2BC4, 0x1F7A1, 0x1F7A2, // 0xC0 + 0x1F7A3, 0x1F7A4, 0x1F7A5, 0x1F7A6, 0x1F7A7, 0x1F7A8, 0x1F7A9, 0x1F7AA, // 0xC8 + 0x1F7AB, 0x1F7AC, 0x1F7AD, 0x1F7AE, 0x1F7AF, 0x1F7B0, 0x1F7B1, 0x1F7B2, // 0xD0 + 0x1F7B3, 0x1F7B4, 0x1F7B5, 0x1F7B6, 0x1F7B7, 0x1F7B8, 0x1F7B9, 0x1F7BA, // 0xD8 + 0x1F7BB, 0x1F7BC, 0x1F7BD, 0x1F7BE, 0x1F7BF, 0x1F7C0, 0x1F7C2, 0x1F7C4, // 0xE0 + 0x1F7C6, 0x1F7C9, 0x1F7CA, 0x2736, 0x1F7CC, 0x1F7CE, 0x1F7D0, 0x1F7D2, // 0xE8 + 0x2739, 0x1F7C3, 0x1F7C7, 0x272F, 0x1F7CD, 0x1F7D4, 0x2BCC, 0x2BCD, // 0xF0 + 0x203B, 0x2042, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, // 0xF8 +]; + +const WINGDINGS_3: [u32; 224] = [ + 0x0020, 0x2B60, 0x2B62, 0x2B61, 0x2B63, 0x2B66, 0x2B67, 0x2B69, // 0x20 + 0x2B68, 0x2B70, 0x2B72, 0x2B71, 0x2B73, 0x2B76, 0x2B78, 0x2B7B, // 0x28 + 0x2B7D, 0x2B64, 0x2B65, 0x2B6A, 0x2B6C, 0x2B6B, 0x2B6D, 0x2B4D, // 0x30 + 0x2BA0, 0x2BA1, 0x2BA2, 0x2BA3, 0x2BA4, 0x2BA5, 0x2BA6, 0x2BA7, // 0x38 + 0x2B90, 0x2B91, 0x2B92, 0x2B93, 0x2B80, 0x2B83, 0x2B7E, 0x2B7F, // 0x40 + 0x2B84, 0x2B86, 0x2B85, 0x2B87, 0x2B8F, 0x2B8D, 0x2B8E, 0x2B8C, // 0x48 + 0x2B6E, 0x2B6F, 0x238B, 0x2324, 0x2303, 0x2325, 0x2423, 0x237D, // 0x50 + 0x21EA, 0x2BB8, 0x1F8A0, 0x1F8A1, 0x1F8A2, 0x1F8A3, 0x1F8A4, 0x1F8A5, // 0x58 + 0x1F8A6, 0x1F8A7, 0x1F8A8, 0x1F8A9, 0x1F8AA, 0x1F8AB, 0x1F850, 0x1F852, // 0x60 + 0x1F851, 0x1F853, 0x1F854, 0x1F855, 0x1F857, 0x1F856, 0x1F858, 0x1F859, // 0x68 + 0x25B2, 0x25BC, 0x25B3, 0x25BD, 0x25C0, 0x25B6, 0x25C1, 0x25B7, // 0x70 + 0x25E3, 0x25E2, 0x25E4, 0x25E5, 0x1F780, 0x1F782, 0x1F781, 0x0000, // 0x78 + 0x1F783, 0x2BC5, 0x2BC6, 0x2BC7, 0x2BC8, 0x2B9C, 0x2B9E, 0x2B9D, // 0x80 + 0x2B9F, 0x1F810, 0x1F812, 0x1F811, 0x1F813, 0x1F814, 0x1F816, 0x1F815, // 0x88 + 0x1F817, 0x1F818, 0x1F81A, 0x1F819, 0x1F81B, 0x1F81C, 0x1F81E, 0x1F81D, // 0x90 + 0x1F81F, 0x1F800, 0x1F802, 0x1F801, 0x1F803, 0x1F804, 0x1F806, 0x1F805, // 0x98 + 0x1F807, 0x1F808, 0x1F80A, 0x1F809, 0x1F80B, 0x1F820, 0x1F822, 0x1F824, // 0xA0 + 0x1F826, 0x1F828, 0x1F82A, 0x1F82C, 0x1F89C, 0x1F89D, 0x1F89E, 0x1F89F, // 0xA8 + 0x1F82E, 0x1F830, 0x1F832, 0x1F834, 0x1F836, 0x1F838, 0x1F83A, 0x1F839, // 0xB0 + 0x1F83B, 0x1F898, 0x1F89A, 0x1F899, 0x1F89B, 0x1F83C, 0x1F83E, 0x1F83D, // 0xB8 + 0x1F83F, 0x1F840, 0x1F842, 0x1F841, 0x1F843, 0x1F844, 0x1F846, 0x1F845, // 0xC0 + 0x1F847, 0x2BA8, 0x2BA9, 0x2BAA, 0x2BAB, 0x2BAC, 0x2BAD, 0x2BAE, // 0xC8 + 0x2BAF, 0x1F860, 0x1F862, 0x1F861, 0x1F863, 0x1F864, 0x1F865, 0x1F867, // 0xD0 + 0x1F866, 0x1F870, 0x1F872, 0x1F871, 0x1F873, 0x1F874, 0x1F875, 0x1F877, // 0xD8 + 0x1F876, 0x1F880, 0x1F882, 0x1F881, 0x1F883, 0x1F884, 0x1F885, 0x1F887, // 0xE0 + 0x1F886, 0x1F890, 0x1F892, 0x1F891, 0x1F893, 0x1F894, 0x1F896, 0x1F895, // 0xE8 + 0x1F897, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, // 0xF0 + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, // 0xF8 +]; + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn both_storage_forms_map_alike() { + for f in ["Symbol", "Wingdings", "Wingdings 2", "Wingdings 3", "Webdings"] { + assert!(is_symbol_font(f)); + for code in 0x20u32..=0xFF { + let (Some(pua), Some(plain)) = (char::from_u32(0xF000 + code), char::from_u32(code)) else { continue }; + if code == 0xAD { + assert_eq!(symbol_char(f, plain), None); + continue; + } + assert_eq!(symbol_char(f, pua), symbol_char(f, plain), "{f} {code:#X}"); + } + } + } + + #[test] + fn pcl6_symbol_encoding() { + for family in ["Symbol (PCL6)", " symbol (pcl6) "] { + assert!(is_symbol_font(family)); + for code in 0x20..=0xFF { + let c = char::from_u32(code).unwrap(); + assert_eq!(symbol_char(family, c), symbol_char("Symbol", c)); + let pua = char::from_u32(0xF000 + code).unwrap(); + assert_eq!(symbol_char(family, pua), symbol_char("Symbol", pua)); + } + assert_eq!(symbol_char(family, '\u{F0B3}'), Some('≥')); + } + assert!(!is_symbol_font("Symbol (unknown)")); + } + + #[test] + fn symbol_uses_the_adobe_symbol_encoding() { + assert_eq!(symbol_char("Symbol", '\u{F061}'), Some('α')); + assert_eq!(symbol_char("symbol", 'a'), Some('α')); + assert_eq!(symbol_char("Symbol", 'm'), Some('μ')); + assert_eq!(symbol_char("Symbol", 'W'), Some('Ω')); + assert_eq!(symbol_char("Symbol", '\u{F0B7}'), Some('•')); + assert_eq!(symbol_char("Symbol", '\u{F0A5}'), Some('∞')); + assert_eq!(symbol_char("Symbol", '\u{F0D6}'), Some('√')); + // The radical extender is private use in the Adobe table: no equivalent. + assert_eq!(symbol_char("Symbol", '\u{F060}'), None); + } + + #[test] + fn wingdings_codes() { + assert_eq!(symbol_char("Wingdings", '\u{F04A}'), Some('☺')); + assert_eq!(symbol_char("Wingdings", 'J'), Some('☺')); + assert_eq!(symbol_char("Wingdings", 'L'), Some('☹')); + assert_eq!(symbol_char("Wingdings", '\u{F0FC}'), Some('✓')); + assert_eq!(symbol_char("Wingdings", '\u{F0FE}'), Some('\u{1F5F9}')); + assert_eq!(symbol_char("Wingdings", '\u{F06E}'), Some('◼')); + assert_eq!(symbol_char("Wingdings", '\u{F0A7}'), Some('▪')); + assert_eq!(symbol_char("Wingdings", '\u{F076}'), Some('❖')); + assert_eq!(symbol_char("Wingdings", '\u{F028}'), Some('\u{1F57F}')); + // The one character Unicode didn't encode. + assert_eq!(symbol_char("Wingdings", '\u{F0FF}'), None); + } + + #[test] + fn wingdings_2_3_and_webdings_codes() { + assert_eq!(symbol_char("Wingdings 2", '\u{F050}'), Some('\u{1F5F8}')); + assert_eq!(symbol_char("Wingdings 2", 'O'), Some('\u{1F5F4}')); + assert_eq!(symbol_char("Wingdings 2", '\u{F0FA}'), None); + assert_eq!(symbol_char("Wingdings 3", '\u{F021}'), Some('⭠')); + assert_eq!(symbol_char("Wingdings 3", '\u{F0F1}'), None); + assert_eq!(symbol_char("Webdings", '\u{F059}'), Some('\u{1F394}')); + assert_eq!(symbol_char("Webdings", 'a'), Some('\u{2714}')); + assert_eq!(symbol_char("Webdings", '\u{F0FF}'), Some('\u{1F54A}')); + } + + #[test] + fn other_fonts_and_characters_are_left_alone() { + assert!(!is_symbol_font("Arial")); + assert_eq!(symbol_char("Arial", '\u{F04A}'), None); + assert_eq!(symbol_char("Wingdings", '\u{1F600}'), None); + assert_eq!(symbol_char("Wingdings", '\u{10FFFF}'), None); + assert_eq!(symbol_char("Wingdings", '\u{1F}'), None); + assert_eq!(map_text("Wingdings", "J\u{F04C}é"), "☺☹\u{1F879}"); + assert_eq!(map_text("Arial", "J\u{F04C}"), "J\u{F04C}"); + } +} diff --git a/crates/math/tests/corpus.rs b/crates/math/tests/corpus.rs new file mode 100644 index 000000000..90a7aaae7 --- /dev/null +++ b/crates/math/tests/corpus.rs @@ -0,0 +1,81 @@ +//! Round trips over equaSHion's corpus (storytold/craft-libs#29): its typed LaTeX equations +//! (`corpus/equations.tsv`), the MathLive-style LaTeX of `corpus/latex.tsv` and its OMML samples +//! from WordCraft and DeckCraft files (`corpus/omml/`). + +use craft_math::{Math, math, math_latex, mathml, omml}; + +const DIR: &str = concat!(env!("CARGO_MANIFEST_DIR"), "/tests/corpus"); + +fn rows(file: &str) -> Vec> { + let text = std::fs::read_to_string(format!("{DIR}/{file}")).unwrap(); + text.lines().filter(|l| !l.trim().is_empty() && !l.starts_with('#')).map(|l| l.split('\t').map(str::to_string).collect()).collect() +} + +/// The equations of `equations.tsv`: (id, tree, display). +fn equations() -> Vec<(String, math::Arg, bool)> { + let v: Vec<_> = rows("equations.tsv").into_iter().map(|f| (f[0].clone(), math_latex::parse_latex(&f[1]), f[2].trim() == "1")).collect(); + assert!(v.len() >= 40, "{} rows", v.len()); + v +} + +/// Equations whose LaTeX this crate writes in a form it reads back differently (a function +/// raised to a power: `\sin^2\theta` comes back as `(\sin)^2 θ`). Known; kept visible here. +const LATEX_ROUND_TRIP_GAPS: &[&str] = &["func"]; + +#[test] +fn every_corpus_equation_reads_without_unknown_commands() { + for (id, tree, _) in equations() { + let lin = math::to_linear(&tree); + assert!(!tree.is_empty() && !lin.contains('\\'), "{id}: {lin}"); + } + for f in rows("latex.tsv") { + let lin = math::to_linear(&math_latex::parse_latex(&f[0])); + assert!(!lin.contains('\\'), "{}: {lin}", f[0]); + } +} + +#[test] +fn every_corpus_equation_survives_omml() { + for (id, tree, display) in equations() { + let eq = Math { nodes: tree.clone(), ..Default::default() }; + let xml = omml::write_str("", display, &eq, None); + let back = omml::read_str(&xml).unwrap_or_else(|| panic!("{id}: {xml}")); + // The writer states matrix column alignment the parser left implicit; the text is the same. + assert_eq!(math::to_linear(&back.nodes), math::to_linear(&tree), "{id}: {xml}"); + // And the OMML written from what was read is the same OMML. + let again = omml::write_str("", display, &Math { omml: String::new(), ..back }, None); + assert_eq!(again, xml, "{id}"); + } +} + +#[test] +fn every_corpus_equation_survives_mathml_and_latex() { + for (id, tree, display) in equations() { + let lin = math::to_linear(&tree); + let ml = mathml::to_mathml(&tree, display, ""); + assert_eq!(mathml::parse(&ml).and_then(|m| mathml::read(&m)), Some((lin.clone(), display)), "{id}: {ml}"); + if !LATEX_ROUND_TRIP_GAPS.contains(&id.as_str()) { + let tex = math_latex::to_latex(&tree); + assert_eq!(math::to_linear(&math_latex::parse_latex(&tex)), lin, "{id}: {tex}"); + } + } +} + +#[test] +fn omml_samples_read_and_write_back() { + let mut n = 0; + for e in std::fs::read_dir(format!("{DIR}/omml")).unwrap().flatten() { + let xml = std::fs::read_to_string(e.path()).unwrap(); + let name = e.file_name(); + let m = omml::read_str(&xml).unwrap_or_else(|| panic!("{name:?}")); + assert!(!m.nodes.is_empty() && m.omml.starts_with("ab diff --git a/crates/math/tests/corpus/omml/deckcraft_pptx_roundtrip_frac_rpr.xml b/crates/math/tests/corpus/omml/deckcraft_pptx_roundtrip_frac_rpr.xml new file mode 100644 index 000000000..ca4636abf --- /dev/null +++ b/crates/math/tests/corpus/omml/deckcraft_pptx_roundtrip_frac_rpr.xml @@ -0,0 +1 @@ +x2 diff --git a/crates/math/tests/corpus/omml/wordcraft_docx_fixture_ssup.xml b/crates/math/tests/corpus/omml/wordcraft_docx_fixture_ssup.xml new file mode 100644 index 000000000..de1ea3b30 --- /dev/null +++ b/crates/math/tests/corpus/omml/wordcraft_docx_fixture_ssup.xml @@ -0,0 +1 @@ +x2 diff --git a/crates/math/tests/corpus/omml/wordcraft_docx_roundtrip_display_rad.xml b/crates/math/tests/corpus/omml/wordcraft_docx_roundtrip_display_rad.xml new file mode 100644 index 000000000..38a71f375 --- /dev/null +++ b/crates/math/tests/corpus/omml/wordcraft_docx_roundtrip_display_rad.xml @@ -0,0 +1 @@ +x diff --git a/crates/math/tests/corpus/omml/wordcraft_docx_roundtrip_foreign_attrs.xml b/crates/math/tests/corpus/omml/wordcraft_docx_roundtrip_foreign_attrs.xml new file mode 100644 index 000000000..270088e0b --- /dev/null +++ b/crates/math/tests/corpus/omml/wordcraft_docx_roundtrip_foreign_attrs.xml @@ -0,0 +1 @@ +x diff --git a/crates/math/tests/corpus/omml/wordcraft_docx_roundtrip_frac.xml b/crates/math/tests/corpus/omml/wordcraft_docx_roundtrip_frac.xml new file mode 100644 index 000000000..e13045b5e --- /dev/null +++ b/crates/math/tests/corpus/omml/wordcraft_docx_roundtrip_frac.xml @@ -0,0 +1 @@ +ab+1 diff --git a/crates/math/tests/omml_latex.rs b/crates/math/tests/omml_latex.rs new file mode 100644 index 000000000..6ecd9adc1 --- /dev/null +++ b/crates/math/tests/omml_latex.rs @@ -0,0 +1,41 @@ +//! OMML read into the tree and written as LaTeX means what GridCraft's direct OMML-to-LaTeX +//! reader (`crates/xlsx/src/omml.rs`) gave for its test cases, so GridCraft can read equations in +//! drawings through this crate. The LaTeX is compared by what it reads back as, not byte for byte. + +use craft_math::{math, math_latex, omml}; +fn tex(inner: &str) -> String { + let x = format!(r#"{inner}"#); + math_latex::to_latex(&omml::read_str(&x).unwrap().nodes) +} +#[test] +fn gridcraft_omml_cases_mean_the_same_latex() { + let brace = |chr: &str, pos: &str| { + format!( + "x" + ) + }; + let lim = "n"; + let under = format!("{}{lim}", brace("\u{23DF}", "bot")); + let cases: Vec<(String, &str)> = vec![ + ("x+1".into(), "x+1"), + ("ab".into(), "\\frac{a}{b}"), + ("x2".into(), "{x}^{2}"), + ("π".into(), "\\sqrt{\\pi }"), + ("i=1ni".into(), "\\sum_{i=1}^{n} {i}"), + ("f".into(), "\\int {f}"), + ("1001".into(), "\\left[ \\begin{matrix}1 & 0 \\\\ 0 & 1\\end{matrix} \\right]"), + ("sinx".into(), "\\sin x"), + ("R".into(), "\\mathbb{R}"), + ("if {x}".into(), "\\text{if \\{x\\}}"), + ("x".into(), "\\hat{x}"), + (under.clone(), "\\underbrace{x}_{n}"), + (format!("{under}2"), "{\\underbrace{x}_{n}}^{2}"), + (format!("{}{lim}", brace("\u{23DE}", "top")), "\\overbrace{x}^{n}"), + (format!("a{lim}"), "\\underset{n}{a}"), + (format!("{}+1{lim}", brace("\u{23DF}", "bot")), "\\underset{n}{\\underbrace{x}+1}"), + ]; + for (x, want) in cases { + let got = tex(&x); + assert_eq!(math::to_linear(&math_latex::parse_latex(&got)), math::to_linear(&math_latex::parse_latex(want)), "{x}: {got} for {want}"); + } +}