Files
wordcraft/crates/doc/src/math.rs
T

1001 lines
34 KiB
Rust
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! 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::props::Rgb;
/// A sequence of math nodes (OMML's `m:e`, `m:num`, `m:sub`…).
pub type Arg = Vec<MNode>;
/// Deepest structure nesting kept (parsers stop here; layout draws nothing deeper).
pub const MAX_DEPTH: usize = 64;
/// 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<T: Default + PartialEq>(v: &T) -> bool {
*v == T::default()
}
impl Math {
pub fn is_empty(&self) -> bool {
self.nodes.is_empty()
}
}
/// 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<MSty>,
#[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<f32>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub color: Option<Rgb>,
/// Font for normal text.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub font: Option<String>,
/// 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<u8>,
}
impl MRun {
pub fn new(text: impl Into<String>) -> MRun {
MRun { text: text.into(), ..Default::default() }
}
pub fn plain(text: impl Into<String>) -> 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<LimLoc>,
#[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<char>,
end: Option<char>,
#[serde(default)]
sep: Option<char>,
/// 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<Arg>,
},
/// `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<Arg>,
},
/// `m:m`.
Matrix {
rows: Vec<Vec<Arg>>,
#[serde(default)]
col_jc: Vec<ColJc>,
},
/// `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 n in a {
lin_node(n, s, depth + 1);
}
}
/// 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 script as one letter or a number (`x_10`), so a
/// longer run is grouped: `V_(dd)`, not `V_dd` (which reads back as V_d then d).
fn lin_script(a: &[MNode], s: &mut String, depth: usize) {
if let [MNode::Run(r)] = a
&& r.text.chars().nth(1).is_some()
&& !(r.text.starts_with(|c: char| c.is_ascii_digit()) && r.text.chars().all(|c| c.is_ascii_digit() || c == '.'))
{
s.push('(');
s.push_str(&r.text);
s.push(')');
} else {
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(crate) 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(crate) 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(crate) 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() {
// A script reads one letter or a number, so `V_dd` is V_d then d: longer scripts are grouped.
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");
}
#[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);
}
}