Equations: Word (OMML) import, layout and save, plus an equation editor like Word's (#191)
* M5.5: Word equations (OMML) import, lay out and save like Word Equations were flattened to plain text on import. Now OMML is read into a math tree (fractions, scripts, radicals, n-ary operators, delimiters, functions, limits, accents, bars, boxes, equation arrays, matrices, phantoms), laid out with the OpenType MATH table (Cambria Math when installed, else STIX Two Math and other open math fonts), drawn to screen and PDF, and written back as the original OMML. Display equations sit on their own centred line. insert.equation parses the linear format into structure. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * M5.5: equation editor like Word's — Equation tab, in-place editing, # numbers Insert › Equation now opens an equation for in-place editing, with the Equation tab: ready-made equations, Unicode/LaTeX input and conversion (professional ↔ linear), Normal Text, eight symbol sets, and the eleven structure galleries (fraction, script, radical, integral, large operator, bracket, function, accent, limit and log, operator, matrix) with tiles rendered by the math engine. Typing builds up like Word (a/b, x^2, √, ∑… then space), Math AutoCorrect turns \alpha, \sqrt… into symbols, Tab moves between empty slots, arrows/Backspace work through structures, and the right-click menu has structure commands (fraction type, radical degree, limit position, matrix rows/columns, remove brackets…). A trailing #(n) numbers a display equation with the number at the right margin; # alone numbers it automatically. DOCX output keeps numbers Word-compatible. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * i18n: equation editor strings in Spanish, Brazilian Portuguese and Ukrainian The catalogs that landed on main after #191 was opened lacked the Equation tab and equation command labels. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Equations: our own names and order for the built-in equations Clean room: the ready-made equation list used another product's gallery entry names and order. Same formulas (and ids), with our own descriptive labels, grouped by subject (geometry, algebra, calculus, trigonometry, probability). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * ROADMAP: equations row matches the landed equation editor Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fmt after merge Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com> Co-authored-by: Brandon Thomas <echelon@gmail.com>
This commit is contained in:
parent
2982e75544
commit
10eb8798cd
|
|
@ -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).
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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<MNode>;
|
||||
|
||||
/// Deepest structure nesting kept (parsers stop here; layout draws nothing deeper).
|
||||
pub const MAX_DEPTH: usize = 64;
|
||||
|
||||
/// An equation's structure, plus what's needed to write it back.
|
||||
#[derive(Clone, Debug, PartialEq, Default, Serialize, Deserialize)]
|
||||
pub struct Math {
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub nodes: Arg,
|
||||
/// The source `m:oMath` element (DOCX import), written back unchanged on save. Empty for
|
||||
/// equations made here.
|
||||
#[serde(default, skip_serializing_if = "String::is_empty")]
|
||||
pub omml: String,
|
||||
/// Justification of a display equation.
|
||||
#[serde(default, skip_serializing_if = "is_default")]
|
||||
pub jc: MathJc,
|
||||
/// Shown as its linear-format text (Equation › Convert › Linear) instead of built up.
|
||||
#[serde(default, skip_serializing_if = "is_default")]
|
||||
pub linear: bool,
|
||||
}
|
||||
|
||||
fn is_default<T: Default + PartialEq>(v: &T) -> bool {
|
||||
*v == T::default()
|
||||
}
|
||||
|
||||
impl Math {
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.nodes.is_empty()
|
||||
}
|
||||
}
|
||||
|
||||
/// Display equation justification (`m:oMathParaPr/m:jc`).
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Default, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum MathJc {
|
||||
#[default]
|
||||
CenterGroup,
|
||||
Center,
|
||||
Left,
|
||||
Right,
|
||||
}
|
||||
|
||||
/// Math run style (`m:sty`): plain, bold, italic, bold italic.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum MSty {
|
||||
Plain,
|
||||
Bold,
|
||||
Italic,
|
||||
BoldItalic,
|
||||
}
|
||||
|
||||
/// Math alphabet (`m:scr`).
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Default, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum MScr {
|
||||
#[default]
|
||||
Roman,
|
||||
Script,
|
||||
Fraktur,
|
||||
DoubleStruck,
|
||||
SansSerif,
|
||||
Monospace,
|
||||
}
|
||||
|
||||
/// A run of math text (`m:r`).
|
||||
#[derive(Clone, Debug, PartialEq, Default, Serialize, Deserialize)]
|
||||
pub struct MRun {
|
||||
pub text: String,
|
||||
/// `None`: math default (letters italic, the rest upright).
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub sty: Option<MSty>,
|
||||
#[serde(default, skip_serializing_if = "is_default")]
|
||||
pub scr: MScr,
|
||||
/// Normal (non-math) text.
|
||||
#[serde(default, skip_serializing_if = "is_default")]
|
||||
pub nor: bool,
|
||||
/// Literal: no automatic operator handling.
|
||||
#[serde(default, skip_serializing_if = "is_default")]
|
||||
pub lit: bool,
|
||||
/// Font size, points.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub size: Option<f32>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub color: Option<Rgb>,
|
||||
/// Font for normal text.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub font: Option<String>,
|
||||
}
|
||||
|
||||
impl MRun {
|
||||
pub fn new(text: impl Into<String>) -> MRun {
|
||||
MRun { text: text.into(), ..Default::default() }
|
||||
}
|
||||
pub fn plain(text: impl Into<String>) -> MRun {
|
||||
MRun { text: text.into(), sty: Some(MSty::Plain), ..Default::default() }
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Default, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum FracKind {
|
||||
/// Stacked with a rule.
|
||||
#[default]
|
||||
Bar,
|
||||
/// Skewed (num ⁄ den).
|
||||
Skewed,
|
||||
/// Linear (num/den).
|
||||
Linear,
|
||||
/// Stacked without a rule.
|
||||
NoBar,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum ScriptKind {
|
||||
Sup,
|
||||
Sub,
|
||||
SubSup,
|
||||
/// Pre-scripts (`m:sPre`).
|
||||
Pre,
|
||||
}
|
||||
|
||||
/// Where an n-ary operator's limits go.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum LimLoc {
|
||||
/// Under and over the operator.
|
||||
UndOvr,
|
||||
/// As sub- and superscripts.
|
||||
SubSup,
|
||||
}
|
||||
|
||||
/// Column justification in a matrix.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Default, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum ColJc {
|
||||
#[default]
|
||||
Center,
|
||||
Left,
|
||||
Right,
|
||||
}
|
||||
|
||||
/// A math node.
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(tag = "t", rename_all = "camelCase")]
|
||||
pub enum MNode {
|
||||
Run(MRun),
|
||||
/// `m:f`.
|
||||
Frac {
|
||||
#[serde(default)]
|
||||
kind: FracKind,
|
||||
num: Arg,
|
||||
den: Arg,
|
||||
},
|
||||
/// `m:sSup`, `m:sSub`, `m:sSubSup`, `m:sPre`.
|
||||
Script {
|
||||
kind: ScriptKind,
|
||||
base: Arg,
|
||||
#[serde(default)]
|
||||
sub: Arg,
|
||||
#[serde(default)]
|
||||
sup: Arg,
|
||||
},
|
||||
/// `m:rad`.
|
||||
Rad {
|
||||
#[serde(default)]
|
||||
deg: Arg,
|
||||
#[serde(default)]
|
||||
deg_hide: bool,
|
||||
e: Arg,
|
||||
},
|
||||
/// `m:nary`: ∑ ∫ ∏ … with limits and an operand.
|
||||
Nary {
|
||||
chr: char,
|
||||
/// `None`: Word's default (integrals take sub/sup limits, the rest under/over).
|
||||
#[serde(default)]
|
||||
lim_loc: Option<LimLoc>,
|
||||
#[serde(default)]
|
||||
grow: bool,
|
||||
#[serde(default)]
|
||||
sub_hide: bool,
|
||||
#[serde(default)]
|
||||
sup_hide: bool,
|
||||
#[serde(default)]
|
||||
sub: Arg,
|
||||
#[serde(default)]
|
||||
sup: Arg,
|
||||
e: Arg,
|
||||
},
|
||||
/// `m:d`: delimiters around elements separated by `sep`.
|
||||
Delim {
|
||||
/// `None` = no delimiter.
|
||||
beg: Option<char>,
|
||||
end: Option<char>,
|
||||
#[serde(default)]
|
||||
sep: Option<char>,
|
||||
/// Delimiters grow to the content (Word's default).
|
||||
#[serde(default = "yes")]
|
||||
grow: bool,
|
||||
/// Grow to match the content exactly rather than centred on the math axis.
|
||||
#[serde(default)]
|
||||
shp_match: bool,
|
||||
elems: Vec<Arg>,
|
||||
},
|
||||
/// `m:func`: function name applied to an argument (sin x).
|
||||
Func {
|
||||
name: Arg,
|
||||
e: Arg,
|
||||
},
|
||||
/// `m:limLow` / `m:limUpp`.
|
||||
Lim {
|
||||
upper: bool,
|
||||
e: Arg,
|
||||
lim: Arg,
|
||||
},
|
||||
/// `m:acc`: accent over the base.
|
||||
Acc {
|
||||
chr: char,
|
||||
e: Arg,
|
||||
},
|
||||
/// `m:bar`: overbar or underbar.
|
||||
Bar {
|
||||
top: bool,
|
||||
e: Arg,
|
||||
},
|
||||
/// `m:borderBox`. `hide`: top, bottom, left, right. `strike`: horizontal, vertical,
|
||||
/// bottom-left→top-right, top-left→bottom-right.
|
||||
BorderBox {
|
||||
#[serde(default)]
|
||||
hide: [bool; 4],
|
||||
#[serde(default)]
|
||||
strike: [bool; 4],
|
||||
e: Arg,
|
||||
},
|
||||
/// `m:box`.
|
||||
Boxed {
|
||||
e: Arg,
|
||||
},
|
||||
/// `m:groupChr`: a stretched character (brace, arrow) under or over the base.
|
||||
GroupChr {
|
||||
chr: char,
|
||||
top: bool,
|
||||
e: Arg,
|
||||
},
|
||||
/// `m:eqArr`: rows aligned at `&`.
|
||||
EqArr {
|
||||
rows: Vec<Arg>,
|
||||
},
|
||||
/// `m:m`.
|
||||
Matrix {
|
||||
rows: Vec<Vec<Arg>>,
|
||||
#[serde(default)]
|
||||
col_jc: Vec<ColJc>,
|
||||
},
|
||||
/// `m:phant`.
|
||||
Phant {
|
||||
#[serde(default = "yes")]
|
||||
show: bool,
|
||||
#[serde(default)]
|
||||
zero_wid: bool,
|
||||
#[serde(default)]
|
||||
zero_asc: bool,
|
||||
#[serde(default)]
|
||||
zero_desc: bool,
|
||||
e: Arg,
|
||||
},
|
||||
}
|
||||
|
||||
fn yes() -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
/// A row holds an equation-number mark (`#`) in a top-level run.
|
||||
pub fn has_number_mark(row: &[MNode]) -> bool {
|
||||
row.iter().any(|n| matches!(n, MNode::Run(r) if !r.lit && r.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);
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
|
|
@ -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<Tok> {
|
||||
let cs: Vec<char> = 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<Tok>,
|
||||
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<Arg> {
|
||||
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<char> {
|
||||
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<MSty>, 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<Arg>> = 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<char>, end: Option<char>| 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>) -> 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<MNode>, 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<MSty>, 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<char>) -> 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)", "sinx+cosy", "e^(-x^2)"] {
|
||||
let n = crate::math::parse_linear(lin);
|
||||
let tex = to_latex(&n);
|
||||
let back = parse_latex(&tex);
|
||||
assert_eq!(crate::math::to_linear(&back), crate::math::to_linear(&n), "{lin} → {tex}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn 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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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<char> {
|
||||
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<char>,
|
||||
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<char> {
|
||||
self.c.get(self.i).copied()
|
||||
}
|
||||
fn peek_at(&self, k: usize) -> Option<char> {
|
||||
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<char>, closers: &[char]) -> MNode {
|
||||
let mut stops: Vec<char> = 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<MNode> {
|
||||
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<char> = 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<MNode> {
|
||||
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<MNode>, 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<Arg>> = 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) },
|
||||
}
|
||||
}
|
||||
|
|
@ -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<char> {
|
||||
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());
|
||||
}
|
||||
}
|
||||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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<Option<char>> {
|
||||
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<Arg> = 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::<usize>().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<MRun> {
|
||||
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::<f32>().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#"<m:oMath xmlns:m="http://schemas.openxmlformats.org/officeDocument/2006/math" xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main">{inner}</m:oMath>"#
|
||||
);
|
||||
crate::xml::parse(x.as_bytes()).unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reads_fraction_radical_and_scripts() {
|
||||
let e = omath(
|
||||
r#"<m:r><m:t>x=</m:t></m:r><m:f><m:num><m:r><m:t>-b±</m:t></m:r><m:rad><m:radPr><m:degHide m:val="1"/></m:radPr><m:deg/><m:e><m:sSup><m:e><m:r><m:t>b</m:t></m:r></m:e><m:sup><m:r><m:t>2</m:t></m:r></m:sup></m:sSup><m:r><m:t>-4ac</m:t></m:r></m:e></m:rad></m:num><m:den><m:r><m:t>2a</m:t></m:r></m:den></m:f>"#,
|
||||
);
|
||||
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("<m:oMath>"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reads_nary_delims_matrix_and_defaults() {
|
||||
let e = omath(
|
||||
r#"<m:nary><m:naryPr><m:chr m:val="∑"/><m:limLoc m:val="undOvr"/></m:naryPr><m:sub><m:r><m:t>i</m:t></m:r></m:sub><m:sup/><m:e><m:r><m:t>i</m:t></m:r></m:e></m:nary><m:d><m:dPr><m:begChr m:val="["/><m:endChr m:val=""/></m:dPr><m:e><m:r><m:t>a</m:t></m:r></m:e><m:e><m:r><m:t>b</m:t></m:r></m:e></m:d><m:nary><m:e/></m:nary><m:m><m:mr><m:e/><m:e/></m:mr></m:m>"#,
|
||||
);
|
||||
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));
|
||||
}
|
||||
}
|
||||
|
|
@ -1,5 +1,6 @@
|
|||
//! DOCX → [`Document`].
|
||||
|
||||
mod math;
|
||||
mod props;
|
||||
mod story;
|
||||
|
||||
|
|
|
|||
|
|
@ -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" => {
|
||||
|
|
|
|||
|
|
@ -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("<m:oMath") && crate::xml::parse(wrap_ns(&math.omml).as_bytes()).is_ok() {
|
||||
w.raw(&math.omml);
|
||||
} else {
|
||||
let parsed;
|
||||
let nodes: &Arg = if math.nodes.is_empty() {
|
||||
parsed = parse_linear(linear);
|
||||
&parsed
|
||||
} else {
|
||||
&math.nodes
|
||||
};
|
||||
w.open("m:oMath", &[]);
|
||||
arg_inner(w, nodes, 0);
|
||||
w.close("m:oMath");
|
||||
}
|
||||
if display {
|
||||
w.close("m:oMathPara");
|
||||
}
|
||||
}
|
||||
|
||||
/// Count the equation's numbered rows; when some use an automatic number (`#` alone), a copy
|
||||
/// with the numbers written out as `(n)`.
|
||||
pub fn resolve_numbers(math: &Math, display: bool, counter: &mut u32) -> Option<Math> {
|
||||
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!("<w:x{decl}>{xml}</w:x>")
|
||||
}
|
||||
|
||||
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<char>) {
|
||||
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");
|
||||
}
|
||||
|
|
@ -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<String, String>,
|
||||
bookmarks: HashMap<String, u32>,
|
||||
|
|
@ -103,6 +106,7 @@ pub fn write_as(doc: &Document, flavor: Flavor) -> Result<Vec<u8>, DocxError> {
|
|||
doc
|
||||
};
|
||||
let mut wr = Writer {
|
||||
eq_number: 0,
|
||||
doc,
|
||||
media_files: BTreeMap::new(),
|
||||
bookmarks: HashMap::new(),
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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("</");
|
||||
s.push_str(&e.name);
|
||||
s.push('>');
|
||||
}
|
||||
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)
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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<CommandSpec> {
|
||||
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<Pos> {
|
||||
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<R>(s: &mut Session, at: &Pos, f: impl FnOnce(&mut Math, &mut bool) -> R) -> Result<R, CmdError> {
|
||||
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<Arg> {
|
||||
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::<MathPos>(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<MathEdit, CmdError> {
|
||||
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<Arg> {
|
||||
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<wordcraft_doc::StoryRef> =
|
||||
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<usize> = 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<Value> =
|
||||
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<Value> = 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::<Vec<_>>(),
|
||||
})).collect::<Vec<_>>(),
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
let symbols: Vec<Value> = crate::math_gallery::SYMBOL_SETS.iter().map(|(n, chars)| json!({"name": n, "chars": chars})).collect();
|
||||
let builtins: Vec<Value> = 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();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -75,16 +75,6 @@ pub fn specs() -> Vec<CommandSpec> {
|
|||
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;
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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<LimLoc>,
|
||||
}
|
||||
|
||||
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<j<n)▒P(i,j)"),
|
||||
stacked("nary.prodA", "Product of A", "∏_(k=1)^n▒A_k"),
|
||||
stacked("nary.unionXY", "Union of intersections", "⋃_(n=1)^m▒〖(X_n∩Y_n)〗"),
|
||||
],
|
||||
),
|
||||
],
|
||||
},
|
||||
Gallery {
|
||||
id: "bracket",
|
||||
label: "Bracket",
|
||||
sections: &[
|
||||
(
|
||||
"Brackets",
|
||||
&[
|
||||
t("brk.paren", "Parentheses", "(⬚)"),
|
||||
t("brk.square", "Brackets", "[⬚]"),
|
||||
t("brk.brace", "Braces", "{⬚}"),
|
||||
t("brk.angle", "Angle Brackets", "⟨⬚⟩"),
|
||||
t("brk.floor", "Floor", "⌊⬚⌋"),
|
||||
t("brk.ceil", "Ceiling", "⌈⬚⌉"),
|
||||
t("brk.abs", "Vertical Bars", "|⬚|"),
|
||||
t("brk.norm", "Double Vertical Bars", "‖⬚‖"),
|
||||
t("brk.dbl", "Double Square Brackets", "⟦⬚⟧"),
|
||||
t("brk.leftSquare", "Left Bracket Twice", "├[⬚[┤"),
|
||||
t("brk.rightSquare", "Right Bracket Twice", "├]⬚]┤"),
|
||||
t("brk.outward", "Outward Brackets", "├]⬚[┤"),
|
||||
],
|
||||
),
|
||||
(
|
||||
"Brackets with Separators",
|
||||
&[
|
||||
t("brk.parenSep", "Parentheses with Separator", "(⬚│⬚)"),
|
||||
t("brk.braceSep", "Braces with Separator", "{⬚│⬚}"),
|
||||
t("brk.angleSep", "Angle Brackets with Separator", "⟨⬚│⬚⟩"),
|
||||
t("brk.angleSep2", "Angle Brackets with Two Separators", "⟨⬚│⬚│⬚⟩"),
|
||||
],
|
||||
),
|
||||
(
|
||||
"Single Brackets",
|
||||
&[
|
||||
t("brk.leftParen", "Single Left Parenthesis", "(⬚┤"),
|
||||
t("brk.rightParen", "Single Right Parenthesis", "├⬚)"),
|
||||
t("brk.leftSquareOnly", "Single Left Bracket", "[⬚┤"),
|
||||
t("brk.rightSquareOnly", "Single Right Bracket", "├⬚]"),
|
||||
t("brk.leftBrace", "Single Left Brace", "{⬚┤"),
|
||||
t("brk.rightBrace", "Single Right Brace", "├⬚}"),
|
||||
t("brk.leftBar", "Single Left Bar", "├|⬚┤"),
|
||||
t("brk.leftAngle", "Single Left Angle", "⟨⬚┤"),
|
||||
],
|
||||
),
|
||||
(
|
||||
"Cases and Stacks",
|
||||
&[
|
||||
t("brk.cases2", "Cases (Two Conditions)", "{█(⬚@⬚)┤"),
|
||||
t("brk.cases3", "Cases (Three Conditions)", "{█(⬚@⬚@⬚)┤"),
|
||||
t("brk.stack2", "Stack Object", "█(⬚@⬚)"),
|
||||
t("brk.stack3", "Stack Object (Three)", "█(⬚@⬚@⬚)"),
|
||||
t("brk.binom", "Binomial Coefficient", "(⬚¦⬚)"),
|
||||
t("brk.binomAngle", "Binomial Coefficient with Angle Brackets", "⟨⬚¦⬚⟩"),
|
||||
],
|
||||
),
|
||||
(
|
||||
"Common Brackets",
|
||||
&[
|
||||
t("brk.casesAbs", "Absolute value cases", "f(x)={█(−x,&x<0@x,&x≥0)┤"),
|
||||
t("brk.nk", "Binomial Coefficient", "(n¦k)"),
|
||||
t("brk.nk2", "Binomial Coefficient (angle)", "⟨n¦k⟩"),
|
||||
],
|
||||
),
|
||||
],
|
||||
},
|
||||
Gallery {
|
||||
id: "function",
|
||||
label: "Function",
|
||||
sections: &[
|
||||
(
|
||||
"Trigonometric Functions",
|
||||
&[
|
||||
t("fn.sin", "Sine Function", "sin⬚"),
|
||||
t("fn.cos", "Cosine Function", "cos⬚"),
|
||||
t("fn.tan", "Tangent Function", "tan⬚"),
|
||||
t("fn.csc", "Cosecant Function", "csc⬚"),
|
||||
t("fn.sec", "Secant Function", "sec⬚"),
|
||||
t("fn.cot", "Cotangent Function", "cot⬚"),
|
||||
],
|
||||
),
|
||||
(
|
||||
"Inverse Functions",
|
||||
&[
|
||||
t("fn.asin", "Inverse Sine Function", "sin^(−1)⬚"),
|
||||
t("fn.acos", "Inverse Cosine Function", "cos^(−1)⬚"),
|
||||
t("fn.atan", "Inverse Tangent Function", "tan^(−1)⬚"),
|
||||
t("fn.acsc", "Inverse Cosecant Function", "csc^(−1)⬚"),
|
||||
t("fn.asec", "Inverse Secant Function", "sec^(−1)⬚"),
|
||||
t("fn.acot", "Inverse Cotangent Function", "cot^(−1)⬚"),
|
||||
],
|
||||
),
|
||||
(
|
||||
"Hyperbolic Functions",
|
||||
&[
|
||||
t("fn.sinh", "Hyperbolic Sine Function", "sinh⬚"),
|
||||
t("fn.cosh", "Hyperbolic Cosine Function", "cosh⬚"),
|
||||
t("fn.tanh", "Hyperbolic Tangent Function", "tanh⬚"),
|
||||
t("fn.csch", "Hyperbolic Cosecant Function", "csch⬚"),
|
||||
t("fn.sech", "Hyperbolic Secant Function", "sech⬚"),
|
||||
t("fn.coth", "Hyperbolic Cotangent Function", "coth⬚"),
|
||||
],
|
||||
),
|
||||
(
|
||||
"Inverse Hyperbolic Functions",
|
||||
&[
|
||||
t("fn.asinh", "Inverse Hyperbolic Sine Function", "sinh^(−1)⬚"),
|
||||
t("fn.acosh", "Inverse Hyperbolic Cosine Function", "cosh^(−1)⬚"),
|
||||
t("fn.atanh", "Inverse Hyperbolic Tangent Function", "tanh^(−1)⬚"),
|
||||
t("fn.acsch", "Inverse Hyperbolic Cosecant Function", "csch^(−1)⬚"),
|
||||
t("fn.asech", "Inverse Hyperbolic Secant Function", "sech^(−1)⬚"),
|
||||
t("fn.acoth", "Inverse Hyperbolic Cotangent Function", "coth^(−1)⬚"),
|
||||
],
|
||||
),
|
||||
(
|
||||
"Common Functions",
|
||||
&[
|
||||
t("fn.sintheta", "Sine theta", "sinθ"),
|
||||
t("fn.cos2x", "Cosine 2x", "cos2x"),
|
||||
t("fn.tanIdentity", "Tangent formula", "tanθ=sinθ/cosθ"),
|
||||
],
|
||||
),
|
||||
],
|
||||
},
|
||||
Gallery {
|
||||
id: "accent",
|
||||
label: "Accent",
|
||||
sections: &[
|
||||
(
|
||||
"Accents",
|
||||
&[
|
||||
t("acc.dot", "Dot", "⬚\u{307}"),
|
||||
t("acc.ddot", "Double Dot", "⬚\u{308}"),
|
||||
t("acc.dddot", "Triple Dot", "⬚\u{20DB}"),
|
||||
t("acc.hat", "Hat", "⬚\u{302}"),
|
||||
t("acc.check", "Check", "⬚\u{30C}"),
|
||||
t("acc.acute", "Acute", "⬚\u{301}"),
|
||||
t("acc.grave", "Grave", "⬚\u{300}"),
|
||||
t("acc.breve", "Breve", "⬚\u{306}"),
|
||||
t("acc.tilde", "Tilde", "⬚\u{303}"),
|
||||
t("acc.bar", "Bar", "⬚\u{305}"),
|
||||
t("acc.dbar", "Double Bar", "⬚\u{33F}"),
|
||||
t("acc.overbrace", "Overbrace", "⏞⬚"),
|
||||
t("acc.underbrace", "Underbrace", "⏟⬚"),
|
||||
t("acc.braceAbove", "Grouping Character Above", "⏞(⬚)┴⬚"),
|
||||
t("acc.braceBelow", "Grouping Character Below", "⏟(⬚)┬⬚"),
|
||||
t("acc.leftArrow", "Leftwards Arrow Above", "⬚\u{20D6}"),
|
||||
t("acc.rightArrow", "Rightwards Arrow Above", "⬚\u{20D7}"),
|
||||
t("acc.bothArrow", "Left Right Arrow Above", "⬚\u{20E1}"),
|
||||
t("acc.leftHarpoon", "Leftwards Harpoon Above", "⬚\u{20D0}"),
|
||||
t("acc.rightHarpoon", "Rightwards Harpoon Above", "⬚\u{20D1}"),
|
||||
],
|
||||
),
|
||||
("Boxed Formulas", &[t("acc.boxed", "Boxed Formula", "▭(⬚)"), t("acc.boxedPythagoras", "Boxed Formula (example)", "▭(a^2=b^2+c^2)")]),
|
||||
("Overbars and Underbars", &[t("acc.overbar", "Overbar", "¯(⬚)"), t("acc.underbar", "Underbar", "▁(⬚)")]),
|
||||
(
|
||||
"Common Accent Objects",
|
||||
&[
|
||||
t("acc.vecA", "Vector A", "A\u{20D7}"),
|
||||
t("acc.vecAB", "Vector AB", "(AB)\u{20E1}"),
|
||||
t("acc.xbar", "x bar", "x\u{305}"),
|
||||
t("acc.notAxorB", "A xor B", "¯(A⊕B)"),
|
||||
],
|
||||
),
|
||||
],
|
||||
},
|
||||
Gallery {
|
||||
id: "limitLog",
|
||||
label: "Limit and\nLog",
|
||||
sections: &[
|
||||
(
|
||||
"Functions",
|
||||
&[
|
||||
t("lim.logBase", "Logarithm with Base", "log_⬚⬚"),
|
||||
t("lim.log", "Logarithm", "log⬚"),
|
||||
t("lim.lim", "Limit", "lim┬⬚⬚"),
|
||||
t("lim.min", "Minimum", "min┬⬚⬚"),
|
||||
t("lim.max", "Maximum", "max┬⬚⬚"),
|
||||
t("lim.ln", "Natural Logarithm", "ln⬚"),
|
||||
],
|
||||
),
|
||||
(
|
||||
"Common Functions",
|
||||
&[t("lim.e", "Limit Example", "lim┬(n→∞)〖(1+1/n)^n〗"), t("lim.maxExample", "Maximum Example", "max┬(0≤x≤1)〖xe^(−x^2)〗")],
|
||||
),
|
||||
],
|
||||
},
|
||||
Gallery {
|
||||
id: "operator",
|
||||
label: "Operator",
|
||||
sections: &[
|
||||
(
|
||||
"Common Operators",
|
||||
&[
|
||||
t("op.colonEq", "Colon Equal", "≔"),
|
||||
t("op.eqeq", "Equal Equal", "=="),
|
||||
t("op.plusEq", "Plus Equal", "+="),
|
||||
t("op.minusEq", "Minus Equal", "−="),
|
||||
t("op.defEq", "Equal by Definition", "≝"),
|
||||
t("op.measured", "Measured By", "≞"),
|
||||
t("op.delta", "Delta Equal To", "≜"),
|
||||
],
|
||||
),
|
||||
(
|
||||
"Operator Structures",
|
||||
&[
|
||||
t("op.leftArrowBelow", "Left Arrow with Text Below", "⟵┬⬚"),
|
||||
t("op.rightArrowBelow", "Right Arrow with Text Below", "⟶┬⬚"),
|
||||
t("op.leftArrowAbove", "Left Arrow with Text Above", "⟵┴⬚"),
|
||||
t("op.rightArrowAbove", "Right Arrow with Text Above", "⟶┴⬚"),
|
||||
t("op.LeftArrowBelow", "Double Left Arrow with Text Below", "⟸┬⬚"),
|
||||
t("op.RightArrowBelow", "Double Right Arrow with Text Below", "⟹┬⬚"),
|
||||
t("op.LeftArrowAbove", "Double Left Arrow with Text Above", "⟸┴⬚"),
|
||||
t("op.RightArrowAbove", "Double Right Arrow with Text Above", "⟹┴⬚"),
|
||||
t("op.bothArrowBelow", "Left Right Arrow with Text Below", "⟷┬⬚"),
|
||||
t("op.bothArrowAbove", "Left Right Arrow with Text Above", "⟷┴⬚"),
|
||||
t("op.BothArrowBelow", "Double Left Right Arrow with Text Below", "⟺┬⬚"),
|
||||
t("op.BothArrowAbove", "Double Left Right Arrow with Text Above", "⟺┴⬚"),
|
||||
t("op.colonEqAbove", "Text over Colon Equal", "≔┴⬚"),
|
||||
t("op.eqAbove", "Text over Equal", "=┴⬚"),
|
||||
],
|
||||
),
|
||||
("Common Operator Structures", &[t("op.yields", "Yields", "⟶┴yields"), t("op.deltaArrow", "Delta over arrow", "⟶┴∆")]),
|
||||
],
|
||||
},
|
||||
Gallery {
|
||||
id: "matrix",
|
||||
label: "Matrix",
|
||||
sections: &[
|
||||
(
|
||||
"Empty Matrices",
|
||||
&[
|
||||
t("mat.1x2", "1×2 Empty Matrix", "■(⬚&⬚)"),
|
||||
t("mat.2x1", "2×1 Empty Matrix", "■(⬚@⬚)"),
|
||||
t("mat.1x3", "1×3 Empty Matrix", "■(⬚&⬚&⬚)"),
|
||||
t("mat.3x1", "3×1 Empty Matrix", "■(⬚@⬚@⬚)"),
|
||||
t("mat.2x2", "2×2 Empty Matrix", "■(⬚&⬚@⬚&⬚)"),
|
||||
t("mat.2x3", "2×3 Empty Matrix", "■(⬚&⬚&⬚@⬚&⬚&⬚)"),
|
||||
t("mat.3x2", "3×2 Empty Matrix", "■(⬚&⬚@⬚&⬚@⬚&⬚)"),
|
||||
t("mat.3x3", "3×3 Empty Matrix", "■(⬚&⬚&⬚@⬚&⬚&⬚@⬚&⬚&⬚)"),
|
||||
],
|
||||
),
|
||||
(
|
||||
"Dots",
|
||||
&[
|
||||
t("mat.cdots", "Midline Dots", "⋯"),
|
||||
t("mat.ldots", "Baseline Dots", "…"),
|
||||
t("mat.vdots", "Vertical Dots", "⋮"),
|
||||
t("mat.ddots", "Diagonal Dots", "⋱"),
|
||||
t("mat.adots", "Up Diagonal Dots", "⋰"),
|
||||
],
|
||||
),
|
||||
(
|
||||
"Identity Matrices",
|
||||
&[
|
||||
t("mat.id2", "2×2 Identity Matrix", "■(1&0@0&1)"),
|
||||
t("mat.id2zeros", "2×2 Identity Matrix (sparse)", "■(1&⬚@⬚&1)"),
|
||||
t("mat.id3", "3×3 Identity Matrix", "■(1&0&0@0&1&0@0&0&1)"),
|
||||
t("mat.id3zeros", "3×3 Identity Matrix (sparse)", "■(1&⬚&⬚@⬚&1&⬚@⬚&⬚&1)"),
|
||||
],
|
||||
),
|
||||
(
|
||||
"Matrices with Brackets",
|
||||
&[
|
||||
t("mat.paren", "2×2 Matrix in Parentheses", "(■(⬚&⬚@⬚&⬚))"),
|
||||
t("mat.square", "2×2 Matrix in Brackets", "[■(⬚&⬚@⬚&⬚)]"),
|
||||
t("mat.det", "2×2 Determinant", "|■(⬚&⬚@⬚&⬚)|"),
|
||||
t("mat.norm", "2×2 Matrix in Double Bars", "‖■(⬚&⬚@⬚&⬚)‖"),
|
||||
],
|
||||
),
|
||||
(
|
||||
"Sparse Matrices",
|
||||
&[
|
||||
t("mat.sparse", "n×n Matrix", "(■(a_11&⋯&a_1n@⋮&⋱&⋮@a_m1&⋯&a_mn))"),
|
||||
t("mat.identityN", "n×n Identity Matrix", "[■(1&0&⋯&0@0&1&⋯&0@⋮&⋮&⋱&⋮@0&0&⋯&1)]"),
|
||||
],
|
||||
),
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
/// Every structure template.
|
||||
pub fn templates() -> impl Iterator<Item = &'static Template> {
|
||||
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!+⋯, −∞<x<∞"),
|
||||
("gaussian", "Integral of e^(−x²)", "∫_(−∞)^∞▒e^(−x^2) dx=√π"),
|
||||
("fourier", "Sine and cosine series of f(x)", "f(x)=a_0+∑_(n=1)^∞▒〖(a_n cos〖nπx/L〗+b_n sin〖nπx/L〗)〗"),
|
||||
("trig1", "Sum or difference of sines", "sinα±sinβ=2 sin〖1/2(α±β)〗 cos〖1/2(α∓β)〗"),
|
||||
("trig2", "Sum of cosines", "cosα+cosβ=2 cos〖1/2(α+β)〗 cos〖1/2(α−β)〗"),
|
||||
("normal", "Normal probability density", "f(x)=1/(σ√(2π)) e^(−(x−μ)^2/(2σ^2))"),
|
||||
("bayes", "Bayes' rule", "P(A|B)=(P(B|A)P(A))/P(B)"),
|
||||
];
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use wordcraft_doc::math::{MNode, to_linear};
|
||||
|
||||
fn has_leftover(a: &[MNode]) -> 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));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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<MathEdit>,
|
||||
/// 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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<Feature> {
|
|||
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};
|
||||
|
|
|
|||
|
|
@ -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<GlyphPart>,
|
||||
}
|
||||
|
||||
/// 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<f32>,
|
||||
pub radical_degree_bottom_raise_percent: f32,
|
||||
pub italics: HashMap<u32, f32>,
|
||||
pub top_accent: HashMap<u32, f32>,
|
||||
pub min_connector_overlap: f32,
|
||||
pub vert: HashMap<u32, Construction>,
|
||||
pub horiz: HashMap<u32, Construction>,
|
||||
}
|
||||
|
||||
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<u16> {
|
||||
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<i16> {
|
||||
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<usize> {
|
||||
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<u32> {
|
||||
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<u32, f32> {
|
||||
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<u32, Construction> {
|
||||
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<MathTable> {
|
||||
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<Arc<MathTable>> {
|
||||
static CACHE: OnceLock<Mutex<HashMap<u32, Option<Arc<MathTable>>>>> = 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<Arc<MathTable>>) {
|
||||
static CACHE: OnceLock<Mutex<HashMap<String, (FaceRef, Option<Arc<MathTable>>)>>> = 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<u8> = (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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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}");
|
||||
|
|
|
|||
|
|
@ -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<Para> {
|
|||
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() {
|
||||
|
|
|
|||
|
|
@ -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<Draw>) {
|
||||
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 {
|
||||
|
|
|
|||
|
|
@ -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<crate::math::MathLayout>)> {
|
||||
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<usize> {
|
||||
self.caret(pos).map(|c| c.page)
|
||||
|
|
|
|||
|
|
@ -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<u32, Arc<ParaLayout>>,
|
||||
/// 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<ParaLayout> {
|
||||
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);
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load Diff
|
|
@ -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<u32>,
|
||||
/// Laid-out equations by object index.
|
||||
pub maths: Vec<(usize, Arc<crate::math::MathLayout>)>,
|
||||
/// 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<std::ops::Range<usize>>,
|
||||
|
|
@ -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<std::ops::Range<usize>> = 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 {
|
||||
|
|
|
|||
|
|
@ -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| {
|
||||
|
|
|
|||
|
|
@ -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<Pos> {
|
|||
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);
|
||||
|
|
|
|||
|
|
@ -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<wordcraft_doc::math::Arg>, 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::<usize>(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<char> = chars.chars().collect();
|
||||
let start = ui.data(|d| d.get_temp::<usize>(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::<usize>(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;
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
Can't render this file because it contains an unexpected character in line 6 and column 25.
|
|
|
@ -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 すべての記号セット
|
||||
|
|
|
|||
|
Can't render this file because it has a wrong number of fields in line 3.
|
|
|
@ -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
|
||||
|
|
|
|||
|
Can't render this file because it contains an unexpected character in line 6 and column 25.
|
|
|
@ -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 Усі набори символів
|
||||
|
|
|
|||
|
Can't render this file because it contains an unexpected character in line 6 and column 31.
|
|
|
@ -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 所有符号集
|
||||
|
|
|
|||
|
Can't render this file because it contains an unexpected character in line 6 and column 25.
|
|
|
@ -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 所有符號集
|
||||
|
|
|
|||
|
Can't render this file because it has a wrong number of fields in line 4.
|
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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<String>,
|
||||
/// 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,
|
||||
|
|
|
|||
|
|
@ -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<TextureHandle> {
|
||||
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<egui::ColorImage> {
|
||||
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<egui::ColorImage> {
|
||||
snippet_blocks(base, vec![Block::Para(para)], w, h, ppp, margin, None)
|
||||
|
|
|
|||
|
|
@ -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 [
|
||||
|
|
|
|||
|
|
@ -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`)
|
||||
|
|
|
|||
Loading…
Reference in New Issue