From 1cea0f7e116ff609d169a8d5c2f584597b5c4c8e Mon Sep 17 00:00:00 2001 From: Dmitrii Khlapov Date: Sun, 18 Oct 2026 15:37:37 +0200 Subject: [PATCH] Equations: a script runs until a space or operator, as in Word (V_dd, x_2a) (#1609) (#1951) * WIP for #1609 * Equations: a script after _ or ^ is the whole run of letters and digits (#1609) Word for Mac 16.113.4: V_dd and V_(dd) build up the same (dd is all subscript), likewise x^ab, and Linear format shows V_dd bare. Co-Authored-By: Claude Opus 5.5 (1M context) * Equations: bare scripts end at a space, digits continue a script run, upright function names (#1609) Co-Authored-By: Claude Opus 5.5 (1M context) * Equations: a bare script also ends before a following period (#1609 review) Co-Authored-By: Claude Sonnet 5.5 * ROADMAP: drop three progress rows main already has Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: Dmitrii Khlapov <19991457+dkhlapov@users.noreply.github.com> Co-authored-by: Claude Opus 5.5 (1M context) Co-authored-by: Brandon Thomas --- ROADMAP.md | 2 + crates/doc/src/math.rs | 118 +++++++++++++++++++++++++++--- crates/doc/src/math_linear.rs | 20 +++-- crates/engine/src/cmd/equation.rs | 50 +++++++++++++ docs/target-app-parity.md | 1 + 5 files changed, 174 insertions(+), 17 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index a44e19f04..6d50a7ec1 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -150,6 +150,7 @@ Ranked, to beta (detail and remaining milestones in [docs/roadmap.md](docs/roadm | 2026-10-15 | Image size limits are checked before allocating (#1321): a picture whose header declares more RGBA bytes than the 512 MiB budget is refused before decoding (render and PDF share one helper in `wordcraft-geom`), and page rasters are capped at 100 million pixels in total, scaled down rather than only per side. | | 2026-10-15 | Saved DOCX files keep the order of mixed right-to-left and left-to-right text in Word: Hebrew around a Latin name, numbers, brackets or an e-mail address no longer changes places, and Word reports the Hebrew as Hebrew rather than Arabic (#1543). | | 2026-10-15 | Web: typing in Cyrillic or another non-Latin script no longer repeats text. WordCraft now honours the input method's request to erase around the caret before retyping (#1480). Chrome can still move the hidden text field's caret, so later letters may still land out of order. That cause is still open | +| 2026-10-15 | Equations: typing `V_dd` or `x^ab` and pressing Space keeps `dd` and `ab` whole in the subscript or superscript, like `V_(dd)`, as Word for Mac does; Linear format shows the bare form | |---|---| | 2026-10-15 | The font family caches are bounded (4096 families for text, 1024 for math, names over 256 bytes aren't remembered), so a document naming thousands of missing fonts can't grow memory; equation fonts are looked up again after a font is added or removed instead of keeping the old choice (#1354) | | 2026-10-15 | AutoCorrect recognizes `-->` as `→` (#2223), respecting replacement settings and undo without changing paragraph-start arrow bullets. | @@ -483,6 +484,7 @@ Ranked, to beta (detail and remaining milestones in [docs/roadmap.md](docs/roadm | 2026-10-16 | trivial | Progress log: Hebrew field results and smart quotes (#1670) | | 2026-10-16 | trivial | Progress log: Ctrl+wheel zoom no longer scrolls the document (#1743) | | 2026-10-16 | trivial | Hint-reliant CJK fonts execute TrueType contour assembly; Windows DFKai-SB no longer loses strokes. Synthetic local-font regression; no font binaries bundled. | +| 2026-10-15 | trivial | Progress log: multi-letter equation scripts (#1609) | | 2026-10-14 | trivial | Progress log: row table border exceptions | | 2026-10-12 | minor | Czech proofing review: short-word splits rank after deletion, NOSUGGEST excludes suggestions while accepting typed forms, affix comments apply only to headers, ratios keep spaces and repeated-word messages use Czech quotes; synthetic engine end-to-end and latency checks | | 2026-10-12 | minor | Czech spelling from the installed Hunspell dictionary, runtime-table suggestions and generic checks; Czech interface writes cs-CZ documents; no Czech hyphenation patterns bundled; estimates unchanged | diff --git a/crates/doc/src/math.rs b/crates/doc/src/math.rs index ce4fc5b44..d62030348 100644 --- a/crates/doc/src/math.rs +++ b/crates/doc/src/math.rs @@ -470,8 +470,27 @@ fn lin_arg(a: &[MNode], s: &mut String, depth: usize) { if depth > MAX_DEPTH { return; } - for n in a { + for (i, n) in a.iter().enumerate() { lin_node(n, s, depth + 1); + // A bare script or limit ends at a space: a letter, digit or period after it is its own item. + if matches!(n, MNode::Script { kind, .. } if *kind != ScriptKind::Pre) || matches!(n, MNode::Lim { .. }) { + let ends_bare = s.chars().next_back().is_some_and(|c| c.is_alphanumeric() || c == '.'); + if ends_bare && a.get(i + 1).and_then(first_lin_char).is_some_and(|c| c.is_alphanumeric() || c == '.') { + s.push(' '); + } + } + } +} + +/// The first character `n` is written with, when that is cheap to tell (letters and digits). +fn first_lin_char(n: &MNode) -> Option { + match n { + MNode::Run(r) => r.text.chars().next(), + MNode::Func { name, .. } => name.first().and_then(first_lin_char), + MNode::Script { kind, base, .. } if *kind != ScriptKind::Pre => base.first().and_then(first_lin_char), + MNode::Lim { e, .. } => e.first().and_then(first_lin_char), + MNode::Frac { num, .. } => num.first().and_then(first_lin_char), + _ => None, } } @@ -499,19 +518,25 @@ fn lin_operand(a: &[MNode], s: &mut String, depth: usize) { } } -/// A script or limit operand. The parser reads a script as one letter or a number (`x_10`), so a -/// longer run is grouped: `V_(dd)`, not `V_dd` (which reads back as V_d then d). +/// A script or limit operand. The parser reads a run of letters and digits up to the next space or +/// operator, so `V_dd` is written bare as Word's Linear format shows it (`V_(dd)` reads back the same). fn lin_script(a: &[MNode], s: &mut String, depth: usize) { + // Bare only when the parser reads the whole run back, in the same style. if let [MNode::Run(r)] = a && r.text.chars().nth(1).is_some() - && !(r.text.starts_with(|c: char| c.is_ascii_digit()) && r.text.chars().all(|c| c.is_ascii_digit() || c == '.')) { - s.push('('); - s.push_str(&r.text); - s.push(')'); - } else { - lin_operand(a, s, depth); + let upright_name = r.sty == Some(MSty::Plain) && crate::math_linear::FUNCTION_NAMES.contains(&r.text.as_str()); + let number = r.text.starts_with(|c: char| c.is_ascii_digit()) && r.text.chars().all(|c| c.is_alphanumeric() || c == '.'); + let word = r.text.starts_with(char::is_alphabetic) && r.text.chars().all(char::is_alphanumeric); + let upright_other = r.sty == Some(MSty::Plain) && !upright_name; + if upright_other || (!number && !word) { + s.push('('); + s.push_str(&r.text); + s.push(')'); + return; + } } + lin_operand(a, s, depth); } fn lin_node(n: &MNode, s: &mut String, depth: usize) { @@ -962,7 +987,8 @@ mod tests { #[test] fn multi_letter_scripts_round_trip() { - // A script reads one letter or a number, so `V_dd` is V_d then d: longer scripts are grouped. + // Word for Mac 16.113.4 (#1609): a script is the whole run up to a space or operator, and Word's + // Linear format shows it bare (`V_dd`); the parenthesised form reads back identically. let script = |kind, sub: &str, sup: &str| MNode::Script { kind, base: vec![run("V")], sub: parse_linear(sub), sup: parse_linear(sup) }; for n in [ vec![script(ScriptKind::Sub, "dd", "")], @@ -985,6 +1011,78 @@ mod tests { // Numbers and single letters stay bare. assert_eq!(to_linear(&[script(ScriptKind::SubSup, "10", "2.5")]), "V_10^2.5"); assert_eq!(to_linear(&[script(ScriptKind::Sub, "d", "")]), "V_d"); + assert_eq!(to_linear(&[script(ScriptKind::Sub, "dd", "")]), "V_dd"); + assert_eq!(to_linear(&[script(ScriptKind::Sup, "", "ab")]), "V^ab"); + } + + #[test] + fn multi_letter_script_operands_follow_word() { + // #1609, Word for Mac 16.113.4: `V_dd` and `V_(dd)` build up the same; `dd` is all subscript. + let sub_dd = MNode::Script { kind: ScriptKind::Sub, base: vec![run("V")], sub: vec![run("dd")], sup: vec![] }; + assert_eq!(parse_linear("V_dd"), vec![sub_dd.clone()]); + assert_eq!(parse_linear("V_(dd)"), vec![sub_dd]); + let sup_ab = MNode::Script { kind: ScriptKind::Sup, base: vec![run("x")], sub: vec![], sup: vec![run("ab")] }; + assert_eq!(parse_linear("x^ab"), vec![sup_ab.clone()]); + assert_eq!(parse_linear("x^(ab)"), vec![sup_ab]); + // Delimiters still end the operand. + let both = MNode::Script { kind: ScriptKind::SubSup, base: vec![run("x")], sub: vec![run("i")], sup: vec![run("2")] }; + assert_eq!(parse_linear("x_i^2"), vec![both]); + let a_i = MNode::Script { kind: ScriptKind::Sub, base: vec![run("a")], sub: vec![run("i")], sup: vec![] }; + assert_eq!(parse_linear("a_i b"), vec![a_i, run("b")]); + let both2 = MNode::Script { kind: ScriptKind::SubSup, base: vec![run("x")], sub: vec![run("ij")], sup: vec![run("kl")] }; + assert_eq!(parse_linear("x_ij^kl"), vec![both2]); + // Round trip is Word's bare form. + assert_eq!(to_linear(&parse_linear("V_(dd)")), "V_dd"); + } + + #[test] + fn a_bare_script_does_not_absorb_what_follows() { + // #1609 review: `x_i` then `y` must not read back as x with subscript `iy`. + let sc = + |kind, base: &str, sub: &str, sup: &str| MNode::Script { kind, base: vec![run(base)], sub: parse_linear(sub), sup: parse_linear(sup) }; + let sin_x = MNode::Func { name: vec![MNode::Run(MRun::plain("sin"))], e: vec![run("x")] }; + let lim = MNode::Lim { upper: false, e: vec![run("a")], lim: vec![run("n")] }; + for n in [ + vec![sc(ScriptKind::Sub, "x", "i", ""), run("y")], + vec![sc(ScriptKind::Sub, "a", "i", ""), sc(ScriptKind::Sub, "b", "j", "")], + vec![sc(ScriptKind::Sup, "x", "", "2"), run("y")], + vec![sc(ScriptKind::Sup, "x", "", "2"), run("3")], + vec![sc(ScriptKind::Sub, "x", "i", ""), sin_x], + vec![lim.clone(), run("y")], + vec![sc(ScriptKind::Sub, "x", "ab", ""), run("y")], + vec![sc(ScriptKind::Sub, "x", "i", ""), run(".5")], + ] { + let lin = to_linear(&n); + assert_eq!(parse_linear(&lin), n, "{lin}"); + } + assert_eq!(to_linear(&[sc(ScriptKind::Sub, "x", "i", ""), run("y")]), "x_i y"); + // Nothing extra when the script already ends in a bracket or a symbol follows. + assert_eq!(to_linear(&[sc(ScriptKind::Sub, "x", "i", ""), run("+")]), "x_i+"); + } + + #[test] + fn a_function_name_in_a_script_stays_upright() { + // Word for Mac (#1609): `x_max` and `x_(max)` both give an upright function name. + let up = vec![MNode::Script { kind: ScriptKind::Sub, base: vec![run("x")], sub: vec![MNode::Run(MRun::plain("max"))], sup: vec![] }]; + assert_eq!(parse_linear("x_max"), up); + assert_eq!(parse_linear("x_(max)"), up); + assert_eq!(parse_linear(&to_linear(&up)), up, "{}", to_linear(&up)); + // Other upright text is not a function name: it keeps its parentheses. + let other = vec![MNode::Script { kind: ScriptKind::Sub, base: vec![run("x")], sub: vec![MNode::Run(MRun::plain("ab"))], sup: vec![] }]; + assert_eq!(to_linear(&other), "x_(ab)"); + } + + #[test] + fn digits_then_letters_continue_a_script() { + // Word for Mac (#1609): a script runs to a space or operator, so `x_2a` is subscript "2a". + let sub = |t: &str| vec![MNode::Script { kind: ScriptKind::Sub, base: vec![run("x")], sub: vec![run(t)], sup: vec![] }]; + assert_eq!(parse_linear("x_2a"), sub("2a")); + assert_eq!(parse_linear("x_a2"), sub("a2")); + assert_eq!(parse_linear("x_αβ"), sub("αβ")); + assert_eq!(parse_linear("x_10"), sub("10")); + assert_eq!(parse_linear("x_2.5"), sub("2.5")); + assert_eq!(to_linear(&sub("2a")), "x_2a"); + assert_eq!(parse_linear(&to_linear(&sub("2a"))), sub("2a")); } #[test] diff --git a/crates/doc/src/math_linear.rs b/crates/doc/src/math_linear.rs index 5ba28a4a0..5c3318732 100644 --- a/crates/doc/src/math_linear.rs +++ b/crates/doc/src/math_linear.rs @@ -587,8 +587,8 @@ impl P { } } - /// A script or limit: a parenthesised group, a structure, a number, a signed operand or one - /// character. + /// A script or limit: a parenthesised group, a structure, a number, a signed operand, a run of + /// letters (and digits) or one other character. fn script_operand(&mut self, stops: &[char]) -> Arg { if self.tired() { return self.rest(); @@ -612,7 +612,7 @@ impl P { 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 == '.') { + while self.peek().is_some_and(|k| k.is_alphanumeric() || k == '.') { self.i += 1; } let t: String = self.c.get(start..self.i).unwrap_or(&[]).iter().collect(); @@ -624,10 +624,16 @@ impl P { 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 c.is_alphabetic() => { + // Word (#1609): the whole run of letters and digits up to a space or operator, so + // `V_dd` is V with a subscript `dd`, exactly like `V_(dd)`. + let start = self.i; + while self.peek().is_some_and(|k| k.is_alphanumeric()) { + self.i += 1; + } + let t: String = self.c.get(start..self.i).unwrap_or(&[]).iter().collect(); + // A known function name stays upright, as Word builds it. + vec![MNode::Run(if FUNCTION_NAMES.contains(&t.as_str()) { MRun::plain(t) } else { MRun::new(t) })] } Some(c) if !stops.contains(&c) && c != ' ' && !is_closer(c) => { self.i += 1; diff --git a/crates/engine/src/cmd/equation.rs b/crates/engine/src/cmd/equation.rs index 5371705e7..a9aea6204 100644 --- a/crates/engine/src/cmd/equation.rs +++ b/crates/engine/src/cmd/equation.rs @@ -891,4 +891,54 @@ mod tests { assert_eq!(s.run("equation.get", &json!({})).unwrap()["latex"], want, "{tex}"); } } + + #[test] + fn typed_multi_letter_scripts_stay_whole() { + // #1609, Word for Mac 16.113.4: `V_dd` + Space leaves "dd" entirely in the subscript. + for typed in ["V_dd", "V_(dd)"] { + let mut s = session(); + s.run("insert.equation", &json!({})).unwrap(); + s.run("equation.type", &json!({"text": typed})).unwrap(); + s.run("equation.type", &json!({"text": " "})).unwrap(); + let nodes = nodes_of(&s, &s.math.as_ref().unwrap().at).unwrap(); + assert_eq!(nodes.len(), 1, "{typed}: {nodes:#?}"); + assert!( + matches!(&nodes[0], MNode::Script { sub, .. } if matches!(sub.as_slice(), [MNode::Run(r)] if r.text == "dd")), + "{typed}: {nodes:#?}" + ); + } + let mut s = session(); + s.run("insert.equation", &json!({})).unwrap(); + s.run("equation.type", &json!({"text": "x^ab "})).unwrap(); + let nodes = nodes_of(&s, &s.math.as_ref().unwrap().at).unwrap(); + assert!(matches!(&nodes[0], MNode::Script { sup, .. } if matches!(sup.as_slice(), [MNode::Run(r)] if r.text == "ab")), "{nodes:#?}"); + } + + #[test] + fn a_built_script_keeps_the_letter_typed_after_it() { + // #1609 review: `x_i y ` built x_{iy}: the letter after a script is its own item. + let mut s = session(); + s.run("insert.equation", &json!({})).unwrap(); + s.run("equation.type", &json!({"text": "x_i y "})).unwrap(); + let nodes = nodes_of(&s, &s.math.as_ref().unwrap().at).unwrap(); + assert_eq!(nodes.len(), 2, "{nodes:#?}"); + assert!(matches!(&nodes[0], MNode::Script { sub, .. } if matches!(sub.as_slice(), [MNode::Run(r)] if r.text == "i")), "{nodes:#?}"); + assert!(matches!(&nodes[1], MNode::Run(r) if r.text.trim() == "y"), "{nodes:#?}"); + let mut s = session(); + s.run("insert.equation", &json!({})).unwrap(); + s.run("equation.type", &json!({"text": "a_i b_j "})).unwrap(); + let nodes = nodes_of(&s, &s.math.as_ref().unwrap().at).unwrap(); + let subs: Vec<&str> = nodes + .iter() + .filter_map(|n| match n { + MNode::Script { sub, .. } => match sub.as_slice() { + [MNode::Run(r)] => Some(r.text.as_str()), + _ => None, + }, + _ => None, + }) + .collect(); + assert_eq!(subs, ["i", "j"], "{nodes:#?}"); + assert!(nodes.iter().all(|n| matches!(n, MNode::Script { .. }) || matches!(n, MNode::Run(r) if r.text.trim().is_empty())), "{nodes:#?}"); + } } diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index db2a42bdb..9534c00fe 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -252,6 +252,7 @@ can be used without committing Word output. | Date | Change | Summary | |---|---|---| | 2026-10-16 | trivial | East Asian typography row: `w:eastAsianLayout` kept on save, not laid out; no percentage change | +| 2026-10-15 | trivial | Equations: a script or limit after `_` or `^` is the whole run of letters and digits (`V_dd`, `x^ab`), as in Word for Mac 16.113.4, and Linear format writes it bare (#1609) | | 2026-10-12 | minor | Czech proofing review: short-word splits rank after deletion, NOSUGGEST excludes suggestions while accepting typed forms, affix comments apply only to headers, ratios keep spaces and repeated-word messages use Czech quotes; synthetic engine end-to-end and latency checks | | 2026-10-12 | minor | Czech spelling from the installed Hunspell dictionary, runtime-table suggestions and generic checks; Czech interface writes cs-CZ documents; no Czech hyphenation patterns bundled; estimates unchanged | | 2026-10-13 | trivial | Windows Magnifier follows the caret onto a new line (#1119): Accessibility row updated; no percentage change |