mirror of
https://github.com/storytold/wordcraft.git
synced 2026-10-31 22:07:06 -06:00
* 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:
co-authored by
Claude Opus 5.5
Dmitrii Khlapov
Brandon Thomas
parent
d04d756c4e
commit
1cea0f7e11
@@ -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
@@ -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]
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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:#?}");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 |
|
||||
|
||||
Reference in New Issue
Block a user