diff --git a/ROADMAP.md b/ROADMAP.md index 5e404ab..9c46fb1 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -59,7 +59,7 @@ the icon art). | Review (spelling, grammar, thesaurus, comments, track changes, compare, protect) | Good; comment balloons in the margin; no track-changes balloons | 75% | | Mailings (mail merge, rules, preview, envelopes, labels) | Working | 70% | | Pictures & shapes (insert, size, crop, recolour, effects, styles, float position) | Text wraps around floats (rectangular); text boxes render and edit their text; select, drag to move, resize handles, arrow nudge on the canvas; no tight/contour wrap, no rotation handles | 60% | -| Draw tab (ink), SmartArt, charts, 3D models, equations editor | Not started / linear equations only | 5% | +| Draw tab (ink), SmartArt, charts, 3D models, equations editor | Equations: full OMML read/write, 2D layout (OpenType MATH) and in-place editing with an Equation tab; long display equations don't break across lines yet. Ink, SmartArt, charts, 3D not started | 20% | | File formats: DOCX read/write | Good (Word opens our files); charts/SmartArt/OLE dropped; untested on a real-world corpus | 75% | | File formats: PDF, ODT, RTF, HTML, Markdown, LaTeX, TXT | Working | 70% | | File formats: Word 97-2003 `.doc`/`.dot` import | Text, formatting, styles, sections, headers/footers, tables, lists, notes, fields, bookmarks, pictures (spec-based reader; metafile images and Word 6/95 rejected) | 60% | @@ -77,7 +77,7 @@ the icon art). grouping, z-order polish, track-changes balloons and formatting revisions. 3. **Draw tab / ink** (≈15 h). 4. **Charts (own renderer) and SmartArt-style diagrams** (≈20 h). -5. **Equation editor (OMML read/write, 2D layout)** (≈15 h). +5. **Equation editor** — done: OMML read/write, 2D layout, in-place editing with Word's Equation tab (structures, symbols, built-ins, Unicode/LaTeX input, build-up, Math AutoCorrect, `#` numbers, structure commands). Remaining: line breaking of long display equations, ink equations (≈4 h). 6. **Dialog depth**: every Word dialog with all its options (Font, Paragraph, Tabs, Borders and Shading, Page Setup, Styles, Columns, Index/TOC options, Mail Merge wizard, Options panes) (≈20 h). 7. **Accessibility (screen readers), localisation, RTL and complex scripts** (≈15 h). diff --git a/crates/doc/src/lib.rs b/crates/doc/src/lib.rs index f2199d1..55bfa01 100644 --- a/crates/doc/src/lib.rs +++ b/crates/doc/src/lib.rs @@ -13,6 +13,11 @@ pub mod edit; pub mod encoding; pub mod fields; +pub mod math; +pub mod math_edit; +pub mod math_latex; +pub mod math_linear; +pub mod math_symbols; pub mod numbering; pub mod para; pub mod props; diff --git a/crates/doc/src/math.rs b/crates/doc/src/math.rs new file mode 100644 index 0000000..d876afe --- /dev/null +++ b/crates/doc/src/math.rs @@ -0,0 +1,766 @@ +//! 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; + +/// 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(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, +} + +/// 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, +} + +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.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(')'); + } +} + +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_operand(sub, s, depth); + s.push('^'); + lin_operand(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_operand(sub, s, depth); + } + if matches!(kind, ScriptKind::Sup | ScriptKind::SubSup) { + s.push('^'); + lin_operand(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_operand(sub, s, depth); + } + if !sup_hide && !sup.is_empty() { + s.push('^'); + lin_operand(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_operand(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 structures. +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 + { + prev.text.push_str(&r.text); + continue; + } + MNode::Run(r) + } + MNode::Frac { kind, num, den } => MNode::Frac { kind, num: merge_runs(num), den: merge_runs(den) }, + MNode::Script { kind, base, sub, sup } => MNode::Script { kind, base: merge_runs(base), sub: merge_runs(sub), sup: merge_runs(sup) }, + MNode::Rad { deg, deg_hide, e } => MNode::Rad { deg: merge_runs(deg), deg_hide, e: merge_runs(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: merge_runs(sub), sup: merge_runs(sup), e: merge_runs(e) } + } + MNode::Delim { beg, end, sep, grow, shp_match, elems } => { + MNode::Delim { beg, end, sep, grow, shp_match, elems: elems.into_iter().map(merge_runs).collect() } + } + MNode::Func { name, e } => MNode::Func { name: merge_runs(name), e: merge_runs(e) }, + other => other, + }; + out.push(n); + } + out +} + +#[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)); + } + } + + #[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); + } +} diff --git a/crates/doc/src/math_edit.rs b/crates/doc/src/math_edit.rs new file mode 100644 index 0000000..b402ca7 --- /dev/null +++ b/crates/doc/src/math_edit.rs @@ -0,0 +1,1275 @@ +//! Editing inside an equation: caret positions, typing, inserting structures, deleting, +//! moving the caret, and Word-style build-up (typing `a/b` then a space makes a fraction) and +//! Math AutoCorrect (`\alpha` then a space makes α). +//! +//! A caret position is an argument — a path of (node index, child argument) from the equation's +//! top-level argument — and an offset in *units*: each character of a run is one unit, every +//! other node is one unit. All operations take any position and clamp it; none panics. + +use serde::{Deserialize, Serialize}; + +use crate::math::{Arg, LimLoc, MAX_DEPTH, MNode, MRun, MScr, MSty, ScriptKind, has_number_mark, merge_runs, to_linear}; + +/// A caret position inside an equation. +#[derive(Clone, Debug, PartialEq, Eq, Default, Serialize, Deserialize)] +pub struct MathPos { + /// (node index, child argument index) from the top level down to the argument. + #[serde(default)] + pub path: Vec<(usize, usize)>, + /// Units into that argument. + #[serde(default)] + pub off: usize, +} + +impl MathPos { + pub fn new(path: Vec<(usize, usize)>, off: usize) -> MathPos { + MathPos { path, off } + } +} + +/// How newly typed text is set. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub struct TypeStyle { + /// Normal (non-math) text. + pub nor: bool, +} + +/// Units in an argument. +pub fn units(a: &[MNode]) -> usize { + a.iter().map(node_units).sum() +} + +fn node_units(n: &MNode) -> usize { + match n { + MNode::Run(r) => r.text.chars().count(), + _ => 1, + } +} + +/// 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, + } +} + +/// Children in reading order, without hidden ones (hidden limits, a hidden radical degree, +/// script slots the script kind doesn't use). +pub fn nav_children(n: &MNode) -> Vec { + match n { + MNode::Script { kind, .. } => match kind { + ScriptKind::Sup => vec![0, 2], + ScriptKind::Sub => vec![0, 1], + ScriptKind::SubSup => vec![0, 1, 2], + ScriptKind::Pre => vec![1, 2, 0], + }, + MNode::Rad { deg_hide, .. } => { + if *deg_hide { + vec![1] + } else { + vec![0, 1] + } + } + MNode::Nary { sub_hide, sup_hide, lim_loc, .. } => { + let mut v = Vec::new(); + // Under/over limits read top to bottom as Word does: upper first only for sub/sup. + let _ = lim_loc; + if !sub_hide { + v.push(0); + } + if !sup_hide { + v.push(1); + } + v.push(2); + v + } + _ => (0..child_count(n)).collect(), + } +} + +/// The argument at `path`. +pub fn arg_at<'a>(root: &'a Arg, path: &[(usize, usize)]) -> Option<&'a Arg> { + let mut a = root; + for (n, c) in path { + a = child(a.get(*n)?, *c)?; + } + Some(a) +} + +pub fn arg_at_mut<'a>(root: &'a mut Arg, path: &[(usize, usize)]) -> Option<&'a mut Arg> { + let mut a = root; + for (n, c) in path { + a = child_mut(a.get_mut(*n)?, *c)?; + } + Some(a) +} + +/// A valid position: the longest valid path prefix, the offset clamped. +pub fn clamp(root: &Arg, pos: &MathPos) -> MathPos { + let mut path = Vec::new(); + let mut a = root; + for (n, c) in pos.path.iter().take(MAX_DEPTH) { + match a.get(*n).and_then(|node| child(node, *c)) { + Some(next) => { + path.push((*n, *c)); + a = next; + } + None => { + // The node is gone or has no such child: put the caret before it. + let off = node_start(a, (*n).min(a.len())); + return MathPos { path, off }; + } + } + } + MathPos { off: pos.off.min(units(a)), path } +} + +/// Units before node `i`. +pub fn node_start(a: &[MNode], i: usize) -> usize { + a.iter().take(i).map(node_units).sum() +} + +/// Where an offset falls: before node `i` (`i` may be the length), or inside run `i` at char `k`. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum Loc { + Before(usize), + Inside(usize, usize), +} + +fn locate(a: &[MNode], off: usize) -> Loc { + let mut u = 0; + for (i, n) in a.iter().enumerate() { + if off == u { + return Loc::Before(i); + } + let len = node_units(n); + if off < u + len { + return Loc::Inside(i, off - u); + } + u += len; + } + Loc::Before(a.len()) +} + +/// Byte index of char `k` in `s`. +fn byte_at(s: &str, k: usize) -> usize { + s.char_indices().nth(k).map(|(b, _)| b).unwrap_or(s.len()) +} + +/// Split a run so that `off` falls between nodes; returns that node index. +fn split_at(a: &mut Arg, off: usize) -> usize { + match locate(a, off) { + Loc::Before(i) => i, + Loc::Inside(i, k) => { + let Some(MNode::Run(r)) = a.get_mut(i) else { return i }; + let b = byte_at(&r.text, k); + let tail = r.text.split_off(b); + let mut right = r.clone(); + right.text = tail; + a.insert(i + 1, MNode::Run(right)); + i + 1 + } + } +} + +fn compatible(r: &MRun, style: TypeStyle) -> bool { + r.nor == style.nor && !r.lit +} + +/// Type `text` at `pos`. Returns the caret after it. +pub fn insert_text(root: &mut Arg, pos: &MathPos, text: &str, style: TypeStyle) -> MathPos { + let pos = clamp(root, pos); + let n = text.chars().count(); + if n == 0 { + return pos; + } + let Some(a) = arg_at_mut(root, &pos.path) else { return pos }; + match locate(a, pos.off) { + Loc::Inside(i, k) => { + if let Some(MNode::Run(r)) = a.get_mut(i) + && compatible(r, style) + { + let b = byte_at(&r.text, k); + r.text.insert_str(b, text); + } else { + let at = split_at(a, pos.off); + a.insert(at, MNode::Run(new_run(text, style))); + } + } + Loc::Before(i) => { + let prev_ok = i > 0 && matches!(a.get(i - 1), Some(MNode::Run(r)) if compatible(r, style)); + let next_ok = matches!(a.get(i), Some(MNode::Run(r)) if compatible(r, style)); + if prev_ok { + if let Some(MNode::Run(r)) = a.get_mut(i - 1) { + r.text.push_str(text); + } + } else if next_ok { + if let Some(MNode::Run(r)) = a.get_mut(i) { + r.text.insert_str(0, text); + } + } else { + a.insert(i.min(a.len()), MNode::Run(new_run(text, style))); + } + } + } + MathPos { path: pos.path, off: pos.off + n } +} + +fn new_run(text: &str, style: TypeStyle) -> MRun { + if style.nor { MRun { text: text.to_string(), nor: true, sty: Some(MSty::Plain), ..Default::default() } } else { MRun::new(text) } +} + +/// Insert structures at `pos`. The caret goes to the first empty argument inside them, or after +/// them. +pub fn insert_nodes(root: &mut Arg, pos: &MathPos, nodes: Arg) -> MathPos { + let pos = clamp(root, pos); + let count = nodes.len(); + let total = units(&nodes); + let Some(a) = arg_at_mut(root, &pos.path) else { return pos }; + let at = split_at(a, pos.off).min(a.len()); + for (k, n) in nodes.into_iter().enumerate() { + a.insert(at + k, n); + } + // First empty argument in the inserted nodes. + for k in at..at + count { + if let Some(n) = a.get(k) + && let Some(mut inner) = first_empty(n, 0) + { + let mut path = pos.path.clone(); + path.push((k, inner.remove(0).1)); + path.extend(inner); + return MathPos { path, off: 0 }; + } + } + let off = node_start(a, at) + total; + tidy(root); + clamp(root, &MathPos { path: pos.path, off }) +} + +/// Path (relative, starting with this node as index 0) to the first empty visible argument. +fn first_empty(n: &MNode, depth: usize) -> Option> { + if depth > MAX_DEPTH { + return None; + } + for c in nav_children(n) { + let a = child(n, c)?; + if a.is_empty() { + return Some(vec![(0, c)]); + } + for (k, inner) in a.iter().enumerate() { + if let Some(mut p) = first_empty(inner, depth + 1) { + let first = p.remove(0); + let mut out = vec![(0, c), (k, first.1)]; + out.extend(p); + return Some(out); + } + } + } + None +} + +/// Merge runs and drop empty ones everywhere (structures stay, empty or not). +pub fn tidy(root: &mut Arg) { + fn go(a: &mut Arg, depth: usize) { + if depth > MAX_DEPTH { + return; + } + a.retain(|n| !matches!(n, MNode::Run(r) if r.text.is_empty())); + let taken = std::mem::take(a); + *a = merge_shallow(taken); + for n in a.iter_mut() { + for c in 0..child_count(n) { + if let Some(ch) = child_mut(n, c) { + go(ch, depth + 1); + } + } + } + } + go(root, 0); +} + +fn merge_shallow(a: Arg) -> Arg { + let mut out: Arg = Vec::with_capacity(a.len()); + for n in a { + if let MNode::Run(r) = &n + && 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 + { + prev.text.push_str(&r.text); + continue; + } + out.push(n); + } + out +} + +/// Every visible argument of the node is empty. +fn all_empty(n: &MNode) -> bool { + nav_children(n).iter().all(|c| child(n, *c).is_none_or(|a| a.is_empty())) +} + +/// The children's contents in reading order, one after another (dissolving a structure). +fn dissolve(n: MNode) -> Arg { + let order = nav_children(&n); + let mut n = n; + let mut out = Vec::new(); + for c in order { + if let Some(a) = child_mut(&mut n, c) { + out.append(a); + } + } + out +} + +/// Backspace at `pos`. +pub fn delete_back(root: &mut Arg, pos: &MathPos) -> MathPos { + let pos = clamp(root, pos); + if pos.off > 0 { + let Some(a) = arg_at_mut(root, &pos.path) else { return pos }; + let (i, k) = match locate(a, pos.off - 1) { + Loc::Before(i) => (i, 0), + Loc::Inside(i, k) => (i, k), + }; + match a.get_mut(i) { + Some(MNode::Run(r)) => { + let b = byte_at(&r.text, k); + let e = byte_at(&r.text, k + 1); + r.text.replace_range(b..e, ""); + let out = MathPos { path: pos.path.clone(), off: pos.off - 1 }; + tidy(root); + return clamp(root, &out); + } + Some(n) => { + if all_empty(n) { + a.remove(i); + let out = MathPos { path: pos.path.clone(), off: pos.off - 1 }; + tidy(root); + return clamp(root, &out); + } + // Into the structure's last argument, at its end. + if let Some(c) = nav_children(n).last().copied() { + let len = child(n, c).map(|a| units(a)).unwrap_or(0); + let mut path = pos.path.clone(); + path.push((i, c)); + return MathPos { path, off: len }; + } + } + None => {} + } + return pos; + } + // At the start of an argument: remove an empty structure, or dissolve it from its first + // argument, or move to the previous argument. + let Some(((j, c), parent_path)) = pos.path.split_last() else { return pos }; + let parent_path = parent_path.to_vec(); + let (j, c) = (*j, *c); + let Some(parent) = arg_at_mut(root, &parent_path) else { return pos }; + let start = node_start(parent, j); + let Some(node) = parent.get(j) else { return pos }; + let order = nav_children(node); + let first = order.first() == Some(&c); + if all_empty(node) || first { + let n = parent.remove(j); + let contents = if all_empty(&n) { Vec::new() } else { dissolve(n) }; + for (k, x) in contents.into_iter().enumerate() { + parent.insert(j + k, x); + } + tidy(root); + return clamp(root, &MathPos { path: parent_path, off: start }); + } + let prev = order.iter().position(|x| *x == c).and_then(|p| p.checked_sub(1)).and_then(|p| order.get(p)).copied(); + match prev { + Some(pc) => { + let len = child(node, pc).map(|a| units(a)).unwrap_or(0); + let mut path = parent_path; + path.push((j, pc)); + MathPos { path, off: len } + } + None => MathPos { path: parent_path, off: start }, + } +} + +/// Delete (forward) at `pos`. +pub fn delete_forward(root: &mut Arg, pos: &MathPos) -> MathPos { + let pos = clamp(root, pos); + let len = arg_at(root, &pos.path).map(|a| units(a)).unwrap_or(0); + if pos.off < len { + let Some(a) = arg_at_mut(root, &pos.path) else { return pos }; + let (i, k) = match locate(a, pos.off) { + Loc::Before(i) => (i, 0), + Loc::Inside(i, k) => (i, k), + }; + match a.get_mut(i) { + Some(MNode::Run(r)) => { + let b = byte_at(&r.text, k); + let e = byte_at(&r.text, k + 1); + r.text.replace_range(b..e, ""); + } + Some(n) => { + if all_empty(n) { + a.remove(i); + } else if let Some(c) = nav_children(n).first().copied() { + let mut path = pos.path.clone(); + path.push((i, c)); + return MathPos { path, off: 0 }; + } + } + None => {} + } + tidy(root); + return clamp(root, &pos); + } + // At the end of an argument: remove an empty structure, else move on. + let Some(((j, c), parent_path)) = pos.path.split_last() else { return pos }; + let parent_path = parent_path.to_vec(); + let (j, c) = (*j, *c); + let Some(parent) = arg_at_mut(root, &parent_path) else { return pos }; + let start = node_start(parent, j); + let Some(node) = parent.get(j) else { return pos }; + if all_empty(node) { + parent.remove(j); + tidy(root); + return clamp(root, &MathPos { path: parent_path, off: start }); + } + let order = nav_children(node); + let next = order.iter().position(|x| *x == c).and_then(|p| order.get(p + 1)).copied(); + match next { + Some(nc) => { + let mut path = parent_path; + path.push((j, nc)); + MathPos { path, off: 0 } + } + None => MathPos { path: parent_path, off: start + 1 }, + } +} + +/// One step right; `None` at the very end (the caret leaves the equation). +pub fn move_right(root: &Arg, pos: &MathPos) -> Option { + let pos = clamp(root, pos); + let a = arg_at(root, &pos.path)?; + if pos.off < units(a) { + match locate(a, pos.off) { + Loc::Inside(..) => return Some(MathPos { path: pos.path, off: pos.off + 1 }), + Loc::Before(i) => { + let n = a.get(i)?; + if let MNode::Run(_) = n { + return Some(MathPos { path: pos.path, off: pos.off + 1 }); + } + if let Some(c) = nav_children(n).first().copied() { + let mut path = pos.path; + path.push((i, c)); + return Some(MathPos { path, off: 0 }); + } + return Some(MathPos { path: pos.path, off: pos.off + 1 }); + } + } + } + let ((j, c), parent_path) = pos.path.split_last()?; + let parent = arg_at(root, parent_path)?; + let node = parent.get(*j)?; + let order = nav_children(node); + let next = order.iter().position(|x| x == c).and_then(|p| order.get(p + 1)).copied(); + let mut path = parent_path.to_vec(); + Some(match next { + Some(nc) => { + path.push((*j, nc)); + MathPos { path, off: 0 } + } + None => MathPos { off: node_start(parent, *j) + 1, path }, + }) +} + +/// One step left; `None` at the very start. +pub fn move_left(root: &Arg, pos: &MathPos) -> Option { + let pos = clamp(root, pos); + let a = arg_at(root, &pos.path)?; + if pos.off > 0 { + let (i, inside) = match locate(a, pos.off - 1) { + Loc::Before(i) => (i, false), + Loc::Inside(i, _) => (i, true), + }; + let n = a.get(i)?; + if inside || matches!(n, MNode::Run(_)) { + return Some(MathPos { path: pos.path, off: pos.off - 1 }); + } + if let Some(c) = nav_children(n).last().copied() { + let len = child(n, c).map(|x| units(x)).unwrap_or(0); + let mut path = pos.path; + path.push((i, c)); + return Some(MathPos { path, off: len }); + } + return Some(MathPos { path: pos.path, off: pos.off - 1 }); + } + let ((j, c), parent_path) = pos.path.split_last()?; + let parent = arg_at(root, parent_path)?; + let node = parent.get(*j)?; + let order = nav_children(node); + let prev = order.iter().position(|x| x == c).and_then(|p| p.checked_sub(1)).and_then(|p| order.get(p)).copied(); + let mut path = parent_path.to_vec(); + Some(match prev { + Some(pc) => { + let len = child(node, pc).map(|x| units(x)).unwrap_or(0); + path.push((*j, pc)); + MathPos { path, off: len } + } + None => MathPos { off: node_start(parent, *j), path }, + }) +} + +/// The child to move to from child `c` of `n` going up (`up`) or down. +fn vertical_target(n: &MNode, c: usize, up: bool) -> Option { + match n { + MNode::Frac { .. } => match (c, up) { + (1, true) => Some(0), + (0, false) => Some(1), + _ => None, + }, + MNode::Script { kind, .. } => { + let has_sub = matches!(kind, ScriptKind::Sub | ScriptKind::SubSup | ScriptKind::Pre); + let has_sup = matches!(kind, ScriptKind::Sup | ScriptKind::SubSup | ScriptKind::Pre); + match (c, up) { + (1, true) if has_sup => Some(2), + (2, false) if has_sub => Some(1), + (0, true) if has_sup => Some(2), + (0, false) if has_sub => Some(1), + _ => None, + } + } + MNode::Nary { sub_hide, sup_hide, .. } => match (c, up) { + (0, true) if !sup_hide => Some(1), + (1, false) if !sub_hide => Some(0), + _ => None, + }, + MNode::Lim { upper, .. } => match (c, up, upper) { + (0, true, true) | (0, false, false) => Some(1), + (1, false, true) | (1, true, false) => Some(0), + _ => None, + }, + MNode::EqArr { rows } => { + if up { + c.checked_sub(1) + } else { + (c + 1 < rows.len()).then_some(c + 1) + } + } + MNode::Matrix { rows, .. } => { + // Row and column of flat index `c`. + let mut k = 0; + let mut rc = None; + for (r, row) in rows.iter().enumerate() { + if c < k + row.len() { + rc = Some((r, c - k)); + break; + } + k += row.len(); + } + let (r, col) = rc?; + let tr = if up { r.checked_sub(1)? } else { r + 1 }; + let row = rows.get(tr)?; + if row.is_empty() { + return None; + } + let start: usize = rows.iter().take(tr).map(|x| x.len()).sum(); + Some(start + col.min(row.len() - 1)) + } + _ => None, + } +} + +/// Up or down into the nearest stacked argument (denominator, superscript, matrix row…). +pub fn move_vertical(root: &Arg, pos: &MathPos, up: bool) -> Option { + let pos = clamp(root, pos); + for level in (0..pos.path.len()).rev() { + let (j, c) = *pos.path.get(level)?; + let parent = arg_at(root, pos.path.get(..level)?)?; + let node = parent.get(j)?; + if let Some(t) = vertical_target(node, c, up) { + let target = child(node, t)?; + let mut path = pos.path.get(..level)?.to_vec(); + path.push((j, t)); + let off = if level + 1 == pos.path.len() { pos.off.min(units(target)) } else { units(target) }; + return Some(MathPos { path, off }); + } + } + None +} + +/// Start (`end` false) or end of the current argument; from there, of the whole equation. +pub fn home_end(root: &Arg, pos: &MathPos, end: bool) -> MathPos { + let pos = clamp(root, pos); + let len = arg_at(root, &pos.path).map(|a| units(a)).unwrap_or(0); + let target = if end { len } else { 0 }; + if pos.off != target || pos.path.is_empty() { + return MathPos { path: pos.path, off: target }; + } + MathPos { path: Vec::new(), off: if end { units(root) } else { 0 } } +} + +/// All argument paths in reading order (for Tab between placeholders). +fn arg_paths(a: &[MNode], prefix: &mut Vec<(usize, usize)>, out: &mut Vec>, depth: usize) { + if depth > MAX_DEPTH || out.len() > 10_000 { + return; + } + out.push(prefix.clone()); + for (i, n) in a.iter().enumerate() { + for c in nav_children(n) { + if let Some(ch) = child(n, c) { + prefix.push((i, c)); + arg_paths(ch, prefix, out, depth + 1); + prefix.pop(); + } + } + } +} + +/// The next (or previous) empty argument after `pos`, wrapping around; `None` when there is none. +pub fn next_placeholder(root: &Arg, pos: &MathPos, back: bool) -> Option { + let mut paths = Vec::new(); + arg_paths(root, &mut Vec::new(), &mut paths, 0); + let empties: Vec<&Vec<(usize, usize)>> = paths.iter().filter(|p| !p.is_empty() && arg_at(root, p).is_some_and(|a| a.is_empty())).collect(); + if empties.is_empty() { + return None; + } + let cur = paths.iter().position(|p| *p == pos.path).unwrap_or(0); + let idx = |p: &Vec<(usize, usize)>| paths.iter().position(|q| q == p).unwrap_or(0); + let pick = if back { + empties.iter().rev().find(|p| idx(p) < cur).or_else(|| empties.last()) + } else { + empties.iter().find(|p| idx(p) > cur).or_else(|| empties.first()) + }; + pick.map(|p| MathPos { path: (*p).clone(), off: 0 }) +} + +/// Math AutoCorrect: replace `\name` just before the caret with its character. Returns the new +/// caret when something changed. +pub fn autocorrect(root: &mut Arg, pos: &MathPos) -> Option { + let pos = clamp(root, pos); + let a = arg_at_mut(root, &pos.path)?; + let (i, k) = match locate(a, pos.off) { + Loc::Inside(i, k) => (i, k), + Loc::Before(i) => { + let i = i.checked_sub(1)?; + (i, node_units(a.get(i)?)) + } + }; + let MNode::Run(r) = a.get_mut(i)? else { return None }; + if r.nor || r.lit { + return None; + } + let before: Vec = r.text.chars().take(k).collect(); + let slash = before.iter().rposition(|c| *c == '\\')?; + let name: String = before.get(slash + 1..)?.iter().collect(); + if name.is_empty() || !name.chars().all(|c| c.is_ascii_alphanumeric()) { + return None; + } + let c = crate::math_symbols::symbol(&name)?; + let b0 = byte_at(&r.text, slash); + let b1 = byte_at(&r.text, k); + r.text.replace_range(b0..b1, &c.to_string()); + Some(MathPos { path: pos.path, off: pos.off - name.chars().count() }) +} + +/// Characters that make typed linear text build up into structures. +fn builds(s: &str) -> bool { + s.chars().any(|c| { + matches!( + c, + '/' | '⁄' + | '∕' + | '¦' + | '^' + | '_' + | '√' + | '∛' + | '∜' + | '▒' + | '┬' + | '┴' + | '█' + | '■' + | '▭' + | '□' + | '¯' + | '▁' + | '⟡' + | '⏞' + | '⏟' + | '(' + | '[' + | '{' + | '|' + | '‖' + | '⟨' + | '⌈' + | '⌊' + | '〖' + | '├' + | '\u{2061}' + ) || crate::math_linear::is_combining(c) + || crate::math::is_nary_char(c) + }) || crate::math_linear::FUNCTION_NAMES.iter().any(|f| s.contains(f)) +} + +/// A run is plain math text that build-up may rewrite. +fn plain(n: &MNode) -> bool { + match n { + MNode::Run(r) => !r.nor && !r.lit && r.scr == MScr::Roman && r.color.is_none() && r.size.is_none() && r.font.is_none(), + // Arrays and matrices are finished pieces; typing after them builds up on its own. + MNode::EqArr { .. } | MNode::Matrix { .. } => false, + _ => true, + } +} + +/// Unclosed brackets (still being typed) or LaTeX braces. +fn unbalanced(nodes: &[MNode], depth: usize) -> bool { + if depth > MAX_DEPTH { + return false; + } + nodes.iter().any(|n| match n { + MNode::Delim { beg: Some(_), end: None, .. } => true, + MNode::Run(r) => r.text.contains('\\'), + other => (0..child_count(other)).any(|c| child(other, c).is_some_and(|a| unbalanced(a, depth + 1))), + }) +} + +/// Build up the linear text before the caret in its argument (`latex`: read it as LaTeX). +/// Returns the new caret when the structure changed. +pub fn build_up(root: &mut Arg, pos: &MathPos, latex: bool) -> Option { + let pos = clamp(root, pos); + let a = arg_at_mut(root, &pos.path)?; + let at = split_at(a, pos.off); + // Rewrite from after the last formatted run (its formatting would be lost) to the caret. + let start = a.get(..at)?.iter().rposition(|n| !plain(n)).map(|k| k + 1).unwrap_or(0); + let region: Arg = a.get(start..at)?.to_vec(); + if region.is_empty() { + tidy(root); + return None; + } + let text = to_linear(®ion); + if !latex && !builds(&text) { + tidy(root); + return None; + } + let mut built = if latex { crate::math_latex::parse_latex(&text) } else { crate::math_linear::parse(&text) }; + // `#` numbering wraps the whole equation, not a piece of it. + if let [MNode::EqArr { rows }] = built.as_slice() + && rows.len() == 1 + && rows.first().is_some_and(|r| has_number_mark(r)) + { + built = rows.first().cloned().unwrap_or_default(); + } + let built = merge_runs(built); + let unchanged = merge_runs(region.clone()) == built; + if unchanged || (!latex && unbalanced(&built, 0)) || (latex && text.matches('{').count() != text.matches('}').count()) { + tidy(root); + return None; + } + let new_units = units(&built); + let before = node_start(a, start); + a.splice(start..at, built); + let out = MathPos { path: pos.path, off: before + new_units }; + tidy(root); + Some(clamp(root, &out)) +} + +/// A top-level `#` numbers the equation: wrap it as a one-row equation array. +pub fn number_equation(root: &mut Arg) -> bool { + if has_number_mark(root) && !matches!(root.as_slice(), [MNode::EqArr { .. }]) { + let row = std::mem::take(root); + *root = vec![MNode::EqArr { rows: vec![row] }]; + return true; + } + false +} + +/// Word's right-click actions for the structures around the caret: (level, action id, label), +/// innermost first. `level` indexes `pos.path` (the structure is the node at that step). +pub fn structure_actions(root: &Arg, pos: &MathPos) -> Vec<(usize, &'static str, &'static str)> { + let pos = clamp(root, pos); + let mut out = Vec::new(); + for level in (0..pos.path.len()).rev() { + let Some(&(j, _)) = pos.path.get(level) else { continue }; + let Some(node) = pos.path.get(..level).and_then(|p| arg_at(root, p)).and_then(|a| a.get(j)) else { continue }; + let mut add = |a: &'static str, l: &'static str| out.push((level, a, l)); + match node { + MNode::Frac { kind, .. } => { + use crate::math::FracKind::*; + for (k, a, l) in [ + (Bar, "frac.bar", "Change to Stacked Fraction"), + (Skewed, "frac.skewed", "Change to Skewed Fraction"), + (Linear, "frac.linear", "Change to Linear Fraction"), + (NoBar, "frac.noBar", "Remove Fraction Bar"), + ] { + if *kind != k { + add(a, l); + } + } + } + MNode::Rad { deg_hide, .. } => add("rad.toggleDegree", if *deg_hide { "Show Degree" } else { "Hide Degree" }), + MNode::Nary { lim_loc, sub_hide, sup_hide, grow, chr, .. } => { + let under = lim_loc.unwrap_or(if crate::math::is_integral(*chr) { LimLoc::SubSup } else { LimLoc::UndOvr }) == LimLoc::UndOvr; + add( + if under { "nary.limitsSide" } else { "nary.limitsUnder" }, + if under { "Change Limit Location to Subscript-Superscript" } else { "Change Limit Location to Under-Over" }, + ); + add("nary.toggleLower", if *sub_hide { "Show Lower Limit" } else { "Hide Lower Limit" }); + add("nary.toggleUpper", if *sup_hide { "Show Upper Limit" } else { "Hide Upper Limit" }); + add("nary.toggleGrow", if *grow { "Don't Grow with Argument" } else { "Grow with Argument" }); + } + MNode::Delim { grow, elems, .. } => { + add("delim.addSeparator", "Insert Argument After"); + if elems.len() > 1 { + add("delim.deleteArgument", "Delete Argument"); + } + add("delim.toggleGrow", if *grow { "Don't Stretch Brackets" } else { "Stretch Brackets" }); + } + MNode::Script { kind: ScriptKind::SubSup, .. } => { + add("script.removeSub", "Remove Subscript"); + add("script.removeSup", "Remove Superscript"); + } + MNode::Script { kind: ScriptKind::Sub, .. } => add("script.addSup", "Add Superscript"), + MNode::Script { kind: ScriptKind::Sup, .. } => add("script.addSub", "Add Subscript"), + MNode::Matrix { .. } => { + add("matrix.rowAbove", "Insert Row Above"); + add("matrix.rowBelow", "Insert Row Below"); + add("matrix.colBefore", "Insert Column Before"); + add("matrix.colAfter", "Insert Column After"); + add("matrix.deleteRow", "Delete Row"); + add("matrix.deleteCol", "Delete Column"); + } + MNode::EqArr { .. } => { + add("eqarr.rowAbove", "Insert Equation Before"); + add("eqarr.rowBelow", "Insert Equation After"); + add("eqarr.deleteRow", "Delete Equation"); + } + MNode::GroupChr { top, .. } => add("group.flip", if *top { "Move Character Below" } else { "Move Character Above" }), + MNode::Bar { top, .. } => add("bar.flip", if *top { "Change to Underbar" } else { "Change to Overbar" }), + _ => {} + } + add( + "remove", + match node { + MNode::Delim { .. } => "Remove Brackets", + MNode::Rad { .. } => "Remove Radical", + MNode::Frac { .. } => "Remove Fraction", + MNode::Script { .. } => "Remove Scripts", + MNode::Nary { .. } => "Remove Operator", + MNode::Acc { .. } => "Remove Accent", + MNode::Matrix { .. } => "Remove Matrix", + _ => "Remove Structure", + }, + ); + } + out +} + +/// Apply a structure action (from [`structure_actions`]) at `level`. Returns the new caret. +pub fn apply_structure(root: &mut Arg, pos: &MathPos, level: usize, action: &str) -> Option { + let pos = clamp(root, pos); + let &(j, c) = pos.path.get(level)?; + let parent_path = pos.path.get(..level)?.to_vec(); + let inner = MathPos { path: pos.path.clone(), off: pos.off }; + let parent = arg_at_mut(root, &parent_path)?; + let start = node_start(parent, j); + let node = parent.get_mut(j)?; + let here = |child: usize| { + let mut p = parent_path.clone(); + p.push((j, child)); + MathPos { path: p, off: 0 } + }; + let out = match (node, action) { + (_, "remove") => { + let n = parent.remove(j); + for (k, x) in dissolve(n).into_iter().enumerate() { + parent.insert(j + k, x); + } + MathPos { path: parent_path.clone(), off: start } + } + (MNode::Frac { kind, .. }, a) if a.starts_with("frac.") => { + use crate::math::FracKind::*; + *kind = match a { + "frac.skewed" => Skewed, + "frac.linear" => Linear, + "frac.noBar" => NoBar, + _ => Bar, + }; + inner + } + (MNode::Rad { deg_hide, deg, .. }, "rad.toggleDegree") => { + *deg_hide = !*deg_hide; + if *deg_hide { + deg.clear(); + here(1) + } else { + here(0) + } + } + (MNode::Nary { lim_loc, .. }, "nary.limitsSide") => { + *lim_loc = Some(LimLoc::SubSup); + inner + } + (MNode::Nary { lim_loc, .. }, "nary.limitsUnder") => { + *lim_loc = Some(LimLoc::UndOvr); + inner + } + (MNode::Nary { sub_hide, sub, .. }, "nary.toggleLower") => { + *sub_hide = !*sub_hide; + if *sub_hide { + sub.clear(); + here(2) + } else { + here(0) + } + } + (MNode::Nary { sup_hide, sup, .. }, "nary.toggleUpper") => { + *sup_hide = !*sup_hide; + if *sup_hide { + sup.clear(); + here(2) + } else { + here(1) + } + } + (MNode::Nary { grow, .. }, "nary.toggleGrow") | (MNode::Delim { grow, .. }, "delim.toggleGrow") => { + *grow = !*grow; + inner + } + (MNode::Delim { elems, sep, .. }, "delim.addSeparator") => { + let at = (c + 1).min(elems.len()); + elems.insert(at, Vec::new()); + if sep.is_none() { + *sep = Some('|'); + } + here(at) + } + (MNode::Delim { elems, .. }, "delim.deleteArgument") if elems.len() > 1 => { + let k = c.min(elems.len() - 1); + elems.remove(k); + here(k.min(elems.len() - 1)) + } + (MNode::Script { kind, sub, .. }, "script.removeSub") => { + *kind = ScriptKind::Sup; + sub.clear(); + here(2) + } + (MNode::Script { kind, sup, .. }, "script.removeSup") => { + *kind = ScriptKind::Sub; + sup.clear(); + here(1) + } + (MNode::Script { kind, .. }, "script.addSup") | (MNode::Script { kind, .. }, "script.addSub") => { + *kind = ScriptKind::SubSup; + here(if action == "script.addSup" { 2 } else { 1 }) + } + (MNode::Matrix { rows, .. }, a) if a.starts_with("matrix.") => { + let ncols = rows.iter().map(|r| r.len()).max().unwrap_or(1).max(1); + // Row and column of the caret's cell. + let mut k = 0; + let mut rc = (0, 0); + for (r, row) in rows.iter().enumerate() { + if c < k + row.len() { + rc = (r, c - k); + break; + } + k += row.len(); + } + let (r, col) = rc; + let (mut tr, mut tc) = (r, col); + match a { + "matrix.rowAbove" | "matrix.rowBelow" if rows.len() < 1000 => { + let at = if a == "matrix.rowAbove" { r } else { r + 1 }; + rows.insert(at.min(rows.len()), vec![Vec::new(); ncols]); + tr = at; + } + "matrix.colBefore" | "matrix.colAfter" if ncols < 64 => { + let at = if a == "matrix.colBefore" { col } else { col + 1 }; + for row in rows.iter_mut() { + row.insert(at.min(row.len()), Vec::new()); + } + tc = at; + } + "matrix.deleteRow" if rows.len() > 1 => { + rows.remove(r.min(rows.len() - 1)); + tr = r.min(rows.len() - 1); + } + "matrix.deleteCol" if ncols > 1 => { + for row in rows.iter_mut() { + if col < row.len() { + row.remove(col); + } + } + tc = col.min(ncols - 2); + } + _ => {} + } + let flat: usize = rows.iter().take(tr).map(|x| x.len()).sum::() + tc; + here(flat) + } + (MNode::EqArr { rows }, a) if a.starts_with("eqarr.") => match a { + "eqarr.rowAbove" | "eqarr.rowBelow" if rows.len() < 1000 => { + let at = if a == "eqarr.rowAbove" { c } else { c + 1 }.min(rows.len()); + rows.insert(at, Vec::new()); + here(at) + } + "eqarr.deleteRow" if rows.len() > 1 => { + let k = c.min(rows.len() - 1); + rows.remove(k); + here(k.min(rows.len() - 1)) + } + _ => inner, + }, + (MNode::GroupChr { top, chr, .. }, "group.flip") => { + *top = !*top; + *chr = match *chr { + '⏞' => '⏟', + '⏟' => '⏞', + '⏜' => '⏝', + '⏝' => '⏜', + '⎴' => '⎵', + '⎵' => '⎴', + other => other, + }; + inner + } + (MNode::Bar { top, .. }, "bar.flip") => { + *top = !*top; + inner + } + _ => return None, + }; + tidy(root); + Some(clamp(root, &out)) +} + +/// Set the limit placement of n-ary operators (`None`: Word's default by operator). +pub fn set_limits(nodes: &mut Arg, loc: Option) { + for n in nodes.iter_mut() { + if let MNode::Nary { lim_loc, .. } = n { + *lim_loc = loc; + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::math::parse_linear; + + fn lin(a: &Arg) -> String { + to_linear(a) + } + + #[test] + fn typing_and_structures() { + let mut eq: Arg = Vec::new(); + let mut p = MathPos::default(); + p = insert_text(&mut eq, &p, "x=", TypeStyle::default()); + p = insert_nodes(&mut eq, &p, parse_linear("⬚/⬚")); + assert_eq!(p.path, vec![(1, 0)], "caret in the numerator"); + p = insert_text(&mut eq, &p, "a", TypeStyle::default()); + p = move_right(&eq, &p).unwrap(); + assert_eq!(p.path, vec![(1, 1)], "then the denominator"); + p = insert_text(&mut eq, &p, "b", TypeStyle::default()); + p = move_right(&eq, &p).unwrap(); + assert_eq!(p, MathPos::new(vec![], 3), "then after the fraction"); + assert_eq!(move_right(&eq, &p), None); + assert_eq!(lin(&eq), "x=a/b"); + // Up from the denominator reaches the numerator. + let den = MathPos::new(vec![(1, 1)], 1); + assert_eq!(move_vertical(&eq, &den, true).unwrap().path, vec![(1, 0)]); + } + + #[test] + fn build_up_and_autocorrect() { + let mut eq: Arg = Vec::new(); + let p = insert_text(&mut eq, &MathPos::default(), "x^2+\\alpha", TypeStyle::default()); + let p = autocorrect(&mut eq, &p).unwrap(); + assert_eq!(lin(&eq), "x^2+α"); + let p = build_up(&mut eq, &p, false).unwrap(); + assert!(matches!(eq[0], MNode::Script { kind: ScriptKind::Sup, .. }), "{eq:#?}"); + assert_eq!(p.off, units(&eq)); + // An unclosed bracket waits. + let mut eq: Arg = Vec::new(); + let p = insert_text(&mut eq, &MathPos::default(), "(a+b", TypeStyle::default()); + assert!(build_up(&mut eq, &p, false).is_none()); + // LaTeX input. + let mut eq: Arg = Vec::new(); + let p = insert_text(&mut eq, &MathPos::default(), "\\frac{a}{b}", TypeStyle::default()); + assert!(build_up(&mut eq, &p, true).is_some()); + assert!(matches!(eq[0], MNode::Frac { .. })); + } + + #[test] + fn backspace_dissolves_and_removes() { + let mut eq = parse_linear("√x"); + // At the start of the radicand: the radical goes, x stays. + let p = delete_back(&mut eq, &MathPos::new(vec![(0, 1)], 0)); + assert_eq!(lin(&eq), "x"); + assert_eq!(p, MathPos::new(vec![], 0)); + // An empty structure is removed by backspace after it. + let mut eq = parse_linear("a+⬚/⬚"); + let p = delete_back(&mut eq, &MathPos::new(vec![], 3)); + assert_eq!(lin(&eq), "a+"); + assert_eq!(p.off, 2); + } + + #[test] + fn placeholders_and_numbering() { + let eq = parse_linear("∑_⬚^⬚▒⬚"); + let p = next_placeholder(&eq, &MathPos::default(), false).unwrap(); + assert_eq!(p.path, vec![(0, 0)]); + let p = next_placeholder(&eq, &p, false).unwrap(); + assert_eq!(p.path, vec![(0, 1)]); + let mut eq = parse_linear("E=mc^2"); + let end = MathPos::new(vec![], units(&eq)); + let p = insert_text(&mut eq, &end, "#(1)", TypeStyle::default()); + let _ = build_up(&mut eq, &p, false); + assert!(number_equation(&mut eq)); + assert_eq!(lin(&eq), "E=mc^2#(1)"); + } + + #[test] + fn structure_actions_work() { + let mut eq = parse_linear("■(a&b@c&d)"); + let p = MathPos::new(vec![(0, 3)], 0); + let acts = structure_actions(&eq, &p); + assert!(acts.iter().any(|a| a.1 == "matrix.rowBelow")); + let p = apply_structure(&mut eq, &p, 0, "matrix.rowBelow").unwrap(); + let p = apply_structure(&mut eq, &p, 0, "matrix.colAfter").unwrap(); + assert_eq!(lin(&eq), "■(a&b&@c&d&@&&)"); + let _ = apply_structure(&mut eq, &p, 0, "matrix.deleteRow").unwrap(); + let mut eq = parse_linear("√x"); + let p = apply_structure(&mut eq, &MathPos::new(vec![(0, 1)], 1), 0, "rad.toggleDegree").unwrap(); + assert_eq!(p.path, vec![(0, 0)], "caret into the degree"); + let mut eq = parse_linear("a+(b)"); + apply_structure(&mut eq, &MathPos::new(vec![(1, 0)], 0), 0, "remove").unwrap(); + assert_eq!(lin(&eq), "a+b"); + // Every action on every structure, at hostile positions, never panics. + let all = parse_linear("x=(-b±√(3&b^2-4ac))/2a+∑_(i=1)^n▒i+■(a&b@c&d)+█(x@y)+⏞(a)+¯(b)+x_1^2+(a│b)"); + let mut paths = Vec::new(); + arg_paths(&all, &mut Vec::new(), &mut paths, 0); + for path in paths { + let p = MathPos::new(path, 0); + for (level, act, _) in structure_actions(&all, &p) { + let mut e = all.clone(); + let _ = apply_structure(&mut e, &p, level, act); + let _ = apply_structure(&mut e, &p, level + 5, act); + } + } + } + + #[test] + fn hostile_positions_never_panic() { + let mut eq = parse_linear("x=(-b±√(b^2-4ac))/2a+■(a&b@c&d)"); + let bad = [MathPos::new(vec![(99, 99)], 5), MathPos::new(vec![(1, 9)], 999), MathPos::new(vec![(0, 0); 300], 0), MathPos::default()]; + for p in &bad { + let _ = move_left(&eq, p); + let _ = move_right(&eq, p); + let _ = move_vertical(&eq, p, true); + let _ = home_end(&eq, p, true); + let _ = next_placeholder(&eq, p, false); + let _ = insert_text(&mut eq.clone(), p, "é", TypeStyle { nor: true }); + let _ = insert_nodes(&mut eq.clone(), p, parse_linear("⬚/⬚")); + let _ = delete_back(&mut eq.clone(), p); + let _ = delete_forward(&mut eq.clone(), p); + let _ = build_up(&mut eq, p, false); + let _ = autocorrect(&mut eq, p); + } + // Walk the whole equation both ways: always terminates. + let mut p = MathPos::default(); + for _ in 0..500 { + match move_right(&eq, &p) { + Some(n) => p = n, + None => break, + } + } + assert_eq!(p, MathPos::new(vec![], units(&eq))); + for _ in 0..500 { + match move_left(&eq, &p) { + Some(n) => p = n, + None => break, + } + } + assert_eq!(p, MathPos::default()); + } +} diff --git a/crates/doc/src/math_latex.rs b/crates/doc/src/math_latex.rs new file mode 100644 index 0000000..9ba6fc6 --- /dev/null +++ b/crates/doc/src/math_latex.rs @@ -0,0 +1,983 @@ +//! 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, FracKind, MAX_DEPTH, MNode, MRun, MScr, MSty, ScriptKind, is_nary_char, math_class, merge_runs}; +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), +} + +fn tokenize(s: &str) -> Vec { + let cs: Vec = s.chars().take(20_000).collect(); + let mut out = Vec::new(); + let mut i = 0; + while let Some(&c) = cs.get(i) { + i += 1; + match c { + '\\' => { + let start = i; + while cs.get(i).is_some_and(|c| c.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 { + out.push(Tok::Cmd(cs.get(start..i).unwrap_or(&[]).iter().collect())); + } + } + '{' => out.push(Tok::Open), + '}' => out.push(Tok::Close), + '^' => out.push(Tok::Sup), + '_' => out.push(Tok::Sub), + '&' => out.push(Tok::Amp), + ' ' | '\t' | '\n' | '\r' => {} + '~' => 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.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(); + 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) + } + + /// 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; + while let Some(t) = self.peek().cloned() { + self.i += 1; + match t { + Tok::Close if level == 0 => break, + 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 }) + } + "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" | "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" => { + let t = self.raw_arg(); + one(MNode::Run(MRun { text: t, nor: true, sty: Some(MSty::Italic), ..Default::default() })) + } + "operatorname" => { + let t = self.raw_arg(); + let name = vec![MNode::Run(MRun::plain(t))]; + self.apply(name) + } + "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) + } + "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" => { + let top = c.starts_with("over"); + let chr = match c { + "overbrace" => '⏞', + "underbrace" => '⏟', + "overparen" => '⏜', + _ => '⏝', + }; + 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) + } + } + "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(); + 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 { + // 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 base = env.trim_end_matches('*'); + 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" => { + let m = MNode::Matrix { rows, col_jc: Vec::new() }; + 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. +fn nary_operands(a: Arg) -> 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() => { + 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 = nary_operands(body); + out.push(MNode::Nary { chr, lim_loc, grow, sub_hide, sup_hide, sub, sup, e: merge_runs(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), + }, + } + } +} + +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 node(n: &MNode, s: &mut String, depth: usize) { + match n { + MNode::Run(r) => { + let (open, close) = if r.nor { + ("\\text{", "}") + } else { + match (r.sty, r.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 r.text.chars().any(|c| c.is_alphabetic()) => { + if FUNCTION_NAMES.contains(&r.text.as_str()) { + s.push('\\'); + s.push_str(&r.text); + s.push(' '); + return; + } + ("\\mathrm{", "}") + } + _ => ("", ""), + } + }; + s.push_str(open); + if r.nor { + s.push_str(&r.text); + } else { + chars(&r.text, s); + } + s.push_str(close); + } + MNode::Frac { kind, num, den } => match kind { + FracKind::NoBar => { + s.push_str("\\genfrac{}{}{0pt}{}"); + braced(num, s, depth); + braced(den, s, depth); + } + FracKind::Linear | FracKind::Skewed => { + braced(num, s, depth); + s.push('/'); + braced(den, s, depth); + } + FracKind::Bar => { + s.push_str("\\frac"); + braced(num, s, depth); + braced(den, s, depth); + } + }, + MNode::Script { kind, base, sub, sup } => { + if *kind == ScriptKind::Pre { + s.push_str("{}_"); + braced(sub, s, depth); + s.push('^'); + braced(sup, s, depth); + braced(base, s, depth); + return; + } + braced(base, s, depth); + if matches!(kind, ScriptKind::Sub | ScriptKind::SubSup) { + s.push('_'); + braced(sub, s, depth); + } + if matches!(kind, ScriptKind::Sup | ScriptKind::SubSup) { + s.push('^'); + braced(sup, s, depth); + } + } + MNode::Rad { deg, deg_hide, e } => { + s.push_str("\\sqrt"); + if !deg_hide && !deg.is_empty() { + s.push('['); + arg(deg, s, depth); + s.push(']'); + } + braced(e, s, depth); + } + MNode::Nary { chr, lim_loc, sub_hide, sup_hide, sub, sup, e, .. } => { + match name_of(*chr) { + Some(n) => { + s.push('\\'); + s.push_str(n); + } + None => s.push(*chr), + } + match lim_loc { + Some(crate::math::LimLoc::UndOvr) => s.push_str("\\limits"), + Some(crate::math::LimLoc::SubSup) if !crate::math::is_integral(*chr) => s.push_str("\\nolimits"), + _ => {} + } + if !sub_hide && !sub.is_empty() { + s.push('_'); + braced(sub, s, depth); + } + if !sup_hide && !sup.is_empty() { + s.push('^'); + braced(sup, s, depth); + } + s.push(' '); + braced(e, s, depth); + } + MNode::Delim { beg, end, elems, .. } => { + s.push_str("\\left"); + s.push_str(&delim_tex(*beg)); + for (i, e) in elems.iter().enumerate() { + if i > 0 { + s.push_str("\\middle|"); + } + arg(e, s, depth); + } + s.push_str("\\right"); + s.push_str(&delim_tex(*end)); + } + MNode::Func { name, e } => { + arg(name, s, depth); + braced(e, s, depth); + } + MNode::Lim { upper, e, lim } => { + // `\lim_{…}` keeps its name; other bases stack. + let simple = matches!(e.as_slice(), [MNode::Run(r)] if r.sty == Some(MSty::Plain) && FUNCTION_NAMES.contains(&r.text.as_str())); + if simple && !upper { + arg(e, s, depth); + s.push('_'); + braced(lim, s, depth); + } else if let [MNode::GroupChr { chr, top, e: inner }] = e.as_slice() { + s.push_str(if *top { "\\overbrace" } else { "\\underbrace" }); + braced(inner, s, depth); + s.push(if *upper { '^' } else { '_' }); + braced(lim, s, depth); + let _ = chr; + } else { + s.push_str(if *upper { "\\overset" } else { "\\underset" }); + braced(lim, s, depth); + braced(e, s, depth); + } + } + MNode::Acc { chr, e } => { + let cmd = match chr { + '\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", + }; + s.push('\\'); + s.push_str(cmd); + braced(e, s, depth); + } + MNode::Bar { top, e } => { + s.push_str(if *top { "\\overline" } else { "\\underline" }); + braced(e, s, depth); + } + MNode::BorderBox { e, .. } => { + s.push_str("\\boxed"); + braced(e, s, depth); + } + MNode::Boxed { e } => braced(e, s, depth), + MNode::GroupChr { top, e, .. } => { + s.push_str(if *top { "\\overbrace" } else { "\\underbrace" }); + braced(e, s, depth); + } + MNode::EqArr { rows } => { + if rows.len() == 1 + && let Some(r) = rows.first() + && crate::math::has_number_mark(r) + { + arg(r, s, depth); + return; + } + s.push_str("\\begin{aligned}"); + for (i, r) in rows.iter().enumerate() { + if i > 0 { + s.push_str("\\\\"); + } + for n in r { + match n { + MNode::Run(run) if !run.lit && run.text.contains('&') => { + for (k, part) in run.text.split('&').enumerate() { + if k > 0 { + s.push('&'); + } + let mut p = run.clone(); + p.text = part.to_string(); + node(&MNode::Run(p), s, depth + 1); + } + } + other => node(other, s, depth + 1), + } + } + } + s.push_str("\\end{aligned}"); + } + MNode::Matrix { rows, .. } => { + s.push_str("\\begin{matrix}"); + for (i, r) in rows.iter().enumerate() { + if i > 0 { + s.push_str("\\\\"); + } + for (j, c) in r.iter().enumerate() { + if j > 0 { + s.push('&'); + } + arg(c, s, depth); + } + } + s.push_str("\\end{matrix}"); + } + MNode::Phant { e, .. } => { + s.push_str("\\phantom"); + braced(e, s, depth); + } + } +} + +#[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_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 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); + } +} diff --git a/crates/doc/src/math_linear.rs b/crates/doc/src/math_linear.rs new file mode 100644 index 0000000..3b5aee3 --- /dev/null +++ b/crates/doc/src/math_linear.rs @@ -0,0 +1,715 @@ +//! 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, is_nary_char, math_class, merge_runs}; + +/// 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", +]; + +/// 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); + 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); + let base = out.pop(); + out.push(attach_script(base, c == '^', s)); + } + } + '┬' | '┴' => { + self.i += 1; + let lim = self.script_operand(stops); + 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; + 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; + while self.c.get(end).is_some_and(|c| c.is_ascii_alphabetic()) { + end += 1; + } + let word: String = 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() + }; + match c { + '^' | '_' => { + self.i += 1; + let s = self.script_operand(stops); + let b = base(out); + out.push(attach_script(b, c == '^', s)); + } + '┬' | '┴' if self.no_limits == 0 => { + self.i += 1; + 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 }); + } + c if is_combining(c) => { + self.i += 1; + let b = base(out); + out.push(MNode::Acc { chr: c, e: b.map(|n| strip_parens(vec![n])).unwrap_or_default() }); + } + _ => break, + } + } + } + + /// A script or limit: a parenthesised group, a structure, a number, a signed operand or one + /// 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_ascii_digit() || 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_ascii_alphabetic() => { + // A function name as a script (`e^sin x` is rare): one letter. + self.i += 1; + vec![text(c)] + } + 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/doc/src/math_symbols.rs b/crates/doc/src/math_symbols.rs new file mode 100644 index 0000000..c0af4b5 --- /dev/null +++ b/crates/doc/src/math_symbols.rs @@ -0,0 +1,389 @@ +//! 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", '∖'), + ("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}'), +]; + +/// 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/doc/src/para.rs b/crates/doc/src/para.rs index 0ca3d53..d136db0 100644 --- a/crates/doc/src/para.rs +++ b/crates/doc/src/para.rs @@ -254,11 +254,15 @@ pub enum InlineObject { CommentEnd { id: u32, }, - /// An equation in linear format (`x=(-b±√(b^2-4ac))/2a`). + /// An equation: its linear format (`x=(-b±√(b^2-4ac))/2a`, for plain text) and structure. + /// A display equation sits on a line of its own. Equation { linear: String, #[serde(default)] display: bool, + /// The structure; empty = parse `linear`. + #[serde(default, skip_serializing_if = "crate::math::Math::is_empty")] + math: crate::math::Math, }, /// Something we don't model, kept for round-trip (raw XML of the source format). Opaque { diff --git a/crates/docx/src/read/math.rs b/crates/docx/src/read/math.rs new file mode 100644 index 0000000..5a87e19 --- /dev/null +++ b/crates/docx/src/read/math.rs @@ -0,0 +1,328 @@ +//! Office Math (OMML, ECMA-376 Part 1 §22.1) → [`wordcraft_doc::math`] tree. + +use wordcraft_doc::math::{Arg, ColJc, FracKind, LimLoc, MAX_DEPTH, MNode, MRun, MScr, MSty, Math, MathJc, ScriptKind, merge_runs}; +use wordcraft_doc::props::Rgb; + +use crate::xml::El; + +/// 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 } +} + +/// `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("m:val")) { + Some("left") => MathJc::Left, + Some("right") => MathJc::Right, + Some("center") => MathJc::Center, + _ => MathJc::CenterGroup, + } +} + +/// OMML on/off property: absent `m:val` = on. +fn on(e: &El) -> bool { + match e.attr("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("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("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.els() { + 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. + "w:ins" | "w:moveTo" | "w:smartTag" | "w:customXml" | "m:oMath" => { + if depth <= MAX_DEPTH { + for c in k.els() { + node(c, d, out); + } + } + } + "w:sdt" => { + if let Some(c) = k.child("w:sdtContent") { + for c in c.els() { + node(c, d, out); + } + } + } + _ => {} + } +} + +/// `m:t` (and `w:t`) text of a run. +fn run_text(r: &El) -> String { + let mut s = String::new(); + for c in r.els() { + match c.name.as_str() { + "m:t" | "w:t" => s.push_str(&c.text()), + "w:tab" => s.push('\t'), + _ => {} + } + } + 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("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("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); + } + 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("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("w:val")).and_then(Rgb::parse) { + out.color = Some(c); + } + if out.nor { + if let Some(f) = pr.child("w:rFonts").and_then(|f| f.attr("w:ascii").or_else(|| f.attr("w:hAnsi"))) { + out.font = Some(f.to_string()); + } + let bold = pr.child("w:b").is_some_and(crate::units::on_off); + let italic = pr.child("w:i").is_some_and(crate::units::on_off); + if bold || italic { + out.sty = Some(match (bold, italic) { + (true, true) => MSty::BoldItalic, + (true, false) => MSty::Bold, + _ => MSty::Italic, + }); + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn omath(inner: &str) -> El { + let x = format!( + r#"{inner}"# + ); + crate::xml::parse(x.as_bytes()).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!(wordcraft_doc::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)); + } +} diff --git a/crates/docx/src/read/mod.rs b/crates/docx/src/read/mod.rs index 594fa83..d3e0b44 100644 --- a/crates/docx/src/read/mod.rs +++ b/crates/docx/src/read/mod.rs @@ -1,5 +1,6 @@ //! DOCX → [`Document`]. +mod math; mod props; mod story; diff --git a/crates/docx/src/read/story.rs b/crates/docx/src/read/story.rs index 81aa2de..fe9f66a 100644 --- a/crates/docx/src/read/story.rs +++ b/crates/docx/src/read/story.rs @@ -328,12 +328,17 @@ impl Reader<'_> { "w:smartTag" | "w:customXml" | "w:dir" | "w:bdo" => self.read_inline_children(sc, pb, k, rels, ctx, depth + 1), "m:oMath" => { let props = self.run_props_none(ctx); - self.emit_obj(sc, pb, InlineObject::Equation { linear: k.deep_text(), display: false }, &props); + let math = super::math::read_omath(k, Default::default()); + let linear = wordcraft_doc::math::to_linear(&math.nodes); + self.emit_obj(sc, pb, InlineObject::Equation { linear, display: false, math }, &props); } "m:oMathPara" => { let props = self.run_props_none(ctx); + let jc = super::math::para_jc(k); for m in k.children("m:oMath") { - self.emit_obj(sc, pb, InlineObject::Equation { linear: m.deep_text(), display: true }, &props); + let math = super::math::read_omath(m, jc); + let linear = wordcraft_doc::math::to_linear(&math.nodes); + self.emit_obj(sc, pb, InlineObject::Equation { linear, display: true, math }, &props); } } "mc:AlternateContent" => { diff --git a/crates/docx/src/write/math.rs b/crates/docx/src/write/math.rs new file mode 100644 index 0000000..b656a07 --- /dev/null +++ b/crates/docx/src/write/math.rs @@ -0,0 +1,415 @@ +//! [`wordcraft_doc::math`] tree → Office Math (OMML). + +use wordcraft_doc::math::{Arg, ColJc, FracKind, LimLoc, MAX_DEPTH, MNode, MRun, MScr, MSty, Math, MathJc, ScriptKind, parse_linear}; + +use crate::xml::W; + +/// 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. +pub fn write_equation(w: &mut W, linear: &str, display: bool, math: &Math) { + 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 !wordcraft_doc::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 kept XML with namespace declarations, for validating it on its own. +fn wrap_ns(xml: &str) -> String { + let decl: String = crate::xml::body_ns().iter().filter(|(k, _)| k.starts_with("xmlns:")).map(|(k, v)| format!(" {k}=\"{v}\"")).collect(); + format!("{xml}") +} + +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 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 { chr, lim_loc, grow, sub_hide, sup_hide, sub, sup, e } => { + 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"); + } + 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| wordcraft_doc::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 { rows, col_jc } => { + let cols = rows.iter().map(|r| r.len()).max().unwrap_or(0).max(1); + 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 r in rows { + w.open("m:mr", &[]); + for c in 0..cols { + arg(w, "m:e", r.get(c).map(|v| v.as_slice()).unwrap_or(&[]), depth); + } + w.close("m:mr"); + } + w.close("m:m"); + } + 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 { + 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); + } + 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/docx/src/write/mod.rs b/crates/docx/src/write/mod.rs index f827c6f..02f4f1e 100644 --- a/crates/docx/src/write/mod.rs +++ b/crates/docx/src/write/mod.rs @@ -1,5 +1,6 @@ //! [`Document`] → DOCX. +mod math; mod props; mod story; @@ -52,6 +53,8 @@ impl PartRels { pub(crate) struct Writer<'d> { doc: &'d Document, + /// Numbered display equations written so far (automatic numbers are written as text). + pub(crate) eq_number: u32, /// Media key → file name under `word/media/`. media_files: BTreeMap, bookmarks: HashMap, @@ -103,6 +106,7 @@ pub fn write_as(doc: &Document, flavor: Flavor) -> Result, DocxError> { doc }; let mut wr = Writer { + eq_number: 0, doc, media_files: BTreeMap::new(), bookmarks: HashMap::new(), diff --git a/crates/docx/src/write/story.rs b/crates/docx/src/write/story.rs index ed1bc16..530ce84 100644 --- a/crates/docx/src/write/story.rs +++ b/crates/docx/src/write/story.rs @@ -301,17 +301,11 @@ impl Writer<'_> { w.close("w:r"); } } - InlineObject::Equation { linear, display } => { - if *display { - w.open("m:oMathPara", &[]); - } - w.open("m:oMath", &[]); - w.open("m:r", &[]); - w.leaf("m:t", &[("xml:space", "preserve")], linear); - w.close("m:r"); - w.close("m:oMath"); - if *display { - w.close("m:oMathPara"); + InlineObject::Equation { linear, display, math } => { + // Automatic equation numbers become text: Word numbers nothing by itself. + match super::math::resolve_numbers(math, *display, &mut self.eq_number) { + Some(m) => super::math::write_equation(w, linear, *display, &m), + None => super::math::write_equation(w, linear, *display, math), } } InlineObject::Field { instr, result, locked } => self.field(w, instr, result, *locked, props), diff --git a/crates/docx/src/xml.rs b/crates/docx/src/xml.rs index 44849ae..3a82659 100644 --- a/crates/docx/src/xml.rs +++ b/crates/docx/src/xml.rs @@ -156,6 +156,44 @@ impl El { } go(self, name, 0) } + /// The element as XML with canonical prefixes (for re-emitting kept content). Elements and + /// attributes in unknown namespaces are dropped. + pub fn to_xml(&self) -> String { + fn go(e: &El, s: &mut String, depth: usize) { + if depth > MAX_DEPTH || e.name.starts_with("?:") { + return; + } + s.push('<'); + s.push_str(&e.name); + for (k, v) in &e.attrs { + if k.starts_with("?:") { + continue; + } + s.push(' '); + s.push_str(k); + s.push_str("=\""); + s.push_str(&esc(v)); + s.push('"'); + } + if e.kids.is_empty() { + s.push_str("/>"); + return; + } + s.push('>'); + for n in &e.kids { + match n { + Node::Text(t) => s.push_str(&esc(t)), + Node::El(c) => go(c, s, depth + 1), + } + } + s.push_str("'); + } + let mut s = String::new(); + go(self, &mut s, 0); + s + } /// Local part of the name. pub fn local(&self) -> &str { self.name.rsplit(':').next().unwrap_or(&self.name) diff --git a/crates/docx/tests/roundtrip.rs b/crates/docx/tests/roundtrip.rs index f879b07..f756bda 100644 --- a/crates/docx/tests/roundtrip.rs +++ b/crates/docx/tests/roundtrip.rs @@ -741,7 +741,7 @@ fn shapes_textboxes_equations_dropcaps_round_trip() { float: Float::default(), story: None, }; - let eq = InlineObject::Equation { linear: "x=(-b±√(b^2-4ac))/2a".into(), display: false }; + let eq = InlineObject::Equation { linear: "x=(-b±√(b^2-4ac))/2a".into(), display: false, math: Default::default() }; let mut p = Paragraph::with_text("shapes ", CharProps::default()); for o in [tb, star.clone(), eq.clone()] { let end = p.len(); @@ -766,7 +766,14 @@ fn shapes_textboxes_equations_dropcaps_round_trip() { o => panic!("{o:?}"), } assert_eq!(got[0].objects[1], star); - assert_eq!(got[0].objects[2], eq); + match &got[0].objects[2] { + InlineObject::Equation { linear, display, math } => { + assert_eq!(linear, "x=(-b±√(b^2-4ac))/2a"); + assert!(!display); + assert_eq!(math.nodes, wordcraft_doc::math::parse_linear(linear), "structure is written as OMML and read back"); + } + o => panic!("{o:?}"), + } assert_eq!(got.len(), 2, "drop cap paragraph merges back"); assert_eq!(got[1].text, dc.text); assert_eq!(got[1].props, dc.props); diff --git a/crates/engine/src/catalog.rs b/crates/engine/src/catalog.rs index 3d36d5e..04e2205 100644 --- a/crates/engine/src/catalog.rs +++ b/crates/engine/src/catalog.rs @@ -359,6 +359,28 @@ Table Layout|Data|Sort|table.sort Table Layout|Data|Repeat Header Rows|table.repeatHeader Table Layout|Data|Convert to Text|table.toText Table Layout|Data|Formula|table.formula +Equation|Tools|Equation|insert.equation +Equation|Tools|Ink Equation|insert.inkEquation +Equation|Conversions|Unicode|equation.inputFormat +Equation|Conversions|LaTeX|equation.inputFormat +Equation|Conversions|Convert|equation.convert +Equation|Conversions|Normal Text|equation.normalText +Equation|Symbols|Symbols|equation.insertSymbol +Equation|Structures|Fraction|equation.insertStructure +Equation|Structures|Script|equation.insertStructure +Equation|Structures|Radical|equation.insertStructure +Equation|Structures|Integral|equation.insertStructure +Equation|Structures|Large Operator|equation.insertStructure +Equation|Structures|Bracket|equation.insertStructure +Equation|Structures|Function|equation.insertStructure +Equation|Structures|Accent|equation.insertStructure +Equation|Structures|Limit and Log|equation.insertStructure +Equation|Structures|Operator|equation.insertStructure +Equation|Structures|Matrix|equation.insertStructure +Equation|Equation Options|Change to Inline / Display|equation.display +Equation|Equation Options|Justification|equation.justify +Equation|Equation Options|Equation Number (#)|equation.number +Equation|Equation Options|Structure Commands|equation.structure Picture Format|Adjust|Remove Background|picture.removeBackground Picture Format|Adjust|Corrections|picture.corrections Picture Format|Adjust|Color|picture.color diff --git a/crates/engine/src/cmd/equation.rs b/crates/engine/src/cmd/equation.rs new file mode 100644 index 0000000..d30728c --- /dev/null +++ b/crates/engine/src/cmd/equation.rs @@ -0,0 +1,729 @@ +//! Equations: insert (empty, from the gallery, linear format or LaTeX), edit in place (type, +//! structures, symbols, caret movement, delete, build-up), convert between built-up and linear +//! form, inline/display, justification and equation numbers. + +use serde_json::{Value, json}; +use wordcraft_doc::math::{Arg, MNode, Math, MathJc, parse_linear, to_linear}; +use wordcraft_doc::math_edit::{self as me, MathPos, TypeStyle}; +use wordcraft_doc::math_latex::{parse_latex, to_latex}; +use wordcraft_doc::para::OBJ; +use wordcraft_doc::{InlineObject, Pos}; + +use super::{delete_selection, sel_result, split_para}; +use crate::{CmdError, CmdResult, CommandSpec, MathEdit, Selection, Session, p}; + +fn editing(s: &Session) -> Option<&'static str> { + if s.math.is_none() { Some("not editing an equation") } else { None } +} + +fn near_equation(s: &Session) -> Option<&'static str> { + if s.math.is_some() || equation_at_caret(s).is_some() { None } else { Some("no equation at the caret") } +} + +pub fn specs() -> Vec { + vec![ + CommandSpec::new("insert.equation", "Equation", "Insert › Symbols", insert) + .key("Alt+=") + .params(r#"{"linear"?: string, "latex"?: string, "builtin"?: "pythagoras|area|quadratic|binomial|expansion|euler|derivative|taylor|gaussian|fourier|trig1|trig2|normal|bayes", "display"?: bool, "edit"?: bool (default true)}"#), + CommandSpec::new("equation.edit", "Edit Equation", "Equation", edit) + .pure() + .when(near_equation) + .params(r#"{"pos"?: {"path": [[node, child]…], "off": n}}"#), + CommandSpec::new("equation.exit", "Leave Equation", "Equation", exit).pure().when(editing).params(r#"{"before"?: bool}"#), + CommandSpec::new("equation.type", "Type in Equation", "Equation", type_text).when(editing).params(r#"{"text": string}"#), + CommandSpec::new("equation.insertStructure", "Insert Structure", "Equation › Structures", insert_structure) + .params(r#"{"id"?: string (see equation.gallery), "linear"?: string, "latex"?: string}"#), + CommandSpec::new("equation.insertSymbol", "Insert Symbol", "Equation › Symbols", insert_symbol).params(r#"{"char": string}"#), + CommandSpec::new("equation.move", "Move in Equation", "Equation", move_caret) + .pure() + .when(editing) + .params(r#"{"dir": "left|right|up|down|home|end|next|prev"}"#), + CommandSpec::new("equation.backspace", "Delete Back in Equation", "Equation", |s, _| delete(s, true)).when(editing), + CommandSpec::new("equation.delete", "Delete in Equation", "Equation", |s, _| delete(s, false)).when(editing), + CommandSpec::new("equation.enter", "Enter in Equation", "Equation", enter).when(editing), + CommandSpec::new("equation.convert", "Convert", "Equation › Conversions", convert) + .when(near_equation_or_all) + .params(r#"{"to": "professional|linear", "all"?: bool}"#), + CommandSpec::new("equation.inputFormat", "Input Format", "Equation › Conversions", |s, v| { + if let Some(f) = p::str(v, "format") { + s.math_latex = f.eq_ignore_ascii_case("latex"); + } + Ok(json!({"format": if s.math_latex { "latex" } else { "unicode" }})) + }) + .pure() + .params(r#"{"format": "unicode|latex"}"#), + CommandSpec::new("equation.normalText", "Normal Text", "Equation › Tools", |s, v| { + s.math_normal_text = p::bool(v, "value").unwrap_or(!s.math_normal_text); + Ok(json!({"normalText": s.math_normal_text})) + }) + .pure() + .params(r#"{"value"?: bool}"#), + CommandSpec::new("equation.display", "Change to Display", "Equation", set_display).when(near_equation).params(r#"{"value"?: bool}"#), + CommandSpec::new("equation.justify", "Justification", "Equation", justify) + .when(near_equation) + .params(r#"{"jc": "centerGroup|center|left|right"}"#), + CommandSpec::new("equation.number", "Equation Number", "Equation", number).when(near_equation).params(r#"{"value"?: bool}"#), + CommandSpec::new("equation.set", "Set Equation", "Equation", set).when(near_equation).params(r#"{"linear"?: string, "latex"?: string}"#), + CommandSpec::new("equation.get", "Equation Info", "Equation", get).pure().when(near_equation), + CommandSpec::new("equation.gallery", "Equation Gallery", "Equation › Structures", gallery).pure(), + CommandSpec::new("equation.structureActions", "Structure Actions", "Equation", structure_actions).pure().when(editing), + CommandSpec::new("equation.structure", "Change Structure", "Equation", structure) + .when(editing) + .params(r#"{"action": string (see equation.structureActions), "level"?: n}"#), + ] +} + +fn near_equation_or_all(_: &Session) -> Option<&'static str> { + None +} + +/// The equation the caret is at: on its U+FFFC or just after it (or the one being edited). +fn equation_at_caret(s: &Session) -> Option { + if let Some(m) = &s.math { + return Some(m.at.clone()); + } + let f = &s.sel.focus; + let para = s.doc.para_at(f)?; + let is_eq = |off: usize| matches!(para.object_at(off), Some(InlineObject::Equation { .. })); + if is_eq(f.off) { + return Some(f.clone()); + } + let before = f.off.checked_sub(OBJ.len_utf8())?; + if is_eq(before) { + return Some(Pos { off: before, ..f.clone() }); + } + // A selection holding exactly one equation. + let (a, b) = s.sel.ordered(); + if a.path == b.path && b.off == a.off + OBJ.len_utf8() && is_eq(a.off) { + return Some(a); + } + None +} + +/// Run `f` on the equation at `at`; keeps its linear text in step and drops the source OMML +/// (it no longer matches). +fn with_eq(s: &mut Session, at: &Pos, f: impl FnOnce(&mut Math, &mut bool) -> R) -> Result { + let para = s.doc.para_mut(at.story, &at.path)?; + let Some(InlineObject::Equation { linear, display, math }) = para.object_at_mut(at.off) else { + return Err(CmdError::Failed("no equation there".into())); + }; + if math.nodes.is_empty() && !linear.is_empty() && !math.linear { + math.nodes = parse_linear(linear); + } + let r = f(math, display); + *linear = if math.linear { plain(&math.nodes) } else { to_linear(&math.nodes) }; + math.omml.clear(); + para.touch(); + Ok(r) +} + +/// All the text of an equation's runs (its linear form while shown linear). +fn plain(nodes: &[MNode]) -> String { + let mut s = String::new(); + for n in nodes { + if let MNode::Run(r) = n { + s.push_str(&r.text); + } + } + s +} + +fn result(s: &Session) -> CmdResult { + let Some(m) = &s.math else { return sel_result(s) }; + let info = s.doc.para_at(&m.at).and_then(|p| match p.object_at(m.at.off) { + Some(InlineObject::Equation { linear, display, .. }) => Some((linear.clone(), *display)), + _ => None, + }); + let (linear, display) = info.unwrap_or_default(); + Ok(json!({"editing": true, "linear": linear, "display": display, "pos": serde_json::to_value(&m.pos).unwrap_or(Value::Null)})) +} + +/// Nodes from `linear`, `latex` or a gallery `id` / `builtin` parameter. +fn nodes_from(v: &Value) -> Option { + if let Some(id) = p::str(v, "id") { + return crate::math_gallery::template(id).map(|t| t.nodes()); + } + if let Some(b) = p::str(v, "builtin") { + return crate::math_gallery::BUILT_INS.iter().find(|(id, _, _)| *id == b).map(|(_, _, lin)| parse_linear(lin)); + } + if let Some(l) = p::str(v, "latex") { + return Some(parse_latex(l)); + } + p::str(v, "linear").map(parse_linear) +} + +fn insert(s: &mut Session, v: &Value) -> CmdResult { + let selected = if s.sel.is_collapsed() { String::new() } else { s.selected_text() }; + let nodes = match nodes_from(v) { + Some(n) => n, + None if p::str(v, "id").is_some() || p::str(v, "builtin").is_some() => return Err(CmdError::Params("unknown equation".into())), + // Selected text becomes the equation (Word converts it). + None => parse_linear(selected.trim()), + }; + insert_nodes_as_equation(s, nodes, v) +} + +fn insert_nodes_as_equation(s: &mut Session, nodes: Arg, v: &Value) -> CmdResult { + s.math = None; + let props = s.typing_props(); + let at = delete_selection(s)?; + // On an empty line it's a display equation, in text an inline one. + let empty_line = s.doc.para_at(&at).is_none_or(|p| p.text.trim().is_empty()); + let display = p::bool(v, "display").unwrap_or(empty_line); + let linear = to_linear(&nodes); + let caret = match me::next_placeholder(&nodes, &MathPos::default(), false) { + Some(p) if !nodes.is_empty() => p, + _ => MathPos::new(Vec::new(), me::units(&nodes)), + }; + let obj = InlineObject::Equation { linear, display, math: Math { nodes, ..Default::default() } }; + let end = s.doc.insert_object(&at, obj, &props)?; + if p::bool(v, "edit").unwrap_or(true) { + s.sel = Selection::caret(at.clone()); + s.math = Some(MathEdit { at, pos: caret }); + } else { + s.sel = Selection::caret(end); + } + result(s) +} + +fn edit(s: &mut Session, v: &Value) -> CmdResult { + let at = equation_at_caret(s).ok_or_else(|| CmdError::Disabled("no equation at the caret".into()))?; + let nodes = match s.doc.para_at(&at).and_then(|p| p.object_at(at.off)) { + Some(InlineObject::Equation { math, linear, .. }) => { + if math.nodes.is_empty() && !math.linear { + parse_linear(linear) + } else { + math.nodes.clone() + } + } + _ => return Err(CmdError::Failed("no equation there".into())), + }; + let pos = + v.get("pos").and_then(|p| serde_json::from_value::(p.clone()).ok()).unwrap_or_else(|| MathPos::new(Vec::new(), me::units(&nodes))); + s.sel = Selection::caret(at.clone()); + s.math = Some(MathEdit { pos: me::clamp(&nodes, &pos), at }); + result(s) +} + +fn exit(s: &mut Session, v: &Value) -> CmdResult { + if let Some(m) = s.math.take() { + let before = p::bool(v, "before").unwrap_or(false); + let off = if before { m.at.off } else { m.at.off + OBJ.len_utf8() }; + s.sel = Selection::caret(Pos { off, ..m.at }); + } + sel_result(s) +} + +fn current(s: &Session) -> Result { + s.math.clone().ok_or_else(|| CmdError::Disabled("not editing an equation".into())) +} + +fn type_text(s: &mut Session, v: &Value) -> CmdResult { + let text = p::req_str(v, "text")?.to_string(); + let m = current(s)?; + let latex = s.math_latex; + let style = TypeStyle { nor: s.math_normal_text }; + let mut pos = m.pos.clone(); + with_eq(s, &m.at, |math, _| { + if math.linear { + // Linear form is plain text: no build-up. + pos = me::insert_text(&mut math.nodes, &pos, &text, TypeStyle::default()); + set_literal(&mut math.nodes); + return; + } + for c in text.chars().take(10_000) { + let rel = matches!(wordcraft_doc::math::math_class(c), wordcraft_doc::math::MClass::Rel); + if !style.nor && (c == ' ' || c == '\t' || rel) { + let mut changed = false; + if let Some(p) = me::autocorrect(&mut math.nodes, &pos) { + pos = p; + changed = true; + } + if let Some(p) = me::build_up(&mut math.nodes, &pos, latex) { + pos = p; + changed = true; + } + if c == ' ' && changed { + continue; + } + } + pos = me::insert_text(&mut math.nodes, &pos, &c.to_string(), style); + } + })?; + if let Some(m) = s.math.as_mut() { + m.pos = pos; + } + result(s) +} + +/// Runs of a linear-form equation are literal text. +fn set_literal(nodes: &mut Arg) { + for n in nodes.iter_mut() { + if let MNode::Run(r) = n { + r.lit = true; + } + } +} + +fn insert_structure(s: &mut Session, v: &Value) -> CmdResult { + let nodes = nodes_from(v).ok_or_else(|| CmdError::Params("give an id, linear or latex".into()))?; + let Some(m) = s.math.clone() else { + // Outside an equation: a new equation holding the structure. + return insert_nodes_as_equation(s, nodes, &json!({})); + }; + let mut pos = m.pos.clone(); + with_eq(s, &m.at, |math, _| { + if math.linear { + pos = me::insert_text(&mut math.nodes, &pos, &to_linear(&nodes), TypeStyle::default()); + set_literal(&mut math.nodes); + } else { + pos = me::insert_nodes(&mut math.nodes, &pos, nodes); + } + })?; + if let Some(m) = s.math.as_mut() { + m.pos = pos; + } + result(s) +} + +fn insert_symbol(s: &mut Session, v: &Value) -> CmdResult { + let c = p::req_str(v, "char")?.to_string(); + if c.is_empty() { + return Err(CmdError::Params("empty char".into())); + } + let Some(m) = s.math.clone() else { + return insert_nodes_as_equation(s, parse_linear(&c), &json!({})); + }; + let mut pos = m.pos.clone(); + let style = TypeStyle { nor: s.math_normal_text }; + with_eq(s, &m.at, |math, _| { + pos = me::insert_text(&mut math.nodes, &pos, &c, style); + if math.linear { + set_literal(&mut math.nodes); + } + })?; + if let Some(m) = s.math.as_mut() { + m.pos = pos; + } + result(s) +} + +fn nodes_of(s: &Session, at: &Pos) -> Option { + match s.doc.para_at(at)?.object_at(at.off)? { + InlineObject::Equation { math, linear, .. } => { + Some(if math.nodes.is_empty() && !math.linear { parse_linear(linear) } else { math.nodes.clone() }) + } + _ => None, + } +} + +fn move_caret(s: &mut Session, v: &Value) -> CmdResult { + let m = current(s)?; + // Leaving a slot finishes what was typed there (AutoCorrect names, build-up). + let latex = s.math_latex; + let mut pos = m.pos.clone(); + let mut changed = false; + if !s.math_normal_text { + with_eq(s, &m.at, |math, _| { + if math.linear { + return; + } + if let Some(p) = me::autocorrect(&mut math.nodes, &pos) { + pos = p; + changed = true; + } + if let Some(p) = me::build_up(&mut math.nodes, &pos, latex) { + pos = p; + changed = true; + } + })?; + } + if changed && let Some(mm) = s.math.as_mut() { + mm.pos = pos; + } + let m = current(s)?; + let nodes = nodes_of(s, &m.at).ok_or_else(|| CmdError::Failed("no equation there".into()))?; + let dir = p::str(v, "dir").unwrap_or("right"); + let next = match dir { + "left" => me::move_left(&nodes, &m.pos), + "right" => me::move_right(&nodes, &m.pos), + "up" => Some(me::move_vertical(&nodes, &m.pos, true).unwrap_or(m.pos.clone())), + "down" => Some(me::move_vertical(&nodes, &m.pos, false).unwrap_or(m.pos.clone())), + "home" => Some(me::home_end(&nodes, &m.pos, false)), + "end" => Some(me::home_end(&nodes, &m.pos, true)), + "next" | "prev" => Some(me::next_placeholder(&nodes, &m.pos, dir == "prev").unwrap_or(m.pos.clone())), + other => return Err(CmdError::Params(format!("unknown direction {other}"))), + }; + match next { + Some(p) => { + if let Some(m) = s.math.as_mut() { + m.pos = p; + } + result(s) + } + // Past either end: the caret leaves the equation. + None => exit(s, &json!({"before": dir == "left"})), + } +} + +fn delete(s: &mut Session, back: bool) -> CmdResult { + let m = current(s)?; + let nodes = nodes_of(s, &m.at).ok_or_else(|| CmdError::Failed("no equation there".into()))?; + // An empty equation goes away entirely. + if nodes.is_empty() { + s.math = None; + s.sel = Selection { anchor: m.at.clone(), focus: Pos { off: m.at.off + OBJ.len_utf8(), ..m.at.clone() } }; + let at = delete_selection(s)?; + s.sel = Selection::caret(at); + return sel_result(s); + } + let mut pos = m.pos.clone(); + with_eq(s, &m.at, |math, _| { + pos = if back { me::delete_back(&mut math.nodes, &pos) } else { me::delete_forward(&mut math.nodes, &pos) }; + })?; + if let Some(m) = s.math.as_mut() { + m.pos = pos; + } + result(s) +} + +fn enter(s: &mut Session, _: &Value) -> CmdResult { + let m = current(s)?; + let latex = s.math_latex; + let mut pos = m.pos.clone(); + let mut changed = false; + with_eq(s, &m.at, |math, _| { + if math.linear { + return; + } + if let Some(p) = me::autocorrect(&mut math.nodes, &pos) { + pos = p; + changed = true; + } + if let Some(p) = me::build_up(&mut math.nodes, &pos, latex) { + pos = p; + changed = true; + } + if me::number_equation(&mut math.nodes) { + pos = MathPos::new(Vec::new(), me::units(&math.nodes)); + changed = true; + } + })?; + if changed { + if let Some(m) = s.math.as_mut() { + m.pos = pos; + } + return result(s); + } + // Nothing to build: Enter ends the equation and starts a new paragraph. + s.math = None; + let after = Pos { off: m.at.off + OBJ.len_utf8(), ..m.at }; + let new = split_para(s, &after)?; + s.sel = Selection::caret(new); + sel_result(s) +} + +/// Built-up ↔ linear form for one equation's nodes. +fn convert_math(math: &mut Math, professional: bool, latex: bool) { + if professional { + if math.linear { + let text = plain(&math.nodes); + math.nodes = if latex { parse_latex(&text) } else { parse_linear(&text) }; + math.linear = false; + } + } else if !math.linear { + let text = if latex { to_latex(&math.nodes) } else { to_linear(&math.nodes) }; + math.nodes = vec![MNode::Run(wordcraft_doc::math::MRun { text, lit: true, ..Default::default() })]; + math.linear = true; + } +} + +fn convert(s: &mut Session, v: &Value) -> CmdResult { + let professional = p::str(v, "to").map(|t| !t.eq_ignore_ascii_case("linear")).unwrap_or(true); + let latex = s.math_latex; + if p::bool(v, "all").unwrap_or(false) { + let mut n = 0; + let stories: Vec = + std::iter::once(wordcraft_doc::StoryRef::Body).chain(s.doc.parts.keys().map(|k| wordcraft_doc::StoryRef::Part(*k))).collect(); + for story in stories { + for path in s.doc.para_paths(story) { + let offs: Vec = s + .doc + .para(story, &path) + .map(|p| p.object_offsets().into_iter().filter(|o| matches!(p.object_at(*o), Some(InlineObject::Equation { .. }))).collect()) + .unwrap_or_default(); + for off in offs { + let at = Pos { story, path: path.clone(), off }; + with_eq(s, &at, |math, _| convert_math(math, professional, latex))?; + n += 1; + } + } + } + if let Some(m) = s.math.as_mut() { + m.pos = MathPos::default(); + } + s.clamp_selection(); + return Ok(json!({"converted": n})); + } + let at = equation_at_caret(s).ok_or_else(|| CmdError::Disabled("no equation at the caret".into()))?; + let mut end = 0; + with_eq(s, &at, |math, _| { + convert_math(math, professional, latex); + end = me::units(&math.nodes); + })?; + if let Some(m) = s.math.as_mut() { + m.pos = MathPos::new(Vec::new(), end); + } + result(s) +} + +fn set_display(s: &mut Session, v: &Value) -> CmdResult { + let at = equation_at_caret(s).ok_or_else(|| CmdError::Disabled("no equation at the caret".into()))?; + with_eq(s, &at, |_, display| *display = p::bool(v, "value").unwrap_or(!*display))?; + result(s) +} + +fn justify(s: &mut Session, v: &Value) -> CmdResult { + let at = equation_at_caret(s).ok_or_else(|| CmdError::Disabled("no equation at the caret".into()))?; + let jc = match p::str(v, "jc").unwrap_or("centerGroup") { + "left" => MathJc::Left, + "right" => MathJc::Right, + "center" => MathJc::Center, + _ => MathJc::CenterGroup, + }; + with_eq(s, &at, |math, display| { + math.jc = jc; + *display = true; + })?; + result(s) +} + +/// Add (or remove) an automatic equation number: `#` with nothing after it. +fn number(s: &mut Session, v: &Value) -> CmdResult { + let at = equation_at_caret(s).ok_or_else(|| CmdError::Disabled("no equation at the caret".into()))?; + let mut pos_reset = false; + with_eq(s, &at, |math, display| { + let numbered = matches!(math.nodes.as_slice(), [MNode::EqArr { rows }] if rows.iter().any(|r| wordcraft_doc::math::has_number_mark(r))); + let want = p::bool(v, "value").unwrap_or(!numbered); + if want && !numbered { + let mut row = std::mem::take(&mut math.nodes); + row.push(MNode::Run(wordcraft_doc::math::MRun::new("#"))); + math.nodes = vec![MNode::EqArr { rows: vec![row] }]; + *display = true; + pos_reset = true; + } else if !want && numbered { + if let [MNode::EqArr { rows }] = math.nodes.as_mut_slice() + && let Some(row) = rows.first_mut() + { + // Drop everything from the `#` on. + let mut out = Vec::new(); + for n in std::mem::take(row) { + if let MNode::Run(mut r) = n { + if let Some(k) = r.text.find('#') { + r.text.truncate(k); + if !r.text.is_empty() { + out.push(MNode::Run(r)); + } + break; + } + out.push(MNode::Run(r)); + } else { + out.push(n); + } + } + math.nodes = out; + } + pos_reset = true; + } + })?; + if pos_reset && let Some(m) = s.math.as_mut() { + m.pos = MathPos::default(); + } + s.clamp_selection(); + result(s) +} + +fn set(s: &mut Session, v: &Value) -> CmdResult { + let at = equation_at_caret(s).ok_or_else(|| CmdError::Disabled("no equation at the caret".into()))?; + let nodes = nodes_from(v).ok_or_else(|| CmdError::Params("give linear or latex".into()))?; + let end = me::units(&nodes); + with_eq(s, &at, |math, _| { + math.nodes = nodes; + math.linear = false; + })?; + if let Some(m) = s.math.as_mut() { + m.pos = MathPos::new(Vec::new(), end); + } + result(s) +} + +fn get(s: &mut Session, _: &Value) -> CmdResult { + let at = equation_at_caret(s).ok_or_else(|| CmdError::Disabled("no equation at the caret".into()))?; + let Some(InlineObject::Equation { linear, display, math }) = s.doc.para_at(&at).and_then(|p| p.object_at(at.off)) else { + return Err(CmdError::Failed("no equation there".into())); + }; + let nodes = if math.nodes.is_empty() && !math.linear { parse_linear(linear) } else { math.nodes.clone() }; + Ok(json!({ + "linear": linear, + "latex": to_latex(&nodes), + "display": display, + "linearForm": math.linear, + "editing": s.math.is_some(), + "pos": s.math.as_ref().map(|m| serde_json::to_value(&m.pos).unwrap_or(Value::Null)), + "at": super::pos_json(&at), + })) +} + +fn structure_actions(s: &mut Session, _: &Value) -> CmdResult { + let m = current(s)?; + let nodes = nodes_of(s, &m.at).ok_or_else(|| CmdError::Failed("no equation there".into()))?; + let acts: Vec = + me::structure_actions(&nodes, &m.pos).into_iter().map(|(l, a, label)| json!({"level": l, "action": a, "label": label})).collect(); + Ok(json!({"actions": acts})) +} + +fn structure(s: &mut Session, v: &Value) -> CmdResult { + let m = current(s)?; + let action = p::req_str(v, "action")?.to_string(); + let nodes = nodes_of(s, &m.at).ok_or_else(|| CmdError::Failed("no equation there".into()))?; + let level = match p::u64(v, "level") { + Some(l) => l as usize, + None => me::structure_actions(&nodes, &m.pos) + .into_iter() + .find(|(_, a, _)| *a == action) + .map(|(l, _, _)| l) + .ok_or_else(|| CmdError::Params(format!("{action} doesn't apply here")))?, + }; + let mut pos = None; + with_eq(s, &m.at, |math, _| pos = me::apply_structure(&mut math.nodes, &m.pos, level, &action))?; + let pos = pos.ok_or_else(|| CmdError::Params(format!("{action} doesn't apply here")))?; + if let Some(m) = s.math.as_mut() { + m.pos = pos; + } + result(s) +} + +fn gallery(_: &mut Session, _: &Value) -> CmdResult { + let structures: Vec = crate::math_gallery::STRUCTURES + .iter() + .map(|g| { + json!({ + "id": g.id, + "label": g.label.replace('\n', " "), + "sections": g.sections.iter().map(|(name, ts)| json!({ + "name": name, + "templates": ts.iter().map(|t| json!({"id": t.id, "label": t.label, "linear": t.linear})).collect::>(), + })).collect::>(), + }) + }) + .collect(); + let symbols: Vec = crate::math_gallery::SYMBOL_SETS.iter().map(|(n, chars)| json!({"name": n, "chars": chars})).collect(); + let builtins: Vec = crate::math_gallery::BUILT_INS.iter().map(|(id, l, lin)| json!({"id": id, "label": l, "linear": lin})).collect(); + Ok(json!({"structures": structures, "symbols": symbols, "builtins": builtins})) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::Session; + use wordcraft_doc::Document; + + fn session() -> Session { + let mut s = Session::new(Document::new()); + s.run("document.setText", &json!({"text": ""})).unwrap(); + s + } + + fn linear(s: &Session) -> String { + s.math.as_ref().and_then(|m| s.doc.para_at(&m.at).and_then(|p| p.object_at(m.at.off))).map(|o| o.plain_text().to_string()).unwrap_or_default() + } + + #[test] + fn type_an_equation_like_word() { + let mut s = session(); + s.run("insert.equation", &json!({})).unwrap(); + assert!(s.math.is_some(), "editing the new equation"); + // An empty line gives a display equation. + assert!(matches!(s.doc.para_at(&s.math.as_ref().unwrap().at).unwrap().object_at(0), Some(InlineObject::Equation { display: true, .. }))); + for t in ["x", "=", "(-b\\pm", " ", "\\sqrt", " ", "(b^2-4ac))/2a", " "] { + s.run("equation.type", &json!({"text": t})).unwrap(); + } + assert_eq!(linear(&s), "x=(-b±√(b^2-4ac))/2a"); + let nodes = nodes_of(&s, &s.math.as_ref().unwrap().at).unwrap(); + assert!(matches!(nodes.get(1), Some(MNode::Frac { .. })), "{nodes:#?}"); + // One undo step for the typing. + s.run("equation.exit", &json!({})).unwrap(); + s.run("edit.undo", &json!({})).unwrap(); + assert!(linear(&s).is_empty()); + } + + #[test] + fn structures_symbols_and_navigation() { + let mut s = session(); + s.run("equation.insertStructure", &json!({"id": "frac.stacked"})).unwrap(); + s.run("equation.type", &json!({"text": "1"})).unwrap(); + s.run("equation.move", &json!({"dir": "down"})).unwrap(); + s.run("equation.insertSymbol", &json!({"char": "π"})).unwrap(); + assert_eq!(linear(&s), "1/π"); + s.run("equation.move", &json!({"dir": "right"})).unwrap(); + s.run("equation.insertStructure", &json!({"id": "nary.sumLimits"})).unwrap(); + s.run("equation.type", &json!({"text": "k=1"})).unwrap(); + s.run("equation.move", &json!({"dir": "next"})).unwrap(); + s.run("equation.type", &json!({"text": "n"})).unwrap(); + s.run("equation.move", &json!({"dir": "next"})).unwrap(); + s.run("equation.type", &json!({"text": "k"})).unwrap(); + assert_eq!(linear(&s), "1/π∑_(k=1)^n▒k"); + let r = s.run("equation.get", &json!({})).unwrap(); + assert!(r["latex"].as_str().unwrap().contains("\\sum")); + // Moving right past the end leaves the equation. + for _ in 0..20 { + if s.math.is_none() { + break; + } + let _ = s.run("equation.move", &json!({"dir": "right"})); + } + assert!(s.math.is_none()); + } + + #[test] + fn numbering_conversion_and_display() { + let mut s = session(); + s.run("insert.equation", &json!({"linear": "E=mc^2"})).unwrap(); + s.run("equation.type", &json!({"text": "#(1)"})).unwrap(); + s.run("equation.enter", &json!({})).unwrap(); + assert_eq!(linear(&s), "E=mc^2#(1)"); + s.run("equation.number", &json!({"value": false})).unwrap(); + assert_eq!(linear(&s), "E=mc^2"); + s.run("equation.number", &json!({})).unwrap(); + assert_eq!(linear(&s), "E=mc^2#"); + s.run("equation.convert", &json!({"to": "linear"})).unwrap(); + s.run("equation.convert", &json!({"to": "professional"})).unwrap(); + assert_eq!(linear(&s), "E=mc^2#"); + s.run("equation.display", &json!({"value": false})).unwrap(); + let r = s.run("equation.get", &json!({})).unwrap(); + assert_eq!(r["display"], false); + // LaTeX input. + s.run("equation.set", &json!({"latex": "\\frac{a}{b}"})).unwrap(); + assert_eq!(linear(&s), "a/b"); + // Backspacing an equation empty, then once more, removes it. + let mut n = 0; + while s.math.is_some() && n < 50 { + s.run("equation.backspace", &json!({})).unwrap(); + n += 1; + } + assert!(s.math.is_none()); + assert_eq!(s.doc.para_at(&s.sel.focus).unwrap().objects.len(), 0); + } + + #[test] + fn gallery_lists_everything() { + let mut s = session(); + let g = s.run("equation.gallery", &json!({})).unwrap(); + assert_eq!(g["structures"].as_array().unwrap().len(), 11); + assert!(g["symbols"].as_array().unwrap().len() >= 8); + for b in crate::math_gallery::BUILT_INS { + let mut s = session(); + s.run("insert.equation", &json!({"builtin": b.0, "edit": false})).unwrap(); + } + } +} diff --git a/crates/engine/src/cmd/insert.rs b/crates/engine/src/cmd/insert.rs index 17ebe0d..e99d75a 100644 --- a/crates/engine/src/cmd/insert.rs +++ b/crates/engine/src/cmd/insert.rs @@ -75,16 +75,6 @@ pub fn specs() -> Vec { sel_result(s) }) .params(r#"{"char": string}"#), - CommandSpec::new("insert.equation", "Equation", "Insert › Symbols", |s, v| { - let lin = p::str(v, "linear").unwrap_or("a^2+b^2=c^2").to_string(); - let props = s.typing_props(); - let at = delete_selection(s)?; - let end = s.doc.insert_object(&at, InlineObject::Equation { linear: lin, display: false }, &props)?; - s.sel = Selection::caret(end); - sel_result(s) - }) - .key("Alt+=") - .params(r#"{"linear"?: string}"#), CommandSpec::new("insert.field", "Field", "Insert › Text › Quick Parts", field).key("Mod+F9").params(r#"{"instr": string, "result"?: string}"#), CommandSpec::new("insert.dropCap", "Drop Cap", "Insert › Text", |s, v| { let lines = p::u64(v, "lines").unwrap_or(3).min(10) as u8; diff --git a/crates/engine/src/cmd/mod.rs b/crates/engine/src/cmd/mod.rs index f6aa32b..5e63f4c 100644 --- a/crates/engine/src/cmd/mod.rs +++ b/crates/engine/src/cmd/mod.rs @@ -4,6 +4,7 @@ pub mod caret; pub mod citations; pub mod design; pub mod edit; +pub mod equation; pub mod file; pub mod format; pub mod insert; @@ -35,6 +36,7 @@ pub fn registry() -> Registry { v.extend(para::specs()); v.extend(view::specs()); v.extend(insert::specs()); + v.extend(equation::specs()); v.extend(page::specs()); v.extend(table::specs()); v.extend(review::specs()); diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 6234e59..82b9440 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -10,6 +10,7 @@ pub mod catalog; pub mod cmd; pub mod io; mod io_ext; +pub mod math_gallery; pub mod sample; mod session; pub mod speech; @@ -18,7 +19,7 @@ use std::collections::HashMap; use serde_json::Value; -pub use session::{FindState, Prefs, Selection, Session, ViewState}; +pub use session::{FindState, MathEdit, Prefs, Selection, Session, ViewState}; pub use wordcraft_doc as doc; pub use wordcraft_layout as layout; pub use wordcraft_render as render; diff --git a/crates/engine/src/math_gallery.rs b/crates/engine/src/math_gallery.rs new file mode 100644 index 0000000..fcf99c8 --- /dev/null +++ b/crates/engine/src/math_gallery.rs @@ -0,0 +1,569 @@ +//! The Equation tab's galleries: structure templates (fractions, scripts, radicals, integrals, +//! large operators, brackets, functions, accents, limits and logs, operators, matrices), symbol +//! sets and ready-made equations. Every template is linear-format text (`⬚` = empty slot) that +//! [`wordcraft_doc::math::parse_linear`] builds into structures. + +use wordcraft_doc::math::{Arg, LimLoc, parse_linear}; + +/// One template. +#[derive(Clone, Copy, Debug)] +pub struct Template { + pub id: &'static str, + pub label: &'static str, + /// Linear format; `⬚` marks an empty argument. + pub linear: &'static str, + /// Limit placement for n-ary operators (`None`: the operator's default). + pub limits: Option, +} + +const fn t(id: &'static str, label: &'static str, linear: &'static str) -> Template { + Template { id, label, linear, limits: None } +} +const fn stacked(id: &'static str, label: &'static str, linear: &'static str) -> Template { + Template { id, label, linear, limits: Some(LimLoc::UndOvr) } +} +const fn beside(id: &'static str, label: &'static str, linear: &'static str) -> Template { + Template { id, label, linear, limits: Some(LimLoc::SubSup) } +} + +impl Template { + /// The template as nodes. + pub fn nodes(&self) -> Arg { + let mut n = parse_linear(self.linear); + if self.limits.is_some() { + wordcraft_doc::math_edit::set_limits(&mut n, self.limits); + } + n + } +} + +/// A structure gallery (one Equation tab button) with its sections. +pub struct Gallery { + pub id: &'static str, + pub label: &'static str, + pub sections: &'static [(&'static str, &'static [Template])], +} + +pub const STRUCTURES: &[Gallery] = &[ + Gallery { + id: "fraction", + label: "Fraction", + sections: &[ + ( + "Fraction", + &[ + t("frac.stacked", "Stacked Fraction", "⬚/⬚"), + t("frac.skewed", "Skewed Fraction", "⬚⁄⬚"), + t("frac.linear", "Linear Fraction", "⬚∕⬚"), + t("frac.nobar", "Stacked, no bar", "⬚¦⬚"), + ], + ), + ( + "Common Fractions", + &[ + t("frac.dydx", "Differential", "dy/dx"), + t("frac.DyDx", "Change in y over change in x", "Δy/Δx"), + t("frac.partial", "Partial Differential", "∂y/∂x"), + t("frac.delta", "Small change", "δy/δx"), + t("frac.pi2", "Pi Over 2", "π/2"), + t("frac.half", "One half", "1/2"), + ], + ), + ], + }, + Gallery { + id: "script", + label: "Script", + sections: &[ + ( + "Subscripts and Superscripts", + &[ + t("script.sup", "Superscript", "⬚^⬚"), + t("script.sub", "Subscript", "⬚_⬚"), + t("script.subsup", "Subscript-Superscript", "⬚_⬚^⬚"), + t("script.pre", "Left Subscript-Superscript", "_⬚^⬚▒⬚"), + ], + ), + ( + "Common Subscripts and Superscripts", + &[ + t("script.xy2", "Subscript-Superscript", "x_y^2"), + t("script.eiwt", "Exponential", "e^(−iωt)"), + t("script.x2", "Square", "x^2"), + t("script.pre1n", "Left Subscript-Superscript", "_1^n▒Y"), + ], + ), + ], + }, + Gallery { + id: "radical", + label: "Radical", + sections: &[ + ( + "Radicals", + &[ + t("rad.sqrt", "Square Root", "√⬚"), + t("rad.nth", "Radical with Degree", "√(⬚&⬚)"), + t("rad.sqrt2", "Square Root with Degree", "√(2&⬚)"), + t("rad.cbrt", "Cube Root", "√(3&⬚)"), + ], + ), + ("Common Radicals", &[t("rad.quadratic", "Quadratic Root", "(−b±√(b^2−4ac))/2a"), t("rad.hyp", "Hypotenuse", "√(a^2+b^2)")]), + ], + }, + Gallery { + id: "integral", + label: "Integral", + sections: &[ + ( + "Integrals", + &[ + t("int.plain", "Integral", "∫▒⬚"), + beside("int.limits", "Integral with Limits", "∫_⬚^⬚▒⬚"), + stacked("int.stacked", "Integral with Stacked Limits", "∫_⬚^⬚▒⬚"), + t("int.double", "Double Integral", "∬▒⬚"), + beside("int.doubleLimits", "Double Integral with Limits", "∬_⬚^⬚▒⬚"), + stacked("int.doubleStacked", "Double Integral with Stacked Limits", "∬_⬚^⬚▒⬚"), + t("int.triple", "Triple Integral", "∭▒⬚"), + beside("int.tripleLimits", "Triple Integral with Limits", "∭_⬚^⬚▒⬚"), + stacked("int.tripleStacked", "Triple Integral with Stacked Limits", "∭_⬚^⬚▒⬚"), + ], + ), + ( + "Contour Integrals", + &[ + t("int.contour", "Contour Integral", "∮▒⬚"), + beside("int.contourLimits", "Contour Integral with Limits", "∮_⬚^⬚▒⬚"), + stacked("int.contourStacked", "Contour Integral with Stacked Limits", "∮_⬚^⬚▒⬚"), + t("int.surface", "Surface Integral", "∯▒⬚"), + beside("int.surfaceLimits", "Surface Integral with Limits", "∯_⬚^⬚▒⬚"), + stacked("int.surfaceStacked", "Surface Integral with Stacked Limits", "∯_⬚^⬚▒⬚"), + t("int.volume", "Volume Integral", "∰▒⬚"), + beside("int.volumeLimits", "Volume Integral with Limits", "∰_⬚^⬚▒⬚"), + stacked("int.volumeStacked", "Volume Integral with Stacked Limits", "∰_⬚^⬚▒⬚"), + ], + ), + ( + "Differentials", + &[t("int.dx", "Differential x", "dx"), t("int.dy", "Differential y", "dy"), t("int.dtheta", "Differential theta", "dθ")], + ), + ], + }, + Gallery { + id: "largeOperator", + label: "Large\nOperator", + sections: &[ + ( + "Summations", + &[ + t("nary.sum", "Summation", "∑▒⬚"), + stacked("nary.sumLimits", "Summation with Limits", "∑_⬚^⬚▒⬚"), + beside("nary.sumScripts", "Summation with Subscript-Superscript Limits", "∑_⬚^⬚▒⬚"), + stacked("nary.sumLower", "Summation with Lower Limit", "∑_⬚▒⬚"), + beside("nary.sumSub", "Summation with Subscript Limit", "∑_⬚▒⬚"), + ], + ), + ( + "Products and Coproducts", + &[ + t("nary.prod", "Product", "∏▒⬚"), + stacked("nary.prodLimits", "Product with Limits", "∏_⬚^⬚▒⬚"), + beside("nary.prodScripts", "Product with Subscript-Superscript Limits", "∏_⬚^⬚▒⬚"), + stacked("nary.prodLower", "Product with Lower Limit", "∏_⬚▒⬚"), + t("nary.coprod", "Coproduct", "∐▒⬚"), + stacked("nary.coprodLimits", "Coproduct with Limits", "∐_⬚^⬚▒⬚"), + beside("nary.coprodScripts", "Coproduct with Subscript-Superscript Limits", "∐_⬚^⬚▒⬚"), + stacked("nary.coprodLower", "Coproduct with Lower Limit", "∐_⬚▒⬚"), + ], + ), + ( + "Unions and Intersections", + &[ + t("nary.union", "Union", "⋃▒⬚"), + stacked("nary.unionLimits", "Union with Limits", "⋃_⬚^⬚▒⬚"), + beside("nary.unionScripts", "Union with Subscript-Superscript Limits", "⋃_⬚^⬚▒⬚"), + stacked("nary.unionLower", "Union with Lower Limit", "⋃_⬚▒⬚"), + t("nary.intersection", "Intersection", "⋂▒⬚"), + stacked("nary.intersectionLimits", "Intersection with Limits", "⋂_⬚^⬚▒⬚"), + beside("nary.intersectionScripts", "Intersection with Subscript-Superscript Limits", "⋂_⬚^⬚▒⬚"), + stacked("nary.intersectionLower", "Intersection with Lower Limit", "⋂_⬚▒⬚"), + ], + ), + ( + "Other Large Operators", + &[ + t("nary.or", "Logical OR", "⋁▒⬚"), + stacked("nary.orLimits", "Logical OR with Limits", "⋁_⬚^⬚▒⬚"), + t("nary.and", "Logical AND", "⋀▒⬚"), + stacked("nary.andLimits", "Logical AND with Limits", "⋀_⬚^⬚▒⬚"), + t("nary.oplus", "Circled Plus", "⨁▒⬚"), + t("nary.otimes", "Circled Times", "⨂▒⬚"), + t("nary.odot", "Circled Dot", "⨀▒⬚"), + t("nary.uplus", "Union with Plus", "⨄▒⬚"), + t("nary.sqcup", "Square Union", "⨆▒⬚"), + ], + ), + ( + "Common Large Operators", + &[ + stacked("nary.binomial", "Summation over binomials", "∑_k▒〖(n¦k)〗"), + stacked("nary.sum0n", "Summation from 0 to n", "∑_(i=0)^n▒⬚"), + stacked("nary.double", "Double summation", "∑_(0≤i≤m,0 impl Iterator { + STRUCTURES.iter().flat_map(|g| g.sections.iter().flat_map(|(_, ts)| ts.iter())) +} + +pub fn template(id: &str) -> Option<&'static Template> { + templates().find(|t| t.id == id) +} + +/// Symbol sets (the Symbols gallery's categories). +pub const SYMBOL_SETS: &[(&str, &str)] = &[ + ("Basic Math", "±∞=≠~×÷!∝<≪>≫≤≥∓≅≈≡∀∁∂√∛∜∪∩∅%°℉℃∆∇∃∄∈∋←↑→↓↔∴+−¬αβγδεϵθϑμπρστφωω∗∙⋮⋯⋰⋱ℵℶ∎"), + ("Greek Letters", "αβγδεϵζηθϑικλμνξοπϖρϱσςτυφϕχψωΑΒΓΔΕΖΗΘΙΚΛΜΝΞΟΠΡΣΤΥΦΧΨΩ"), + ("Letter-Like Symbols", "∀∁ℂ∂ðℇϝℱℊℋℌℍℎℏıℑȷℒℓℕ℘ℙℚℛℜℝℤ℧Åℬℭℰℯℳℴℵℶℷℸⅅⅆⅇⅈⅉ"), + ("Operators", "+−÷×±∓∝∕∗∘∙√∛∜∩∪⊎⊓⊔∧∨⊕⊖⊗⊘⊙⊚⊛⊞⊟⊠⊡⋄⋅⋆⋉⋊∔∸∖≀=≠<>≤≥≮≰≯≱≡∼≃≈≅≢≄≉≇∝≪≫∈∋∉⊂⊃⊆⊇≺≻≼≽⊏⊐⊑⊒∥⊥⊢⊣⋈≍∴∵∷≐≑≒≓≔≕≖≗≜≝≞≟∑∫∬∭∮∯∰∏∐⋂⋃⋀⋁⨀⨁⨂⨄⨆"), + ("Arrows", "←→↑↓↔↕⇐⇒⇑⇓⇔⇕⟵⟶⟷⟸⟹⟺↦⟼↩↪↼↽⇀⇁↿↾⇃⇂⇋⇌⇄⇆⇇⇉⇈⇊↖↗↘↙↶↷↺↻⊸↜↝↞↠↢↣↫↬↭⇚⇛↰↱⇝"), + ("Negated Relations", "≠≮≯≰≱≢≁≄≉≇≭≨≩⊀⊁⋠⋡∉∌⊄⊅⊈⊉⊊⊋⋢⋣⋦⋧⋨⋩⋪⋫⋬⋭∤∦⊬⊭⊮⊯∄"), + ("Scripts", "𝒜ℬ𝒞𝒟ℰℱ𝒢ℋℐ𝒥𝒦ℒℳ𝒩𝒪𝒫𝒬ℛ𝒮𝒯𝒰𝒱𝒲𝒳𝒴𝒵𝒶𝒷𝒸𝒹ℯ𝒻ℊ𝒽𝒾𝒿𝓀𝓁𝓂𝓃ℴ𝓅𝓆𝓇𝓈𝓉𝓊𝓋𝓌𝓍𝓎𝓏𝔄𝔅ℭ𝔇𝔈𝔉𝔊ℌℑ𝔍𝔎𝔏𝔐𝔑𝔒𝔓𝔔ℜ𝔖𝔗𝔘𝔙𝔚𝔛𝔜ℨ𝔸𝔹ℂ𝔻𝔼𝔽𝔾ℍ𝕀𝕁𝕂𝕃𝕄ℕ𝕆ℙℚℝ𝕊𝕋𝕌𝕍𝕎𝕏𝕐ℤ"), + ("Geometry", "∟∠∡∢⊾⊿⋕⊥∤∥∦∶∷∴∵∎△▭▱○◊□◇⊡⊙"), +]; + +/// Ready-made equations (Insert › Equation), grouped by subject: geometry, algebra, calculus, +/// trigonometry, probability. The labels are our own descriptions of the formulas. +pub const BUILT_INS: &[(&str, &str, &str)] = &[ + ("pythagoras", "Sides of a right triangle", "a^2+b^2=c^2"), + ("area", "Circle area from its radius", "A=πr^2"), + ("quadratic", "Roots of ax²+bx+c", "x=(−b±√(b^2−4ac))/2a"), + ("binomial", "Binomial expansion of (x+a)ⁿ", "(x+a)^n=∑_(k=0)^n▒〖(n¦k) x^k a^(n−k)〗"), + ("expansion", "Power series of (1+x)ⁿ", "(1+x)^n=1+nx/1!+(n(n−1)x^2)/2!+⋯"), + ("euler", "Euler's identity", "e^(iπ)+1=0"), + ("derivative", "Derivative as a limit", "f'(x)=lim┬(h→0)⁡〖(f(x+h)−f(x))/h〗"), + ("taylor", "Exponential series", "e^x=1+x/1!+x^2/2!+x^3/3!+⋯, −∞ bool { + a.iter().any(|n| match n { + MNode::Run(r) => r.text.contains('⬚') || r.text.contains('▒') || r.text.contains('┬') || r.text.contains('┴'), + other => { + (0..wordcraft_doc::math_edit::child_count(other)).any(|c| wordcraft_doc::math_edit::child(other, c).is_some_and(|x| has_leftover(x))) + } + }) + } + + #[test] + fn every_template_builds() { + let mut ids = std::collections::HashSet::new(); + for tpl in templates() { + assert!(ids.insert(tpl.id), "duplicate id {}", tpl.id); + let n = tpl.nodes(); + assert!(!n.is_empty(), "{} builds nothing", tpl.id); + assert!(!has_leftover(&n), "{} left linear operators: {}", tpl.id, to_linear(&n)); + // Templates with placeholders are structures, not text. + if tpl.linear.contains('⬚') { + assert!(n.iter().any(|x| !matches!(x, MNode::Run(_))), "{} → {n:?}", tpl.id); + } + } + for (id, _, lin) in BUILT_INS { + let n = parse_linear(lin); + assert!(!has_leftover(&n), "{id}: {}", to_linear(&n)); + } + } +} diff --git a/crates/engine/src/session.rs b/crates/engine/src/session.rs index f235a2e..3eb9925 100644 --- a/crates/engine/src/session.rs +++ b/crates/engine/src/session.rs @@ -164,6 +164,21 @@ pub struct Session { pub read_aloud: crate::speech::ReadAloud, /// Preferences the front end saves between runs. pub prefs: Prefs, + /// Editing inside an equation: which one and the caret in it. + pub math: Option, + /// Equations are typed in LaTeX rather than the linear format. + pub math_latex: bool, + /// Text typed into equations is normal (non-math) text. + pub math_normal_text: bool, +} + +/// The equation being edited. +#[derive(Clone, Debug, PartialEq)] +pub struct MathEdit { + /// The equation object (its U+FFFC). + pub at: Pos, + /// The caret inside it. + pub pos: wordcraft_doc::math_edit::MathPos, } /// Editing preferences that persist between runs (the front end saves and restores them). @@ -222,6 +237,9 @@ impl Session { bib_style: "APA".into(), merge: Default::default(), ui_requests: Vec::new(), + math: None, + math_latex: false, + math_normal_text: false, prefs: Prefs::default(), read_aloud: Default::default(), } @@ -360,6 +378,21 @@ impl Session { pub fn clamp_selection(&mut self) { self.sel.anchor = self.doc.clamp(&self.sel.anchor); self.sel.focus = self.doc.clamp(&self.sel.focus); + // The equation being edited must still be there (undo, edits elsewhere). + if let Some(m) = &self.math { + let eq = self.doc.para_at(&m.at).and_then(|p| match p.object_at(m.at.off) { + Some(wordcraft_doc::InlineObject::Equation { math, .. }) => Some(wordcraft_doc::math_edit::clamp(&math.nodes, &m.pos)), + _ => None, + }); + match eq { + Some(pos) => { + if let Some(m) = self.math.as_mut() { + m.pos = pos; + } + } + None => self.math = None, + } + } } /// Run a command by id with JSON params (the single entry point for the UI, CLI, MCP and @@ -394,15 +427,24 @@ impl Session { if spec.mutates && !matches!(id, "edit.undo" | "edit.redo" | "edit.repeat") { self.last_command = Some((id.to_string(), params.clone())); } + // Typing, caret movement and selection outside the equation commands leave the equation. + if self.math.is_some() + && (id.starts_with("text.") || id.starts_with("caret.") || id.starts_with("select.") || id.starts_with("edit.paste")) + && let Some(m) = self.math.take() + { + let off = m.at.off + wordcraft_doc::para::OBJ.len_utf8(); + self.sel = Selection::caret(Pos { off, ..m.at }); + } // The undo stacks are snapshotted whole, not as a length: commands run nested commands, // whose checkpoints, undos and redos (and evictions at the limit) must be put back too. let before_doc = if spec.mutates { Some((self.doc.clone(), self.sel.clone(), self.history.clone(), self.redo.clone(), self.typing_open)) } else { None }; - if spec.mutates && spec.id != "text.insert" { + let typing = spec.id == "text.insert" || spec.id == "equation.type"; + if spec.mutates && !typing { self.typing_open = false; } if spec.mutates { - let label = if spec.id == "text.insert" { "Typing" } else { spec.label }; + let label = if typing { "Typing" } else { spec.label }; if !join || self.history.is_empty() { self.checkpoint(label); } diff --git a/crates/fonts/src/lib.rs b/crates/fonts/src/lib.rs index 5d89659..09c7d27 100644 --- a/crates/fonts/src/lib.rs +++ b/crates/fonts/src/lib.rs @@ -8,6 +8,7 @@ #![forbid(unsafe_code)] mod fontdb; +pub mod math; pub mod word; pub use fontdb::{FALLBACK_FAMILY, FaceRef, FontDb, FontFace, base_style, bundled, system_font_dirs}; @@ -94,6 +95,11 @@ pub fn feature(tag: &str) -> Option { Some(Feature::new(Tag::new(&[b[0], b[1], b[2], b[3]]), on as u32, ..)) } +/// An OpenType feature with a value (`ssty` = 2 for second-level script glyphs). +pub fn feature_value(tag: &[u8; 4], value: u32) -> Feature { + Feature::new(Tag::new(tag), value, ..) +} + /// A strong right-to-left character (Hebrew, Arabic, …)? pub fn is_rtl(c: char) -> bool { use unicode_bidi::BidiClass::{AL, R}; diff --git a/crates/fonts/src/math.rs b/crates/fonts/src/math.rs new file mode 100644 index 0000000..694e285 --- /dev/null +++ b/crates/fonts/src/math.rs @@ -0,0 +1,362 @@ +//! The OpenType `MATH` table (constants, italic corrections, accent attachment, glyph size +//! variants and assemblies) read into owned, bounds-checked data, and math font resolution. +//! +//! Spec: OpenType 1.9 `MATH` table. Every read is checked; a malformed table yields `None` or +//! partial data, never a panic. + +use std::collections::HashMap; +use std::sync::{Arc, Mutex, OnceLock}; + +use kurbo::Shape; + +use crate::{FaceRef, FontDb, FontFace}; + +/// `MathConstants` value-record fields, in table order (after the four 16-bit leading fields). +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +#[repr(usize)] +pub enum C { + MathLeading = 0, + AxisHeight, + AccentBaseHeight, + FlattenedAccentBaseHeight, + SubscriptShiftDown, + SubscriptTopMax, + SubscriptBaselineDropMin, + SuperscriptShiftUp, + SuperscriptShiftUpCramped, + SuperscriptBottomMin, + SuperscriptBaselineDropMax, + SubSuperscriptGapMin, + SuperscriptBottomMaxWithSubscript, + SpaceAfterScript, + UpperLimitGapMin, + UpperLimitBaselineRiseMin, + LowerLimitGapMin, + LowerLimitBaselineDropMin, + StackTopShiftUp, + StackTopDisplayStyleShiftUp, + StackBottomShiftDown, + StackBottomDisplayStyleShiftDown, + StackGapMin, + StackDisplayStyleGapMin, + StretchStackTopShiftUp, + StretchStackBottomShiftDown, + StretchStackGapAboveMin, + StretchStackGapBelowMin, + FractionNumeratorShiftUp, + FractionNumeratorDisplayStyleShiftUp, + FractionDenominatorShiftDown, + FractionDenominatorDisplayStyleShiftDown, + FractionNumeratorGapMin, + FractionNumDisplayStyleGapMin, + FractionRuleThickness, + FractionDenominatorGapMin, + FractionDenomDisplayStyleGapMin, + SkewedFractionHorizontalGap, + SkewedFractionVerticalGap, + OverbarVerticalGap, + OverbarRuleThickness, + OverbarExtraAscender, + UnderbarVerticalGap, + UnderbarRuleThickness, + UnderbarExtraDescender, + RadicalVerticalGap, + RadicalDisplayStyleVerticalGap, + RadicalRuleThickness, + RadicalExtraAscender, + RadicalKernBeforeDegree, + RadicalKernAfterDegree, +} + +const VALUE_COUNT: usize = 51; + +/// One part of a glyph assembly (font units). +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct GlyphPart { + pub gid: u32, + pub start_connector: f32, + pub end_connector: f32, + pub full_advance: f32, + pub extender: bool, +} + +/// Size variants of a glyph (smallest first, with their advance along the stretch direction) +/// and an optional assembly for bigger sizes. +#[derive(Clone, Debug, Default, PartialEq)] +pub struct Construction { + pub variants: Vec<(u32, f32)>, + pub parts: Vec, +} + +/// A parsed `MATH` table. Values are font units. +#[derive(Clone, Debug, Default)] +pub struct MathTable { + pub script_percent: f32, + pub script_script_percent: f32, + pub delimited_sub_formula_min_height: f32, + pub display_operator_min_height: f32, + values: Vec, + pub radical_degree_bottom_raise_percent: f32, + pub italics: HashMap, + pub top_accent: HashMap, + pub min_connector_overlap: f32, + pub vert: HashMap, + pub horiz: HashMap, +} + +impl MathTable { + pub fn get(&self, c: C) -> f32 { + self.values.get(c as usize).copied().unwrap_or(0.0) + } +} + +fn u16_at(b: &[u8], off: usize) -> Option { + let s = b.get(off..off.checked_add(2)?)?; + Some(u16::from_be_bytes([*s.first()?, *s.get(1)?])) +} + +fn i16_at(b: &[u8], off: usize) -> Option { + u16_at(b, off).map(|v| v as i16) +} + +/// A table at `base + offset` (offset 0 = absent). +fn sub(b: &[u8], base: usize, offset_at: usize) -> Option { + let o = u16_at(b, offset_at)? as usize; + if o == 0 { + return None; + } + let p = base.checked_add(o)?; + (p < b.len()).then_some(p) +} + +/// Glyphs of a Coverage table in coverage-index order. +fn coverage(b: &[u8], at: usize) -> Vec { + let mut out = Vec::new(); + match u16_at(b, at) { + Some(1) => { + let n = u16_at(b, at + 2).unwrap_or(0) as usize; + for i in 0..n { + match u16_at(b, at + 4 + i * 2) { + Some(g) => out.push(g as u32), + None => break, + } + } + } + Some(2) => { + let n = u16_at(b, at + 2).unwrap_or(0) as usize; + for i in 0..n { + let r = at + 4 + i * 6; + let (Some(s), Some(e), Some(idx)) = (u16_at(b, r), u16_at(b, r + 2), u16_at(b, r + 4)) else { break }; + if e < s { + continue; + } + for g in s..=e { + let k = idx as usize + (g - s) as usize; + if k >= 70_000 { + break; + } + if out.len() <= k { + out.resize(k + 1, u32::MAX); + } + if let Some(slot) = out.get_mut(k) { + *slot = g as u32; + } + } + } + } + _ => {} + } + out +} + +/// `coverageOffset, count, MathValueRecord[count]` → glyph → value. +fn value_map(b: &[u8], at: usize) -> HashMap { + let mut m = HashMap::new(); + let Some(cov) = sub(b, at, at) else { return m }; + let glyphs = coverage(b, cov); + let n = u16_at(b, at + 2).unwrap_or(0) as usize; + for (i, g) in glyphs.iter().enumerate().take(n) { + if let Some(v) = i16_at(b, at + 4 + i * 4) + && *g != u32::MAX + { + m.insert(*g, v as f32); + } + } + m +} + +fn constructions(b: &[u8], variants: usize, cov_at: usize, count: usize, offsets_at: usize) -> HashMap { + let mut m = HashMap::new(); + let Some(cov) = sub(b, variants, cov_at) else { return m }; + let glyphs = coverage(b, cov); + for (i, g) in glyphs.iter().enumerate().take(count) { + let Some(c) = sub(b, variants, offsets_at + i * 2) else { continue }; + let mut con = Construction::default(); + let nv = u16_at(b, c + 2).unwrap_or(0) as usize; + for k in 0..nv.min(256) { + let r = c + 4 + k * 4; + let (Some(vg), Some(adv)) = (u16_at(b, r), u16_at(b, r + 2)) else { break }; + con.variants.push((vg as u32, adv as f32)); + } + if let Some(a) = sub(b, c, c) { + let np = u16_at(b, a + 4).unwrap_or(0) as usize; + for k in 0..np.min(64) { + let r = a + 6 + k * 10; + let (Some(pg), Some(s), Some(e), Some(full), Some(fl)) = + (u16_at(b, r), u16_at(b, r + 2), u16_at(b, r + 4), u16_at(b, r + 6), u16_at(b, r + 8)) + else { + break; + }; + con.parts.push(GlyphPart { + gid: pg as u32, + start_connector: s as f32, + end_connector: e as f32, + full_advance: full as f32, + extender: fl & 1 != 0, + }); + } + } + if *g != u32::MAX { + m.insert(*g, con); + } + } + m +} + +/// Parse a `MATH` table. +pub fn parse(b: &[u8]) -> Option { + if u16_at(b, 0)? != 1 { + return None; + } + let mut t = MathTable::default(); + let c = sub(b, 0, 4)?; + t.script_percent = i16_at(b, c)? as f32; + t.script_script_percent = i16_at(b, c + 2)? as f32; + t.delimited_sub_formula_min_height = u16_at(b, c + 4)? as f32; + t.display_operator_min_height = u16_at(b, c + 6)? as f32; + for i in 0..VALUE_COUNT { + t.values.push(i16_at(b, c + 8 + i * 4).unwrap_or(0) as f32); + } + t.radical_degree_bottom_raise_percent = i16_at(b, c + 8 + VALUE_COUNT * 4).unwrap_or(60) as f32; + if let Some(gi) = sub(b, 0, 6) { + if let Some(ic) = sub(b, gi, gi) { + t.italics = value_map(b, ic); + } + if let Some(ta) = sub(b, gi, gi + 2) { + t.top_accent = value_map(b, ta); + } + } + if let Some(v) = sub(b, 0, 8) { + t.min_connector_overlap = u16_at(b, v).unwrap_or(0) as f32; + let nv = u16_at(b, v + 6).unwrap_or(0) as usize; + let nh = u16_at(b, v + 8).unwrap_or(0) as usize; + t.vert = constructions(b, v, v + 2, nv, v + 10); + t.horiz = constructions(b, v, v + 4, nh, v + 10 + nv * 2); + } + Some(t) +} + +/// The face's `MATH` table (parsed once per face). +pub fn table(face: &FontFace) -> Option> { + static CACHE: OnceLock>>>> = OnceLock::new(); + let cache = CACHE.get_or_init(|| Mutex::new(HashMap::new())); + if let Some(t) = cache.lock().unwrap_or_else(|e| e.into_inner()).get(&face.id()) { + return t.clone(); + } + let t = face + .skrifa() + .and_then(|f| f.table_data(skrifa::raw::types::Tag::new(b"MATH")).map(|d| d.as_bytes().to_vec())) + .and_then(|bytes| parse(&bytes)) + .map(Arc::new); + cache.lock().unwrap_or_else(|e| e.into_inner()).insert(face.id(), t.clone()); + t +} + +/// Ink bounds of a glyph in font units, y up: (x_min, y_min, x_max, y_max). `None` for blank glyphs. +pub fn glyph_bounds(face: &FontFace, gid: u32) -> Option<(f32, f32, f32, f32)> { + let p = FontDb::global().outline(face, gid); + if p.elements().is_empty() { + return None; + } + let r = p.bounding_box(); + // Outlines are cached y-down. + let b = (r.x0 as f32, -r.y1 as f32, r.x1 as f32, -r.y0 as f32); + (b.0.is_finite() && b.1.is_finite() && b.2.is_finite() && b.3.is_finite()).then_some(b) +} + +/// Math fonts to use, in order, for a requested math font that isn't installed: open fonts +/// with a `MATH` table that are close to Cambria Math in weight and proportion first. +pub const MATH_SUBSTITUTES: &[&str] = &[ + "Cambria Math", + "STIX Two Math", + "XITS Math", + "Libertinus Math", + "TeX Gyre Termes Math", + "TeX Gyre Pagella Math", + "Latin Modern Math", + "DejaVu Math TeX Gyre", + "Asana Math", + "Noto Sans Math", +]; + +/// The face to set math in: `family` when installed and it has a `MATH` table, else the first +/// installed substitute that has one, else `family` (or its text substitute) without one. +pub fn resolve_math(family: &str) -> (FaceRef, Option>) { + static CACHE: OnceLock>)>>> = OnceLock::new(); + let cache = CACHE.get_or_init(|| Mutex::new(HashMap::new())); + if let Some(v) = cache.lock().unwrap_or_else(|e| e.into_inner()).get(family) { + return v.clone(); + } + let db = FontDb::global(); + let mut found = None; + for fam in std::iter::once(family).chain(MATH_SUBSTITUTES.iter().copied()) { + if !db.has_family(fam) { + continue; + } + let face = db.face(fam, "Regular"); + if face.family.eq_ignore_ascii_case(fam) + && let Some(t) = table(&face) + { + found = Some((FaceRef::of(&face), Some(t))); + break; + } + } + let v = found.unwrap_or_else(|| (crate::word::resolve(family, false, false).face, None)); + cache.lock().unwrap_or_else(|e| e.into_inner()).insert(family.to_string(), v.clone()); + v +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn garbage_tables_never_panic() { + assert!(parse(&[]).is_none()); + assert!(parse(&[0, 1]).is_none()); + let mut b = vec![0u8, 1, 0, 0, 0, 10, 0, 0xff, 0xff, 0xff]; + b.resize(40, 0xff); + let _ = parse(&b); + for n in 0..64u8 { + let b: Vec = (0..300u32).map(|i| (i as u8).wrapping_mul(n).wrapping_add(7)).collect(); + let mut b = b; + b[0] = 0; + b[1] = 1; + let _ = parse(&b); + } + } + + #[test] + #[cfg(not(target_arch = "wasm32"))] + fn system_math_font_has_a_table() { + // Only meaningful where an open math font is installed (macOS ships STIX Two Math). + let (face, t) = resolve_math("Cambria Math"); + eprintln!("math font: {} (MATH table: {})", face.family, t.is_some()); + if let Some(t) = t { + assert!(t.get(C::AxisHeight) > 0.0, "{}", face.family); + assert!(t.get(C::FractionRuleThickness) > 0.0); + let paren = face.glyph_for('('); + assert!(t.vert.get(&paren).is_some_and(|c| !c.variants.is_empty()), "paren variants in {}", face.family); + } + } +} diff --git a/crates/formats/src/latex.rs b/crates/formats/src/latex.rs index d6523f4..961243e 100644 --- a/crates/formats/src/latex.rs +++ b/crates/formats/src/latex.rs @@ -2989,7 +2989,12 @@ Caf\'e na\"ive \c{c}a \v{s}\'{e} and {\bfseries grouped bold} after. let mut d = Document::new(); let mut p = wordcraft_doc::Paragraph::with_text("Area ", Default::default()); let n = p.len(); - p.insert_object(n, wordcraft_doc::para::InlineObject::Equation { linear: "π r^2".into(), display: false }, &Default::default()).unwrap(); + p.insert_object( + n, + wordcraft_doc::para::InlineObject::Equation { linear: "π r^2".into(), display: false, math: Default::default() }, + &Default::default(), + ) + .unwrap(); d.body = vec![wordcraft_doc::para_block(p)]; let tex = export(&d); assert!(tex.contains("$\\pi r^2$") && tex.contains("\\usepackage{amsmath}"), "{tex}"); diff --git a/crates/formats/src/model.rs b/crates/formats/src/model.rs index f337142..d04fac3 100644 --- a/crates/formats/src/model.rs +++ b/crates/formats/src/model.rs @@ -371,7 +371,11 @@ impl Builder<'_> { Inline::Equation { linear, display } => { let linear = clean_text(linear); if !linear.is_empty() { - let _ = out.insert_object(end, InlineObject::Equation { linear, display: *display }, &CharProps::default()); + let _ = out.insert_object( + end, + InlineObject::Equation { linear, display: *display, math: Default::default() }, + &CharProps::default(), + ); } } } @@ -684,11 +688,14 @@ pub fn flow_paras(doc: &Document, p: &Paragraph) -> Vec { None => {} } } - Some(InlineObject::Equation { linear, display }) => { + Some(InlineObject::Equation { linear, display, math }) => { if !buf.is_empty() { out.push_text(&std::mem::take(&mut buf), &f); } - out.inlines.push(Inline::Equation { linear: linear.clone(), display: *display }); + // The linear format is the shared form; an equation known only by its + // structure gets it from there. + let linear = if linear.is_empty() { wordcraft_doc::math::to_linear(&math.nodes) } else { linear.clone() }; + out.inlines.push(Inline::Equation { linear, display: *display }); } Some(InlineObject::BookmarkStart { name }) if name != "_GoBack" => { if !buf.is_empty() { diff --git a/crates/layout/src/display.rs b/crates/layout/src/display.rs index a9bf389..7f83496 100644 --- a/crates/layout/src/display.rs +++ b/crates/layout/src/display.rs @@ -6,6 +6,7 @@ use wordcraft_doc::{Document, Path, StoryRef}; use wordcraft_fonts::FaceRef; use wordcraft_geom::Rect; +use crate::math::MItem; use crate::para::{ClKind, LineEnd, ParaLayout}; use crate::{Page, Placed}; @@ -79,11 +80,13 @@ pub struct DisplayOptions { pub dim_body: bool, /// Show tracked changes as markup (coloured, underlined/struck). pub markup: bool, + /// Show on-screen-only marks: equation placeholders and prompts (never in print or PDF). + pub placeholders: bool, } impl Default for DisplayOptions { fn default() -> Self { - DisplayOptions { marks: false, dim_header: true, dim_body: false, markup: true } + DisplayOptions { marks: false, dim_header: true, dim_body: false, markup: true, placeholders: false } } } @@ -435,6 +438,11 @@ fn lines( Some(InlineObject::Shape { kind, fill, stroke, stroke_width, .. }) => { out.push(Draw::Shape { rect, kind: *kind, fill: *fill, stroke: *stroke, stroke_width: *stroke_width }) } + Some(InlineObject::Equation { .. }) => { + if let Some((_, ml)) = pl.maths.iter().find(|(k, _)| *k == oi) { + equation(&ml.items, cx, base, alpha, opts.placeholders, out); + } + } _ => {} } } @@ -477,6 +485,41 @@ fn lines( } } +/// An equation's glyphs and rules with its origin at (`x`, `base`). +fn equation(items: &[MItem], x: f32, base: f32, alpha: f32, screen: bool, out: &mut Vec) { + for it in items { + match it { + MItem::Glyphs { face, size, color, synth_bold, synth_italic, glyphs, text } => out.push(Draw::Glyphs { + face: *face, + size: *size, + glyphs: glyphs.iter().map(|(g, gx, gy)| (*g, x + gx, base - gy)).collect(), + color: *color, + alpha, + synth_bold: *synth_bold, + synth_italic: *synth_italic, + text: text.clone(), + link: None, + }), + MItem::Rect { x: rx, y, w, h, color } => out.push(Draw::Fill { rect: Rect::new(x + rx, base - y - h, *w, *h), color: *color, alpha }), + MItem::Line { x0, y0, x1, y1, width, color, dotted } => out.push(Draw::Line { + x0: x + x0, + y0: base - y0, + x1: x + x1, + y1: base - y1, + width: *width, + color: *color, + stroke: if *dotted { Stroke::Dotted } else { Stroke::Solid }, + alpha, + }), + MItem::ScreenOnly(inner) => { + if screen { + equation(std::slice::from_ref(inner), x, base, alpha, screen, out); + } + } + } + } +} + fn author_index(doc: &Document, author: &str) -> u32 { let mut seen: Vec<&str> = Vec::new(); for r in &doc.revisions { diff --git a/crates/layout/src/hit.rs b/crates/layout/src/hit.rs index 1343cfa..4dcd77d 100644 --- a/crates/layout/src/hit.rs +++ b/crates/layout/src/hit.rs @@ -362,6 +362,55 @@ impl DocLayout { out } + /// Where the equation at `pos` (its U+FFFC) is drawn: page, x of its left edge and its + /// baseline (page coordinates), and its layout. + pub fn equation_geom(&self, pos: &Pos, page_hint: usize) -> Option<(usize, f32, f32, std::sync::Arc)> { + for (pi, it) in self.pieces(pos.story, &pos.path, page_hint) { + let Placed::Lines { para, l0, l1, x, y, .. } = it else { continue }; + let li = para.line_of(pos.off); + if li < *l0 || li >= *l1 { + continue; + } + let first = para.lines.get(*l0)?; + let l = para.lines.get(li)?; + for k in l.c0..l.c1 { + let Some(c) = para.clusters.get(k) else { continue }; + let crate::para::ClKind::Object(oi) = c.kind else { continue }; + if c.start != pos.off { + continue; + } + let ml = para.maths.iter().find(|(o, _)| *o == oi)?.1.clone(); + let cx = l.xs.get(k - l.c0).copied()?; + let base = y + (l.baseline - first.top); + return Some((pi, x + cx, base, ml)); + } + } + None + } + + /// The equation under (x, y) on `page` and the caret position inside it nearest the point. + pub fn equation_hit(&self, page: usize, x: f32, y: f32, story: StoryRef) -> Option<(Pos, wordcraft_doc::math_edit::MathPos)> { + let p = self.pages.get(page)?; + for l in page_lines(p, story) { + let Some(line) = l.para.lines.get(l.li) else { continue }; + let base = l.top + (line.baseline - line.top); + for k in line.c0..line.c1 { + let Some(c) = l.para.clusters.get(k) else { continue }; + let crate::para::ClKind::Object(oi) = c.kind else { continue }; + let Some((_, ml)) = l.para.maths.iter().find(|(o, _)| *o == oi) else { continue }; + let x0 = l.x + line.xs.get(k - line.c0).copied().unwrap_or(0.0); + let pad = 2.0; + if x < x0 - pad || x > x0 + ml.width + pad || y < base - ml.ascent - pad || y > base + ml.descent + pad { + continue; + } + let slot = ml.hit(x - x0, base - y)?; + let pos = Pos { story: l.story, path: l.path.clone(), off: c.start }; + return Some((pos, wordcraft_doc::math_edit::MathPos { path: slot.path.clone(), off: slot.off })); + } + } + None + } + /// Page index for a body position (first page showing its line). pub fn page_of(&self, pos: &Pos) -> Option { self.caret(pos).map(|c| c.page) diff --git a/crates/layout/src/lib.rs b/crates/layout/src/lib.rs index a3f0bb8..dc8ae3b 100644 --- a/crates/layout/src/lib.rs +++ b/crates/layout/src/lib.rs @@ -11,6 +11,7 @@ pub mod display; pub mod fields; pub mod hit; +pub mod math; pub mod para; mod table; @@ -185,6 +186,7 @@ struct Key { table: u64, notes: u64, excl: u64, + eq: u32, } /// Memoised paragraph layouts. @@ -241,6 +243,8 @@ struct Ctx<'a> { fields: FieldCtx, notes_hash: u64, numbers: HashMap>, + /// Automatic equation numbers so far. + eq_count: u32, /// Bounds laying out text boxes inside text boxes, for the whole layout. boxes: wordcraft_doc::BoxBudget, } @@ -267,6 +271,7 @@ impl Ctx<'_> { table: None, proofing: false, exclusions: &[], + eq_number: 0, }; let pl = Arc::new(para::layout_para(&p, &env)); self.numbers.insert(n, pl.clone()); @@ -291,6 +296,17 @@ impl Ctx<'_> { label: Option<(String, Level)>, ) -> Arc { let page = if has_page_fields(p) { Some(self.fields.page_key()) } else { None }; + // Automatic equation numbers count through the document. + let eq_here: u32 = p + .objects + .iter() + .map(|o| match o { + InlineObject::Equation { math, display, .. } => math::auto_numbers(math, *display), + _ => 0, + }) + .sum(); + let eq_number = self.eq_count; + self.eq_count = self.eq_count.saturating_add(eq_here); let key = Key { rev: p.rev, width: width.to_bits(), @@ -299,6 +315,7 @@ impl Ctx<'_> { table: table.map(|t| hash_of(&format!("{t:?}"))).unwrap_or(0), notes: if p.objects.iter().any(|o| matches!(o, InlineObject::NoteRef { .. })) { self.notes_hash } else { 0 }, excl: if exclusions.is_empty() { 0 } else { hash_of(&format!("{exclusions:?}")) }, + eq: if eq_here > 0 { eq_number } else { 0 }, }; self.cache.used.insert(key.clone()); if let Some(pl) = self.cache.paras.get(&key) { @@ -316,6 +333,7 @@ impl Ctx<'_> { table, proofing: self.opts.proofing, exclusions, + eq_number, }; let pl = Arc::new(para::layout_para(p, &env)); self.cache.paras.insert(key, pl.clone()); @@ -747,6 +765,7 @@ pub fn layout(doc: &Document, cache: &mut LayoutCache, opts: &LayoutOptions) -> }, notes_hash, numbers: HashMap::new(), + eq_count: 0, boxes: wordcraft_doc::BoxBudget::default(), }; let sections = doc.sections(); @@ -1351,6 +1370,7 @@ fn next_first_line(ctx: &mut Ctx, block: usize, width: f32) -> f32 { table: None, proofing: false, exclusions: &[], + eq_number: ctx.eq_count, }; let _ = rp; let pl = para::layout_para(p, &env); diff --git a/crates/layout/src/math.rs b/crates/layout/src/math.rs new file mode 100644 index 0000000..1a2b80a --- /dev/null +++ b/crates/layout/src/math.rs @@ -0,0 +1,1920 @@ +//! Equation layout: the [`wordcraft_doc::math`] tree → positioned glyphs and rules. +//! +//! Follows the OpenType `MATH` model (the one Word's own math engine and Cambria Math were built +//! around): font constants for script shifts, fraction and radical gaps, limits and rules; size +//! variants and glyph assemblies for stretchy delimiters, radicals, big operators and wide +//! accents; atom classes with TeX/Word inter-atom spacing. Without a math font the layout uses +//! fixed proportions of the em. +//! +//! Coordinates: x right from the equation's left edge, y *up* from its baseline. + +use std::sync::Arc; + +use wordcraft_doc::math::{ + Arg, ColJc, FracKind, LimLoc, MAX_DEPTH, MClass, MNode, MRun, MScr, MSty, ScriptKind, is_integral, math_alnum, parse_linear, +}; +use wordcraft_doc::props::{Rgb, TextColor}; +use wordcraft_doc::resolve::ResolvedChar; +use wordcraft_fonts::math::{C, Construction, MathTable, glyph_bounds}; +use wordcraft_fonts::{FaceRef, FontDb}; + +/// Something to draw. +#[derive(Clone, Debug)] +pub enum MItem { + Glyphs { + face: FaceRef, + size: f32, + color: Rgb, + synth_bold: bool, + synth_italic: bool, + /// Glyph id, x, y (baseline, up). + glyphs: Vec<(u32, f32, f32)>, + text: String, + }, + /// A filled rectangle; `y` is its bottom edge. + Rect { + x: f32, + y: f32, + w: f32, + h: f32, + color: Rgb, + }, + Line { + x0: f32, + y0: f32, + x1: f32, + y1: f32, + width: f32, + color: Rgb, + dotted: bool, + }, + /// Drawn on screen only (placeholders for empty slots, the empty-equation hint). + ScreenOnly(Box), +} + +impl MItem { + fn shift(&mut self, dx: f32, dy: f32) { + match self { + MItem::Glyphs { glyphs, .. } => { + for g in glyphs { + g.1 += dx; + g.2 += dy; + } + } + MItem::Rect { x, y, .. } => { + *x += dx; + *y += dy; + } + MItem::Line { x0, y0, x1, y1, .. } => { + *x0 += dx; + *x1 += dx; + *y0 += dy; + *y1 += dy; + } + MItem::ScreenOnly(i) => i.shift(dx, dy), + } + } +} + +/// A caret position inside an equation, where it is drawn: `x`, the baseline `y` (up from the +/// equation's baseline) and the caret's extent above (`a`) and below (`d`) it. +#[derive(Clone, Debug, PartialEq)] +pub struct Slot { + pub path: Vec<(usize, usize)>, + pub off: usize, + pub x: f32, + pub y: f32, + pub a: f32, + pub d: f32, +} + +/// A laid-out equation. +#[derive(Clone, Debug, Default)] +pub struct MathLayout { + pub width: f32, + /// Extent above and below the baseline, points. + pub ascent: f32, + pub descent: f32, + pub items: Vec, + /// Every caret position, for drawing the caret and hit testing. + pub slots: Vec, +} + +impl MathLayout { + /// Where the caret at (`path`, `off`) is drawn. + pub fn slot(&self, path: &[(usize, usize)], off: usize) -> Option<&Slot> { + self.slots + .iter() + .find(|s| s.path == path && s.off == off) + .or_else(|| self.slots.iter().filter(|s| s.path == path).min_by_key(|s| s.off.abs_diff(off))) + } + /// The caret position nearest to (`x`, `y`) (y up from the baseline). + pub fn hit(&self, x: f32, y: f32) -> Option<&Slot> { + self.slots.iter().min_by(|a, b| slot_dist(a, x, y).total_cmp(&slot_dist(b, x, y))) + } +} + +fn slot_dist(s: &Slot, x: f32, y: f32) -> f32 { + let (top, bottom) = (s.y + s.a, s.y - s.d); + let dy = if y > top { + y - top + } else if y < bottom { + bottom - y + } else { + 0.0 + }; + // Inside a slot's height, prefer the innermost (shortest) argument. + let h = (s.a + s.d).max(0.1); + (s.x - x).abs() + dy * 3.0 + if dy == 0.0 { h * 0.05 } else { 0.0 } +} + +/// Numbered rows (`#`) in an equation: they advance the automatic numbering. +pub fn auto_numbers(math: &wordcraft_doc::math::Math, display: bool) -> u32 { + if !display { + return 0; + } + match math.nodes.as_slice() { + [MNode::EqArr { rows }] => rows.iter().filter(|r| split_number(r).is_some()).count() as u32, + _ => 0, + } +} + +/// A row's equation and number parts around its top-level `#`, and the units before the `#`. +fn split_number(row: &[MNode]) -> Option<(Arg, Arg, usize)> { + let mut eq = Vec::new(); + let mut units = 0usize; + for (i, n) in row.iter().enumerate() { + if let MNode::Run(r) = n + && !r.lit + && let Some(b) = r.text.find('#') + { + let mut left = r.clone(); + left.text = r.text.get(..b).unwrap_or("").to_string(); + let mut right = r.clone(); + right.text = r.text.get(b + 1..).unwrap_or("").to_string(); + units += left.text.chars().count(); + if !left.text.is_empty() { + eq.push(MNode::Run(left)); + } + let mut num = Vec::new(); + if !right.text.is_empty() { + num.push(MNode::Run(right)); + } + num.extend(row.get(i + 1..).unwrap_or(&[]).iter().cloned()); + return Some((eq, num, units)); + } + units += match n { + MNode::Run(r) => r.text.chars().count(), + _ => 1, + }; + eq.push(n.clone()); + } + None +} + +/// Lay out an equation set at the size and colour of `rc`. `display`: an equation on a line of +/// its own (bigger operators, limits above and below, full-size fractions). +/// `avail` is the width of the line (numbered display equations fill it); `counter` counts the +/// document's automatic equation numbers so far and advances past this equation's. +pub fn layout_equation( + math: &wordcraft_doc::math::Math, + linear: &str, + rc: &ResolvedChar, + display: bool, + avail: f32, + counter: &mut u32, +) -> MathLayout { + let parsed; + let nodes: &Arg = if math.nodes.is_empty() && !math.linear { + parsed = parse_linear(linear); + &parsed + } else { + &math.nodes + }; + let (face, table) = wordcraft_fonts::math::resolve_math("Cambria Math"); + let color = match rc.color { + TextColor::Rgb(c) => c, + TextColor::Auto => Rgb::BLACK, + }; + let base = rc.draw_size().clamp(1.0, 1638.0); + let mut cx = Ctx { + face, + t: table, + upem: face.upem.max(1.0) as f32, + base, + color, + text_font: rc.font.clone(), + nodes: 0, + path: Vec::new(), + node_stack: Vec::new(), + }; + let st = St { level: 0, display, cramped: false, upright: false }; + let avail = if avail.is_finite() { avail.clamp(0.0, 1e5) } else { 0.0 }; + let b = match nodes.as_slice() { + [] => cx.hint(st), + [MNode::EqArr { rows }] if display && rows.iter().any(|r| split_number(r).is_some()) => cx.numbered(rows, st, avail, counter), + _ => cx.arg(nodes, st, 0), + }; + let mut slots = b.slots; + let em = base; + for s in &mut slots { + if s.a <= 0.0 && s.d <= 0.0 { + s.a = em * 0.75; + s.d = em * 0.25; + } + } + MathLayout { width: b.w.max(0.0), ascent: b.a.max(0.0), descent: b.d.max(0.0), items: b.items, slots } +} + +/// Layout style: script level (0 = text size, 1 = script, 2 = script-script), display or inline, +/// cramped (superscripts lowered: inside radicals, denominators, subscripts). +#[derive(Clone, Copy, Debug)] +struct St { + level: u8, + display: bool, + cramped: bool, + /// Letters default to upright (function names). + upright: bool, +} + +impl St { + fn script(self) -> St { + St { level: (self.level + 1).min(2), display: false, ..self } + } + fn cramp(self) -> St { + St { cramped: true, ..self } + } + /// Numerator / denominator style. + fn frac(self) -> St { + if self.display { St { display: false, ..self } } else { self.script() } + } +} + +/// TeX atom classes (spacing). +#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)] +enum Cls { + #[default] + Ord, + Op, + Bin, + Rel, + Open, + Close, + Punct, + Inner, +} + +impl Cls { + fn idx(self) -> usize { + self as usize + } +} + +/// Inter-atom space in mu (1/18 em): TeX's table; negative = only outside scripts. +const SPACING: [[i8; 8]; 8] = [ + // Ord Op Bin Rel Open Close Punct Inner + [0, 3, -4, -5, 0, 0, 0, -3], // Ord + [3, 3, 0, -5, 0, 0, 0, -3], // Op + [-4, -4, 0, 0, -4, 0, 0, -4], // Bin + [-5, -5, 0, 0, -5, 0, 0, -5], // Rel + [0, 0, 0, 0, 0, 0, 0, 0], // Open + [0, 3, -4, -5, 0, 0, 0, -3], // Close + [-3, -3, 0, -3, -3, -3, -3, -3], // Punct + [-3, 3, -4, -5, -3, 0, -3, -3], // Inner +]; + +fn spacing_mu(l: Cls, r: Cls, st: St) -> f32 { + let v = SPACING.get(l.idx()).and_then(|row| row.get(r.idx())).copied().unwrap_or(0); + if v < 0 && st.level > 0 { 0.0 } else { v.unsigned_abs() as f32 } +} + +/// A box under construction. +#[derive(Clone, Debug, Default)] +struct Bx { + w: f32, + a: f32, + d: f32, + items: Vec, + /// Italic correction after the box (superscripts move right by it). + ic: f32, + first: Cls, + last: Cls, + /// The box is one glyph: (face, glyph, top-accent attachment x). + glyph: Option<(FaceRef, u32, f32)>, + /// Takes no part in inter-atom spacing (spaces). + transparent: bool, + /// Caret positions inside, relative to the box. + slots: Vec, + /// Text atoms: x of each character boundary (one more than the characters). + cuts: Vec, +} + +impl Bx { + fn cls(mut self, c: Cls) -> Bx { + self.first = c; + self.last = c; + self + } + /// Draw `o` at (dx, dy) into this box (extents grow; width is the caller's business). + fn put(&mut self, o: Bx, dx: f32, dy: f32) { + self.a = self.a.max(o.a + dy); + self.d = self.d.max(o.d - dy); + for mut it in o.items { + it.shift(dx, dy); + self.items.push(it); + } + for mut sl in o.slots { + sl.x += dx; + sl.y += dy; + self.slots.push(sl); + } + } + fn slot(&mut self, path: &[(usize, usize)], off: usize, x: f32) { + self.slots.push(Slot { path: path.to_vec(), off, x, y: 0.0, a: 0.0, d: 0.0 }); + } + fn rect(&mut self, x: f32, y: f32, w: f32, h: f32, color: Rgb) { + if w > 0.0 && h > 0.0 { + self.items.push(MItem::Rect { x, y, w, h, color }); + self.a = self.a.max(y + h); + self.d = self.d.max(-y); + } + } +} + +struct Ctx { + face: FaceRef, + t: Option>, + upem: f32, + base: f32, + color: Rgb, + text_font: String, + /// Nodes laid out so far (caps work on hostile input). + nodes: usize, + /// The argument being laid out (for caret slots). + path: Vec<(usize, usize)>, + /// Index of the node being laid out in its argument, innermost last. + node_stack: Vec, +} + +const MAX_NODES: usize = 200_000; + +/// Constants as fractions of the em when there's no `MATH` table. +fn fallback(c: C) -> f32 { + match c { + C::MathLeading => 0.15, + C::AxisHeight => 0.25, + C::AccentBaseHeight => 0.45, + C::FlattenedAccentBaseHeight => 0.66, + C::SubscriptShiftDown => 0.2, + C::SubscriptTopMax => 0.36, + C::SubscriptBaselineDropMin => 0.05, + C::SuperscriptShiftUp => 0.36, + C::SuperscriptShiftUpCramped => 0.29, + C::SuperscriptBottomMin => 0.11, + C::SuperscriptBaselineDropMax => 0.25, + C::SubSuperscriptGapMin => 0.2, + C::SuperscriptBottomMaxWithSubscript => 0.36, + C::SpaceAfterScript => 0.05, + C::UpperLimitGapMin => 0.1, + C::UpperLimitBaselineRiseMin => 0.2, + C::LowerLimitGapMin => 0.1, + C::LowerLimitBaselineDropMin => 0.6, + C::StackTopShiftUp => 0.44, + C::StackTopDisplayStyleShiftUp => 0.68, + C::StackBottomShiftDown => 0.34, + C::StackBottomDisplayStyleShiftDown => 0.69, + C::StackGapMin => 0.12, + C::StackDisplayStyleGapMin => 0.28, + C::StretchStackTopShiftUp => 0.2, + C::StretchStackBottomShiftDown => 0.6, + C::StretchStackGapAboveMin => 0.1, + C::StretchStackGapBelowMin => 0.1, + C::FractionNumeratorShiftUp => 0.39, + C::FractionNumeratorDisplayStyleShiftUp => 0.68, + C::FractionDenominatorShiftDown => 0.34, + C::FractionDenominatorDisplayStyleShiftDown => 0.69, + C::FractionNumeratorGapMin => 0.04, + C::FractionNumDisplayStyleGapMin => 0.12, + C::FractionRuleThickness => 0.05, + C::FractionDenominatorGapMin => 0.04, + C::FractionDenomDisplayStyleGapMin => 0.12, + C::SkewedFractionHorizontalGap => 0.35, + C::SkewedFractionVerticalGap => 0.1, + C::OverbarVerticalGap => 0.15, + C::OverbarRuleThickness => 0.05, + C::OverbarExtraAscender => 0.05, + C::UnderbarVerticalGap => 0.15, + C::UnderbarRuleThickness => 0.05, + C::UnderbarExtraDescender => 0.05, + C::RadicalVerticalGap => 0.05, + C::RadicalDisplayStyleVerticalGap => 0.15, + C::RadicalRuleThickness => 0.05, + C::RadicalExtraAscender => 0.05, + C::RadicalKernBeforeDegree => 0.28, + C::RadicalKernAfterDegree => -0.55, + } +} + +/// Characters drawn as operators with their own spacing class. +fn class_of(c: char) -> Cls { + match wordcraft_doc::math::math_class(c) { + MClass::Ord => Cls::Ord, + MClass::Op => Cls::Op, + MClass::Bin => Cls::Bin, + MClass::Rel => Cls::Rel, + MClass::Open => Cls::Open, + MClass::Close => Cls::Close, + MClass::Punct => Cls::Punct, + MClass::Inner => Cls::Inner, + } +} + +/// Accents drawn across the whole base (stretched horizontally). +fn wide_accent(c: char) -> bool { + matches!( + c, + '\u{20D6}' + | '\u{20D7}' + | '\u{20E1}' + | '\u{20D0}' + | '\u{20D1}' + | '\u{2190}' + | '\u{2192}' + | '\u{2194}' + | '\u{23DC}' + | '\u{23DD}' + | '\u{23DE}' + | '\u{23DF}' + | '\u{23B4}' + | '\u{23B5}' + ) +} + +impl Ctx { + fn scale(&self, level: u8) -> f32 { + let (s1, s2) = match &self.t { + Some(t) => (t.script_percent, t.script_script_percent), + None => (70.0, 50.0), + }; + let s1 = if (30.0..=100.0).contains(&s1) { s1 } else { 70.0 }; + let s2 = if (20.0..=100.0).contains(&s2) { s2 } else { 50.0 }; + match level { + 0 => 1.0, + 1 => s1 / 100.0, + _ => s2 / 100.0, + } + } + fn size(&self, st: St) -> f32 { + self.base * self.scale(st.level) + } + /// A constant in points at the style's size. + fn k(&self, c: C, st: St) -> f32 { + let size = self.size(st); + match &self.t { + Some(t) => t.get(c) * size / self.upem, + None => fallback(c) * size, + } + } + fn axis(&self, st: St) -> f32 { + self.k(C::AxisHeight, st) + } + fn em(&self, st: St) -> f32 { + self.size(st) + } + + fn arg(&mut self, a: &[MNode], st: St, depth: usize) -> Bx { + let atoms = self.row(a, st, depth, 0); + let em = self.em(st); + let mut b = compose(atoms.into_iter().map(|(b, _)| b).collect(), st, em); + finalize(&mut b, &self.path, em); + b + } + + /// Child argument `c` of the node being laid out; an empty one shows a placeholder. + fn sub(&mut self, c: usize, a: &[MNode], st: St, depth: usize) -> Bx { + let me = self.node_stack.last().copied().unwrap_or(0); + self.path.push((me, c)); + let b = if a.is_empty() { self.placeholder(st) } else { self.arg(a, st, depth) }; + self.path.pop(); + b + } + + /// The dotted box an empty argument shows on screen. + fn placeholder(&mut self, st: St) -> Bx { + let em = self.em(st); + let (w, a, d, pad) = (em * 0.56, em * 0.7, em * 0.06, em * 0.05); + let mut b = Bx { w: w + 2.0 * pad, a, d, ..Default::default() }; + let (x0, x1, y0, y1) = (pad, pad + w, -d, a); + let width = (em * 0.035).max(0.4); + for ((ax, ay), (bx, by)) in [((x0, y0), (x1, y0)), ((x1, y0), (x1, y1)), ((x1, y1), (x0, y1)), ((x0, y1), (x0, y0))] { + let line = MItem::Line { x0: ax, y0: ay, x1: bx, y1: by, width, color: Rgb(0x60, 0x60, 0x60), dotted: true }; + b.items.push(MItem::ScreenOnly(Box::new(line))); + } + let path = self.path.clone(); + b.slot(&path, 0, pad); + finalize(&mut b, &path, em); + b + } + + /// An empty equation: a grey prompt (screen only). + fn hint(&mut self, st: St) -> Bx { + let size = self.size(st); + let rs = wordcraft_fonts::word::resolve(&self.text_font, false, false); + let mut g = self.glyphs(rs.face, "Type an equation", size, 0, Rgb(0x80, 0x80, 0x80), (rs.synth_bold, rs.synth_italic)); + g.items = std::mem::take(&mut g.items).into_iter().map(|i| MItem::ScreenOnly(Box::new(i))).collect(); + g.glyph = None; + g.cuts.clear(); + g.slots.clear(); + g.slot(&[], 0, 0.0); + finalize(&mut g, &[], size); + g + } + + /// The atoms of a row with their alignment segment (`&` starts a new segment). `u0`: the + /// unit offset of the row's first node (for caret slots). + fn row(&mut self, a: &[MNode], st: St, depth: usize, u0: usize) -> Vec<(Bx, usize)> { + let mut out = Vec::new(); + if depth > MAX_DEPTH { + return out; + } + let mut seg = 0usize; + let mut u = u0; + for (i, n) in a.iter().enumerate() { + self.nodes += 1; + if self.nodes > MAX_NODES { + break; + } + let path = self.path.clone(); + match n { + MNode::Run(r) => { + for part in self.run_atoms(r, st, u, &path) { + match part { + RunPart::Atom(b) => out.push((b, seg)), + RunPart::Align(b) => { + seg += 1; + out.push((b, seg)); + } + } + } + u += r.text.chars().count(); + } + MNode::Nary { .. } | MNode::Func { .. } => { + self.node_stack.push(i); + let mut atoms = self.op_atoms(n, st, depth + 1); + self.node_stack.pop(); + if let Some(f) = atoms.first_mut() { + f.slot(&path, u, 0.0); + } + if let Some(l) = atoms.last_mut() { + let w = l.w; + l.slot(&path, u + 1, w); + } + for b in atoms { + out.push((b, seg)); + } + u += 1; + } + _ => { + self.node_stack.push(i); + let mut b = self.node(n, st, depth + 1); + self.node_stack.pop(); + let w = b.w; + b.slot(&path, u, 0.0); + b.slot(&path, u + 1, w); + out.push((b, seg)); + u += 1; + } + } + } + out + } + + /// Glyph run for `text` in `face` at `size`: a box with ink extents. + fn glyphs(&self, face: FaceRef, text: &str, size: f32, level: u8, color: Rgb, synth: (bool, bool)) -> Bx { + let feats = if level > 0 { vec![wordcraft_fonts::feature_value(b"ssty", level as u32)] } else { Vec::new() }; + let shaped = wordcraft_fonts::shape(&face, text, &feats, |c| c); + let k = size / face.upem.max(1.0) as f32; + let mut x = 0.0f32; + let mut b = Bx::default(); + let mut glyphs = Vec::with_capacity(shaped.len()); + let mut last_gid = None; + for g in &shaped { + let gx = x + g.x_offset as f32 * k; + let gy = g.y_offset as f32 * k; + if let Some((x0, y0, x1, y1)) = glyph_bounds(&face, g.gid) { + b.a = b.a.max(y1 * k + gy); + b.d = b.d.max(-y0 * k - gy); + let _ = (x0, x1); + } + glyphs.push((g.gid, gx, gy)); + x += g.x_advance as f32 * k; + last_gid = Some(g.gid); + } + b.w = x; + // Character boundaries: where the first glyph of each character starts. + let mut cuts = Vec::with_capacity(text.len() + 1); + for (cb, _) in text.char_indices() { + let mut gx = 0.0f32; + let mut found = None; + for g in &shaped { + if g.cluster >= cb { + found = Some(gx); + break; + } + gx += g.x_advance as f32 * k; + } + cuts.push(found.unwrap_or(x)); + } + cuts.push(x); + b.cuts = cuts; + if let (Some(t), Some(g)) = (&self.t, last_gid) + && face.id() == self.face.id() + { + b.ic = t.italics.get(&g).copied().unwrap_or(0.0) * k; + } + if shaped.len() == 1 + && let Some(g) = last_gid + { + let attach = match (&self.t, glyph_bounds(&face, g)) { + (Some(t), _) if face.id() == self.face.id() && t.top_accent.contains_key(&g) => t.top_accent.get(&g).copied().unwrap_or(0.0) * k, + (_, Some((x0, _, x1, _))) => (x0 + x1) / 2.0 * k, + _ => b.w / 2.0, + }; + b.glyph = Some((face, g, attach)); + } + if !glyphs.is_empty() { + b.items.push(MItem::Glyphs { face, size, color, synth_bold: synth.0, synth_italic: synth.1, glyphs, text: text.to_string() }); + } + b + } + + /// A single glyph by id (size variants, assembly parts). + fn glyph_id(&self, gid: u32, size: f32, color: Rgb) -> Bx { + let face = self.face; + let k = size / face.upem.max(1.0) as f32; + let mut b = Bx { w: face.advance(gid) as f32 * k, ..Default::default() }; + if let Some((_, y0, _, y1)) = glyph_bounds(&face, gid) { + b.a = y1 * k; + b.d = -y0 * k; + } + if let Some(t) = &self.t { + b.ic = t.italics.get(&gid).copied().unwrap_or(0.0) * k; + } + b.items.push(MItem::Glyphs { face, size, color, synth_bold: false, synth_italic: false, glyphs: vec![(gid, 0.0, 0.0)], text: String::new() }); + b + } + + /// A text atom with caret slots at its character boundaries (`k0`: the unit offset of its + /// first character). + fn text_atom(&self, face: FaceRef, text: &str, size: f32, level: u8, color: Rgb, synth: (bool, bool), path: &[(usize, usize)], k0: usize) -> Bx { + let mut b = self.glyphs(face, text, size, level, color, synth); + let cuts = std::mem::take(&mut b.cuts); + for (j, x) in cuts.iter().enumerate() { + b.slot(path, k0 + j, *x); + } + b + } + + fn run_atoms(&self, r: &MRun, st: St, u0: usize, path: &[(usize, usize)]) -> Vec { + let mut out = Vec::new(); + let size = r.size.filter(|s| s.is_finite()).unwrap_or(self.base).clamp(1.0, 1638.0) * self.scale(st.level); + let color = r.color.unwrap_or(self.color); + if r.nor { + // Normal text: the text font, upright unless asked. + let fam = r.font.clone().filter(|f| !f.eq_ignore_ascii_case("Cambria Math")).unwrap_or_else(|| self.text_font.clone()); + let (bold, italic) = match r.sty { + Some(MSty::Bold) => (true, false), + Some(MSty::Italic) => (false, true), + Some(MSty::BoldItalic) => (true, true), + _ => (false, false), + }; + let rs = wordcraft_fonts::word::resolve(&fam, bold, italic); + let mut b = self.text_atom(rs.face, &r.text, size, 0, color, (rs.synth_bold, rs.synth_italic), path, u0); + b.glyph = None; + out.push(RunPart::Atom(b.cls(Cls::Ord))); + return out; + } + // Map characters (math alphabets, operators) and split into atoms by class. + let mut cur = String::new(); + let mut cur_k0 = 0usize; + let mut cur_face: Option = None; + let mut synth_italic = false; + let flush = |cur: &mut String, k0: usize, face: Option, synth_italic: bool, out: &mut Vec| { + if !cur.is_empty() { + let f = face.unwrap_or(self.face); + let b = self.text_atom(f, cur, size, st.level, color, (false, synth_italic), path, u0 + k0); + out.push(RunPart::Atom(b.cls(Cls::Ord))); + cur.clear(); + } + }; + // A zero-width (or space) atom that takes no part in spacing, with its two slots. + let blank = |w: f32, k: usize| { + let mut b = Bx { w, transparent: true, ..Default::default() }; + b.slot(path, u0 + k, 0.0); + b.slot(path, u0 + k + 1, w); + b + }; + for (k, c) in r.text.chars().enumerate() { + if c == '&' && !r.lit { + flush(&mut cur, cur_k0, cur_face, synth_italic, &mut out); + out.push(RunPart::Align(blank(0.0, k))); + continue; + } + let c = if r.lit { + c + } else { + match c { + '-' => '−', + '*' => '∗', + '\'' => '′', + _ => c, + } + }; + let is_letter = c.is_alphabetic(); + let greek_cap = ('\u{391}'..='\u{3A9}').contains(&c); + let (bold, italic) = match r.sty { + Some(MSty::Plain) => (false, false), + Some(MSty::Bold) => (true, false), + Some(MSty::Italic) => (false, true), + Some(MSty::BoldItalic) => (true, true), + None => (false, is_letter && !greek_cap && !st.upright && r.scr == MScr::Roman && !r.lit), + }; + let mapped = math_alnum(c, bold, italic, r.scr); + // Fall back to the plain letter (slanted) when the face lacks the math alphabet. + let (ch, slant) = if mapped != c && !self.face.covers(mapped) { (c, italic) } else { (mapped, false) }; + let face = if self.face.covers(ch) || ch.is_whitespace() { + None + } else { + FontDb::global().fallback_for(ch, self.face.id()).map(|f| FaceRef::of(&f)) + }; + let cls = if r.lit { Cls::Ord } else { class_of(ch) }; + if ch == ' ' || ch == '\u{2061}' || ch == '\u{2062}' || ch == '\u{2063}' || ch == '\u{200B}' { + flush(&mut cur, cur_k0, cur_face, synth_italic, &mut out); + // Word sets a typed space in an equation about 0.4 em wide. + out.push(RunPart::Atom(blank(if ch == ' ' { size * 0.4 } else { 0.0 }, k))); + continue; + } + let same_face = cur_face.map(|f| f.id()) == face.map(|f| f.id()); + if cls != Cls::Ord || !same_face || slant != synth_italic { + flush(&mut cur, cur_k0, cur_face, synth_italic, &mut out); + } + if cls != Cls::Ord { + let f = face.unwrap_or(self.face); + let b = self.text_atom(f, &ch.to_string(), size, st.level, color, (false, slant), path, u0 + k); + out.push(RunPart::Atom(b.cls(cls))); + continue; + } + if cur.is_empty() { + cur_k0 = k; + } + cur_face = face; + synth_italic = slant; + cur.push(ch); + } + flush(&mut cur, cur_k0, cur_face, synth_italic, &mut out); + out + } + + /// N-ary operators and functions: the operator atom (class Op) then the operand. + fn op_atoms(&mut self, n: &MNode, st: St, depth: usize) -> Vec { + match n { + MNode::Nary { chr, lim_loc, grow, sub_hide, sup_hide, sub, sup, e } => { + let e = self.sub(2, e, st, depth); + let loc = lim_loc.unwrap_or(if is_integral(*chr) { LimLoc::SubSup } else { LimLoc::UndOvr }); + let loc = if st.display { loc } else { LimLoc::SubSup }; + let size = self.size(st); + let base_gid = self.face.glyph_for(*chr); + let mut target = 0.0f32; + if st.display { + target = match &self.t { + Some(t) => t.display_operator_min_height * size / self.upem, + None => size * 1.5, + }; + } + if *grow { + target = target.max(e.a + e.d); + } + let op = if *grow && target > 0.0 && self.t.is_some() { + self.stretch_v(base_gid, target, size).unwrap_or_else(|| self.glyph_id(base_gid, size, self.color)) + } else if target > 0.0 && self.t.is_some() { + // Word sets display operators about 1.2 em tall: the size variant nearest that. + let want = target.min(size * 1.2) * self.upem / size; + let (g, adv) = self + .t + .as_ref() + .and_then(|t| t.vert.get(&base_gid)) + .and_then(|c| c.variants.iter().min_by(|a, b| (a.1 - want).abs().total_cmp(&(b.1 - want).abs()))) + .copied() + .unwrap_or((base_gid, want)); + // A font without a variant near that size: scale the nearest one. + let f = if adv > 0.0 { (want / adv).clamp(0.6, 1.5) } else { 1.0 }; + let f = if (0.9..=1.1).contains(&f) { 1.0 } else { f }; + self.glyph_id(g, size * f, self.color) + } else if base_gid != 0 { + self.glyph_id(base_gid, size, self.color) + } else { + let s = if st.display { size * 1.4 } else { size }; + self.glyphs(self.face, &chr.to_string(), s, 0, self.color, (false, false)) + }; + // Centre the operator on the math axis. + let dy = self.axis(st) - (op.a - op.d) / 2.0; + let mut ob = Bx { w: op.w, ..Default::default() }; + let ic = op.ic; + ob.put(op, 0.0, dy); + let sub = if *sub_hide { None } else { Some(self.sub(0, sub, st.script().cramp(), depth)) }; + let sup = if *sup_hide { None } else { Some(self.sub(1, sup, st.script(), depth)) }; + let opb = match loc { + LimLoc::UndOvr => self.limits(ob, sub, sup, ic, st, true), + LimLoc::SubSup => { + ob.ic = ic; + self.attach_op(ob, sub, sup, st) + } + }; + vec![opb.cls(Cls::Op), e] + } + MNode::Func { name, e } => { + let name = self.sub(0, name, St { upright: true, ..st }, depth); + let e = self.sub(1, e, St { upright: false, ..st }, depth); + vec![name.cls(Cls::Op), e] + } + _ => Vec::new(), + } + } + + /// Limits under and over `base` (centred), spaced as for big operators (`big`) or as + /// accents-like limits. + fn limits(&self, base: Bx, under: Option, over: Option, ic: f32, st: St, big: bool) -> Bx { + let w = base.w.max(under.as_ref().map_or(0.0, |b| b.w)).max(over.as_ref().map_or(0.0, |b| b.w)); + let mut out = Bx { w, ..Default::default() }; + let (ba, bd) = (base.a, base.d); + let bw = base.w; + out.put(base, (w - bw) / 2.0, 0.0); + if let Some(o) = over { + let rise = if big { + self.k(C::UpperLimitBaselineRiseMin, st).max(self.k(C::UpperLimitGapMin, st) + o.d) + } else { + self.k(C::UpperLimitGapMin, st) + o.d + }; + let x = (w - o.w) / 2.0 + ic / 2.0; + out.put(o, x, ba + rise); + } + if let Some(u) = under { + let drop = if big { + self.k(C::LowerLimitBaselineDropMin, st).max(self.k(C::LowerLimitGapMin, st) + u.a) + } else { + self.k(C::LowerLimitGapMin, st) + u.a + }; + let x = (w - u.w) / 2.0 - ic / 2.0; + out.put(u, x, -bd - drop); + } + out + } + + /// Limits beside a big operator: the superscript at its advance, the subscript tucked left + /// under the slant by the italic correction. + fn attach_op(&self, base: Bx, sub: Option, sup: Option, st: St) -> Bx { + let ic = base.ic; + let mut b = base; + b.ic = 0.0; + let (bw, bd) = (b.w, b.d); + let mut out = self.attach(b, None, sup, st, false); + if let Some(s) = sub { + let v = self.k(C::SubscriptShiftDown, st).max(s.a - self.k(C::SubscriptTopMax, st)).max(bd + self.k(C::SubscriptBaselineDropMin, st)); + let x = (bw - ic).max(0.0); + let sw = s.w; + out.put(s, x, -v); + out.w = out.w.max(x + sw + self.k(C::SpaceAfterScript, st)); + } + out + } + + /// Sub/superscripts after `base`. `char_base`: the base is a single character (no baseline drop). + fn attach(&self, base: Bx, sub: Option, sup: Option, st: St, char_base: bool) -> Bx { + let (bw, ba, bd, ic) = (base.w, base.a, base.d, base.ic); + let (first, last) = (base.first, base.last); + let mut out = Bx { w: bw, ..Default::default() }; + out.put(base, 0.0, 0.0); + let mut u = 0.0f32; + let mut v = 0.0f32; + if let Some(p) = &sup { + let shift = if st.cramped { self.k(C::SuperscriptShiftUpCramped, st) } else { self.k(C::SuperscriptShiftUp, st) }; + u = shift.max(p.d + self.k(C::SuperscriptBottomMin, st)); + if !char_base { + u = u.max(ba - self.k(C::SuperscriptBaselineDropMax, st)); + } + } + if let Some(b) = &sub { + v = self.k(C::SubscriptShiftDown, st).max(b.a - self.k(C::SubscriptTopMax, st)); + if !char_base { + v = v.max(bd + self.k(C::SubscriptBaselineDropMin, st)); + } + } + if let (Some(p), Some(b)) = (&sup, &sub) { + let gap = (u - p.d) - (b.a - v); + let min = self.k(C::SubSuperscriptGapMin, st); + if gap < min { + v += min - gap; + let lift = self.k(C::SuperscriptBottomMaxWithSubscript, st) - (u - p.d); + if lift > 0.0 { + u += lift; + v -= lift; + } + } + } + let space = self.k(C::SpaceAfterScript, st); + let mut w = bw; + if let Some(p) = sup { + let x = bw + ic; + w = w.max(x + p.w); + out.put(p, x, u); + } + if let Some(b) = sub { + let x = bw; + w = w.max(x + b.w); + out.put(b, x, -v); + } + out.w = w + if w > bw { space } else { 0.0 }; + out.first = first; + out.last = last; + out + } + + /// A vertical size variant or assembly of `gid` at least `target` tall (`None` without a + /// `MATH` table or construction for the glyph). + fn stretch_v(&self, gid: u32, target: f32, size: f32) -> Option { + let t = self.t.as_ref()?; + let con = t.vert.get(&gid)?; + self.stretch(con, t, target, size, true) + } + + fn stretch_h(&self, gid: u32, target: f32, size: f32) -> Option { + let t = self.t.as_ref()?; + let con = t.horiz.get(&gid)?; + self.stretch(con, t, target, size, false) + } + + fn stretch(&self, con: &Construction, t: &MathTable, target: f32, size: f32, vertical: bool) -> Option { + let k = size / self.upem; + let target_fu = target / k; + // The first variant big enough. + if let Some((g, _)) = con.variants.iter().find(|(_, adv)| *adv >= target_fu - 1.0) { + return Some(self.glyph_id(*g, size, self.color)); + } + if con.parts.is_empty() { + return con.variants.last().map(|(g, _)| self.glyph_id(*g, size, self.color)); + } + // Assembly: repeat extenders until the parts span the target. + let o = t.min_connector_overlap; + let fixed: f32 = con.parts.iter().filter(|p| !p.extender).map(|p| p.full_advance).sum(); + let ext: f32 = con.parts.iter().filter(|p| p.extender).map(|p| p.full_advance).sum(); + let n_fixed = con.parts.iter().filter(|p| !p.extender).count(); + let n_ext = con.parts.len() - n_fixed; + let mut reps = 0usize; + if n_ext > 0 { + while reps < 100 { + let n = n_fixed + n_ext * reps; + let len = fixed + ext * reps as f32 - o * n.saturating_sub(1) as f32; + if len >= target_fu { + break; + } + reps += 1; + } + } + let seq: Vec<_> = con.parts.iter().flat_map(|p| std::iter::repeat_n(*p, if p.extender { reps } else { 1 })).collect(); + if seq.is_empty() { + return None; + } + let joints = seq.len().saturating_sub(1); + let max_len: f32 = seq.iter().map(|p| p.full_advance).sum::() - o * joints as f32; + // Spread the excess over the joints as extra overlap (within each connector's length). + let excess = (max_len - target_fu).max(0.0); + let per = if joints > 0 { excess / joints as f32 } else { 0.0 }; + let mut b = Bx::default(); + let mut pos = 0.0f32; + let mut glyphs = Vec::with_capacity(seq.len()); + for (i, p) in seq.iter().enumerate() { + if vertical { + let y0 = glyph_bounds(&self.face, p.gid).map(|bb| bb.1).unwrap_or(0.0); + glyphs.push((p.gid, 0.0, (pos - y0) * k)); + b.w = b.w.max(self.face.advance(p.gid) as f32 * k); + } else { + glyphs.push((p.gid, pos * k, 0.0)); + if let Some((_, y0, _, y1)) = glyph_bounds(&self.face, p.gid) { + b.a = b.a.max(y1 * k); + b.d = b.d.max(-y0 * k); + } + } + if let Some(next) = seq.get(i + 1) { + let room = p.end_connector.min(next.start_connector).max(o); + let ov = (o + per).min(room); + pos += p.full_advance - ov; + } else { + pos += p.full_advance; + } + } + if vertical { + b.a = pos * k; + b.d = 0.0; + } else { + b.w = pos * k; + } + b.items.push(MItem::Glyphs { face: self.face, size, color: self.color, synth_bold: false, synth_italic: false, glyphs, text: String::new() }); + Some(b) + } + + /// A delimiter (or separator) glyph covering `a`/`d` around the axis (or the content exactly + /// with `exact`). + fn delimiter(&self, c: char, a: f32, d: f32, st: St, grow: bool, exact: bool) -> Bx { + let size = self.size(st); + let axis = self.axis(st); + let mut glyph = self.glyphs(self.face, &c.to_string(), size, 0, self.color, (false, false)); + glyph.glyph = None; + if !grow { + return glyph; + } + let target = if exact { a + d } else { 2.0 * (a - axis).max(d + axis) }; + // Small content keeps the plain glyph where it sits. + if glyph.a + glyph.d >= target - size * 0.02 { + return glyph; + } + let gid = self.face.glyph_for(c); + let Some(big) = (if gid != 0 { self.stretch_v(gid, target, size) } else { None }) else { return glyph }; + let dy = if exact { (a - d) / 2.0 - (big.a - big.d) / 2.0 } else { axis - (big.a - big.d) / 2.0 }; + let mut out = Bx { w: big.w, ..Default::default() }; + out.put(big, 0.0, dy); + out + } + + fn node(&mut self, n: &MNode, st: St, depth: usize) -> Bx { + if depth > MAX_DEPTH { + return Bx::default(); + } + let d = depth + 1; + let em = self.em(st); + match n { + MNode::Run(r) => { + let atoms: Vec = self + .run_atoms(r, st, 0, &self.path.clone()) + .into_iter() + .map(|p| match p { + RunPart::Atom(b) | RunPart::Align(b) => b, + }) + .collect(); + compose(atoms, st, em) + } + MNode::Frac { kind, num, den } => self.frac(*kind, num, den, st, d), + MNode::Script { kind, base, sub, sup } => { + let b = self.sub(0, base, st, d); + let char_base = b.glyph.is_some(); + let sub_b = if matches!(kind, ScriptKind::Sub | ScriptKind::SubSup | ScriptKind::Pre) { + Some(self.sub(1, sub, st.script().cramp(), d)) + } else { + None + }; + let sup_b = if matches!(kind, ScriptKind::Sup | ScriptKind::SubSup | ScriptKind::Pre) { + Some(self.sub(2, sup, st.script(), d)) + } else { + None + }; + if *kind == ScriptKind::Pre { + // Pre-scripts: attach to an empty base, then put the base after them. + let empty = Bx::default(); + let mut pre = self.attach(empty, sub_b, sup_b, st, false); + let pw = pre.w; + let (f, l) = (b.first, b.last); + pre.w = pw + b.w; + pre.put(b, pw, 0.0); + pre.first = f; + pre.last = l; + return pre; + } + self.attach(b, sub_b, sup_b, st, char_base) + } + MNode::Rad { deg, deg_hide, e } => self.radical(deg, *deg_hide, e, st, d), + MNode::Nary { .. } | MNode::Func { .. } => { + let atoms = self.op_atoms(n, st, d); + compose(atoms, st, em) + } + MNode::Delim { beg, end, sep, grow, shp_match, elems } => { + let parts: Vec = elems.iter().enumerate().map(|(k, e)| self.sub(k, e, st, d)).collect(); + let a = parts.iter().map(|b| b.a).fold(0.0f32, f32::max); + let dd = parts.iter().map(|b| b.d).fold(0.0f32, f32::max); + let mut row: Vec = Vec::new(); + if let Some(c) = beg { + row.push(self.delimiter(*c, a, dd, st, *grow, *shp_match).cls(Cls::Open)); + } + let n = parts.len(); + for (i, p) in parts.into_iter().enumerate() { + row.push(p.cls(Cls::Inner)); + if i + 1 < n + && let Some(c) = sep + { + row.push(self.delimiter(*c, a, dd, st, *grow, *shp_match).cls(Cls::Ord)); + } + } + if let Some(c) = end { + row.push(self.delimiter(*c, a, dd, st, *grow, *shp_match).cls(Cls::Close)); + } + // No spacing inside the brackets. + let mut out = hcat(row); + out.first = if beg.is_some() { Cls::Open } else { Cls::Inner }; + out.last = if end.is_some() { Cls::Close } else { Cls::Inner }; + out + } + MNode::Lim { upper, e, lim } => { + let base = self.sub(0, e, st, d); + let ls = St { upright: false, ..st.script() }; + let l = self.sub(1, lim, if *upper { ls } else { ls.cramp() }, d); + let (f, la) = (base.first, base.last); + let mut out = + if *upper { self.limits(base, None, Some(l), 0.0, st, false) } else { self.limits(base, Some(l), None, 0.0, st, false) }; + out.first = f; + out.last = la; + out + } + MNode::Acc { chr, e } => self.accent(*chr, e, st, d), + MNode::Bar { top, e } => { + let b = self.sub(0, e, st, d); + let (w, a, dd) = (b.w, b.a, b.d); + let mut out = Bx { w, ..Default::default() }; + out.put(b, 0.0, 0.0); + let color = self.color; + if *top { + let t = self.k(C::OverbarRuleThickness, st).max(0.4); + let y = a + self.k(C::OverbarVerticalGap, st); + out.rect(0.0, y, w, t, color); + out.a = out.a.max(y + t + self.k(C::OverbarExtraAscender, st)); + } else { + let t = self.k(C::UnderbarRuleThickness, st).max(0.4); + let y = -dd - self.k(C::UnderbarVerticalGap, st) - t; + out.rect(0.0, y, w, t, color); + out.d = out.d.max(-y + self.k(C::UnderbarExtraDescender, st)); + } + out + } + MNode::BorderBox { hide, strike, e } => { + let b = self.sub(0, e, st, d); + let t = self.k(C::FractionRuleThickness, st).max(0.4); + let pad = em * 0.12; + let (w, a, dd) = (b.w + 2.0 * pad, b.a + pad, b.d + pad); + let mut out = Bx { w: w + 2.0 * t, ..Default::default() }; + out.put(b, pad + t, 0.0); + let color = self.color; + let [ht, hb, hl, hr] = *hide; + let (x0, x1, y0, y1) = (0.0, w + 2.0 * t, -dd - t, a + t); + if !ht { + out.rect(x0, a, x1 - x0, t, color); + } + if !hb { + out.rect(x0, y0, x1 - x0, t, color); + } + if !hl { + out.rect(x0, y0, t, y1 - y0, color); + } + if !hr { + out.rect(x1 - t, y0, t, y1 - y0, color); + } + let [sh, sv, bltr, tlbr] = *strike; + let mid = (a - dd) / 2.0; + let line = |out: &mut Bx, p: (f32, f32, f32, f32)| { + out.items.push(MItem::Line { x0: p.0, y0: p.1, x1: p.2, y1: p.3, width: t, color, dotted: false }) + }; + if sh { + line(&mut out, (x0, mid, x1, mid)); + } + if sv { + line(&mut out, ((x0 + x1) / 2.0, y0, (x0 + x1) / 2.0, y1)); + } + if bltr { + line(&mut out, (x0, y0, x1, y1)); + } + if tlbr { + line(&mut out, (x0, y1, x1, y0)); + } + out.a = out.a.max(y1); + out.d = out.d.max(-y0); + out + } + MNode::Boxed { e } => self.sub(0, e, st, d), + MNode::GroupChr { chr, top, e } => { + let b = self.sub(0, e, st, d); + let size = self.size(st); + let gid = self.face.glyph_for(*chr); + let g = + self.stretch_h(gid, b.w, size).unwrap_or_else(|| self.glyphs(self.face, &chr.to_string(), size, 0, self.color, (false, false))); + let w = b.w.max(g.w); + let (ba, bd, bw) = (b.a, b.d, b.w); + let (ga, gd, gw) = (g.a, g.d, g.w); + let mut out = Bx { w, ..Default::default() }; + out.put(b, (w - bw) / 2.0, 0.0); + if *top { + let y = ba + self.k(C::StretchStackGapAboveMin, st) + gd; + out.put(g, (w - gw) / 2.0, y); + } else { + let y = -bd - self.k(C::StretchStackGapBelowMin, st) - ga; + out.put(g, (w - gw) / 2.0, y); + } + out + } + MNode::EqArr { rows } => self.eq_arr(rows, st, d), + MNode::Matrix { rows, col_jc } => self.matrix(rows, col_jc, st, d), + MNode::Phant { show, zero_wid, zero_asc, zero_desc, e } => { + let mut b = self.sub(0, e, st, d); + if !show { + b.items.clear(); + } + if *zero_wid { + b.w = 0.0; + } + if *zero_asc { + b.a = 0.0; + } + if *zero_desc { + b.d = 0.0; + } + b.glyph = None; + b + } + } + } + + fn frac(&mut self, kind: FracKind, num: &[MNode], den: &[MNode], st: St, d: usize) -> Bx { + let em = self.em(st); + match kind { + FracKind::Linear => { + let n = self.sub(0, num, st, d); + let dn = self.sub(1, den, st, d); + let slash = self.glyphs(self.face, "/", self.size(st), st.level, self.color, (false, false)).cls(Cls::Ord); + hcat(vec![n, slash, dn]) + } + FracKind::Skewed => { + let n = self.sub(0, num, st.script(), d); + let dn = self.sub(1, den, st.script().cramp(), d); + let size = self.size(st); + let slash = self.glyphs(self.face, "\u{2044}", size, 0, self.color, (false, false)); + let gap = self.k(C::SkewedFractionHorizontalGap, st) / 2.0; + let (nw, sw) = (n.w, slash.w); + let mut out = Bx { w: n.w + gap + slash.w + gap + dn.w, ..Default::default() }; + let up = self.axis(st) + self.k(C::SkewedFractionVerticalGap, st) / 2.0; + out.put(n, 0.0, up.max(0.0)); + out.put(slash, nw + gap, 0.0); + out.put(dn, nw + gap + sw + gap, -self.k(C::SkewedFractionVerticalGap, st) / 2.0); + out + } + FracKind::Bar | FracKind::NoBar => { + let cs = st.frac(); + let n = self.sub(0, num, St { cramped: st.cramped, ..cs }, d); + let dn = self.sub(1, den, cs.cramp(), d); + let axis = self.axis(st); + let (u, v) = if kind == FracKind::Bar { + let t = self.k(C::FractionRuleThickness, st); + let (su, sd, gn, gd) = if st.display { + ( + self.k(C::FractionNumeratorDisplayStyleShiftUp, st), + self.k(C::FractionDenominatorDisplayStyleShiftDown, st), + self.k(C::FractionNumDisplayStyleGapMin, st), + self.k(C::FractionDenomDisplayStyleGapMin, st), + ) + } else { + ( + self.k(C::FractionNumeratorShiftUp, st), + self.k(C::FractionDenominatorShiftDown, st), + self.k(C::FractionNumeratorGapMin, st), + self.k(C::FractionDenominatorGapMin, st), + ) + }; + (su.max(gn + t / 2.0 + axis + n.d), sd.max(gd + t / 2.0 - axis + dn.a)) + } else { + let (mut su, mut sd, gap) = if st.display { + ( + self.k(C::StackTopDisplayStyleShiftUp, st), + self.k(C::StackBottomDisplayStyleShiftDown, st), + self.k(C::StackDisplayStyleGapMin, st), + ) + } else { + (self.k(C::StackTopShiftUp, st), self.k(C::StackBottomShiftDown, st), self.k(C::StackGapMin, st)) + }; + let g = (su - n.d) - (dn.a - sd); + if g < gap { + su += (gap - g) / 2.0; + sd += (gap - g) / 2.0; + } + (su, sd) + }; + let pad = em * 0.08; + let w = n.w.max(dn.w) + 2.0 * pad; + let (nw, dw) = (n.w, dn.w); + let mut out = Bx { w, ..Default::default() }; + out.put(n, (w - nw) / 2.0, u); + out.put(dn, (w - dw) / 2.0, -v); + if kind == FracKind::Bar { + let t = self.k(C::FractionRuleThickness, st).max(0.4); + out.rect(pad * 0.5, axis - t / 2.0, w - pad, t, self.color); + } + out + } + } + } + + fn radical(&mut self, deg: &[MNode], deg_hide: bool, e: &[MNode], st: St, d: usize) -> Bx { + let b = self.sub(1, e, st.cramp(), d); + let size = self.size(st); + let t = self.k(C::RadicalRuleThickness, st).max(0.4); + let mut gap = if st.display { self.k(C::RadicalDisplayStyleVerticalGap, st) } else { self.k(C::RadicalVerticalGap, st) }; + let target = b.a + b.d + gap + t; + let gid = self.face.glyph_for('√'); + let g = self.stretch_v(gid, target, size).unwrap_or_else(|| { + let mut g = self.glyphs(self.face, "√", size, 0, self.color, (false, false)); + g.glyph = None; + g + }); + let extra = (g.a + g.d) - target; + if extra > 0.0 { + gap += extra / 2.0; + } + // The sign's top meets the rule over the base. + let top = b.a + gap + t; + let dy = top - g.a; + let (ga, gd, gw) = (g.a, g.d, g.w); + let mut sign = Bx { w: gw, ..Default::default() }; + sign.put(g, 0.0, dy); + // Degree. + let mut x0 = 0.0f32; + let mut out = Bx::default(); + if !deg_hide { + let db = self.sub(0, deg, St { level: (st.level + 2).min(2), display: false, cramped: true, upright: st.upright }, d); + let kb = self.k(C::RadicalKernBeforeDegree, st); + let ka = self.k(C::RadicalKernAfterDegree, st); + let raise = self.t.as_ref().map_or(60.0, |t| t.radical_degree_bottom_raise_percent) / 100.0 * (ga + gd); + let y = (dy - gd) + raise; + let dw = db.w; + out.put(db, kb, y); + x0 = (kb + dw + ka).max(0.0); + } + let bw = b.w; + out.put(sign, x0, 0.0); + out.rect(x0 + gw, top - t, bw, t, self.color); + out.put(b, x0 + gw, 0.0); + out.w = x0 + gw + bw; + out.a = out.a.max(top + self.k(C::RadicalExtraAscender, st)); + out + } + + fn accent(&mut self, chr: char, e: &[MNode], st: St, d: usize) -> Bx { + let b = self.sub(0, e, st.cramp(), d); + let size = self.size(st); + let color = self.color; + // Overline-style accents are rules. + if matches!(chr, '\u{305}' | '\u{AF}' | '\u{304}' | '\u{203E}') { + let (w, a) = (b.w, b.a); + let mut out = Bx { w, ..Default::default() }; + let (f, l) = (b.first, b.last); + out.put(b, 0.0, 0.0); + let t = self.k(C::OverbarRuleThickness, st).max(0.4); + let y = a + self.k(C::OverbarVerticalGap, st); + out.rect(0.0, y, w, t, color); + out.first = f; + out.last = l; + return out; + } + let gid = self.face.glyph_for(chr); + // Combining marks have no advance: compare the base with the accent's ink. + let ink = glyph_bounds(&self.face, gid).map(|(x0, _, x1, _)| (x1 - x0) * size / self.upem).unwrap_or(0.0); + let narrow = gid != 0 && b.w <= ink * 1.2; + let mut acc = if wide_accent(chr) && gid != 0 && !narrow { + self.stretch_h(gid, b.w, size).unwrap_or_else(|| self.glyph_id(gid, size, color)) + } else if gid != 0 { + self.glyph_id(gid, size, color) + } else { + self.glyphs(self.face, &chr.to_string(), size, 0, color, (false, false)) + }; + acc.glyph = None; + // Horizontal: the base's attachment point under the accent's. + let base_x = b.glyph.map(|(_, _, ax)| ax).unwrap_or(b.w / 2.0); + let acc_x = { + let single = acc.items.first().and_then(|it| match it { + MItem::Glyphs { glyphs, .. } if glyphs.len() == 1 => glyphs.first().map(|g| g.0), + _ => None, + }); + let k = size / self.upem; + match single { + Some(g) => match self.t.as_ref().and_then(|t| t.top_accent.get(&g).copied()) { + Some(v) => v * k, + None => glyph_bounds(&self.face, g).map(|(x0, _, x1, _)| (x0 + x1) / 2.0 * k).unwrap_or(acc.w / 2.0), + }, + None => acc.w / 2.0, + } + }; + // Vertical: accents sit on an x-height base; taller bases push them up. + let abh = self.k(C::AccentBaseHeight, st); + let raise = (b.a - abh).max(0.0); + let (f, l, w, ic) = (b.first, b.last, b.w, b.ic); + let glyph = b.glyph; + let mut out = Bx { w, ..Default::default() }; + out.put(b, 0.0, 0.0); + let mut x = base_x - acc_x; + if x < 0.0 && acc.w > w { + x = (w - acc.w) / 2.0; + } + out.put(acc, x, raise); + out.first = f; + out.last = l; + out.ic = ic; + out.glyph = glyph.filter(|_| false); + out + } + + /// Rows stacked and centred on the math axis; `gap`: least space between rows, `min_skip`: + /// least baseline-to-baseline distance. + fn stack_rows(&self, rows: Vec<(Bx, f32)>, st: St, gap: f32, min_skip: f32) -> (Bx, Vec) { + // (row box, x) → baselines going down. + let mut ys = Vec::with_capacity(rows.len()); + let mut y = 0.0f32; + let mut prev_d: Option = None; + for (b, _) in &rows { + if let Some(pd) = prev_d { + y -= (pd + gap + b.a).max(min_skip); + } + ys.push(y); + prev_d = Some(b.d); + } + let top = rows.first().map_or(0.0, |(b, _)| b.a); + let bottom = rows.last().map_or(0.0, |(b, _)| b.d) - ys.last().copied().unwrap_or(0.0); + // Centre on the axis. + let shift = self.axis(st) + (bottom - top) / 2.0; + let mut out = Bx::default(); + let mut bases = Vec::with_capacity(ys.len()); + for ((b, x), yy) in rows.into_iter().zip(ys) { + out.w = out.w.max(x + b.w); + out.put(b, x, yy + shift); + bases.push(yy + shift); + } + (out, bases) + } + + fn eq_arr(&mut self, rows: &[Arg], st: St, d: usize) -> Bx { + self.eq_rows(rows, st, d).0 + } + + /// Equation-array rows aligned at `&`, and each row's baseline. + fn eq_rows(&mut self, rows: &[Arg], st: St, d: usize) -> (Bx, Vec) { + let em = self.em(st); + let me = self.node_stack.last().copied().unwrap_or(0); + // Each row: segments split at `&`. + let mut segs_rows: Vec> = Vec::new(); + let mut row_paths = Vec::new(); + for (ri, r) in rows.iter().take(1000).enumerate() { + self.path.push((me, ri)); + row_paths.push(self.path.clone()); + let atoms = if r.is_empty() { vec![(self.placeholder(st), 0)] } else { self.row(r, st, d, 0) }; + self.path.pop(); + let n = atoms.iter().map(|(_, s)| *s + 1).max().unwrap_or(1).min(64); + let all: Vec = atoms.iter().map(|(b, _)| b.clone()).collect(); + let gaps = gaps_between(&all, st, em); + let mut segs: Vec> = vec![Vec::new(); n]; + for (i, (mut b, s)) in atoms.into_iter().enumerate() { + // Keep the spacing before an atom with the atom. + let g = gaps.get(i).copied().unwrap_or(0.0); + if g > 0.0 { + let mut wrapped = Bx { w: g + b.w, a: b.a, d: b.d, first: b.first, last: b.last, ..Default::default() }; + wrapped.put(b, g, 0.0); + b = wrapped; + } + if let Some(seg) = segs.get_mut(s.min(n - 1)) { + seg.push(b); + } + } + segs_rows.push(segs.into_iter().map(hcat).collect()); + } + let ncols = segs_rows.iter().map(|r| r.len()).max().unwrap_or(1); + let mut col_w = vec![0.0f32; ncols]; + for r in &segs_rows { + for (i, b) in r.iter().enumerate() { + if let Some(c) = col_w.get_mut(i) { + *c = c.max(b.w); + } + } + } + let total: f32 = col_w.iter().sum(); + let mut placed = Vec::new(); + for (r, path) in segs_rows.into_iter().zip(row_paths) { + let mut row = Bx::default(); + let mut x = 0.0f32; + let single = r.len() == 1 && ncols > 1; + for (i, b) in r.into_iter().enumerate() { + let cw = col_w.get(i).copied().unwrap_or(0.0); + let bw = b.w; + let dx = if ncols == 1 { + 0.0 + } else if single { + // A row without `&` aligns with the first column's right edge. + cw - bw + } else if i % 2 == 0 { + cw - bw + } else { + 0.0 + }; + row.put(b, x + dx, 0.0); + x += cw; + row.w = row.w.max(x); + } + let rx = if ncols == 1 { (total - row.w) / 2.0 } else { 0.0 }; + finalize(&mut row, &path, em); + placed.push((row, rx.max(0.0))); + } + let (mut out, bases) = self.stack_rows(placed, st, em * 0.1, em * 1.1); + out.w = out.w.max(total); + (out, bases) + } + + /// A display equation with numbers (`#`): the equation centred on the line, each row's + /// number at the right margin. Automatic numbers (`#` alone) count up from `counter`. + fn numbered(&mut self, rows: &[Arg], st: St, avail: f32, counter: &mut u32) -> Bx { + let em = self.em(st); + let parts: Vec<(Arg, Arg, usize)> = + rows.iter().take(1000).map(|r| split_number(r).unwrap_or_else(|| (r.clone(), Vec::new(), usize::MAX))).collect(); + self.node_stack.push(0); + let eq_rows: Vec = parts.iter().map(|(e, _, _)| e.clone()).collect(); + let (eq, bases) = self.eq_rows(&eq_rows, st, 1); + // Numbers. + let mut nums = Vec::new(); + for (ri, (_, num, hash)) in parts.iter().enumerate() { + if *hash == usize::MAX { + nums.push(None); + continue; + } + self.path.push((0, ri)); + let path = self.path.clone(); + // Every numbered equation counts; `#` alone shows the count. + *counter = counter.saturating_add(1); + let b = if num.is_empty() { + let r = MRun { text: format!("({counter})"), sty: Some(MSty::Plain), ..Default::default() }; + let mut b = compose( + self.run_atoms(&r, St { upright: true, ..st }, 0, &path) + .into_iter() + .map(|p| match p { + RunPart::Atom(b) | RunPart::Align(b) => b, + }) + .collect(), + st, + em, + ); + b.slots.clear(); + b.slot(&path, hash + 1, 0.0); + b + } else { + let atoms = self.row(num, st, 2, hash + 1); + let mut b = compose(atoms.into_iter().map(|(b, _)| b).collect(), st, em); + b.slot(&path, hash + 1, 0.0); + b + }; + let mut b = b; + finalize(&mut b, &path, em); + self.path.pop(); + nums.push(Some(b)); + } + self.node_stack.pop(); + let num_w = nums.iter().flatten().map(|b| b.w).fold(0.0f32, f32::max); + let gap = em; + let mut x_eq = ((avail - eq.w) / 2.0).max(0.0); + if x_eq + eq.w + gap + num_w > avail { + x_eq = (avail - num_w - gap - eq.w).max(0.0); + } + let width = avail.max(x_eq + eq.w + gap + num_w); + let mut out = Bx { w: width, ..Default::default() }; + out.put(eq, x_eq, 0.0); + for (b, y) in nums.into_iter().zip(bases) { + if let Some(b) = b { + let bw = b.w; + out.put(b, width - bw, y); + } + } + out + } + + fn matrix(&mut self, rows: &[Vec], col_jc: &[ColJc], st: St, d: usize) -> Bx { + let em = self.em(st); + let mut cells: Vec> = Vec::new(); + let mut flat = 0usize; + for r in rows.iter().take(1000) { + let mut row = Vec::new(); + for (ci, c) in r.iter().enumerate() { + if ci < 64 { + row.push(self.sub(flat, c, st, d)); + } + flat += 1; + } + cells.push(row); + } + let ncols = cells.iter().map(|r| r.len()).max().unwrap_or(0); + let mut col_w = vec![0.0f32; ncols]; + for r in &cells { + for (i, b) in r.iter().enumerate() { + if let Some(c) = col_w.get_mut(i) { + *c = c.max(b.w); + } + } + } + let gap = em * 0.8; + let mut placed = Vec::new(); + for r in cells { + let mut row = Bx::default(); + let mut x = 0.0f32; + for (i, b) in r.into_iter().enumerate() { + let cw = col_w.get(i).copied().unwrap_or(0.0); + let bw = b.w; + let dx = match col_jc.get(i).copied().unwrap_or_default() { + ColJc::Center => (cw - bw) / 2.0, + ColJc::Left => 0.0, + ColJc::Right => cw - bw, + }; + row.put(b, x + dx, 0.0); + x += cw + gap; + } + row.w = (x - gap).max(0.0); + placed.push((row, 0.0)); + } + let (mut out, _) = self.stack_rows(placed, st, em * 0.25, em * 1.2); + out.w = col_w.iter().sum::() + gap * ncols.saturating_sub(1) as f32; + out + } +} + +enum RunPart { + Atom(Bx), + /// An `&` alignment mark (a zero-width atom holding its caret slots). + Align(Bx), +} + +/// Give the caret slots of argument `path` in `b` the argument's height. +fn finalize(b: &mut Bx, path: &[(usize, usize)], em: f32) { + let (a, d) = (b.a.max(em * 0.72), b.d.max(em * 0.22)); + for s in &mut b.slots { + if s.path == path && s.a <= 0.0 && s.d <= 0.0 { + s.a = a - s.y; + s.d = d + s.y; + } + } +} + +/// Spacing before each atom (TeX rules: a binary operator after nothing, an operator, a +/// relation, an opening bracket or punctuation is ordinary, as is one before a relation, +/// closing bracket or punctuation). +fn gaps_between(atoms: &[Bx], st: St, em: f32) -> Vec { + let mut cls: Vec<(Cls, Cls)> = atoms.iter().map(|b| (b.first, b.last)).collect(); + let solid: Vec = (0..atoms.len()).filter(|i| atoms.get(*i).is_some_and(|b| !b.transparent)).collect(); + for (k, &i) in solid.iter().enumerate() { + let prev = k.checked_sub(1).and_then(|p| solid.get(p)).and_then(|p| cls.get(*p)).map(|c| c.1); + let next = solid.get(k + 1).and_then(|n| cls.get(*n)).map(|c| c.0); + if let Some(c) = cls.get_mut(i) { + if c.0 == Cls::Bin && matches!(prev, None | Some(Cls::Bin | Cls::Op | Cls::Rel | Cls::Open | Cls::Punct)) { + c.0 = Cls::Ord; + if c.1 == Cls::Bin { + c.1 = Cls::Ord; + } + } + if c.1 == Cls::Bin && matches!(next, None | Some(Cls::Rel | Cls::Close | Cls::Punct)) { + c.1 = Cls::Ord; + if c.0 == Cls::Bin { + c.0 = Cls::Ord; + } + } + } + } + let mut gaps = vec![0.0f32; atoms.len()]; + for k in 1..solid.len() { + let (Some(&p), Some(&i)) = (solid.get(k - 1), solid.get(k)) else { continue }; + let (Some(l), Some(r)) = (cls.get(p), cls.get(i)) else { continue }; + if let Some(g) = gaps.get_mut(i) { + *g = spacing_mu(l.1, r.0, st) * em / 18.0; + } + } + gaps +} + +/// Atoms side by side with inter-atom spacing. +fn compose(mut atoms: Vec, st: St, em: f32) -> Bx { + if atoms.len() == 1 + && let Some(b) = atoms.pop() + { + return b; + } + let gaps = gaps_between(&atoms, st, em); + let first = atoms.iter().find(|b| !b.transparent).map(|b| b.first).unwrap_or_default(); + let last = atoms.iter().rev().find(|b| !b.transparent).map(|b| b.last).unwrap_or_default(); + let ic = atoms.last().map(|b| b.ic).unwrap_or(0.0); + let mut out = Bx::default(); + let mut x = 0.0f32; + for (b, g) in atoms.into_iter().zip(gaps) { + x += g; + let w = b.w; + out.put(b, x, 0.0); + x += w; + } + out.w = x; + out.first = first; + out.last = last; + out.ic = ic; + out +} + +/// Boxes side by side, no spacing. +fn hcat(parts: Vec) -> Bx { + let first = parts.first().map(|b| b.first).unwrap_or_default(); + let last = parts.last().map(|b| b.last).unwrap_or_default(); + let mut out = Bx::default(); + let mut x = 0.0f32; + for b in parts { + let w = b.w; + out.put(b, x, 0.0); + x += w; + } + out.w = x; + out.first = first; + out.last = last; + out +} + +#[cfg(test)] +mod tests { + use super::*; + use wordcraft_doc::math::Math; + + fn rc() -> ResolvedChar { + wordcraft_doc::styles::StyleSheet::default().resolve_char(None, &Default::default()) + } + + fn lay(lin: &str, display: bool) -> MathLayout { + layout_equation(&Math::default(), lin, &rc(), display, 400.0, &mut 0) + } + + #[test] + fn fraction_is_stacked_with_a_rule() { + let flat = lay("ab", true); + let f = lay("a/b", true); + assert!(f.ascent > flat.ascent + 2.0 && f.descent > flat.descent + 2.0, "{f:?}"); + assert!(f.items.iter().any(|i| matches!(i, MItem::Rect { .. })), "fraction rule"); + assert!(f.width < flat.width * 1.2, "stacked, not side by side"); + } + + #[test] + fn superscript_is_raised_and_smaller() { + let m = lay("x^2", false); + let sizes: Vec<(f32, f32)> = m + .items + .iter() + .filter_map(|i| match i { + MItem::Glyphs { size, glyphs, .. } => glyphs.first().map(|g| (*size, g.2)), + _ => None, + }) + .collect(); + assert_eq!(sizes.len(), 2, "{m:?}"); + assert!(sizes[1].0 < sizes[0].0 && sizes[1].1 > 2.0, "{sizes:?}"); + } + + #[test] + fn operators_get_spaces_and_unary_minus_does_not() { + let tight = lay("ab", false); + let spaced = lay("a+b", false); + let unary = lay("−b", false); + let plus_w = lay("+", false).width; + assert!(spaced.width > tight.width + plus_w + 2.0, "medium spaces around +"); + assert!(unary.width < lay("b", false).width + plus_w + 1.0, "no space after a unary minus"); + } + + #[test] + fn display_operators_are_big_with_limits_over_and_under() { + let d = lay("∑_(i=1)^n▒i", true); + let t = lay("∑_(i=1)^n▒i", false); + assert!(d.ascent + d.descent > t.ascent + t.descent, "display sum taller"); + assert!(d.width < t.width + 4.0, "limits stacked in display"); + } + + #[test] + fn delimiters_grow_around_tall_content() { + let small = lay("(x)", true); + let big = lay("((a)/(b))", true); + let h = |m: &MathLayout| m.ascent + m.descent; + assert!(h(&big) > h(&small) * 1.4, "{} vs {}", h(&big), h(&small)); + } + + #[test] + fn every_structure_lays_out() { + use wordcraft_doc::math::*; + let x = || vec![MNode::Run(MRun::new("x"))]; + let nodes = vec![ + MNode::Acc { chr: '\u{302}', e: x() }, + MNode::Acc { chr: '\u{305}', e: x() }, + MNode::Bar { top: false, e: x() }, + MNode::BorderBox { hide: [false; 4], strike: [true; 4], e: x() }, + MNode::Boxed { e: x() }, + MNode::GroupChr { chr: '⏟', top: false, e: x() }, + MNode::EqArr { rows: vec![vec![MNode::Run(MRun::new("x&=1"))], vec![MNode::Run(MRun::new("y&=22"))]] }, + MNode::Matrix { rows: vec![vec![x(), x()], vec![x(), Vec::new()]], col_jc: vec![] }, + MNode::Phant { show: false, zero_wid: false, zero_asc: false, zero_desc: true, e: x() }, + MNode::Lim { upper: false, e: vec![MNode::Run(MRun::plain("lim"))], lim: x() }, + MNode::Rad { deg: x(), deg_hide: false, e: x() }, + MNode::Script { kind: ScriptKind::Pre, base: x(), sub: x(), sup: x() }, + MNode::Frac { kind: FracKind::Skewed, num: x(), den: x() }, + MNode::Frac { kind: FracKind::Linear, num: x(), den: x() }, + MNode::Frac { kind: FracKind::NoBar, num: x(), den: x() }, + MNode::Delim { beg: None, end: Some('|'), sep: Some('|'), grow: true, shp_match: true, elems: vec![x(), x()] }, + MNode::Run(MRun { text: "text".into(), nor: true, ..Default::default() }), + MNode::Run(MRun { text: "R".into(), scr: MScr::DoubleStruck, ..Default::default() }), + ]; + for n in nodes { + let m = layout_equation(&Math { nodes: vec![n.clone()], ..Default::default() }, "", &rc(), true, 400.0, &mut 0); + assert!(m.width > 0.0 && m.ascent + m.descent > 0.0, "{n:?} → {m:?}"); + for it in &m.items { + if let MItem::Glyphs { glyphs, .. } = it { + assert!(glyphs.iter().all(|g| g.1.is_finite() && g.2.is_finite())); + } + } + } + } + + #[test] + fn caret_slots_cover_every_position() { + use wordcraft_doc::math_edit::{MathPos, move_right, units}; + let math = Math { nodes: wordcraft_doc::math::parse_linear("x=(-b±√(b^2-4ac))/2a+■(a&b@c&d)"), ..Default::default() }; + let m = layout_equation(&math, "", &rc(), true, 400.0, &mut 0); + // Walking the caret through the equation, every position has a slot. + let mut p = MathPos::default(); + let mut seen = 0; + loop { + let s = m.slots.iter().find(|s| s.path == p.path && s.off == p.off); + assert!(s.is_some(), "no slot for {p:?}"); + seen += 1; + match move_right(&math.nodes, &p) { + Some(n) => p = n, + None => break, + } + assert!(seen < 500); + } + assert_eq!(p.off, units(&math.nodes)); + // Hit testing finds the denominator below the bar. + let den = m.slots.iter().find(|s| s.path == vec![(1, 1)]).unwrap(); + let hit = m.hit(den.x + 1.0, den.y).unwrap(); + assert_eq!(hit.path, vec![(1, 1)]); + } + + #[test] + fn empty_slots_show_placeholders_on_screen_only() { + let math = Math { nodes: wordcraft_doc::math::parse_linear("⬚/⬚"), ..Default::default() }; + let m = layout_equation(&math, "", &rc(), true, 400.0, &mut 0); + assert!(m.items.iter().all(|i| matches!(i, MItem::ScreenOnly(_) | MItem::Rect { .. })), "{:?}", m.items); + assert!(m.slots.iter().any(|s| s.path == vec![(0, 0)] && s.off == 0)); + let empty = layout_equation(&Math::default(), "", &rc(), true, 400.0, &mut 0); + assert!(empty.width > 10.0 && empty.items.iter().all(|i| matches!(i, MItem::ScreenOnly(_)))); + } + + #[test] + fn numbered_equations_fill_the_line_with_the_number_at_the_right() { + let math = Math { nodes: wordcraft_doc::math::parse_linear("E=mc^2#(1)"), ..Default::default() }; + let m = layout_equation(&math, "", &rc(), true, 400.0, &mut 0); + assert!((m.width - 400.0).abs() < 0.01, "{}", m.width); + let xs: Vec = + m.items.iter().filter_map(|i| if let MItem::Glyphs { glyphs, .. } = i { glyphs.first().map(|g| g.1) } else { None }).collect(); + let max = xs.iter().cloned().fold(0.0f32, f32::max); + assert!(max > 350.0, "number at the right margin: {xs:?}"); + let min = xs.iter().cloned().fold(f32::MAX, f32::min); + assert!(min > 120.0 && min < 220.0, "equation centred: {xs:?}"); + // Automatic numbers count. + let auto = Math { nodes: wordcraft_doc::math::parse_linear("a=b#"), ..Default::default() }; + let mut n = 4; + let _ = layout_equation(&auto, "", &rc(), true, 400.0, &mut n); + assert_eq!(n, 5); + assert_eq!(auto_numbers(&auto, true), 1); + } + + #[test] + fn math_alphabets() { + assert_eq!(math_alnum('x', false, true, MScr::Roman), '𝑥'); + assert_eq!(math_alnum('h', false, true, MScr::Roman), 'ℎ'); + assert_eq!(math_alnum('R', false, false, MScr::DoubleStruck), 'ℝ'); + assert_eq!(math_alnum('α', false, true, MScr::Roman), '𝛼'); + assert_eq!(math_alnum('2', false, true, MScr::Roman), '2'); + assert_eq!(math_alnum('A', true, false, MScr::Roman), '𝐀'); + } + + #[test] + fn hostile_trees_never_panic() { + use wordcraft_doc::math::*; + // Deep nesting. + let mut n = vec![MNode::Run(MRun::new("x"))]; + for _ in 0..500 { + n = vec![MNode::Frac { kind: FracKind::Bar, num: n.clone(), den: Vec::new() }]; + } + let _ = layout_equation(&Math { nodes: n, ..Default::default() }, "", &rc(), true, 400.0, &mut 0); + // Empty everything. + let empty = vec![ + MNode::Nary { chr: '\0', lim_loc: None, grow: true, sub_hide: false, sup_hide: false, sub: vec![], sup: vec![], e: vec![] }, + MNode::Delim { beg: Some('\u{10FFFF}'), end: None, sep: None, grow: true, shp_match: false, elems: vec![] }, + MNode::Matrix { rows: vec![vec![]], col_jc: vec![] }, + MNode::EqArr { rows: vec![] }, + MNode::Run(MRun { text: String::new(), size: Some(f32::NAN), ..Default::default() }), + ]; + let m = layout_equation(&Math { nodes: empty, ..Default::default() }, "", &rc(), true, 400.0, &mut 0); + assert!(m.width.is_finite() && m.ascent.is_finite()); + } +} diff --git a/crates/layout/src/para.rs b/crates/layout/src/para.rs index 6e0c785..3e3edf4 100644 --- a/crates/layout/src/para.rs +++ b/crates/layout/src/para.rs @@ -4,6 +4,7 @@ use std::collections::VecDeque; use std::sync::Arc; +use wordcraft_doc::math::MathJc; use wordcraft_doc::numbering::{Level, LevelSuffix}; use wordcraft_doc::para::{COLUMN_BREAK, InlineObject, LINE_BREAK, NoteKind, OBJ, PAGE_BREAK, SOFT_HYPHEN}; use wordcraft_doc::props::{Align, CharProps, LineSpacing, ParaProps, TabAlign, TabLeader, TabStop}; @@ -67,6 +68,8 @@ pub struct Cluster { pub break_after: bool, /// Height above the baseline for objects (images), points. pub obj_h: f32, + /// Depth below the baseline for objects (equations), points. + pub obj_d: f32, /// The cluster is a decimal separator (decimal tabs align on it). pub dot: bool, } @@ -142,6 +145,10 @@ pub struct ParaLayout { pub drop_cap: Option<(usize, u8, f32)>, /// Clusters after which the line may break with a hyphen (soft hyphens, auto hyphenation), sorted. pub hyph_after: Vec, + /// Laid-out equations by object index. + pub maths: Vec<(usize, Arc)>, + /// Display equations (on lines of their own): cluster index and justification. + pub displays: Vec<(usize, wordcraft_doc::math::MathJc)>, /// Byte ranges of the text laid out as nothing ([`left_out`]): hidden text that is not shown, /// tracked deletions in the final text. Resolved as laid out, table formatting included. pub left_out: Vec>, @@ -175,6 +182,8 @@ pub struct ParaEnv<'a> { /// Areas text must flow around (floating objects), relative to the paragraph: x from the /// column's left edge, y from the top of the first line. pub exclusions: &'a [Exclusion], + /// Automatic equation numbers used before this paragraph. + pub eq_number: u32, } /// An area text wraps around. @@ -321,6 +330,7 @@ impl<'a> Builder<'a> { g1: if kind == ClKind::Marker { g0 } else { g1 }, break_after: false, obj_h: 0.0, + obj_d: 0.0, dot: s == "." || s == ",", }); } @@ -345,6 +355,7 @@ impl<'a> Builder<'a> { g1: g, break_after: false, obj_h: 0.0, + obj_d: 0.0, dot: false, }); return; @@ -361,7 +372,7 @@ impl<'a> Builder<'a> { let style = first.style; let g1 = added.last().map(|c| c.g1).unwrap_or(g0); self.shown.push((self.clusters.len(), text.to_string())); - self.clusters.push(Cluster { start, end, adv: x, kind: ClKind::Text, style, g0, g1, break_after: false, obj_h: 0.0, dot: false }); + self.clusters.push(Cluster { start, end, adv: x, kind: ClKind::Text, style, g0, g1, break_after: false, obj_h: 0.0, obj_d: 0.0, dot: false }); } } @@ -400,6 +411,9 @@ pub fn layout_para(p: &Paragraph, env: &ParaEnv) -> ParaLayout { .filter(|c| !matches!(*c, '\t' | LINE_BREAK | PAGE_BREAK | COLUMN_BREAK | OBJ | '\r' | ' ')) .filter(|c| p.text.len() > c.len_utf8()); let mut drop_cap = None; + let mut maths = Vec::new(); + let mut displays = Vec::new(); + let mut eq_counter = env.eq_number; // The byte ranges of runs left out of the layout, in order, adjacent runs merged. let mut left: Vec> = Vec::new(); for (range, props) in p.run_ranges() { @@ -438,6 +452,7 @@ pub fn layout_para(p: &Paragraph, env: &ParaEnv) -> ParaLayout { g1: g, break_after: false, obj_h: 0.0, + obj_d: 0.0, dot: false, }); } @@ -479,7 +494,7 @@ pub fn layout_para(p: &Paragraph, env: &ParaEnv) -> ParaLayout { let si = b.style(&rc, None, false); let g = b.glyphs.len() as u32; let push = |b: &mut Builder, kind: ClKind, adv: f32, h: f32| { - b.clusters.push(Cluster { start, end, adv, kind, style: si, g0: g, g1: g, break_after: false, obj_h: h, dot: false }) + b.clusters.push(Cluster { start, end, adv, kind, style: si, g0: g, g1: g, break_after: false, obj_h: h, obj_d: 0.0, dot: false }) }; match c { '\t' => push(&mut b, ClKind::Tab, 0.0, 0.0), @@ -522,11 +537,29 @@ pub fn layout_para(p: &Paragraph, env: &ParaEnv) -> ParaLayout { b.shape_atomic(&num, start, end, &Arc::new(sup)); notes.push((b.clusters.len().saturating_sub(1), *id)); } - Some(InlineObject::Equation { linear, .. }) => { - let mut eq = (*rc).clone(); - eq.italic = true; - eq.font = "Cambria Math".into(); - b.shape_atomic(linear, start, end, &Arc::new(eq)); + Some(InlineObject::Equation { linear, display, math }) => { + let avail = (env.width - rp.indent_left.max(0.0) - rp.indent_right.max(0.0)).max(12.0); + let ml = crate::math::layout_equation(math, linear, &rc, *display, avail, &mut eq_counter); + let st = b.styles.get(si as usize); + // The line is at least as tall as the text around it. + let (a, d) = st.map(|s| (s.ascent, s.descent)).unwrap_or((0.0, 0.0)); + b.clusters.push(Cluster { + start, + end, + adv: ml.width, + kind: ClKind::Object(k), + style: si, + g0: g, + g1: g, + break_after: false, + obj_h: ml.ascent.max(a).max(0.01), + obj_d: ml.descent.max(d), + dot: false, + }); + if *display { + displays.push((b.clusters.len() - 1, math.jc)); + } + maths.push((k, Arc::new(ml))); } Some(InlineObject::Opaque { text, .. }) => b.shape_atomic(text, start, end, &rc), _ => push(&mut b, ClKind::Marker, 0.0, 0.0), @@ -627,6 +660,8 @@ pub fn layout_para(p: &Paragraph, env: &ParaEnv) -> ParaLayout { issues: if env.proofing { proof_issues(p) } else { Vec::new() }, drop_cap, hyph_after: Vec::new(), + maths, + displays, left_out: Vec::new(), }; pl.hyph_after = hyphenation_points(&text, to_para, &pl, env.doc.settings.auto_hyphenation && !pl.rp.suppress_hyphens); @@ -791,6 +826,12 @@ fn break_lines(pl: &mut ParaLayout, env: &ParaEnv, mark_style: u16, suffix: Opti } _ => {} } + // A display equation sits on a line of its own. + let is_display = pl.displays.iter().any(|(ci, _)| *ci == j); + if is_display && (c0..j).any(|k| pl.clusters.get(k).is_some_and(|c| c.kind != ClKind::Marker)) { + end = LineEnd::Wrap; + break; + } // Text after a right/centre tab grows leftwards into the tab's space first. let absorbs = pending_tab.is_some_and(|(tj, stop, _)| { let room = pl.clusters.get(tj).map(|t| t.adv).unwrap_or(0.0); @@ -869,6 +910,16 @@ fn break_lines(pl: &mut ParaLayout, env: &ParaEnv, mark_style: u16, suffix: Opti x += delta; } } + if is_display { + // Markers right after it stay on its line; anything else starts a new one. + j += 1; + while j < n && pl.clusters.get(j).is_some_and(|c| c.kind == ClKind::Marker) { + xs.push(x); + j += 1; + } + end = if j >= n { LineEnd::Para } else { LineEnd::Wrap }; + break; + } if c.break_after { let hyph = pl.hyph_after.binary_search(&(j as u32)).is_ok(); if !hyph { @@ -922,8 +973,13 @@ fn break_lines(pl: &mut ParaLayout, env: &ParaEnv, mark_style: u16, suffix: Opti // A picture sits on the baseline of its run: the line keeps that font's ascent // and descent, and grows above the baseline to fit a taller picture. let (a, d) = if matches!(c.kind, ClKind::Object(_)) && c.obj_h > 0.0 { - obj_asc = obj_asc.max(c.obj_h); - (st.ascent, st.descent) + if pl.maths.iter().any(|(k, _)| c.kind == ClKind::Object(*k)) { + // An equation is text: its ascent and descent count like the text's. + (c.obj_h, c.obj_d) + } else { + obj_asc = obj_asc.max(c.obj_h); + (st.ascent, st.descent) + } } else { (st.ascent + st.shift.max(0.0), st.descent + (-st.shift).max(0.0)) }; @@ -972,9 +1028,13 @@ fn break_lines(pl: &mut ParaLayout, env: &ParaEnv, mark_style: u16, suffix: Opti let slack = right_edge - content_end; let last_tab = (c0..c1).rev().find(|k| pl.clusters.get(*k).is_some_and(|c| c.kind == ClKind::Tab)); let align_from = last_tab.map(|k| k + 1).unwrap_or(c0); - let shift = match rp.align { - Align::Center => slack / 2.0, - Align::Right => slack, + let display = pl.displays.iter().find(|(ci, _)| *ci >= c0 && *ci < c1).map(|d| d.1); + let shift = match (display, rp.align) { + (Some(MathJc::Left), _) => 0.0, + (Some(MathJc::Right), _) => slack, + (Some(_), _) => slack / 2.0, + (None, Align::Center) => slack / 2.0, + (None, Align::Right) => slack, _ => 0.0, }; if shift > 0.0 && slack > 0.0 { @@ -982,9 +1042,9 @@ fn break_lines(pl: &mut ParaLayout, env: &ParaEnv, mark_style: u16, suffix: Opti *v += shift; } } - let justify = (rp.align == Align::Justify && end == LineEnd::Wrap) || rp.align == Align::Distribute; + let justify = display.is_none() && ((rp.align == Align::Justify && end == LineEnd::Wrap) || rp.align == Align::Distribute); // A line that only fits with shrunk spaces is shrunk, the last line of the paragraph too. - let shrink = shrink_spaces && slack < 0.0 && last_tab.is_none(); + let shrink = display.is_none() && shrink_spaces && slack < 0.0 && last_tab.is_none(); if shrink { shrink_line_spaces(pl, &mut xs, c0, c1, content_end, slack); } else if justify && slack > 0.0 { diff --git a/crates/pdf/src/lib.rs b/crates/pdf/src/lib.rs index 25285ac..8a718fd 100644 --- a/crates/pdf/src/lib.rs +++ b/crates/pdf/src/lib.rs @@ -523,7 +523,7 @@ impl Exporter<'_> { { self.tagged(s, Role::Artifact(ArtifactType::Watermark), None, |me, s| me.watermark(s, &wm, w, h)); } - let dopts = DisplayOptions { marks: false, dim_header: false, dim_body: false, markup: self.opts.include_markup }; + let dopts = DisplayOptions { marks: false, dim_header: false, dim_body: false, markup: self.opts.include_markup, placeholders: false }; for it in page.header.iter().chain(page.footer.iter()) { let draws = self.draws(page, it, &dopts); self.tagged(s, Role::Artifact(ArtifactType::Other), None, |me, s| { diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index bbf503b..aa7c92f 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -262,6 +262,7 @@ pub fn show(app: &mut WordApp, ui: &mut Ui) { if !fresh && (rendered < 2 || !app.canvas.textures.contains_key(&i) && rendered < 4) { let mut opts = screen_render_options(); opts.display.marks = app.session.view.marks; + opts.display.placeholders = true; opts.display.markup = app.session.view.show_markup; opts.dark = dark_page; opts.dark_paper = dark_paper; @@ -355,7 +356,43 @@ pub fn show(app: &mut WordApp, ui: &mut Ui) { } // Caret. let focused = resp.has_focus() || app.canvas.focused; - if let Some(c) = layout.caret_on(&app.session.sel.focus, app.session.page_hint) + // Editing an equation: shade it and draw the caret inside it. + let mut math_caret = false; + if let Some(m) = app.session.math.clone() + && let Some((pi, ex, base, ml)) = layout.equation_geom(&m.at, app.session.page_hint) + && let Some(pr) = rects.get(pi) + { + math_caret = true; + let s = geo.scale; + let zone = Rect::from_min_max( + pos2(pr.min.x + ex * s - 2.0, pr.min.y + (base - ml.ascent) * s - 2.0), + pos2(pr.min.x + (ex + ml.width) * s + 2.0, pr.min.y + (base + ml.descent) * s + 2.0), + ); + painter.rect_filled(zone, 2.0, Color32::from_black_alpha(16)); + if let Some(slot) = ml.slot(&m.pos.path, m.pos.off) { + let x = (pr.min.x + (ex + slot.x) * s).round() + 0.5; + let y0 = pr.min.y + (base - slot.y - slot.a) * s; + let y1 = pr.min.y + (base - slot.y + slot.d) * s; + let since = crate::now_ms() - app.canvas.caret_visible_since; + if ((since / 530.0) as u64).is_multiple_of(2) && focused { + painter.line_segment([pos2(x, y0), pos2(x, y1)], Stroke::new(1.5, t.caret)); + } + if focused { + ui.ctx().request_repaint_after(std::time::Duration::from_millis(530 - (since as u64 % 530))); + let cr = Rect::from_min_max(pos2(x, y0), pos2(x + 1.0, y1)); + ui.ctx().output_mut(|o| { + o.ime = Some(egui::output::IMEOutput { + rect: cr, + cursor_rect: cr, + purpose: Default::default(), + should_interrupt_composition: false, + }); + }); + } + } + } + if !math_caret + && let Some(c) = layout.caret_on(&app.session.sel.focus, app.session.page_hint) && let Some(pr) = rects.get(c.page) { let scale = layout.pages.get(c.page).map_or(geo.scale, |page| page_screen_scale(*pr, page, geo.scale)); @@ -400,9 +437,26 @@ pub fn show(app: &mut WordApp, ui: &mut Ui) { app.canvas.focused = resp.has_focus(); mouse(app, ui, &resp, &rects, &layout, geo.scale); // Right-click: move the caret there (unless inside the selection), then the context menu. + // Right-click in an equation puts the caret there and opens the equation menu. if resp.secondary_clicked() && let Some(p) = resp.interact_pointer_pos() && let Some((page, x, y)) = page_at(&rects, &layout, geo.scale, p) + { + match layout.equation_hit(page, x, y, app.session.sel.focus.story) { + Some((at, inner)) => { + app.session.sel = wordcraft_engine::Selection::caret(at); + let _ = app.run("equation.edit", json!({"pos": serde_json::to_value(&inner).unwrap_or_default()})); + } + None if app.session.math.is_some() => { + let _ = app.run("equation.exit", json!({})); + } + None => {} + } + } + if resp.secondary_clicked() + && app.session.math.is_none() + && let Some(p) = resp.interact_pointer_pos() + && let Some((page, x, y)) = page_at(&rects, &layout, geo.scale, p) && let Some(pos) = layout.hit(page, x, y, app.session.sel.focus.story) { let (a, b) = app.session.sel.ordered(); @@ -559,6 +613,10 @@ fn mouse(app: &mut WordApp, ui: &Ui, resp: &egui::Response, rects: &[Rect], layo let Some((page, x, y)) = page_at(rects, layout, scale, p) else { return }; let mods = ui.input(|i| i.modifiers); let story = app.session.sel.focus.story; + // Inside an equation, clicks place the caret in it (and double-clicks don't select words). + if (resp.double_clicked() || resp.triple_clicked()) && layout.equation_hit(page, x, y, story).is_some() { + return; + } // Double-click in the header/footer area edits it; double-click in the body leaves it. if resp.double_clicked() { if let Some((s, header)) = layout.header_footer_at(page, y) @@ -605,6 +663,17 @@ fn mouse(app: &mut WordApp, ui: &Ui, resp: &egui::Response, rects: &[Rect], layo _ => story, } }; + if !mods.shift + && let Some((at, inner)) = layout.equation_hit(page, x, y, story) + { + app.session.sel = wordcraft_engine::Selection::caret(at); + let _ = app.run("equation.edit", json!({"pos": serde_json::to_value(&inner).unwrap_or_default()})); + app.session.page_hint = page; + return; + } + if app.session.math.is_some() { + let _ = app.run("equation.exit", json!({})); + } let Some(pos) = layout.hit(page, x, y, story) else { return }; // Ctrl/⌘+click follows a hyperlink. if mods.command @@ -873,7 +942,51 @@ pub fn pos_from_screen(app: &mut WordApp, p: Pos2) -> Option { app.session.layout().hit(page, x, y, story) } +/// Right-click inside an equation: its structures' actions, display and conversion. +fn equation_menu(app: &mut WordApp, ui: &mut Ui) { + ui.set_min_width(260.0); + let acts = app.session.run("equation.structureActions", &json!({})).ok().and_then(|v| v.get("actions").cloned()).unwrap_or_default(); + let mut last_level = None; + for a in acts.as_array().into_iter().flatten() { + let (Some(action), Some(label), Some(level)) = + (a.get("action").and_then(|v| v.as_str()), a.get("label").and_then(|v| v.as_str()), a.get("level").and_then(|v| v.as_u64())) + else { + continue; + }; + if last_level.is_some_and(|l| l != level) { + ui.separator(); + } + last_level = Some(level); + if ui.button(tl!(label)).clicked() { + let _ = app.run("equation.structure", json!({"action": action, "level": level})); + app.canvas.want_focus = true; + ui.close(); + } + } + if last_level.is_some() { + ui.separator(); + } + let display = app.session.run("equation.get", &json!({})).ok().and_then(|v| v.get("display").and_then(|d| d.as_bool())).unwrap_or(false); + let items: [(&str, &str, serde_json::Value); 5] = [ + (if display { "Change to Inline" } else { "Change to Display" }, "equation.display", json!({"value": !display})), + ("Professional", "equation.convert", json!({"to": "professional"})), + ("Linear", "equation.convert", json!({"to": "linear"})), + ("Equation Number", "equation.number", json!({})), + ("Close Equation", "equation.exit", json!({})), + ]; + for (label, id, params) in items { + if ui.button(tl!(label)).clicked() { + let _ = app.run(id, params); + app.canvas.want_focus = true; + ui.close(); + } + } +} + fn context_menu(app: &mut WordApp, ui: &mut Ui) { + if app.session.math.is_some() { + return equation_menu(app, ui); + } ui.set_min_width(220.0); let item = |ui: &mut Ui, app: &mut WordApp, label: &str, id: &str, params: serde_json::Value| { let sc = crate::widgets::shortcut_text(app, id); diff --git a/crates/ui-egui/src/equation_tab.rs b/crates/ui-egui/src/equation_tab.rs new file mode 100644 index 0000000..7b2d144 --- /dev/null +++ b/crates/ui-egui/src/equation_tab.rs @@ -0,0 +1,357 @@ +//! The Equation tab (shown while editing an equation) and the Insert › Equation gallery: +//! ready-made equations, input format and conversion, symbol sets, and the structure galleries +//! (fractions, scripts, radicals, integrals, large operators, brackets, functions, accents, +//! limits and logs, operators, matrices). Tiles and button pictures are rendered by the math +//! engine itself. + +use egui::{Align2, Rect, Sense, Stroke, Ui, pos2, vec2}; +use serde_json::json; +use wordcraft_doc::math::parse_linear; +use wordcraft_engine::math_gallery::{BUILT_INS, STRUCTURES, SYMBOL_SETS, Template}; + +use crate::WordApp; +use crate::theme::{Tokens, regular, semibold}; +use crate::widgets::{CONTENT_H, group, small}; + +/// What each structure button shows. +fn button_picture(id: &str) -> &'static str { + match id { + "fraction" => "x/y", + "script" => "e^x", + "radical" => "√(n&x)", + "integral" => "∫_(−x)^x", + "largeOperator" => "∑_(i=0)^n", + "bracket" => "{()}", + "function" => "sin⁡θ", + "accent" => "ä", + "limitLog" => "lim┬(n→∞)", + "operator" => "≜", + "matrix" => "[■(1&0@0&1)]", + _ => "x", + } +} + +/// A rendered equation drawn into `r` (centred), or its linear text while it renders. +fn paint_math(app: &mut WordApp, ui: &Ui, r: Rect, key: &str, linear: &str, nodes: Option, display: bool, pt: f32) { + let nodes = nodes.unwrap_or_else(|| parse_linear(linear)); + match crate::previews::math_texture(app, ui.ctx(), key, &nodes, display, pt, r.size()) { + Some(tex) => { + ui.painter().image(tex.id(), r, Rect::from_min_max(pos2(0.0, 0.0), pos2(1.0, 1.0)), egui::Color32::WHITE); + } + None => { + let t = Tokens::get(ui.ctx()); + ui.painter().text(r.center(), Align2::CENTER_CENTER, linear, regular(11.0), t.text_dim); + } + } +} + +/// A big ribbon button whose picture is an equation; opens `menu`. +fn math_menu_button(ui: &mut Ui, app: &mut WordApp, picture: &str, label: &str, menu: impl FnOnce(&mut Ui, &mut WordApp)) { + let label = tl!(label); + let t = Tokens::get(ui.ctx()); + let galley_w = + label.split('\n').map(|l| ui.ctx().fonts_mut(|f| f.layout_no_wrap(l.to_string(), regular(11.5), t.text).size().x)).fold(0.0, f32::max); + let w = (galley_w + 22.0).max(46.0); + let (r, resp) = ui.allocate_exact_size(vec2(w, CONTENT_H), Sense::click()); + if resp.hovered() { + ui.painter().rect_filled(r, 4.0, t.hover); + } + let pic = Rect::from_center_size(pos2(r.center().x, r.min.y + 20.0), vec2(40.0, 34.0)); + ui.painter().rect_filled(pic, 3.0, egui::Color32::WHITE); + paint_math(app, ui, pic, &format!("btn:{picture}"), picture, None, false, 12.0); + let lines: Vec<&str> = label.split('\n').collect(); + let mut y = r.min.y + 43.0; + for (i, l) in lines.iter().enumerate() { + let txt = if i + 1 == lines.len() { format!("{l} ▾") } else { (*l).to_string() }; + ui.painter().text(pos2(r.center().x, y), Align2::CENTER_TOP, txt, regular(11.5), t.text); + y += 13.0; + } + let resp = resp.on_hover_text(label.replace('\n', " ")); + egui::Popup::menu(&resp).show(|ui| { + ui.set_min_width(260.0); + menu(ui, app); + }); +} + +/// One gallery tile: the template rendered, its name as a tooltip. +fn tile(ui: &mut Ui, app: &mut WordApp, tpl: &Template, size: egui::Vec2) -> bool { + let t = Tokens::get(ui.ctx()); + let (r, resp) = ui.allocate_exact_size(size, Sense::click()); + ui.painter().rect_filled(r, 2.0, egui::Color32::WHITE); + if resp.hovered() { + ui.painter().rect_stroke(r, 2.0, Stroke::new(1.5, t.accent), egui::StrokeKind::Inside); + } + let pt = if size.x > 120.0 { 13.0 } else { 14.0 }; + paint_math(app, ui, r.shrink(2.0), &format!("tpl:{}", tpl.id), tpl.linear, Some(tpl.nodes()), true, pt); + resp.on_hover_text(tl!(tpl.label)).clicked() +} + +fn section_heading(ui: &mut Ui, name: &str) { + let t = Tokens::get(ui.ctx()); + let (r, _) = ui.allocate_exact_size(vec2(ui.available_width().max(240.0), 20.0), Sense::hover()); + ui.painter().rect_filled(r, 0.0, t.hover); + ui.painter().text(pos2(r.min.x + 6.0, r.center().y), Align2::LEFT_CENTER, tl!(name), semibold(11.5), t.text); +} + +/// A structure gallery's menu. +fn structure_menu(ui: &mut Ui, app: &mut WordApp, gallery_id: &str) { + let Some(g) = STRUCTURES.iter().find(|g| g.id == gallery_id) else { return }; + egui::ScrollArea::vertical().max_height(520.0).show(ui, |ui| { + ui.set_width(330.0); + for (name, templates) in g.sections { + section_heading(ui, name); + let wide = name.starts_with("Common") || templates.iter().all(|t| !t.linear.contains('⬚')) && templates.len() <= 3; + let size = if wide { vec2(158.0, 60.0) } else { vec2(60.0, 56.0) }; + ui.horizontal_wrapped(|ui| { + ui.spacing_mut().item_spacing = vec2(4.0, 4.0); + for tpl in templates.iter() { + if tile(ui, app, tpl, size) { + let _ = app.run("equation.insertStructure", json!({"id": tpl.id})); + app.canvas.want_focus = true; + ui.close(); + } + } + }); + ui.add_space(4.0); + } + }); +} + +/// The ready-made equations (Insert › Equation and Equation › Equation). +pub fn builtin_menu(ui: &mut Ui, app: &mut WordApp) { + egui::ScrollArea::vertical().max_height(560.0).show(ui, |ui| { + ui.set_width(360.0); + section_heading(ui, "Built-In"); + for (id, label, lin) in BUILT_INS { + let t = Tokens::get(ui.ctx()); + ui.label(egui::RichText::new(tl!(label)).font(semibold(11.5)).color(t.text)); + let (r, resp) = ui.allocate_exact_size(vec2(350.0, 64.0), Sense::click()); + ui.painter().rect_filled(r, 2.0, egui::Color32::WHITE); + if resp.hovered() { + ui.painter().rect_stroke(r, 2.0, Stroke::new(1.5, t.accent), egui::StrokeKind::Inside); + } + paint_math(app, ui, r.shrink(2.0), &format!("builtin:{id}"), lin, None, true, 11.0); + if resp.clicked() { + let _ = app.run("insert.equation", json!({"builtin": id})); + app.canvas.want_focus = true; + ui.close(); + } + ui.add_space(4.0); + } + }); + ui.separator(); + if ui.button(tl!("Insert New Equation")).clicked() { + let _ = app.run("insert.equation", json!({})); + app.canvas.want_focus = true; + ui.close(); + } +} + +/// Insert › Symbols › Equation: a split button (insert a new equation, or pick a ready-made one). +pub fn insert_button(ui: &mut Ui, app: &mut WordApp) { + let t = Tokens::get(ui.ctx()); + let label = tl!("Equation"); + let w = (ui.ctx().fonts_mut(|f| f.layout_no_wrap(label.to_string(), regular(11.5), t.text).size().x) + 22.0).max(48.0); + let (r, _) = ui.allocate_exact_size(vec2(w, CONTENT_H), Sense::hover()); + let top = Rect::from_min_max(r.min, pos2(r.max.x, r.min.y + 40.0)); + let bottom = Rect::from_min_max(pos2(r.min.x, r.min.y + 40.0), r.max); + let rt = ui.interact(top, ui.id().with("eq_insert_top"), Sense::click()); + let rb = ui.interact(bottom, ui.id().with("eq_insert_bottom"), Sense::click()); + for (rr, resp) in [(top, &rt), (bottom, &rb)] { + if resp.hovered() { + ui.painter().rect_filled(rr, 4.0, t.hover); + } + } + crate::icons::paint(ui.painter(), Rect::from_center_size(pos2(r.center().x, r.min.y + 20.0), vec2(32.0, 32.0)), "equation", t.icon, t.accent); + ui.painter().text(pos2(r.center().x, r.min.y + 43.0), Align2::CENTER_TOP, format!("{label} ▾"), regular(11.5), t.text); + if rt.on_hover_text(tl!("Insert a new equation (Alt+=)")).clicked() { + let _ = app.run("insert.equation", json!({})); + app.canvas.want_focus = true; + } + egui::Popup::menu(&rb).show(|ui| builtin_menu(ui, app)); +} + +/// The Symbols group: a strip of the current set's symbols, and a menu with every set. +fn symbols(ui: &mut Ui, app: &mut WordApp) { + let id = egui::Id::new("eq_symbol_set"); + let set = ui.data(|d| d.get_temp::(id)).unwrap_or(0).min(SYMBOL_SETS.len().saturating_sub(1)); + let Some((_, chars)) = SYMBOL_SETS.get(set) else { return }; + let start_id = egui::Id::new("eq_symbol_start"); + let per_row = 10usize; + let rows = 3usize; + let all: Vec = chars.chars().collect(); + let start = ui.data(|d| d.get_temp::(start_id)).unwrap_or(0).min(all.len().saturating_sub(1)); + let t = Tokens::get(ui.ctx()); + ui.vertical(|ui| { + ui.spacing_mut().item_spacing = vec2(1.0, 1.0); + for row in 0..rows { + ui.horizontal(|ui| { + ui.spacing_mut().item_spacing = vec2(1.0, 1.0); + for col in 0..per_row { + let k = start + row * per_row + col; + let Some(c) = all.get(k) else { + ui.allocate_exact_size(vec2(22.0, 22.0), Sense::hover()); + continue; + }; + if symbol_button(ui, app, *c, &t) { + let _ = app.run("equation.insertSymbol", json!({"char": c.to_string()})); + app.canvas.want_focus = true; + } + } + }); + } + }); + ui.vertical(|ui| { + ui.spacing_mut().item_spacing = vec2(0.0, 1.0); + let page = per_row * rows; + if ui.add_enabled(start > 0, egui::Button::new("▲").min_size(vec2(16.0, 22.0))).clicked() { + ui.data_mut(|d| d.insert_temp(start_id, start.saturating_sub(per_row))); + } + if ui.add_enabled(start + page < all.len(), egui::Button::new("▼").min_size(vec2(16.0, 22.0))).clicked() { + ui.data_mut(|d| d.insert_temp(start_id, start + per_row)); + } + let resp = ui.add(egui::Button::new("⋯").min_size(vec2(16.0, 22.0))).on_hover_text(tl!("All symbol sets")); + egui::Popup::menu(&resp).show(|ui| { + ui.set_width(420.0); + ui.horizontal_wrapped(|ui| { + for (i, (name, _)) in SYMBOL_SETS.iter().enumerate() { + if ui.selectable_label(i == set, tl!(name)).clicked() { + ui.data_mut(|d| { + d.insert_temp(id, i); + d.insert_temp(start_id, 0usize); + }); + } + } + }); + ui.separator(); + let set = ui.data(|d| d.get_temp::(id)).unwrap_or(0); + if let Some((_, chars)) = SYMBOL_SETS.get(set) { + egui::ScrollArea::vertical().max_height(360.0).show(ui, |ui| { + ui.horizontal_wrapped(|ui| { + ui.spacing_mut().item_spacing = vec2(1.0, 1.0); + for c in chars.chars() { + if symbol_button(ui, app, c, &t) { + let _ = app.run("equation.insertSymbol", json!({"char": c.to_string()})); + app.canvas.want_focus = true; + } + } + }); + }); + } + }); + }); +} + +fn symbol_button(ui: &mut Ui, app: &mut WordApp, c: char, t: &Tokens) -> bool { + let (r, resp) = ui.allocate_exact_size(vec2(22.0, 22.0), Sense::click()); + ui.painter().rect_filled(r, 2.0, egui::Color32::WHITE); + if resp.hovered() { + ui.painter().rect_stroke(r, 2.0, Stroke::new(1.0, t.accent), egui::StrokeKind::Inside); + } + let s = c.to_string(); + let nodes = vec![wordcraft_doc::math::MNode::Run(wordcraft_doc::math::MRun { text: s.clone(), lit: true, ..Default::default() })]; + paint_math(app, ui, r, &format!("sym:{s}"), &s, Some(nodes), false, 11.0); + let name = wordcraft_doc::math_symbols::name_of(c).map(|n| format!("{s} \\{n}")).unwrap_or_else(|| format!("{s} U+{:04X}", c as u32)); + resp.on_hover_text(name).clicked() +} + +/// The Equation tab. +pub fn show(app: &mut WordApp, ui: &mut Ui) { + group(ui, "Tools", None, app, |ui, app| { + math_menu_button(ui, app, "∑_(i=1)^n", "Equation", builtin_menu); + ui.vertical(|ui| { + ui.spacing_mut().item_spacing = vec2(1.0, 0.0); + let latex = app.session.math_latex; + small( + ui, + app, + "equation", + Some("Unicode"), + "Type equations in the linear format (UnicodeMath)", + "equation.inputFormat", + json!({"format": "unicode"}), + !latex, + ); + small(ui, app, "formula", Some("LaTeX"), "Type equations in LaTeX", "equation.inputFormat", json!({"format": "latex"}), latex); + crate::widgets::menu_button(ui, app, "replace", Some("Convert"), "Convert between built-up and linear form", false, |ui, app| { + for (label, to, all) in [ + ("Current - Professional", "professional", false), + ("Current - Linear", "linear", false), + ("All - Professional", "professional", true), + ("All - Linear", "linear", true), + ] { + if ui.button(tl!(label)).clicked() { + let _ = app.run("equation.convert", json!({"to": to, "all": all})); + app.canvas.want_focus = true; + ui.close(); + } + } + }); + }); + ui.vertical(|ui| { + ui.spacing_mut().item_spacing = vec2(1.0, 0.0); + let normal = app.session.math_normal_text; + small(ui, app, "case", Some("Normal Text"), "Type normal (non-math) text in the equation", "equation.normalText", json!({}), normal); + }); + }); + group(ui, "Symbols", None, app, symbols); + group(ui, "Structures", None, app, |ui, app| { + for g in STRUCTURES { + let id = g.id; + math_menu_button(ui, app, button_picture(id), g.label, |ui, app| structure_menu(ui, app, id)); + } + }); + group(ui, "Equation Options", None, app, |ui, app| { + ui.vertical(|ui| { + ui.spacing_mut().item_spacing = vec2(1.0, 0.0); + let info = app.session.run("equation.get", &json!({})).ok(); + let display = info.as_ref().and_then(|v| v.get("display")).and_then(|v| v.as_bool()).unwrap_or(false); + let numbered = info.as_ref().and_then(|v| v.get("linear")).and_then(|v| v.as_str()).is_some_and(|l| l.contains('#')); + small( + ui, + app, + "alignCenter", + Some("Display"), + "Show the equation on a line of its own", + "equation.display", + json!({"value": true}), + display, + ); + small( + ui, + app, + "alignLeft", + Some("Inline"), + "Show the equation in the line of text", + "equation.display", + json!({"value": false}), + !display, + ); + small( + ui, + app, + "numbering", + Some("Number"), + "Number the equation; the number sits at the right margin (or type # then the number)", + "equation.number", + json!({}), + numbered, + ); + }); + ui.vertical(|ui| { + ui.spacing_mut().item_spacing = vec2(1.0, 0.0); + crate::widgets::menu_button(ui, app, "justify", Some("Justification"), "Justification of a display equation", false, |ui, app| { + for (label, jc) in [("Left", "left"), ("Right", "right"), ("Centered", "center"), ("Centered as Group", "centerGroup")] { + if ui.button(tl!(label)).clicked() { + let _ = app.run("equation.justify", json!({"jc": jc})); + ui.close(); + } + } + }); + if ui.button(tl!("Close Equation")).on_hover_text(tl!("Leave the equation (Esc)")).clicked() { + let _ = app.run("equation.exit", json!({})); + app.canvas.want_focus = true; + } + }); + }); +} diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index aee1da7..2ec44f8 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -841,3 +841,46 @@ Don't Save No guardar That format doesn't keep everything, so the document is still open. Save it as a Word document (.docx) to go on. Ese formato no conserva todo, así que el documento sigue abierto. Guárdelo como documento de Word (.docx) para continuar. Your changes will be lost if you don't save them. Si no guarda los cambios, se perderán. +# Equations. + Change Structure Cambiar estructura + Change to Display Cambiar a presentación + Change to Inline Cambiar a en línea + Conversions Conversiones + Delete Back in Equation Eliminar hacia atrás en la ecuación + Delete in Equation Eliminar en la ecuación + Edit Equation Editar ecuación + Enter in Equation Intro en la ecuación + Equation Gallery Galería de ecuaciones + Equation Info Información de la ecuación + Equation Number Número de ecuación + Input Format Formato de entrada + Insert Structure Insertar estructura + Insert Symbol Insertar símbolo + Justification Alineación + Leave Equation Salir de la ecuación + Move in Equation Moverse en la ecuación + Normal Text Texto normal + Set Equation Establecer ecuación + Structure Actions Acciones de estructura + Structures Estructuras + Tools Herramientas + Type in Equation Escribir en la ecuación + Inline En línea + Number Número + Close Equation Cerrar ecuación + Insert New Equation Insertar nueva ecuación + Built-In Integradas + Professional Profesional + Linear Lineal + Fraction Fracción + Script Índice + Radical Radical + Integral Integral + Large\nOperator Operador\ngrande + Bracket Corchete + Function Función + Accent Acento + Limit and\nLog Límite y\nlogaritmo + Operator Operador + Equation Options Opciones de ecuación + All symbol sets Todos los conjuntos de símbolos diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index c57014c..fad7722 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -824,3 +824,46 @@ Your changes will be lost if you don't save them. 保存しないと、変更内容は失われます。 Scroll ribbon left リボンを左にスクロール Scroll ribbon right リボンを右にスクロール +# Equations. + Change Structure 構造の変更 + Change to Display 別行表示に変更 + Change to Inline 行内表示に変更 + Conversions 変換 + Delete Back in Equation 数式内で前を削除 + Delete in Equation 数式内で削除 + Edit Equation 数式の編集 + Enter in Equation 数式内で Enter + Equation Gallery 数式ギャラリー + Equation Info 数式の情報 + Equation Number 数式番号 + Input Format 入力形式 + Insert Structure 構造の挿入 + Insert Symbol 記号の挿入 + Justification 配置 + Leave Equation 数式を終了 + Move in Equation 数式内で移動 + Normal Text 標準テキスト + Set Equation 数式の設定 + Structure Actions 構造の操作 + Structures 構造 + Tools ツール + Type in Equation 数式に入力 + Inline 行内 + Number 番号 + Close Equation 数式を閉じる + Insert New Equation 新しい数式の挿入 + Built-In 組み込み + Professional 2 次元形式 + Linear 線形形式 + Fraction 分数 + Script 上付き/下付き + Radical べき乗根 + Integral 積分 + Large\nOperator 大型\n演算子 + Bracket かっこ + Function 関数 + Accent アクセント + Limit and\nLog 極限と\n対数 + Operator 演算子 + Equation Options 数式のオプション + All symbol sets すべての記号セット diff --git a/crates/ui-egui/src/i18n/pt-br.tsv b/crates/ui-egui/src/i18n/pt-br.tsv index 6f28fc2..5c4aabb 100644 --- a/crates/ui-egui/src/i18n/pt-br.tsv +++ b/crates/ui-egui/src/i18n/pt-br.tsv @@ -836,3 +836,46 @@ Don't Save Não Salvar That format doesn't keep everything, so the document is still open. Save it as a Word document (.docx) to go on. Esse formato não preserva tudo, então o documento continua aberto. Salve-o como documento do Word (.docx) para continuar. Your changes will be lost if you don't save them. Suas alterações serão perdidas se você não salvá-las. +# Equations. + Change Structure Alterar estrutura + Change to Display Alterar para exibição + Change to Inline Alterar para embutida + Conversions Conversões + Delete Back in Equation Excluir para trás na equação + Delete in Equation Excluir na equação + Edit Equation Editar equação + Enter in Equation Enter na equação + Equation Gallery Galeria de equações + Equation Info Informações da equação + Equation Number Número da equação + Input Format Formato de entrada + Insert Structure Inserir estrutura + Insert Symbol Inserir símbolo + Justification Alinhamento + Leave Equation Sair da equação + Move in Equation Mover na equação + Normal Text Texto normal + Set Equation Definir equação + Structure Actions Ações de estrutura + Structures Estruturas + Tools Ferramentas + Type in Equation Digitar na equação + Inline Embutida + Number Número + Close Equation Fechar equação + Insert New Equation Inserir nova equação + Built-In Internas + Professional Profissional + Linear Linear + Fraction Fração + Script Índice + Radical Radical + Integral Integral + Large\nOperator Operador\ngrande + Bracket Colchete + Function Função + Accent Acento + Limit and\nLog Limite e\nlogaritmo + Operator Operador + Equation Options Opções de equação + All symbol sets Todos os conjuntos de símbolos diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index bf96cf4..b7b7020 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -836,3 +836,46 @@ Don't Save Не зберігати That format doesn't keep everything, so the document is still open. Save it as a Word document (.docx) to go on. Цей формат зберігає не все, тому документ залишається відкритим. Щоб продовжити, збережіть його як документ Word (.docx). Your changes will be lost if you don't save them. Якщо не зберегти зміни, їх буде втрачено. +# Equations. + Change Structure Змінити структуру + Change to Display Змінити на окремий рядок + Change to Inline Змінити на вбудований + Conversions Перетворення + Delete Back in Equation Видалити назад у рівнянні + Delete in Equation Видалити в рівнянні + Edit Equation Редагувати рівняння + Enter in Equation Enter у рівнянні + Equation Gallery Колекція рівнянь + Equation Info Відомості про рівняння + Equation Number Номер рівняння + Input Format Формат введення + Insert Structure Вставити структуру + Insert Symbol Вставити символ + Justification Вирівнювання + Leave Equation Вийти з рівняння + Move in Equation Переміщення в рівнянні + Normal Text Звичайний текст + Set Equation Задати рівняння + Structure Actions Дії зі структурою + Structures Структури + Tools Знаряддя + Type in Equation Введення в рівнянні + Inline Вбудований + Number Номер + Close Equation Закрити рівняння + Insert New Equation Вставити нове рівняння + Built-In Вбудовані + Professional Професійний + Linear Лінійний + Fraction Дріб + Script Індекс + Radical Радикал + Integral Інтеграл + Large\nOperator Великий\nоператор + Bracket Дужки + Function Функція + Accent Діакритичний знак + Limit and\nLog Границя та\nлогарифм + Operator Оператор + Equation Options Параметри рівняння + All symbol sets Усі набори символів diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv index f9e9f4e..0558ac3 100644 --- a/crates/ui-egui/src/i18n/zh-hans.tsv +++ b/crates/ui-egui/src/i18n/zh-hans.tsv @@ -835,3 +835,46 @@ Your changes will be lost if you don't save them. 如果不保存,您的更改将会丢失。 Scroll ribbon left 向左滚动功能区 Scroll ribbon right 向右滚动功能区 +# Equations. + Change Structure 更改结构 + Change to Display 更改为显示公式 + Change to Inline 更改为行内公式 + Conversions 转换 + Delete Back in Equation 在公式中向前删除 + Delete in Equation 在公式中删除 + Edit Equation 编辑公式 + Enter in Equation 在公式中按 Enter + Equation Gallery 公式库 + Equation Info 公式信息 + Equation Number 公式编号 + Input Format 输入格式 + Insert Structure 插入结构 + Insert Symbol 插入符号 + Justification 对齐方式 + Leave Equation 退出公式 + Move in Equation 在公式中移动 + Normal Text 普通文本 + Set Equation 设置公式 + Structure Actions 结构操作 + Structures 结构 + Tools 工具 + Type in Equation 在公式中输入 + Inline 行内 + Number 编号 + Close Equation 关闭公式 + Insert New Equation 插入新公式 + Built-In 内置 + Professional 二维格式 + Linear 线性格式 + Fraction 分数 + Script 上下标 + Radical 根式 + Integral 积分 + Large\nOperator 大型\n运算符 + Bracket 括号 + Function 函数 + Accent 重音符号 + Limit and\nLog 极限和\n对数 + Operator 运算符 + Equation Options 公式选项 + All symbol sets 所有符号集 diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv index 21a7ee8..1b1dac3 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -825,3 +825,46 @@ Your changes will be lost if you don't save them. 如果不儲存,您的變更將會遺失。 Scroll ribbon left 向左捲動功能區 Scroll ribbon right 向右捲動功能區 +# Equations. + Change Structure 變更結構 + Change to Display 變更為顯示方程式 + Change to Inline 變更為行內方程式 + Conversions 轉換 + Delete Back in Equation 在方程式中向前刪除 + Delete in Equation 在方程式中刪除 + Edit Equation 編輯方程式 + Enter in Equation 在方程式中按 Enter + Equation Gallery 方程式庫 + Equation Info 方程式資訊 + Equation Number 方程式編號 + Input Format 輸入格式 + Insert Structure 插入結構 + Insert Symbol 插入符號 + Justification 對齊方式 + Leave Equation 結束方程式 + Move in Equation 在方程式中移動 + Normal Text 一般文字 + Set Equation 設定方程式 + Structure Actions 結構操作 + Structures 結構 + Tools 工具 + Type in Equation 在方程式中輸入 + Inline 行內 + Number 編號 + Close Equation 關閉方程式 + Insert New Equation 插入新方程式 + Built-In 內建 + Professional 二維格式 + Linear 線性格式 + Fraction 分數 + Script 上下標 + Radical 根式 + Integral 積分 + Large\nOperator 大型\n運算子 + Bracket 括號 + Function 函數 + Accent 重音符號 + Limit and\nLog 極限與\n對數 + Operator 運算子 + Equation Options 方程式選項 + All symbol sets 所有符號集 diff --git a/crates/ui-egui/src/keys.rs b/crates/ui-egui/src/keys.rs index 11dde36..476801f 100644 --- a/crates/ui-egui/src/keys.rs +++ b/crates/ui-egui/src/keys.rs @@ -109,6 +109,29 @@ fn dispatch(app: &mut WordApp, key: Key, m: Modifiers) -> bool { false } +/// Keys while editing inside an equation. Returns true when handled. +fn equation_key(app: &mut WordApp, key: Key, m: Modifiers) -> bool { + if m.command || m.alt || (m.ctrl && !cfg!(target_os = "macos")) { + return false; + } + let (id, params) = match key { + Key::ArrowLeft => ("equation.move", json!({"dir": "left"})), + Key::ArrowRight => ("equation.move", json!({"dir": "right"})), + Key::ArrowUp => ("equation.move", json!({"dir": "up"})), + Key::ArrowDown => ("equation.move", json!({"dir": "down"})), + Key::Home => ("equation.move", json!({"dir": "home"})), + Key::End => ("equation.move", json!({"dir": "end"})), + Key::Tab => ("equation.move", json!({"dir": if m.shift { "prev" } else { "next" }})), + Key::Backspace => ("equation.backspace", json!({})), + Key::Delete => ("equation.delete", json!({})), + Key::Enter => ("equation.enter", json!({})), + Key::Escape => ("equation.exit", json!({})), + _ => return false, + }; + let _ = app.run(id, params); + true +} + /// Events for the focused canvas: text, editing keys, clipboard, IME. While keytips are showing /// every key belongs to them (letters pick badges, Escape cancels), so nothing here runs. pub fn canvas_events(app: &mut WordApp, ctx: &egui::Context) { @@ -117,6 +140,31 @@ pub fn canvas_events(app: &mut WordApp, ctx: &egui::Context) { } let events = ctx.input(|i| i.events.clone()); for e in events { + // Editing an equation: text and editing keys go into it. + if app.session.math.is_some() { + match &e { + egui::Event::Text(t) => { + let m = ctx.input(|i| i.modifiers); + if !(m.command || (m.ctrl && !cfg!(target_os = "macos"))) && !t.is_empty() && t.chars().all(|c| !c.is_control()) { + let _ = app.run("equation.type", json!({"text": t})); + } + continue; + } + egui::Event::Ime(egui::ImeEvent::Commit(text)) => { + app.canvas.ime_preedit.clear(); + if !text.is_empty() { + let _ = app.run("equation.type", json!({"text": text})); + } + continue; + } + egui::Event::Paste(t) => { + let _ = app.run("equation.type", json!({"text": t})); + continue; + } + egui::Event::Key { key, pressed: true, modifiers, .. } if equation_key(app, *key, *modifiers) => continue, + _ => {} + } + } match e { egui::Event::Text(t) => { let m = ctx.input(|i| i.modifiers); diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index e245808..d92e1bb 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -19,6 +19,7 @@ pub mod chrome; pub mod control; pub mod credits; pub mod dialogs; +pub mod equation_tab; pub mod frame; pub mod i18n; pub mod icons; @@ -148,6 +149,8 @@ pub struct WordApp { pub autosave: bool, pub word_count: (u64, usize), last_autosave: f64, + /// The tab shown before the Equation tab came up (restored when editing ends). + pub(crate) equation_prev_tab: Option, /// Zotero commands in flight (`ui.zotero.*`). pub zotero: zotero::ZoteroLink, /// The egui context, once the first frame has run (background work wakes the UI with it). @@ -207,6 +210,7 @@ impl WordApp { autosave: true, word_count: (0, 0), last_autosave: 0.0, + equation_prev_tab: None, zotero: zotero::ZoteroLink::default(), ctx: None, read_aloud_at: None, diff --git a/crates/ui-egui/src/previews.rs b/crates/ui-egui/src/previews.rs index d2eb987..8cb2dc1 100644 --- a/crates/ui-egui/src/previews.rs +++ b/crates/ui-egui/src/previews.rs @@ -130,6 +130,59 @@ impl Previews { } } +/// An equation rasterised for a gallery tile or a button (`pt`: font size; `size`: the tile in +/// points), with its empty slots shown. Cached by `key`; `None` while waiting for this frame's +/// rendering budget. +pub fn math_texture( + app: &mut WordApp, + ctx: &egui::Context, + key: &str, + nodes: &wordcraft_doc::math::Arg, + display: bool, + pt: f32, + size: egui::Vec2, +) -> Option { + let ppp = ctx.pixels_per_point(); + let k = format!("math:{key}:{pt}:{}x{}:{ppp}", size.x, size.y); + app.previews.get_or(ctx, &k, || math_image(nodes, display, pt, size.x, size.y, ppp)) +} + +fn math_image(nodes: &wordcraft_doc::math::Arg, display: bool, pt: f32, w: f32, h: f32, ppp: f32) -> Option { + use wordcraft_doc::math::{Math, to_linear}; + let props = CharProps { size: Some(pt), ..Default::default() }; + let mut p = Paragraph::with_text("", props.clone()); + p.props.align = Some(wordcraft_doc::props::Align::Center); + p.props.space_before = Some(0.0); + p.props.space_after = Some(0.0); + p.props.line_spacing = Some(wordcraft_doc::props::LineSpacing::Multiple(1.0)); + let eq = wordcraft_doc::InlineObject::Equation { linear: to_linear(nodes), display, math: Math { nodes: nodes.clone(), ..Default::default() } }; + p.insert_object(0, eq, &props).ok()?; + let mut d = Document::new(); + d.body = vec![para_block(p)]; + d.last_section.page_w = w; + d.last_section.page_h = 2000.0; + d.last_section.margin_left = 0.0; + d.last_section.margin_right = 0.0; + d.last_section.margin_top = 0.0; + d.last_section.margin_bottom = 0.0; + let l = wordcraft_layout::layout(&d, &mut wordcraft_layout::LayoutCache::new(), &Default::default()); + let page = l.pages.first()?; + // Centre the equation's line in the tile. + let mid = page + .items + .iter() + .find_map(|it| match it { + wordcraft_layout::Placed::Lines { para, y, .. } => Some(y + para.height / 2.0), + _ => None, + }) + .unwrap_or(h / 2.0); + let mut opts = wordcraft_render::RenderOptions::default(); + opts.display.placeholders = true; + let img = wordcraft_render::render_area(&d, page, 0.0, mid - h / 2.0, w, h, ppp, &opts); + let px = img.to_straight(); + Some(egui::ColorImage::from_rgba_unmultiplied([img.width as usize, img.height as usize], &px)) +} + /// Lay out `para` (with `base`'s styles) in a `w`×`h` pt box and rasterise it. pub fn snippet(base: &Document, para: Paragraph, w: f32, h: f32, ppp: f32, margin: f32) -> Option { snippet_blocks(base, vec![Block::Para(para)], w, h, ppp, margin, None) diff --git a/crates/ui-egui/src/ribbon.rs b/crates/ui-egui/src/ribbon.rs index f493cd4..6d4d9fb 100644 --- a/crates/ui-egui/src/ribbon.rs +++ b/crates/ui-egui/src/ribbon.rs @@ -24,6 +24,10 @@ pub fn contextual_tabs(s: &wordcraft_engine::Session) -> Vec<&'static str> { if has_picture_selected(s) { tabs.push("Picture Format"); } + // Editing an equation. + if s.math.is_some() { + tabs.push("Equation"); + } tabs } @@ -39,6 +43,17 @@ pub fn in_table_public(app: &WordApp) -> bool { pub fn show(app: &mut WordApp, ui: &mut Ui) { let t = Tokens::get(ui.ctx()); + // Editing an equation brings up the Equation tab; leaving it goes back. + if app.session.math.is_some() { + if app.equation_prev_tab.is_none() { + app.equation_prev_tab = Some(app.ui.tab.clone()); + app.ui.tab = "Equation".into(); + } + } else if let Some(prev) = app.equation_prev_tab.take() + && app.ui.tab == "Equation" + { + app.ui.tab = prev; + } // A contextual tab (Table, Picture Format) may be stored while the selection moved away. { let mut tabs: Vec<&str> = TABS.to_vec(); @@ -62,7 +77,7 @@ pub fn show(app: &mut WordApp, ui: &mut Ui) { } } for tab in tabs { - let contextual = tab.starts_with("Table ") || tab == "Picture Format"; + let contextual = tab.starts_with("Table ") || tab == "Picture Format" || tab == "Equation"; let shown = tl!(tab); let w = ui.ctx().fonts_mut(|f| f.layout_no_wrap(shown.to_string(), medium(12.5), t.text).size().x) + 18.0; let (r, resp) = ui.allocate_exact_size(vec2(w, 30.0), Sense::click()); @@ -151,6 +166,7 @@ pub fn show(app: &mut WordApp, ui: &mut Ui) { "Help" => help(app, ui), "Table Design" => table_design(app, ui), "Table Layout" => table_layout(app, ui), + "Equation" if app.session.math.is_some() => crate::equation_tab::show(app, ui), "Picture Format" => picture_format(app, ui), _ => home(app, ui), } @@ -535,7 +551,7 @@ fn insert(app: &mut WordApp, ui: &mut Ui) { }); }); group(ui, "Symbols", None, app, |ui, app| { - big(ui, app, "equation", "Equation", "insert.equation", json!({}), false); + crate::equation_tab::insert_button(ui, app); menu_button(ui, app, "symbol", Some("Symbol"), "Symbol", true, |ui, app| { egui::Grid::new("syms").show(ui, |ui| { for (i, c) in [ diff --git a/docs/parity.md b/docs/parity.md index 9cf2894..e8389e7 100644 --- a/docs/parity.md +++ b/docs/parity.md @@ -2,13 +2,13 @@ Generated by `cargo xtask parity` (`wordcraft-cli parity --markdown`): the word-processor feature catalog (`crates/engine/src/catalog.rs`) compared with the live command registry. -**355 of 405 catalog features have commands (87.7%).** +**377 of 427 catalog features have commands (88.3%).** | Tab | Live | Total | |---|---|---| | File | 20 | 20 | | Quick Access | 4 | 4 | -| Home | 90 | 100 | +| Home | 91 | 100 | | Insert | 42 | 53 | | Draw | 0 | 11 | | Design | 10 | 10 | @@ -19,6 +19,7 @@ Generated by `cargo xtask parity` (`wordcraft-cli parity --markdown`): the word- | View | 25 | 29 | | Table Design | 5 | 5 | | Table Layout | 28 | 30 | +| Equation | 21 | 22 | | Picture Format | 13 | 13 | | Shape Format | 2 | 6 | | Header & Footer | 5 | 5 | @@ -89,6 +90,10 @@ Generated by `cargo xtask parity` (`wordcraft-cli parity --markdown`): the word- - Draw › Draw Table (`table.draw`) - Draw › Eraser (`table.eraser`) +### Equation + +- Tools › Ink Equation (`insert.inkEquation`) + ### Shape Format - Shape Styles › Shape Effects (`shape.effects`)