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) <noreply@anthropic.com>

* 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) <noreply@anthropic.com>

* Equations: a bare script also ends before a following period (#1609 review)

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>

* ROADMAP: drop three progress rows main already has

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Dmitrii Khlapov <19991457+dkhlapov@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
This commit is contained in:
Dmitrii Khlapov
2026-10-18 09:37:37 -04:00
committed by GitHub
co-authored by Claude Opus 5.5 Dmitrii Khlapov Brandon Thomas
parent d04d756c4e
commit 1cea0f7e11
5 changed files with 174 additions and 17 deletions
+2
View File
@@ -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 |
+108 -10
View File
@@ -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<char> {
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]
+13 -7
View File
@@ -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;
+50
View File
@@ -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:#?}");
}
}
+1
View File
@@ -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 |