From 498d9aaa9bf5d327e3929a87f97f3f0b9f0f6b6f Mon Sep 17 00:00:00 2001 From: dhruvLearner8 <83899022+dhruvLearner8@users.noreply.github.com> Date: Sun, 18 Oct 2026 02:21:05 -0600 Subject: [PATCH 01/38] Context menu: Remove Table of Contents on a TOC entry (#2179) (#2217) --- crates/ui-egui/src/canvas.rs | 36 ++++++++++++++++++++++++++++++++++++ 1 file changed, 36 insertions(+) diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 884a686218..ec1e4e7527 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -2990,6 +2990,7 @@ fn context_menu(app: &mut WordApp, ui: &mut Ui) { // The TOC field sits in the table's heading; its entries are plain paragraphs. ui.separator(); item(ui, app, tl!("Update Field"), "references.updateToc", json!({})); + item(ui, app, tl!("Remove Table of Contents"), "references.removeToc", json!({})); } let selected_object = wordcraft_engine::cmd::objects::selected(&app.session).is_some(); if selected_object || app.session.sel.focus.path.cell().is_some() { @@ -4105,6 +4106,41 @@ mod tests { assert_eq!(text.matches("Fresh heading").count(), 2, "the new heading is listed in the table: {text}"); } + /// #2179: the context menu on a table of contents entry also offers Remove Table of Contents. + #[test] + fn context_menu_on_a_toc_entry_removes_the_table() { + use egui_kittest::kittest::Queryable; + let mut a = WordApp::new(wordcraft_engine::Session::new(wordcraft_engine::sample::report()), Default::default()); + a.run("caret.docStart", json!({})).unwrap(); + a.run("references.toc", json!({})).unwrap(); + let is_entry = |b: &wordcraft_doc::Block| b.as_para().is_some_and(|p| p.props.style.as_deref() == Some("TOC1")); + let entry = a.session.doc.body.iter().position(|b| is_entry(b)).expect("a TOC entry"); + let mut h = egui_kittest::Harness::builder().with_size(egui::vec2(1440.0, 900.0)).build_ui_state( + |ui, app: &mut WordApp| { + let ctx = ui.ctx().clone(); + app.logic(&ctx); + app.ui(ui); + }, + a, + ); + for _ in 0..6 { + h.step(); + } + let c = h.state_mut().session.layout().caret(&Pos::body(entry, 2)).expect("the entry is laid out"); + let at = page_to_screen(h.state_mut(), c.page, c.x + 1.0, c.top + c.height / 2.0).expect("on screen"); + h.event(egui::Event::PointerMoved(at)); + for pressed in [true, false] { + h.event(egui::Event::PointerButton { pos: at, button: egui::PointerButton::Secondary, pressed, modifiers: Default::default() }); + h.step(); + } + h.step(); + h.get_by_label_contains("Remove Table of Contents").click(); + for _ in 0..3 { + h.step(); + } + assert!(!h.state().session.doc.body.iter().any(|b| is_entry(b)), "the table is gone"); + } + /// #996: on macOS a Control-click is the right-click (the trackpad's and the one-button /// mouse's way to it), so it opens the same menu and leaves the caret where the menu needs it. #[cfg(target_os = "macos")] From 35cbdb8002075781266d16145ed31d4fa7f979e7 Mon Sep 17 00:00:00 2001 From: divyanshu2074 <43274510+divyanshu2074@users.noreply.github.com> Date: Sun, 18 Oct 2026 13:51:24 +0530 Subject: [PATCH 02/38] fix(docx): support hex without hash, rgb(), and case-insensitive color names in VML and DrawingML (#2222) Co-authored-by: Brandon Thomas --- crates/docx/src/read/drawing_color.rs | 31 ++++++++++++++++++-- crates/docx/src/read/shape_style.rs | 42 ++++++++++++++++++++++----- 2 files changed, 62 insertions(+), 11 deletions(-) diff --git a/crates/docx/src/read/drawing_color.rs b/crates/docx/src/read/drawing_color.rs index 4d2c7b9283..3dd8bc7dcf 100644 --- a/crates/docx/src/read/drawing_color.rs +++ b/crates/docx/src/read/drawing_color.rs @@ -79,7 +79,8 @@ pub(crate) fn color(e: &El, theme: &[Rgb]) -> Option { /// A preset colour (`a:prstClr/@val`, ECMA-376 Part 1 §20.1.10.48): the common ones; others are /// unknown (`None`). pub(crate) fn preset_color(name: &str) -> Option { - Some(match name { + let lower = name.to_ascii_lowercase(); + Some(match lower.as_str() { "black" => Rgb(0, 0, 0), "white" => Rgb(0xFF, 0xFF, 0xFF), "red" => Rgb(0xFF, 0, 0), @@ -91,8 +92,8 @@ pub(crate) fn preset_color(name: &str) -> Option { "magenta" | "fuchsia" => Rgb(0xFF, 0, 0xFF), "gray" | "grey" => Rgb(0x80, 0x80, 0x80), "silver" => Rgb(0xC0, 0xC0, 0xC0), - "ltGray" | "ltGrey" => Rgb(0xD3, 0xD3, 0xD3), - "dkGray" | "dkGrey" => Rgb(0xA9, 0xA9, 0xA9), + "ltgray" | "ltgrey" | "lightgray" | "lightgrey" => Rgb(0xD3, 0xD3, 0xD3), + "dkgray" | "dkgrey" | "darkgray" | "darkgrey" => Rgb(0xA9, 0xA9, 0xA9), "maroon" => Rgb(0x80, 0, 0), "navy" => Rgb(0, 0, 0x80), "olive" => Rgb(0x80, 0x80, 0), @@ -101,6 +102,18 @@ pub(crate) fn preset_color(name: &str) -> Option { "orange" => Rgb(0xFF, 0xA5, 0), "brown" => Rgb(0xA5, 0x2A, 0x2A), "pink" => Rgb(0xFF, 0xC0, 0xCB), + "gold" => Rgb(0xFF, 0xD7, 0), + "darkblue" | "dkblue" => Rgb(0, 0, 0x8B), + "darkred" | "dkred" => Rgb(0x8B, 0, 0), + "darkgreen" | "dkgreen" => Rgb(0, 0x64, 0), + "lightblue" | "ltblue" => Rgb(0xAD, 0xD8, 0xE6), + "coral" => Rgb(0xFF, 0x7F, 0x50), + "salmon" => Rgb(0xFA, 0x80, 0x72), + "tomato" => Rgb(0xFF, 0x63, 0x47), + "violet" => Rgb(0xEE, 0x82, 0xEE), + "indigo" => Rgb(0x4B, 0, 0x82), + "beige" => Rgb(0xF5, 0xF5, 0xDC), + "ivory" => Rgb(0xFF, 0xFF, 0xF0), _ => return None, }) } @@ -241,4 +254,16 @@ mod tests { let b = el("a:srgbClr", r#"val="808080""#, r#""#); assert_ne!(color(&a, &theme), color(&b, &theme)); } + + #[test] + fn preset_colors_are_case_insensitive_and_include_common_names() { + assert_eq!(preset_color("red"), Some(Rgb(255, 0, 0))); + assert_eq!(preset_color("Red"), Some(Rgb(255, 0, 0))); + assert_eq!(preset_color("darkBlue"), Some(Rgb(0, 0, 0x8B))); + assert_eq!(preset_color("dkBlue"), Some(Rgb(0, 0, 0x8B))); + assert_eq!(preset_color("lightGray"), Some(Rgb(0xD3, 0xD3, 0xD3))); + assert_eq!(preset_color("ltGray"), Some(Rgb(0xD3, 0xD3, 0xD3))); + assert_eq!(preset_color("gold"), Some(Rgb(0xFF, 0xD7, 0))); + assert_eq!(preset_color("unknownColor"), None); + } } diff --git a/crates/docx/src/read/shape_style.rs b/crates/docx/src/read/shape_style.rs index 62d0e01f69..9d66436a99 100644 --- a/crates/docx/src/read/shape_style.rs +++ b/crates/docx/src/read/shape_style.rs @@ -169,18 +169,39 @@ fn dash_ratio(value: &str) -> Option { Some(units.round() as u32) } -/// A VML colour (`strokecolor`, `fillcolor`): `#rrggbb` or `#rgb` (Word follows it with the -/// theme slot, as in `#4472c4 [3204]`), or a colour name. +/// A VML colour (`strokecolor`, `fillcolor`): `#rrggbb`, `rrggbb`, `#rgb` or `rgb` (Word follows it +/// with the theme slot, as in `#4472c4 [3204]` or `4472c4 [3204]`), an `rgb(...)` function, or a +/// colour name. pub(crate) fn vml_color(v: &str) -> Option { - let v = v.split_whitespace().next()?; - if let Some(hex) = v.strip_prefix('#') { - if hex.len() == 3 && hex.is_ascii() { - let long: String = hex.chars().flat_map(|c| [c, c]).collect(); - return Rgb::parse(&long); + let s = v.trim(); + if let Some(inner) = s.strip_prefix("rgb(").or_else(|| s.strip_prefix("rgba(")) { + let nums: Vec = inner + .trim_end_matches(')') + .split(',') + .take(3) + .filter_map(|x| { + let x = x.trim(); + if let Some(p) = x.strip_suffix('%') { + p.trim().parse::().ok().map(|f| (f.clamp(0.0, 100.0) * 2.55).round() as u8) + } else { + x.parse::().ok().map(|f| f.clamp(0.0, 255.0).round() as u8) + } + }) + .collect(); + if let [r, g, b] = nums[..] { + return Some(Rgb(r, g, b)); } + } + let tok = s.split_whitespace().next()?; + let hex = tok.strip_prefix('#').unwrap_or(tok); + if hex.len() == 3 && hex.chars().all(|c| c.is_ascii_hexdigit()) { + let long: String = hex.chars().flat_map(|c| [c, c]).collect(); + return Rgb::parse(&long); + } + if hex.len() == 6 && hex.chars().all(|c| c.is_ascii_hexdigit()) { return Rgb::parse(hex); } - super::drawing_color::preset_color(v) + super::drawing_color::preset_color(tok) } #[cfg(test)] @@ -279,8 +300,13 @@ mod tests { #[test] fn vml_colours() { assert_eq!(vml_color("#ffc000 [3207]"), Some(Rgb(0xFF, 0xC0, 0))); + assert_eq!(vml_color("ffc000 [3207]"), Some(Rgb(0xFF, 0xC0, 0))); assert_eq!(vml_color("#f00"), Some(Rgb(0xFF, 0, 0))); + assert_eq!(vml_color("f00"), Some(Rgb(0xFF, 0, 0))); assert_eq!(vml_color("black"), Some(Rgb(0, 0, 0))); + assert_eq!(vml_color("Red"), Some(Rgb(0xFF, 0, 0))); + assert_eq!(vml_color("rgb(255, 128, 0)"), Some(Rgb(255, 128, 0))); + assert_eq!(vml_color("rgb(100%, 0%, 0%)"), Some(Rgb(255, 0, 0))); assert_eq!(vml_color("#é"), None); assert_eq!(vml_color(""), None); } From 1d370431287e14ad152a7999a47b45bbce0af5c0 Mon Sep 17 00:00:00 2001 From: Dmitrii Khlapov Date: Sun, 18 Oct 2026 10:21:42 +0200 Subject: [PATCH 03/38] AutoCorrect: reversing a capitalisation adds an automatic exception, as in Word (#1857) (#2227) * Co-Authored-By: Claude Opus 5.5 * Co-Authored-By: Claude Opus 5.5 * #1857: forget the capital when a command fails; INitial CAps auto-add checked in Word 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 Co-authored-by: Brandon Thomas --- ROADMAP.md | 1 + crates/engine/src/cmd/text.rs | 14 ++- crates/engine/src/cmd/tools.rs | 140 ++++++++++++++++++++++++--- crates/engine/src/session.rs | 18 ++++ crates/engine/src/tests_typing.rs | 101 +++++++++++++++++++ crates/ui-egui/src/dialogs_review.rs | 4 + docs/typing-parity.md | 25 ++++- docs/ui-parity.md | 1 + 8 files changed, 288 insertions(+), 16 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 3c4d1f06a6..fa6afca0e6 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -137,6 +137,7 @@ Ranked, to beta (detail and remaining milestones in [docs/roadmap.md](docs/roadm - 2026-10-15: Find opens Navigation on Results; editing the query restores Results after manual Headings/Pages navigation. Typed and selected-text searches retain document contents (#2096). | Date | What landed | +| 2026-10-15 | Reversing an automatic sentence capital (Undo, or Backspace and retype) adds the word before it to AutoCorrect's First Letter exceptions, as Word does; "Automatically add words to list" switches for First Letter and INitial CAps (#1857). | | 2026-10-15 | Dialog close control: a Windows-style box at the title bar's right end, the whole box clickable, red on hover, in both themes (#1885) | | 2026-10-15 | Font box: a Recently Used Fonts section at the top of the list (the fonts applied lately, most recent first, ten at most, kept between runs), then All Fonts, as in Word (#2068) | | 2026-10-15 | A missing Thai document font (TH Sarabun New, Angsana New, Cordia New, the UPC families…) is substituted by one Thai face for the whole run, Latin letters and digits included, as Word substitutes Cordia New; Thai no longer sits in one font next to larger Latin in another (#1549) | diff --git a/crates/engine/src/cmd/text.rs b/crates/engine/src/cmd/text.rs index f741ef9c45..39dbd168fe 100644 --- a/crates/engine/src/cmd/text.rs +++ b/crates/engine/src/cmd/text.rs @@ -109,10 +109,10 @@ fn insert(s: &mut Session, v: &Value) -> CmdResult { // A list label ("a. ") becomes a list before AutoCorrect could capitalise it. let listed = text == " " && s.prefs.autocorrect.enabled && list_autoformat(s)?; if !listed { - super::tools::autocorrect(s)?; + super::tools::autocorrect(s).inspect_err(|_| s.autocap = None)?; } if text == " " && !listed && s.prefs.autocorrect.enabled && s.prefs.autocorrect.dashes { - dash_autoformat(s)?; + dash_autoformat(s).inspect_err(|_| s.autocap = None)?; } if let Some((para, sel)) = typed && s.doc.para_at(&f) != Some(¶) @@ -122,8 +122,14 @@ fn insert(s: &mut Session, v: &Value) -> CmdResult { *p = para; p.touch(); s.push_undo("AutoFormat", doc, sel); + // Undo right after it makes the capitalised word an exception. + let depth = s.undo_steps_so_far(); + if let Some(c) = s.autocap.as_mut().filter(|c| c.depth == u64::MAX) { + c.depth = depth; + } } } + s.autocap.take_if(|c| c.depth == u64::MAX); sel_result(s) } @@ -587,6 +593,7 @@ fn new_paragraph(s: &mut Session, _: &Value) -> CmdResult { // Enter finishes a word like a space does ("teh" → "the", sentence capitals, links). if s.sel.is_collapsed() && at.off > 0 { super::tools::autocorrect_word(s, true)?; + s.autocap.take_if(|c| c.depth == u64::MAX); at = s.sel.focus.clone(); } let new = split_para(s, &at)?; @@ -678,6 +685,9 @@ fn backspace_inner(s: &mut Session, _: &Value) -> CmdResult { let Some(para) = s.doc.para_at(&f) else { return sel_result(s) }; if f.off > 0 { let a = Pos { off: para.backspace_from(f.off), ..f.clone() }; + if let Some(c) = s.autocap.as_mut() { + c.note_backspace(&a, &f); + } s.sel = Selection { anchor: a, focus: f }; delete_selection(s)?; return sel_result(s); diff --git a/crates/engine/src/cmd/tools.rs b/crates/engine/src/cmd/tools.rs index 3a2fa008ad..2b919fe30c 100644 --- a/crates/engine/src/cmd/tools.rs +++ b/crates/engine/src/cmd/tools.rs @@ -112,6 +112,11 @@ pub struct AutoCorrectPrefs { pub exceptions_removed: Vec, /// The user's words that keep two initial capitals (besides the built-in ones). pub initial_caps_exceptions: Vec, + /// Undoing (or backspacing and retyping) an automatic sentence capital adds the word before + /// it to the exceptions (First Letter's "Automatically add words to list"). + pub auto_add_first_letter: bool, + /// The same for undoing a TWo INitial CApitals fix (INitial CAps's switch). + pub auto_add_initial_caps: bool, } impl Default for AutoCorrectPrefs { @@ -137,6 +142,8 @@ impl Default for AutoCorrectPrefs { exceptions: Vec::new(), exceptions_removed: Vec::new(), initial_caps_exceptions: Vec::new(), + auto_add_first_letter: true, + auto_add_initial_caps: true, } } } @@ -212,6 +219,30 @@ impl AutoCorrectPrefs { v } + /// Add a First Letter exception (lowercase; one the built-in list has just stops being deleted). + fn add_exception(&mut self, e: String) { + if !plain_word(&e) { + return; + } + self.exceptions_removed.retain(|r| *r != e); + if !NOT_SENTENCE_END.contains(&e.as_str()) && !self.exceptions.contains(&e) && self.exceptions.len() < MAX_ENTRIES { + self.exceptions.push(e); + } + } + + /// Add a word that keeps its two initial capitals. + fn add_initial_caps_exception(&mut self, w: &str) { + if !plain_word(w) { + return; + } + if !INITIAL_CAPS_EXCEPTIONS.contains(&w) + && !self.initial_caps_exceptions.iter().any(|e| e == w) + && self.initial_caps_exceptions.len() < MAX_ENTRIES + { + self.initial_caps_exceptions.push(w.to_string()); + } + } + /// Abbreviations after which a sentence doesn't end, lowercase. pub fn exception_list(&self) -> Vec { let mut v: Vec = NOT_SENTENCE_END @@ -226,6 +257,50 @@ impl AutoCorrectPrefs { } } +/// Is `w` a word an exception list may hold: one short line, no spaces or object characters? +fn plain_word(w: &str) -> bool { + !w.trim().is_empty() && w.chars().count() <= MAX_FROM && !w.contains(|c: char| c.is_whitespace() || c == '\u{FFFC}') +} + +/// The newest automatic capitalisation (a sentence capital, a TWo INitial CApitals fix), kept so +/// that undoing it, or backspacing it and typing the word again, makes an exception (#1857). +#[derive(Clone, Debug)] +pub struct AutoCap { + /// Undo steps pushed (ever) right after the change (`u64::MAX` until the caller has pushed its step). + pub(crate) depth: u64, + story: wordcraft_doc::StoryRef, + path: wordcraft_doc::Path, + /// Byte offset of the word in its paragraph. + start: usize, + /// The token before a sentence capital's word, lowercase ("there."); empty when none or when + /// the capital wasn't a sentence one. + prev: String, + /// The TWo INitial CApitals word as typed ("TWo"). + two_caps: Option, + /// The user backspaced into the word after the change. + backspaced: bool, +} + +impl AutoCap { + pub(crate) fn add_exceptions(self, ac: &mut AutoCorrectPrefs) { + if ac.auto_add_first_letter && !self.prev.is_empty() { + ac.add_exception(self.prev); + } + if ac.auto_add_initial_caps + && let Some(w) = self.two_caps + { + ac.add_initial_caps_exception(&w); + } + } + + /// A Backspace deleted `a..f`; it reached back over the capitalised word's first letter. + pub(crate) fn note_backspace(&mut self, a: &Pos, f: &Pos) { + if f.story == self.story && f.path == self.path && a.off <= self.start && f.off > self.start { + self.backspaced = true; + } + } +} + /// Saved AutoCorrect options: anything that doesn't read as them reads as the defaults (a /// damaged `ui.json` never loses the other preferences). pub fn lenient_autocorrect<'de, D: serde::Deserializer<'de>>(d: D) -> Result { @@ -252,6 +327,8 @@ fn autocorrect_options(s: &mut Session, v: &Value) -> CmdResult { ("bullets", &mut ac.bullets), ("numbering", &mut ac.numbering), ("borderLines", &mut ac.border_lines), + ("autoAddFirstLetter", &mut ac.auto_add_first_letter), + ("autoAddInitialCaps", &mut ac.auto_add_initial_caps), ] { if let Some(b) = p::bool(v, k) { *f = b; @@ -286,10 +363,7 @@ fn autocorrect_options(s: &mut Session, v: &Value) -> CmdResult { if bad(&e, MAX_FROM) || e.contains(char::is_whitespace) { return Err(CmdError::Params("an exception is one abbreviation, like \"approx.\"".into())); } - ac.exceptions_removed.retain(|r| *r != e); - if !NOT_SENTENCE_END.contains(&e.as_str()) && !ac.exceptions.contains(&e) && ac.exceptions.len() < MAX_ENTRIES { - ac.exceptions.push(e); - } + ac.add_exception(e); } if let Some(e) = p::str(v, "deleteException") { let e = e.trim().to_lowercase(); @@ -303,12 +377,7 @@ fn autocorrect_options(s: &mut Session, v: &Value) -> CmdResult { if bad(w, MAX_FROM) || w.contains(char::is_whitespace) { return Err(CmdError::Params("an INitial CAps exception is one word, like \"IDs\"".into())); } - if !INITIAL_CAPS_EXCEPTIONS.contains(&w) - && !ac.initial_caps_exceptions.iter().any(|e| e == w) - && ac.initial_caps_exceptions.len() < MAX_ENTRIES - { - ac.initial_caps_exceptions.push(w.to_string()); - } + ac.add_initial_caps_exception(w); } if let Some(w) = p::str(v, "deleteInitialCapsException") { let w = w.trim(); @@ -331,6 +400,8 @@ fn autocorrect_options(s: &mut Session, v: &Value) -> CmdResult { "bullets": ac.bullets, "numbering": ac.numbering, "borderLines": ac.border_lines, + "autoAddFirstLetter": ac.auto_add_first_letter, + "autoAddInitialCaps": ac.auto_add_initial_caps, "entries": ac.list().into_iter().map(|(a, b, builtin)| json!({"from": a, "to": b, "builtIn": builtin})).collect::>(), "user": ac.entries, "exceptions": ac.exception_list(), @@ -366,7 +437,7 @@ pub fn specs() -> Vec { .pure(), CommandSpec::new("tools.autocorrect", "AutoCorrect Options", "File › Options › Proofing", autocorrect_options) .params( - r#"{"enabled"?: bool (all of AutoCorrect), "replaceText"|"twoInitialCaps"|"capSentences"|"capCells"|"capDays"|"capsLock"|"smartQuotes"|"fractions"|"ordinals"|"dashes"|"links"|"bullets"|"numbering"|"borderLines"?: bool, "add"?: {"from": string, "to": string} (adds or replaces an entry), "delete"?: string (an entry's "from", built in or added), "addException"?: string, "deleteException"?: string, "addInitialCapsException"?: string (keeps TWo INitial CApitals, like "IDs"), "deleteInitialCapsException"?: string} → options, entries and exceptions"#, + r#"{"enabled"?: bool (all of AutoCorrect), "replaceText"|"twoInitialCaps"|"capSentences"|"capCells"|"capDays"|"capsLock"|"smartQuotes"|"fractions"|"ordinals"|"dashes"|"links"|"bullets"|"numbering"|"borderLines"?: bool, "autoAddFirstLetter"|"autoAddInitialCaps"?: bool (Automatically add words to the exception lists when an automatic capital is undone; default true), "add"?: {"from": string, "to": string} (adds or replaces an entry), "delete"?: string (an entry's "from", built in or added), "addException"?: string, "deleteException"?: string, "addInitialCapsException"?: string (keeps TWo INitial CApitals, like "IDs"), "deleteInitialCapsException"?: string} → options, entries and exceptions"#, ) .pure(), CommandSpec::new("tools.customizeKeyboard", "Customize Keyboard", "File › Options", customize_keyboard) @@ -793,6 +864,18 @@ pub fn autocorrect_word(s: &mut Session, on_enter: bool) -> Result<(), CmdError> } end = from; } + s.autocap = Some(AutoCap { + depth: u64::MAX, + story: f.story, + path: f.path.clone(), + start, + prev: String::new(), + two_caps: (ac.two_initial_caps + && letters.chars().nth(1).is_some_and(char::is_uppercase) + && letters.chars().next().is_some_and(char::is_uppercase)) + .then(|| letters.clone()), + backspaced: false, + }); let fixed: String = reps.concat(); f.off = (f.off + fixed.len()).saturating_sub(letters.len()); s.sel = Selection::caret(f.clone()); @@ -818,6 +901,7 @@ pub fn autocorrect_word(s: &mut Session, on_enter: bool) -> Result<(), CmdError> }, None => (start, None), }; + let replaced = rep.is_some(); let mut caret = f.off; if let Some(rep) = rep { let end = start + word.len(); @@ -866,7 +950,18 @@ pub fn autocorrect_word(s: &mut Session, on_enter: bool) -> Result<(), CmdError> } else { last }; - let sentence = sentence_on && starts_sentence(before_word, &exceptions) && should_capitalize(first, next); + let mut sentence = sentence_on && starts_sentence(before_word, &exceptions) && should_capitalize(first, next); + let prev = if sentence { prev_token(before_word) } else { String::new() }; + // Typing the word again after backspacing an automatic capital: the user doesn't want it + // (Word's "Automatically add words to list"); an exception, so no capital this time either. + if sentence + && !prev.is_empty() + && s.autocap.take_if(|c| c.backspaced && c.story == f.story && c.path == f.path && c.start == start && c.prev == prev).is_some() + && ac.auto_add_first_letter + { + s.prefs.autocorrect.add_exception(prev.clone()); + sentence = false; + } let day = ac.cap_days && DAYS.contains(&first); if sentence || day { let para = s.doc.para_mut(f.story, &f.path)?; @@ -876,12 +971,31 @@ pub fn autocorrect_word(s: &mut Session, on_enter: bool) -> Result<(), CmdError> para.delete(start, start + c.len_utf8())?; para.insert_text(start, &up, &props)?; let off = (caret + up.len()).saturating_sub(c.len_utf8()); - s.sel = Selection::caret(Pos { off, ..f }); + s.sel = Selection::caret(Pos { off, ..f.clone() }); + if sentence && !prev.is_empty() && !replaced { + match s.autocap.as_mut().filter(|c| c.depth == u64::MAX) { + Some(c) => c.prev = prev, + None => { + s.autocap = Some(AutoCap { depth: u64::MAX, story: f.story, path: f.path, start, prev, two_caps: None, backspaced: false }) + } + } + } } } Ok(()) } +/// The lowercase token before a word that starts a sentence ("there." in "there. world"), as +/// [`starts_sentence`] reads it; empty at the start of a paragraph. +fn prev_token(before: &str) -> String { + let t = before.trim_end().trim_end_matches(|c: char| "\"'”’)]".contains(c)); + // Only full stops: Word's exception was observed with "there." (`there?` isn't added). + if !t.ends_with('.') { + return String::new(); + } + t.rsplit(char::is_whitespace).next().unwrap_or("").trim_start_matches(|c: char| "\"'“‘([".contains(c)).to_lowercase() +} + /// Built-in words that keep their two initial capitals (plurals of two-letter abbreviations). pub const INITIAL_CAPS_EXCEPTIONS: &[&str] = &["CDs", "IDs", "PCs", "TVs", "DJs", "GBs", "KBs", "MBs", "TBs", "VMs"]; diff --git a/crates/engine/src/session.rs b/crates/engine/src/session.rs index 5668a8f93b..cfbb9f97f9 100644 --- a/crates/engine/src/session.rs +++ b/crates/engine/src/session.rs @@ -394,6 +394,8 @@ pub struct Session { typing_open: bool, /// The next mutating command joins the previous undo step (later frames of a drag). join_next: bool, + /// The newest automatic capitalisation, for Word's "Automatically add words to list" (#1857). + pub(crate) autocap: Option, rev: u64, /// Which document this is: changes when [`Session::set_document`] replaces it (open, new, /// mail merge, recover), never on an edit. @@ -628,6 +630,7 @@ impl Session { redo: Vec::new(), typing_open: false, join_next: false, + autocap: None, rev: 1, document_id: 1, cache: LayoutCache::new(), @@ -920,6 +923,10 @@ impl Session { pub fn redo_label(&self) -> Option<&str> { self.redo.last().map(|u| u.label.as_str()) } + /// Undo steps pushed so far, evicted ones included: only grows while steps are added. + pub(crate) fn undo_steps_so_far(&self) -> u64 { + self.history.len() as u64 + self.undo_evicted + } /// The number of undo steps. pub fn undo_depth(&self) -> usize { self.history.len() @@ -944,6 +951,12 @@ impl Session { } pub fn undo(&mut self) -> bool { self.typing_open = false; + // Undoing the automatic capitalisation's own step makes its word an exception; any + // other Undo forgets the capital. + let steps = self.history.len() as u64 + self.undo_evicted; + if let Some(c) = self.autocap.take().filter(|c| c.depth == steps) { + c.add_exceptions(&mut self.prefs.autocorrect); + } let Some(u) = self.history.pop().map(Arc::unwrap_or_clone) else { return false }; let cur = Undo { label: u.label.clone(), @@ -977,6 +990,7 @@ impl Session { } /// Drop history (after open / new). pub fn reset_history(&mut self) { + self.autocap = None; self.history.clear(); self.redo.clear(); self.typing_open = false; @@ -1009,6 +1023,7 @@ impl Session { /// Replace the document (open/new). pub fn set_document(&mut self, doc: Document) { + self.autocap = None; self.document_id = self.document_id.wrapping_add(1); self.bib_style = doc_bib_style(&doc); self.doc = doc; @@ -1079,6 +1094,7 @@ impl Session { redo, typing_open, join_next, + autocap: _, rev: _, cache: _, layout: _, @@ -1143,6 +1159,7 @@ impl Session { self.history.splice(0..0, head.into_iter().take(back)); } self.undo_evicted = evicted; + self.autocap = None; self.doc = doc; self.sel = sel; self.also_selected.clear(); @@ -1300,6 +1317,7 @@ impl Session { } } Err(e) => { + self.autocap = None; if let Some((d, s, h, r, t, ev, c)) = before_doc { self.doc = d; self.sel = s; diff --git a/crates/engine/src/tests_typing.rs b/crates/engine/src/tests_typing.rs index 5566c22566..99da04e28e 100644 --- a/crates/engine/src/tests_typing.rs +++ b/crates/engine/src/tests_typing.rs @@ -1385,3 +1385,104 @@ fn delete_removes_a_section_break_on_the_last_paragraph() { assert_eq!((sections[0].1.headers.default, sections[1].1.headers.default), (header, None), "{then:?}: undo"); } } + +fn first_letter_list(s: &Session) -> Vec { + s.prefs.autocorrect.exceptions.clone() +} + +/// Undoing an automatic sentence capital adds the word before it to the First Letter exceptions +/// (Word: "Automatically add words to list", #1857); typing the line again isn't capitalised. +#[test] +fn undoing_a_sentence_capital_adds_a_first_letter_exception() { + let mut s = typed("hello there. world "); + assert_eq!(s.doc.plain_text(StoryRef::Body), "Hello there. World "); + run(&mut s, "edit.undo", json!({})); + assert_eq!(s.doc.plain_text(StoryRef::Body), "Hello there. world "); + assert_eq!(first_letter_list(&s), ["there."]); + run(&mut s, "text.newParagraph", json!({})); + typ(&mut s, "x there. world "); + assert_eq!(s.doc.plain_text(StoryRef::Body), "Hello there. world \nX there. world "); +} + +#[test] +fn backspacing_a_sentence_capital_and_retyping_adds_a_first_letter_exception() { + let mut s = typed("hello there. world "); + typ(&mut s, "\u{8}\u{8}\u{8}\u{8}\u{8}\u{8}world "); + assert_eq!(s.doc.plain_text(StoryRef::Body), "Hello there. world "); + assert_eq!(first_letter_list(&s), ["there."]); +} + +#[test] +fn automatic_exceptions_can_be_turned_off() { + let mut s = typed(""); + run(&mut s, "tools.autocorrect", json!({"autoAddFirstLetter": false})); + typ(&mut s, "hello there. world "); + run(&mut s, "edit.undo", json!({})); + assert!(first_letter_list(&s).is_empty()); + let mut s = typed(""); + run(&mut s, "tools.autocorrect", json!({"autoAddFirstLetter": false})); + typ(&mut s, "hello there. world \u{8}\u{8}\u{8}\u{8}\u{8}\u{8}world "); + assert_eq!(s.doc.plain_text(StoryRef::Body), "Hello there. World "); + assert!(first_letter_list(&s).is_empty()); +} + +#[test] +fn undoing_a_replacement_adds_no_exception() { + let mut s = typed("so teh "); + assert_eq!(s.doc.plain_text(StoryRef::Body), "So the "); + run(&mut s, "edit.undo", json!({})); + assert_eq!(s.doc.plain_text(StoryRef::Body), "So teh "); + assert!(first_letter_list(&s).is_empty()); + assert!(s.prefs.autocorrect.entries.is_empty() && s.prefs.autocorrect.removed.is_empty()); + typ(&mut s, "and teh "); + assert_eq!(s.doc.plain_text(StoryRef::Body), "So teh and the "); +} + +/// Checked in Word for Mac: the same rule as First Letter. +#[test] +fn undoing_an_initial_caps_fix_adds_an_initial_caps_exception() { + let mut s = typed("so TWo "); + run(&mut s, "edit.undo", json!({})); + assert_eq!(s.prefs.autocorrect.initial_caps_exceptions, ["TWo"]); + typ(&mut s, "and TWo "); + assert_eq!(s.doc.plain_text(StoryRef::Body), "So TWo and TWo "); +} + +#[test] +fn undoing_other_edits_after_a_capital_adds_no_exception() { + // Typing more, then one Undo, and a second Undo that reverses the capital's step later. + let mut s = typed("hello there. world more text"); + run(&mut s, "edit.undo", json!({})); + assert!(first_letter_list(&s).is_empty()); + run(&mut s, "edit.undo", json!({})); + assert!(first_letter_list(&s).is_empty(), "only the Undo that reverses the capital itself counts"); +} + +#[test] +fn a_full_undo_history_does_not_match_later_undos() { + let mut s = typed(""); + for _ in 0..300 { + typ(&mut s, "x\n"); + } + typ(&mut s, "hello there. world more text\nand more"); + run(&mut s, "edit.undo", json!({})); + assert!(first_letter_list(&s).is_empty()); +} + +#[test] +fn a_new_document_forgets_the_capital() { + let mut s = typed("hello there. world "); + run(&mut s, "file.new", json!({})); + typ(&mut s, "abc\ndef\nghi\njkl"); + for _ in 0..8 { + let _ = s.run("edit.undo", &json!({})); + } + assert!(first_letter_list(&s).is_empty()); +} + +#[test] +fn an_overlong_word_is_not_added_as_an_exception() { + let mut s = typed(&format!("hi {}. world ", "a".repeat(5000))); + run(&mut s, "edit.undo", json!({})); + assert!(first_letter_list(&s).is_empty()); +} diff --git a/crates/ui-egui/src/dialogs_review.rs b/crates/ui-egui/src/dialogs_review.rs index 0465bee45f..a465b61aa7 100644 --- a/crates/ui-egui/src/dialogs_review.rs +++ b/crates/ui-egui/src/dialogs_review.rs @@ -349,6 +349,8 @@ impl AutoCorrectForm { "bullets": o.bullets, "numbering": o.numbering, "borderLines": o.border_lines, + "autoAddFirstLetter": o.auto_add_first_letter, + "autoAddInitialCaps": o.auto_add_initial_caps, }) } } @@ -479,6 +481,8 @@ fn autocorrect_tab(app: &mut WordApp, ui: &mut Ui, f: &mut AutoCorrectForm) { fn exceptions(app: &mut WordApp, ui: &mut Ui, f: &mut AutoCorrectForm) { egui::Frame::group(ui.style()).show(ui, |ui| { + ui.checkbox(&mut f.options.auto_add_first_letter, tl!("Automatically add words to list (First Letter)")); + ui.checkbox(&mut f.options.auto_add_initial_caps, tl!("Automatically add words to list (INitial CAps)")); ui.label(tl!("Don't capitalize after:")); let list = f.options.exception_list(); let mut picked = None; diff --git a/docs/typing-parity.md b/docs/typing-parity.md index e87e995c77..4f53c5c37b 100644 --- a/docs/typing-parity.md +++ b/docs/typing-parity.md @@ -1,6 +1,6 @@ # Typing parity with Word -> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-18 · **Change:** trivial (smart quotes open after guillemets and low quotes and nest; previously trivial: Enter on an empty list item numbered by its paragraph style moves it up a level, #2143; previously trivial: Backspace after a section break and Delete before it remove the break, also on the last paragraph, #2127; previously trivial: a table inserted in right-to-left text is right to left, so Tab moves left, #2634; previously trivial: Shift+Delete cuts the selection on every platform, #1669; previously trivial: Select All then Delete removes a table the document starts with, #1198; previously trivial: Alt+numpad character codes, #961; previously trivial: Down and Up from a line start beside a table go into it, #1007; previously trivial: sample-text formulas on Enter, #1067; previously trivial: Track Changes: Accept All after a tracked delete from a paragraph end keeps the next paragraph; Reject All after typing into an empty paragraph keeps it (#864); previously minor: Correct TWo INitial CApitals, cAPS LOCK and table-cell capitals, #544; previously trivial: complex-script caret and Backspace, #604; Move Paragraph Up/Down, Promote/Demote, Go Back and Alt+X keys, #343) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4 +> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-18 · **Change:** trivial (reversing an automatic capital adds an exception, #1857; previously trivial: smart quotes open after guillemets and low quotes and nest; previously trivial: Enter on an empty list item numbered by its paragraph style moves it up a level, #2143; previously trivial: Backspace after a section break and Delete before it remove the break, also on the last paragraph, #2127; previously trivial: a table inserted in right-to-left text is right to left, so Tab moves left, #2634; previously trivial: Shift+Delete cuts the selection on every platform, #1669; previously trivial: Select All then Delete removes a table the document starts with, #1198; previously trivial: Alt+numpad character codes, #961; previously trivial: Down and Up from a line start beside a table go into it, #1007; previously trivial: sample-text formulas on Enter, #1067; previously trivial: Track Changes: Accept All after a tracked delete from a paragraph end keeps the next paragraph; Reject All after typing into an empty paragraph keeps it (#864); previously minor: Correct TWo INitial CApitals, cAPS LOCK and table-cell capitals, #544; previously trivial: complex-script caret and Backspace, #604; Move Paragraph Up/Down, Promote/Demote, Go Back and Alt+X keys, #343) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4 What Word does when you type, observed black-box (same keystrokes typed into Word and WordCraft, results read back paragraph by paragraph) and pinned by tests in @@ -96,6 +96,28 @@ Applied when a word is finished by a space, punctuation **or Enter** (`enter_fin the rest lowercase loses its second capital (`WOrd`→`Word`, `TEh`→`The`); not the exceptions (`IDs`, `CDs`, `PCs`, `TVs`, … plus the user's, `tools.autocorrect {"addInitialCapsException"}`) (`two_initial_capitals_are_corrected`). +- Reversing an automatic capital makes an exception (Word's "Automatically add words to list", + observed on the Mac, #1857): Undo of the sentence capital's own step adds the word before it + to the First Letter list (`there.`; only words ending in a full stop, the one form seen in + Word), and so does Backspacing over the capitalised word and typing it again; the word is then + not capitalised after `there.` any more. Any other Undo (of later typing, of a paste) adds + nothing, and the capital is forgotten once another Undo runs, a new document is opened, or the + undo history is cleared. Each list has its own switch + (`tools.autocorrect {"autoAddFirstLetter", "autoAddInitialCaps"}`, on by default); words over + 255 characters or with spaces are never added. Undoing a replacement (`teh`→`the`) adds + nothing: Word doesn't either. Undoing a TWo INitial CApitals fix adds the word to the INitial + CAps list (as in Word, checked on the Mac: `TWo ` typed again isn't corrected; same switch rule as First Letter). The first word of a + paragraph has no word before it, so nothing is added there; Word also left the retyped first + word ("hello") lowercase in that paragraph, which WordCraft doesn't (paragraph-level + suppression, only for the paragraph where the Undo happened: Word capitalises the first word + of a new paragraph again; a separate follow-up). Word adds nothing in bulleted-list paragraphs; not covered + (`undoing_a_sentence_capital_adds_a_first_letter_exception`, + `backspacing_a_sentence_capital_and_retyping_adds_a_first_letter_exception`, + `automatic_exceptions_can_be_turned_off`, `undoing_a_replacement_adds_no_exception`, + `undoing_an_initial_caps_fix_adds_an_initial_caps_exception`, + `undoing_other_edits_after_a_capital_adds_no_exception`, + `a_full_undo_history_does_not_match_later_undos`, `a_new_document_forgets_the_capital`, + `an_overlong_word_is_not_added_as_an_exception`). - Correct accidental usage of cAPS LOCK: `wORD`→`Word` (three or more letters; not `iOS`, `mRNA`, …). Word also switches Caps Lock off; we only correct the word (`accidental_caps_lock_is_corrected`). @@ -207,6 +229,7 @@ Word. | 2026-10-15 | trivial | Backspace after a section break and Delete before it remove the break, also one on the last paragraph's mark; the text takes the following section's setup and keeps the headers it showed (#2127) | | 2026-10-15 | trivial | Enter on an empty nested list item whose numbering comes from its paragraph style (List Number 2) moves it up a level instead of adding another item (#2143) | | 2026-10-18 | trivial | Smart quotes open after `«` `‹` `„` `‚` and after the other kind of opening quote, so nested quotes curl correctly | +| 2026-10-15 | minor | AutoCorrect: undoing the capital's own step (or backspacing it and retyping) adds an exception; "Automatically add words to list" switches (#1857) | | 2026-10-14 | trivial | Shift+Delete cuts the selection, and does nothing without one, on macOS and Linux too, as in Word for Windows (#1669); Shift+Backspace already erased (#707) | | 2026-10-13 | trivial | Select All then Delete or Backspace removes a table the document starts with (#1198) | | 2026-10-12 | trivial | Editing keys: Down and Up from the start of a line beside a table go into it instead of skipping it (#1007) | diff --git a/docs/ui-parity.md b/docs/ui-parity.md index ec75cd7082..192beeb86c 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -79,6 +79,7 @@ Find selects the Results tab, including when it prefills selected text. Editing | 2026-10-16 | trivial | Comment and revision balloons are readable in the dark theme: the card, the markup area, the author stripes and the leader lines come from theme tokens, and the text reaches 4.5:1 (#2045) | | 2026-10-15 | trivial | Layout › Columns works on the selected text, as in Word: section breaks around it, the rest of the document unchanged; More Columns… adds "Selected text" to Apply to and picks it when text is selected (#2024) | | 2026-10-15 | trivial | Navigation Find and query edits expose Results (#2096) | +| 2026-10-15 | trivial | AutoCorrect Options › Exceptions: "Automatically add words to list" switches for First Letter and INitial CAps (#1857) | | 2026-10-13 | trivial | New/Modify Table Style: No Border on a part that inherits its lines hides them; emptying the font box makes the part's font inherited again (#1288) | | 2026-10-14 | trivial | Tab stops edited on the ruler: corner kind selector, click to set, drag to move or off to clear, per-kind markers mirrored for RTL; each gesture one `para.tabs` call and one undo step; Rulers 85% → 90% | | 2026-10-12 | trivial | Tables on the page: one selected cell (Select Cell, or a click along its left edge) is a cell selection when its border is dragged: the border moves in its row only (#962) | From 8cdd85f7e75c649f52a8daff215784d9bf160ac4 Mon Sep 17 00:00:00 2001 From: divyanshu2074 <43274510+divyanshu2074@users.noreply.github.com> Date: Sun, 18 Oct 2026 13:51:53 +0530 Subject: [PATCH 04/38] fix(engine): support none in wordart reflection and outline color (#2238) --- crates/engine/src/cmd/wordart.rs | 12 +++++++----- crates/engine/src/tests_wordart.rs | 24 ++++++++++++++++++++++++ 2 files changed, 31 insertions(+), 5 deletions(-) diff --git a/crates/engine/src/cmd/wordart.rs b/crates/engine/src/cmd/wordart.rs index 6270d85df8..33f7c7acaa 100644 --- a/crates/engine/src/cmd/wordart.rs +++ b/crates/engine/src/cmd/wordart.rs @@ -15,7 +15,7 @@ use wordcraft_doc::{Paragraph, PartKind, Pos, StoryRef, para_block}; use super::sel_result; use crate::{CmdError, CmdResult, CommandSpec, Selection, Session, p}; -const EFFECT_PARAMS: &str = r#"{"style"?: "solid"|"outlined"|"hollow"|"gradient"|"shadowed"|"glowing"|"reflected"|"sunset", "clear"?: bool, "fill"?: "RRGGBB"|"none" (hollow)|{"color": "RRGGBB", "transparency"?: %}|{"gradient": ["RRGGBB", …], "angle"?: deg}|null (the run's colour), "outline"?: "RRGGBB"|"none"|{"color"?: "RRGGBB"|null, "width"?: pt, "transparency"?: %}|null, "shadow"?: preset|{"color"?, "transparency"?, "blur"?, "distance"?, "angle"?}|null, "glow"?: pt|{"color"?, "size"?, "transparency"?}|null, "reflection"?: bool|{"transparency"?: %, "size"?: %, "distance"?: pt}|null} (an omitted key is left as it is; with a shape selected or the caret in a text box, the box's whole text)"#; +const EFFECT_PARAMS: &str = r#"{"style"?: "solid"|"outlined"|"hollow"|"gradient"|"shadowed"|"glowing"|"reflected"|"sunset", "clear"?: bool, "fill"?: "RRGGBB"|"none" (hollow)|{"color": "RRGGBB", "transparency"?: %}|{"gradient": ["RRGGBB", …], "angle"?: deg}|null (the run's colour), "outline"?: "RRGGBB"|"none"|{"color"?: "RRGGBB"|null, "width"?: pt, "transparency"?: %}|null, "shadow"?: preset|{"color"?, "transparency"?, "blur"?, "distance"?, "angle"?}|null, "glow"?: pt|{"color"?, "size"?, "transparency"?}|null, "reflection"?: bool|"none"|{"transparency"?: %, "size"?: %, "distance"?: pt}|null} (an omitted key is left as it is; with a shape selected or the caret in a text box, the box's whole text)"#; pub fn specs() -> Vec { vec![ @@ -100,13 +100,14 @@ fn parse_outline(v: Option<&Value>) -> Result, CmdError> { Ok(match v { None => None, Some(Value::Null) => Some(None), - Some(Value::String(x)) if x == "none" => Some(Some(TextOutline { color: None, ..d })), + Some(Value::String(x)) if x.eq_ignore_ascii_case("none") => Some(Some(TextOutline { color: None, ..d })), Some(Value::String(x)) => { Some(Some(TextOutline { color: Some(Rgb::parse(x).ok_or_else(|| CmdError::Params("`outline`: a colour".into()))?), ..d })) } Some(o @ Value::Object(_)) => Some(Some(TextOutline { color: match o.get("color") { Some(Value::Null) => None, + Some(Value::String(s)) if s.eq_ignore_ascii_case("none") => None, Some(_) => color(o, "color").or(d.color), None => d.color, }, @@ -123,7 +124,7 @@ fn parse_shadow(v: Option<&Value>) -> Result, CmdError> { None => None, Some(Value::Null) => Some(None), Some(Value::Bool(b)) => Some(b.then(Shadow::default)), - Some(Value::String(id)) if id == "none" => Some(None), + Some(Value::String(id)) if id.eq_ignore_ascii_case("none") => Some(None), Some(Value::String(id)) => Some(Some(preset(id)?)), Some(o @ Value::Object(_)) => { let mut sh = match p::str(o, "preset") { @@ -146,7 +147,7 @@ fn parse_glow(v: Option<&Value>, accent: Rgb) -> Result, CmdError> Ok(match v { None => None, Some(Value::Null) => Some(None), - Some(Value::String(x)) if x == "none" => Some(None), + Some(Value::String(x)) if x.eq_ignore_ascii_case("none") => Some(None), Some(n @ Value::Number(_)) => Some(Some(Glow { size: n.as_f64().unwrap_or(0.0) as f32, ..d }.sanitized()).filter(|g| g.size > 0.0)), Some(o @ Value::Object(_)) => Some( Some( @@ -169,13 +170,14 @@ fn parse_reflection(v: Option<&Value>) -> Result, CmdError> { None => None, Some(Value::Null) => Some(None), Some(Value::Bool(b)) => Some(b.then_some(d)), + Some(Value::String(x)) if x.eq_ignore_ascii_case("none") => Some(None), Some(o @ Value::Object(_)) => Some(Some(Reflection { transparency: p::f32(o, "transparency").unwrap_or(d.transparency), size: p::f32(o, "size").unwrap_or(d.size), distance: p::f32(o, "distance").unwrap_or(d.distance), blur: p::f32(o, "blur").unwrap_or(d.blur), })), - Some(_) => return Err(CmdError::Params("`reflection`: true, an object or null".into())), + Some(_) => return Err(CmdError::Params("`reflection`: true, \"none\", an object or null".into())), }) } diff --git a/crates/engine/src/tests_wordart.rs b/crates/engine/src/tests_wordart.rs index 9c7b63bd08..13b0b08df6 100644 --- a/crates/engine/src/tests_wordart.rs +++ b/crates/engine/src/tests_wordart.rs @@ -139,3 +139,27 @@ fn wordart_is_inserted_styled_and_bent() { ); run(&mut s, "wordArt.transform", json!({"preset": "none"})); } + +#[test] +fn wordart_reflection_and_outline_none_options() { + let mut s = Session::new(Document::new()); + let r = run(&mut s, "insert.wordArt", json!({"text": "Reflect", "style": "solid"})); + let id = r["story"].as_u64().unwrap() as u32; + + // Setting reflection + run(&mut s, "format.textEffects", json!({"reflection": true})); + let props = s.doc.para_at(&s.doc.start_of(StoryRef::Part(id))).unwrap().props_at(0).clone(); + assert!(props.text_effects.as_ref().unwrap().reflection.is_some()); + + // Clearing reflection using "none" + run(&mut s, "format.textEffects", json!({"reflection": "none"})); + let props = s.doc.para_at(&s.doc.start_of(StoryRef::Part(id))).unwrap().props_at(0).clone(); + assert!(props.text_effects.as_ref().and_then(|fx| fx.reflection).is_none()); + + // Setting outline with color "none" + run(&mut s, "wordArt.textOutline", json!({"color": "none", "width": 1.5})); + let props = s.doc.para_at(&s.doc.start_of(StoryRef::Part(id))).unwrap().props_at(0).clone(); + let outline = props.text_effects.as_ref().unwrap().outline.unwrap(); + assert_eq!(outline.color, None); + assert_eq!(outline.width, 1.5); +} From e7f5d32c35b28e841f6f4446f4b7a3bbd2ccf9e4 Mon Sep 17 00:00:00 2001 From: Dmitrii Khlapov Date: Sun, 18 Oct 2026 10:22:12 +0200 Subject: [PATCH 05/38] Numbered headings: heading-linked multilevel list presets keep the heading styles (#1793) (#2239) * Co-Authored-By: Claude Opus 5.5 * Co-Authored-By: Claude Opus 5.5 * Co-Authored-By: Claude Opus 5.5 * #1793: wording: other paragraph styles become Heading 1, not only body text 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 Co-authored-by: Brandon Thomas --- ROADMAP.md | 4 +- crates/engine/src/catalog.rs | 1 + crates/engine/src/cmd/lists.rs | 227 ++++++++++++++++++++++++++++ crates/engine/src/cmd/para.rs | 9 +- crates/engine/src/cmd/text.rs | 5 +- crates/engine/src/tests_typing.rs | 89 +++++++++++ crates/ui-egui/src/i18n/bg.tsv | 4 + crates/ui-egui/src/i18n/cs.tsv | 4 + crates/ui-egui/src/i18n/de.tsv | 4 + crates/ui-egui/src/i18n/es.tsv | 4 + crates/ui-egui/src/i18n/et.tsv | 4 + crates/ui-egui/src/i18n/fr.tsv | 4 + crates/ui-egui/src/i18n/hr.tsv | 4 + crates/ui-egui/src/i18n/hu.tsv | 4 + crates/ui-egui/src/i18n/it.tsv | 4 + crates/ui-egui/src/i18n/ja.tsv | 4 + crates/ui-egui/src/i18n/ko.tsv | 4 + crates/ui-egui/src/i18n/pl.tsv | 4 + crates/ui-egui/src/i18n/pt-br.tsv | 4 + crates/ui-egui/src/i18n/ru.tsv | 4 + crates/ui-egui/src/i18n/sr-latn.tsv | 4 + crates/ui-egui/src/i18n/sr.tsv | 4 + crates/ui-egui/src/i18n/sv.tsv | 4 + crates/ui-egui/src/i18n/tr.tsv | 4 + crates/ui-egui/src/i18n/uk.tsv | 4 + crates/ui-egui/src/i18n/vi.tsv | 4 + crates/ui-egui/src/i18n/zh-hans.tsv | 4 + crates/ui-egui/src/i18n/zh-hant.tsv | 4 + crates/ui-egui/src/ribbon.rs | 17 +++ docs/gaps.md | 3 +- docs/parity-checklist.md | 6 +- docs/typing-parity.md | 11 +- docs/ui-parity.md | 5 +- 33 files changed, 454 insertions(+), 11 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index fa6afca0e6..a14ef92057 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -2,7 +2,7 @@ **Stage: alpha** · next: beta, ~15 points (60% → 75% ready for real work) and ~150–230 h away -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-18 · **Change:** trivial (Navigation Find exposes Results, #2096; previously trivial: progress log: Columns on selected text, #2024; previously trivial: progress log: legacy outline, shadow, emboss and imprint text effects drawn, #2534; previously trivial: progress log: Indonesian proofing, #498; previously trivial: progress log: DOCX glossary documents (building blocks); previously trivial: progress log: Read Mode keeps the keyboard and shows whole, unobscured columns, #1772); previously trivial: progress log: font list entries of tall-ascent fonts no longer cut off, #2570); previously trivial: progress log: Split Cells and Convert Text to Table dialogs follow the Word check, #2592; previously trivial: a new source's Tag is made like Word's, three letters of the author and two digits of the year with a number added when taken, and an edit keeps the Tag, #2094; previously trivial: Web Layout fills the window width, #1876; previously trivial: progress log: Different First Page headers reachable from page 1, #2826; previously trivial: Language dialog keeps inherited proofing language and no-proof choices (#2717); previously trivial: progress log: web preferences kept between visits, #2132; previously trivial: progress log: undrawable private-use list bullets show the level's default bullet, #1683); previously trivial: table style editor formats every part, #714; previously trivial: VML freeforms read and kept on save; previously trivial: progress log: consistent preferred cell widths; previously trivial: multi-paragraph field instructions no longer add blank lines; previously trivial: page-break-only list paragraphs hide their labels; previously trivial: progress log: grouped `flowChartOr` shapes draw their circle and crossing spokes; previously trivial: progress log: copies keep their formatting in other programs, #1523; previously trivial: progress log: privacy options on save, #1062; previously trivial: progress log: equation pictures in theme colours on Windows, #1002; previously trivial: symbol fonts, #776; previously trivial: the save prompt answers from the keyboard, #861; previously trivial: table column and row markers on the rulers, #700; previously trivial: table borders and the resize handle drag on the page, #217; previously trivial: formatting revisions landed, #41; previously trivial: Draw tab ink, #307, and Draw Table and Eraser, #303, landed: catalog 402/434; previously minor: readiness table with hours per audience; full number back to the additive weighted sum, 60%; mainstream and essentials numbers added; alpha gate checked; previously major: full re-measure against Word for Mac 16.113.4; restructured to the craftrules progress-docs standard) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-18 · **Change:** trivial (numbered headings, #1793; previously trivial: Navigation Find exposes Results, #2096; previously trivial: progress log: Columns on selected text, #2024; previously trivial: progress log: legacy outline, shadow, emboss and imprint text effects drawn, #2534; previously trivial: progress log: Indonesian proofing, #498; previously trivial: progress log: DOCX glossary documents (building blocks); previously trivial: progress log: Read Mode keeps the keyboard and shows whole, unobscured columns, #1772); previously trivial: progress log: font list entries of tall-ascent fonts no longer cut off, #2570); previously trivial: progress log: Split Cells and Convert Text to Table dialogs follow the Word check, #2592; previously trivial: a new source's Tag is made like Word's, three letters of the author and two digits of the year with a number added when taken, and an edit keeps the Tag, #2094; previously trivial: Web Layout fills the window width, #1876; previously trivial: progress log: Different First Page headers reachable from page 1, #2826; previously trivial: Language dialog keeps inherited proofing language and no-proof choices (#2717); previously trivial: progress log: web preferences kept between visits, #2132; previously trivial: progress log: undrawable private-use list bullets show the level's default bullet, #1683); previously trivial: table style editor formats every part, #714; previously trivial: VML freeforms read and kept on save; previously trivial: progress log: consistent preferred cell widths; previously trivial: multi-paragraph field instructions no longer add blank lines; previously trivial: page-break-only list paragraphs hide their labels; previously trivial: progress log: grouped `flowChartOr` shapes draw their circle and crossing spokes; previously trivial: progress log: copies keep their formatting in other programs, #1523; previously trivial: progress log: privacy options on save, #1062; previously trivial: progress log: equation pictures in theme colours on Windows, #1002; previously trivial: symbol fonts, #776; previously trivial: the save prompt answers from the keyboard, #861; previously trivial: table column and row markers on the rulers, #700; previously trivial: table borders and the resize handle drag on the page, #217; previously trivial: formatting revisions landed, #41; previously trivial: Draw tab ink, #307, and Draw Table and Eraser, #303, landed: catalog 402/434; previously minor: readiness table with hours per audience; full number back to the additive weighted sum, 60%; mainstream and essentials numbers added; alpha gate checked; previously major: full re-measure against Word for Mac 16.113.4; restructured to the craftrules progress-docs standard) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4 > **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-17 · **Change:** trivial (progress log: pasted blockquotes are indented paragraphs, #1270; previously trivial: progress log: print-area strings translated in every catalog; previously trivial: progress log: comments readable in the dark theme, #2045; previously trivial: progress log: ODT sections, #1379; previously trivial: progress log: table deletes, Change Case and Unlink Fields respect Track Changes and locked content controls, #1329; previously trivial: progress log: rounded rectangle corners, #1476; previously trivial: progress log: Save As type list, #1399; previously trivial: progress log: privacy options on save, #1062; previously trivial: progress log: equation pictures in theme colours on Windows, #1002; previously trivial: symbol fonts, #776; previously trivial: the save prompt answers from the keyboard, #861; previously trivial: table column and row markers on the rulers, #700; previously trivial: table borders and the resize handle drag on the page, #217; previously trivial: formatting revisions landed, #41; previously trivial: Draw tab ink, #307, and Draw Table and Eraser, #303, landed: catalog 402/434; previously minor: readiness table with hours per audience; full number back to the additive weighted sum, 60%; mainstream and essentials numbers added; alpha gate checked; previously major: full re-measure against Word for Mac 16.113.4; restructured to the craftrules progress-docs standard) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4 > **Printing update:** 2026-10-12 · direct desktop printing through craft-print; details below. @@ -137,6 +137,7 @@ Ranked, to beta (detail and remaining milestones in [docs/roadmap.md](docs/roadm - 2026-10-15: Find opens Navigation on Results; editing the query restores Results after manual Headings/Pages navigation. Typed and selected-text searches retain document contents (#2096). | Date | What landed | +| 2026-10-15 | Numbered headings (#1793): the Multilevel List gallery offers four lists linked to Heading 1–9 (1 / 1.1 / 1.1.1, Article I. / Section 1.01 / (a), I. / A. / 1., Chapter 1; `list.multilevelPreset`). The heading styles carry the numbers, so paragraphs already in a Heading style keep their style and level, any other selected paragraph (Normal, Quote, Title) becomes Heading 1 (checked in Word), a new Heading 2 is numbered 2.1 by itself and body text stays unnumbered; Numbering or typing `1. ` keeps a heading a heading | | 2026-10-15 | Reversing an automatic sentence capital (Undo, or Backspace and retype) adds the word before it to AutoCorrect's First Letter exceptions, as Word does; "Automatically add words to list" switches for First Letter and INitial CAps (#1857). | | 2026-10-15 | Dialog close control: a Windows-style box at the title bar's right end, the whole box clickable, red on hover, in both themes (#1885) | | 2026-10-15 | Font box: a Recently Used Fonts section at the top of the list (the fonts applied lately, most recent first, ten at most, kept between runs), then All Fonts, as in Word (#2068) | @@ -447,6 +448,7 @@ Ranked, to beta (detail and remaining milestones in [docs/roadmap.md](docs/roadm | 2026-10-16 | trivial | Progress log: legacy outline, shadow, emboss and imprint text effects drawn (#2534) | | 2026-10-15 | trivial | Progress log: Columns on selected text (#2024) | | 2026-10-15 | trivial | Navigation Find and query edits expose Results (#2096) | +| 2026-10-15 | trivial | Progress log: numbered headings (#1793) | | 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/engine/src/catalog.rs b/crates/engine/src/catalog.rs index c6a36a3789..81f80a8c18 100644 --- a/crates/engine/src/catalog.rs +++ b/crates/engine/src/catalog.rs @@ -82,6 +82,7 @@ Home|Paragraph|Bullets|para.bullets Home|Paragraph|Numbering|para.numbering Home|Paragraph|Multilevel List|para.multilevel Home|Paragraph|Define New Multilevel List|list.define +Home|Paragraph|Multilevel List Library|list.multilevelPreset Home|Paragraph|Change List Level|para.listLevel Home|Paragraph|Restart Numbering|para.restartNumbering Home|Paragraph|Define New Bullet|para.defineBullet diff --git a/crates/engine/src/cmd/lists.rs b/crates/engine/src/cmd/lists.rs index 80ee95e582..671778c1f8 100644 --- a/crates/engine/src/cmd/lists.rs +++ b/crates/engine/src/cmd/lists.rs @@ -23,6 +23,9 @@ pub fn specs() -> Vec { CommandSpec::new("list.get", "List Definition", "Home › Paragraph › Multilevel List", get) .params(r#"{"num"?: list id (default: the caret's list)} → {"num", "levels": [as list.define]}"#) .pure(), + CommandSpec::new("list.multilevelPreset", "Multilevel List Library", "Home › Paragraph › Multilevel List", preset).params( + r#"{"preset": "headings" (1 / 1.1 / 1.1.1) | "article" (Article I. / Section 1.01 / (a)) | "outlineHeadings" (I. / A. / 1.) | "chapter" (Chapter 1, lower levels unnumbered)} — a new list whose nine levels are linked to Heading 1–9: every paragraph in those styles is numbered, selected paragraphs already in a Heading style keep it, and any other selected paragraph becomes Heading 1 (the first level)"#, + ), ] } @@ -226,6 +229,96 @@ fn define(s: &mut Session, v: &Value) -> CmdResult { Ok(r) } +/// The heading-linked lists of the Multilevel List gallery's library, by preset id: level `i` +/// is linked to Heading `i + 1`. +fn preset_levels(preset: &str) -> Option> { + use NumFormat as F; + let levels = (0..LEVELS) + .map(|i| { + let n = i + 1; + // Numbers start at the margin; each level's text a little further in. + let at = |indent: f32| Level { indent, hanging: indent, style: Some(format!("Heading{n}")), ..Level::default() }; + let (format, text, indent) = match preset { + "headings" => (F::Decimal, (1..=n).map(|k| format!("%{k}")).collect::>().join("."), 21.6 + 7.2 * i as f32), + "article" => match i { + 0 => (F::UpperRoman, "Article %1.".to_string(), 0.0), + 1 => (F::DecimalZero, "Section %1.%2".to_string(), 0.0), + _ => { + let f = [F::LowerLetter, F::LowerRoman, F::Decimal, F::LowerLetter, F::LowerRoman, F::LowerLetter, F::LowerRoman]; + let t = if i < 4 { + format!("(%{n})") + } else if i < 7 { + format!("%{n})") + } else { + format!("%{n}.") + }; + (f.get(i - 2).copied().unwrap_or(F::Decimal), t, 36.0 + 7.2 * (i - 2) as f32) + } + }, + "outlineHeadings" => { + let f = [ + F::UpperRoman, + F::UpperLetter, + F::Decimal, + F::LowerLetter, + F::Decimal, + F::LowerLetter, + F::LowerRoman, + F::LowerLetter, + F::LowerRoman, + ]; + let t = match i { + 0..=2 => format!("%{n}."), + 3 => format!("%{n})"), + _ => format!("(%{n})"), + }; + (f.get(i).copied().unwrap_or(F::Decimal), t, 36.0 * n as f32) + } + "chapter" if i == 0 => (F::Decimal, "Chapter %1".to_string(), 0.0), + "chapter" => (F::None, String::new(), 0.0), + _ => return None, + }; + // "Section 1.01" shows the article number in digits (legal numbering). + Some(Level { format, text, legal: preset == "article" && i == 1, ..at(indent) }) + }) + .collect::>>()?; + Some(levels) +} + +/// A Multilevel List gallery preset: a new list linked to Heading 1–9. The heading styles carry +/// the number, so every heading (and every heading added later) is numbered. Paragraphs in a +/// Heading style keep their level; any other paragraph under the caret or selection (Normal, +/// Quote, Title…) becomes Heading 1, as in Word. +fn preset(s: &mut Session, v: &Value) -> CmdResult { + let id = p::req_str(v, "preset")?; + let levels = preset_levels(id).ok_or_else(|| CmdError::Params("`preset` must be headings, article, outlineHeadings or chapter".into()))?; + let num = s.doc.numbering.try_add_levels(None, levels)?; + let mut linked = Vec::new(); + for n in 1..=LEVELS as u8 { + let id = super::para::ensure_heading_style(s, n); + if let Some(st) = s.doc.styles.get_mut(&id) { + st.para.numbering = Some(NumRef { num, level: n - 1 }); + linked.push(id); + } + } + touch_styles(s, &linked)?; + super::para::fmt(s, &|pp| { + // A heading keeps its style and level; any other paragraph (Normal, Quote, Title…) + // becomes the first level's heading, Heading 1. The number comes from the style. + if !pp.style.as_ref().is_some_and(|st| linked.contains(st)) { + pp.style = linked.first().cloned(); + } + pp.numbering = None; + pp.indent_left = None; + pp.indent_first = None; + pp.indent_left_chars = None; + pp.indent_first_chars = None; + })?; + let mut r = sel_result(s)?; + r["num"] = json!(num); + Ok(r) +} + /// Lay out again every paragraph in one of `nums`. fn touch_lists(s: &mut Session, nums: &[u32]) -> Result<(), CmdError> { touch_where(s, &|p| p.props.numbering.is_some_and(|n| nums.contains(&n.num))) @@ -309,4 +402,138 @@ mod tests { // Programmatic calls without levels are an error, not a dialog. assert!(s.run("list.define", &json!({})).is_err()); } + + /// Each body paragraph's number as laid out (`None`: no number), and its style. + fn numbered(s: &mut Session) -> Vec<(Option, String)> { + let l = s.layout(); + let labels: Vec> = l + .pages + .iter() + .flat_map(|p| &p.items) + .filter_map(|i| if let Placed::Lines { para, l0: 0, .. } = i { Some(para.label.as_ref().map(|lb| lb.text.clone())) } else { None }) + .collect(); + let styles = s.doc.body.iter().filter_map(|b| b.as_para()).map(|p| p.props.style.clone().unwrap_or_else(|| "Normal".into())); + labels.into_iter().zip(styles).collect() + } + + fn n(label: Option<&str>, style: &str) -> (Option, String) { + (label.map(str::to_string), style.to_string()) + } + + /// Paragraphs "One" (Heading 1), "Two" (Heading 2), "Three" (`third`), the caret in "One". + fn headings(third: &str) -> Session { + let mut s = Session::new(Document::new()); + for (i, (t, st)) in [("One", "Heading1"), ("Two", "Heading2"), ("Three", third)].iter().enumerate() { + if i > 0 { + s.run("text.newParagraph", &json!({})).unwrap(); + } + s.run("text.insert", &json!({"text": t})).unwrap(); + s.run("para.style", &json!({"style": st})).unwrap(); + } + s.sel = crate::Selection::caret(wordcraft_doc::Pos::body(0, 0)); + s + } + + /// #1793: the Multilevel List gallery's heading presets number headings through their styles + /// (Word: One=1, Two=1.1, Three=2), keep the styles, and survive a save to .docx and back. + #[test] + fn a_heading_preset_numbers_headings_and_keeps_their_styles() { + let mut s = headings("Heading1"); + s.run("list.multilevelPreset", &json!({"preset": "headings"})).unwrap(); + let want = [n(Some("1"), "Heading1"), n(Some("1.1"), "Heading2"), n(Some("2"), "Heading1")]; + assert_eq!(numbered(&mut s), want); + // The number comes from the style, so a heading added later is numbered too. + let h2 = s.doc.styles.get("Heading2").and_then(|st| st.para.numbering).unwrap(); + assert_eq!(h2.level, 1); + let back = wordcraft_docx::read(&wordcraft_docx::write(&s.doc).unwrap()).unwrap(); + assert_eq!(back.styles.get("Heading2").and_then(|st| st.para.numbering).map(|x| x.level), Some(1), "numPr on the style"); + let mut b = Session::new(back); + assert_eq!(numbered(&mut b), want); + } + + /// #1793: the other heading presets of Word's List Library, level by level. + #[test] + fn every_heading_preset_numbers_three_heading_levels() { + for (preset, want) in [ + ("headings", [Some("1"), Some("1.1"), Some("1.1.1")]), + ("article", [Some("Article I."), Some("Section 1.01"), Some("(a)")]), + ("outlineHeadings", [Some("I."), Some("A."), Some("1.")]), + ("chapter", [Some("Chapter 1"), None, None]), + ] { + let mut s = headings("Heading3"); + s.run("list.multilevelPreset", &json!({"preset": preset})).unwrap(); + let got: Vec> = numbered(&mut s).into_iter().map(|x| x.0).collect(); + assert_eq!(got, want.map(|x| x.map(str::to_string)), "{preset}"); + } + let mut s = headings("Heading3"); + assert!(s.run("list.multilevelPreset", &json!({"preset": "nope"})).is_err()); + assert!(s.run("list.multilevelPreset", &json!({})).is_err()); + } + + /// Paragraphs (text, style id), the selection from paragraph `from` to the end of `to`. + fn doc(paras: &[(&str, &str)], from: usize, to: usize) -> Session { + let mut s = Session::new(Document::new()); + for (i, (t, st)) in paras.iter().enumerate() { + if i > 0 { + s.run("text.newParagraph", &json!({})).unwrap(); + } + s.run("text.insert", &json!({"text": t})).unwrap(); + s.run("para.style", &json!({"style": st})).unwrap(); + } + let end = paras.get(to).map_or(0, |p| p.0.len()); + s.sel = crate::Selection { anchor: wordcraft_doc::Pos::body(from, 0), focus: wordcraft_doc::Pos::body(to, end) }; + s + } + + /// #1793 (Word for Mac 16.113.4, 2026-10-16): with a caret or a selection, a heading preset + /// leaves paragraphs already in a Heading style at their own style and level; every other + /// selected paragraph (Normal, Quote, Title…) becomes Heading 1. + #[test] + fn a_heading_preset_keeps_headings_and_makes_other_paragraphs_heading_1() { + let (h1, h2, h3) = ("Heading1", "Heading2", "Heading3"); + let cases: Vec<(Vec<(&str, &str)>, usize, usize, Vec<(Option<&str>, &str)>)> = vec![ + // All selected. + (vec![("One", h1), ("Two", h2), ("Three", h3)], 0, 2, vec![(Some("1"), h1), (Some("1.1"), h2), (Some("1.1.1"), h3)]), + // No Heading 1 above. + (vec![("Two", h2), ("Three", h3)], 0, 1, vec![(Some("1.1"), h2), (Some("1.1.1"), h3)]), + (vec![("Body", "Normal"), ("Three", h3)], 0, 1, vec![(Some("1"), h1), (Some("1.1.1"), h3)]), + (vec![("One", h1), ("Body", "Normal"), ("Two", h2)], 0, 2, vec![(Some("1"), h1), (Some("2"), h1), (Some("2.1"), h2)]), + (vec![("One", h1), ("Two", h2), ("Body", "Normal")], 0, 2, vec![(Some("1"), h1), (Some("1.1"), h2), (Some("2"), h1)]), + // The caret in Two. + (vec![("One", h1), ("Two", h2), ("Three", h3)], 1, 1, vec![(Some("1"), h1), (Some("1.1"), h2), (Some("1.1.1"), h3)]), + (vec![("Quote", "Quote"), ("Two", h2)], 0, 1, vec![(Some("1"), h1), (Some("1.1"), h2)]), + (vec![("Title", "Title")], 0, 0, vec![(Some("1"), h1)]), + // Body text after the selection stays unnumbered. + (vec![("One", h1), ("After", "Normal")], 0, 0, vec![(Some("1"), h1), (None, "Normal")]), + ]; + for (paras, from, to, want) in cases { + let mut s = doc(¶s, from, to); + s.run("list.multilevelPreset", &json!({"preset": "headings"})).unwrap(); + let want: Vec<_> = want.into_iter().map(|(l, st)| n(l, st)).collect(); + assert_eq!(numbered(&mut s), want, "{paras:?} {from}..={to}"); + } + } + + /// #1793 (Word for Mac): the caret alone in a Normal paragraph makes it Heading 1 numbered 1. + #[test] + fn a_heading_preset_on_a_lone_normal_paragraph_makes_it_heading_1() { + let mut s = doc(&[("Body", "Normal")], 0, 0); + s.sel = crate::Selection::caret(wordcraft_doc::Pos::body(0, 2)); + s.run("list.multilevelPreset", &json!({"preset": "headings"})).unwrap(); + assert_eq!(numbered(&mut s), [n(Some("1"), "Heading1")]); + } + + /// #1793: one Undo takes the preset back: paragraph styles and the heading styles' numbers. + #[test] + fn undo_takes_a_heading_preset_back_in_one_step() { + let mut s = doc(&[("Body", "Normal"), ("Two", "Heading2")], 0, 1); + let before = numbered(&mut s); + s.run("list.multilevelPreset", &json!({"preset": "headings"})).unwrap(); + assert_ne!(numbered(&mut s), before); + s.run("edit.undo", &json!({})).unwrap(); + assert_eq!(numbered(&mut s), before); + for n in 1..=9 { + assert_eq!(s.doc.styles.get(&format!("Heading{n}")).and_then(|st| st.para.numbering), None, "Heading{n}"); + } + } } diff --git a/crates/engine/src/cmd/para.rs b/crates/engine/src/cmd/para.rs index b20edbef3a..2de4769254 100644 --- a/crates/engine/src/cmd/para.rs +++ b/crates/engine/src/cmd/para.rs @@ -465,10 +465,13 @@ fn apply_style(s: &mut Session, v: &Value) -> CmdResult { return super::format::apply(s, &|c| c.style = Some(id.clone())); } let (a, b) = s.sel.ordered(); + // A style linked to a list level (a heading in a numbered-headings list) brings its number. + let numbered = s.doc.styles.resolve_para(&ParaProps { style: Some(id.clone()), ..Default::default() }).numbering.is_some(); fmt(s, &|p| { p.style = Some(id.clone()); - // Applying a heading or Normal removes direct list numbering (except list styles). - if (id.starts_with("Heading") || id == "Title" || id == "Normal") && p.numbering.is_some_and(|n| n.num != 0) { + // Applying a heading or Normal removes direct list numbering (except list styles); a + // numbered style's own number replaces any direct numbering, even numbering switched off. + if numbered || ((id.starts_with("Heading") || id == "Title" || id == "Normal") && p.numbering.is_some_and(|n| n.num != 0)) { p.numbering = None; } })?; @@ -755,7 +758,7 @@ pub(crate) fn set_heading(s: &mut Session, story: wordcraft_doc::StoryRef, path: /// Make sure the built-in `Heading{n}` style (and its linked character style) is in the /// document, for files that didn't bring it. -fn ensure_heading_style(s: &mut Session, n: u8) -> String { +pub(crate) fn ensure_heading_style(s: &mut Session, n: u8) -> String { let id = format!("Heading{n}"); if s.doc.styles.get(&id).is_none() { let builtin = wordcraft_doc::styles::StyleSheet::builtin(); diff --git a/crates/engine/src/cmd/text.rs b/crates/engine/src/cmd/text.rs index 39dbd168fe..10bc18d488 100644 --- a/crates/engine/src/cmd/text.rs +++ b/crates/engine/src/cmd/text.rs @@ -171,7 +171,10 @@ fn list_autoformat(s: &mut Session) -> Result { let len = para.len(); para.delete(0, len)?; para.props.numbering = Some(NumRef { num, level: 0 }); - para.props.style = Some("ListParagraph".into()); + // A heading stays a heading (Word: "1. " in a Heading 1 gives a numbered Heading 1). + if para.props.style.as_deref().is_none_or(|st| st == "Normal") { + para.props.style = Some("ListParagraph".into()); + } s.sel = Selection::caret(Pos { off: 0, ..f }); Ok(true) } diff --git a/crates/engine/src/tests_typing.rs b/crates/engine/src/tests_typing.rs index 99da04e28e..a30da9a1b1 100644 --- a/crates/engine/src/tests_typing.rs +++ b/crates/engine/src/tests_typing.rs @@ -1486,3 +1486,92 @@ fn an_overlong_word_is_not_added_as_an_exception() { run(&mut s, "edit.undo", json!({})); assert!(first_letter_list(&s).is_empty()); } + +/// Each body paragraph's laid-out number (`None`: none) and its style. +fn numbers(s: &mut Session) -> Vec<(Option, String)> { + let l = s.layout(); + let labels: Vec> = l + .pages + .iter() + .flat_map(|p| &p.items) + .filter_map(|i| { + if let wordcraft_layout::Placed::Lines { para, l0: 0, .. } = i { Some(para.label.as_ref().map(|lb| lb.text.clone())) } else { None } + }) + .collect(); + let styles = s.doc.body.iter().filter_map(|b| b.as_para()).map(|p| p.props.style.clone().unwrap_or_else(|| "Normal".into())); + labels.into_iter().zip(styles).collect() +} + +fn num(label: Option<&str>, style: &str) -> (Option, String) { + (label.map(str::to_string), style.to_string()) +} + +/// #1793 (Word for Mac, 2026-10-15): after the "1 / 1.1 / 1.1.1" heading list, a new Heading 2 +/// typed after "Three" is numbered 2.1 on its own; the body text after it stays unnumbered. +#[test] +fn a_new_heading_after_a_heading_linked_list_is_numbered() { + let mut s = s(); + typ(&mut s, "One"); + run(&mut s, "para.heading1", json!({})); + typ(&mut s, "\nTwo"); + run(&mut s, "para.heading2", json!({})); + typ(&mut s, "\nThree"); + run(&mut s, "para.heading1", json!({})); + // The caret in "Three" (a Heading 1, the list's first level). + run(&mut s, "list.multilevelPreset", json!({"preset": "headings"})); + run(&mut s, "caret.docEnd", json!({})); + typ(&mut s, "\n"); + run(&mut s, "para.heading2", json!({})); + typ(&mut s, "Four\nBody"); + assert_eq!( + numbers(&mut s), + [num(Some("1"), "Heading1"), num(Some("1.1"), "Heading2"), num(Some("2"), "Heading1"), num(Some("2.1"), "Heading2"), num(None, "Normal")] + ); +} + +/// #1793: plain Numbering on a Heading 1 numbers it "1." and keeps Heading 1 (Word: "1. One"). +#[test] +fn numbering_a_heading_keeps_the_heading_style() { + let mut s = s(); + typ(&mut s, "One"); + run(&mut s, "para.heading1", json!({})); + run(&mut s, "para.numbering", json!({})); + assert_eq!(numbers(&mut s), [num(Some("1."), "Heading1")]); +} + +/// #1793: applying a heading style that carries list numbering numbers the paragraph, even one +/// whose numbering was switched off before. +#[test] +fn applying_a_numbered_heading_style_numbers_the_paragraph() { + let mut s = s(); + typ(&mut s, "One"); + run(&mut s, "para.heading1", json!({})); + run(&mut s, "list.multilevelPreset", json!({"preset": "headings"})); + typ(&mut s, "\nTwo"); + run(&mut s, "para.numbering", json!({})); + run(&mut s, "para.numbering", json!({"off": true})); + assert_eq!(numbers(&mut s)[1].0, None); + run(&mut s, "para.heading2", json!({})); + assert_eq!(numbers(&mut s), [num(Some("1"), "Heading1"), num(Some("1.1"), "Heading2")]); +} + +/// #1793: "1. " typed at the start of a heading numbers it and keeps the heading style; only +/// body text becomes List Paragraph. +#[test] +fn typing_1_dot_in_a_heading_keeps_the_heading_style() { + let mut s = s(); + run(&mut s, "para.heading1", json!({})); + typ(&mut s, "1. One\n"); + run(&mut s, "para.normal", json!({})); + typ(&mut s, "1. Body"); + assert_eq!(numbers(&mut s), [num(Some("1."), "Heading1"), num(Some("1."), "ListParagraph")]); + for (style, keys) in [("Heading2", "a) Part"), ("Subtitle", "1. Item")] { + let mut s = self::s(); + run(&mut s, "para.style", json!({"style": style})); + typ(&mut s, keys); + let got = numbers(&mut s); + assert_eq!(got.len(), 1); + assert!(got[0].0.is_some(), "{style}: a list"); + assert_eq!(got[0].1, style); + } +} diff --git a/crates/ui-egui/src/i18n/bg.tsv b/crates/ui-egui/src/i18n/bg.tsv index f68232f382..9047f9f154 100644 --- a/crates/ui-egui/src/i18n/bg.tsv +++ b/crates/ui-egui/src/i18n/bg.tsv @@ -515,6 +515,10 @@ More templates → Още шаблони → Move or Resize Преместване или преоразмеряване Multilevel List Многостепенен списък + Multilevel List Library Библиотека с многостепенни списъци + Article I. Член I. + Chapter 1 Глава 1 + Section 1.01 Раздел 1.01 Multiple Pages Няколко страници Name (A–Z) Име (А–Я) Name: Име: diff --git a/crates/ui-egui/src/i18n/cs.tsv b/crates/ui-egui/src/i18n/cs.tsv index 740f5a41ae..21ae7e517b 100644 --- a/crates/ui-egui/src/i18n/cs.tsv +++ b/crates/ui-egui/src/i18n/cs.tsv @@ -711,6 +711,10 @@ Move Past Citation Přejít za citaci Move Past\nCitation Přejít za\ncitaci Multilevel List Víceúrovňový seznam + Multilevel List Library Knihovna víceúrovňových seznamů + Article I. Článek I. + Chapter 1 Kapitola 1 + Section 1.01 Oddíl 1.01 Multiple Pages Více stránek Name (A–Z) Jméno (A–Z) Name of the file this document is saved as Název souboru dokumentu diff --git a/crates/ui-egui/src/i18n/de.tsv b/crates/ui-egui/src/i18n/de.tsv index 87baf70e82..f37087f736 100644 --- a/crates/ui-egui/src/i18n/de.tsv +++ b/crates/ui-egui/src/i18n/de.tsv @@ -511,6 +511,10 @@ Modify… Ändern… Move or Resize Verschieben oder Größe ändern Multilevel List Mehrstufige Liste + Multilevel List Library Bibliothek mehrstufiger Listen + Article I. Artikel I. + Chapter 1 Kapitel 1 + Section 1.01 Abschnitt 1.01 Multiple Pages Mehrere Seiten Name (A–Z) Name (A–Z) Name: Name: diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index 7156e4abd7..329640b13a 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -583,6 +583,10 @@ More templates → Más plantillas → Move or Resize Mover o cambiar tamaño Multilevel List Lista multinivel + Multilevel List Library Biblioteca de listas multinivel + Article I. Artículo I. + Chapter 1 Capítulo 1 + Section 1.01 Sección 1.01 Multiple Pages Varias páginas Name (A–Z) Nombre (A–Z) Name: Nombre: diff --git a/crates/ui-egui/src/i18n/et.tsv b/crates/ui-egui/src/i18n/et.tsv index 8c54632af5..930099c0a8 100644 --- a/crates/ui-egui/src/i18n/et.tsv +++ b/crates/ui-egui/src/i18n/et.tsv @@ -459,6 +459,10 @@ More templates → Veel malle → Move or Resize Teisalda või muuda suurust Multilevel List Mitmetasemeline loend + Multilevel List Library Mitmetasemeliste loendite teek + Article I. Artikkel I. + Chapter 1 Peatükk 1 + Section 1.01 Jaotis 1.01 Multiple Pages Mitu lehte Name (A–Z) Nimi (A–Z) Name: Nimi: diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv index 532c5902fc..85bcb83450 100644 --- a/crates/ui-egui/src/i18n/fr.tsv +++ b/crates/ui-egui/src/i18n/fr.tsv @@ -525,6 +525,10 @@ Modify… Modifier… Move or Resize Déplacer ou redimensionner Multilevel List Liste à plusieurs niveaux + Multilevel List Library Bibliothèque de listes à plusieurs niveaux + Article I. Article I. + Chapter 1 Chapitre 1 + Section 1.01 Section 1.01 Multiple Pages Plusieurs pages Name (A–Z) Nom (A–Z) Name: Nom : diff --git a/crates/ui-egui/src/i18n/hr.tsv b/crates/ui-egui/src/i18n/hr.tsv index fb73a43944..dbdfd641e6 100644 --- a/crates/ui-egui/src/i18n/hr.tsv +++ b/crates/ui-egui/src/i18n/hr.tsv @@ -1365,6 +1365,10 @@ template Letter Pismo Moved to Premješteno u Moves Premještanja Multilevel List Višerazinski popis + Multilevel List Library Biblioteka višerazinskih popisa + Article I. Članak I. + Chapter 1 Poglavlje 1 + Section 1.01 Odjeljak 1.01 Multiple Pages Više stranica Name Naziv Name (A–Z) Naziv (A–Ž) diff --git a/crates/ui-egui/src/i18n/hu.tsv b/crates/ui-egui/src/i18n/hu.tsv index 4a4b85164f..7567e1b6c1 100644 --- a/crates/ui-egui/src/i18n/hu.tsv +++ b/crates/ui-egui/src/i18n/hu.tsv @@ -440,6 +440,10 @@ Modify Style Stílus módosítása Modify… Módosítás… Multilevel List Többszintű lista + Multilevel List Library Többszintű listák tára + Article I. I. cikk + Chapter 1 1. fejezet + Section 1.01 1.01. szakasz Multiple Pages Több oldal Name (A–Z) Név (A–Z) Name: Név: diff --git a/crates/ui-egui/src/i18n/it.tsv b/crates/ui-egui/src/i18n/it.tsv index 3ca09c0fca..01b0c3c73e 100644 --- a/crates/ui-egui/src/i18n/it.tsv +++ b/crates/ui-egui/src/i18n/it.tsv @@ -511,6 +511,10 @@ More templates → Altri modelli → Move or Resize Sposta o ridimensiona Multilevel List Elenco a più livelli + Multilevel List Library Raccolta elenchi a più livelli + Article I. Articolo I. + Chapter 1 Capitolo 1 + Section 1.01 Sezione 1.01 Multiple Pages Più pagine Name (A–Z) Nome (A–Z) Name: Nome: diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index 5b649c0b58..1692407caf 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -568,6 +568,10 @@ Modify… 変更… Move or Resize 移動またはサイズ変更 Multilevel List アウトライン + Multilevel List Library アウトライン ライブラリ + Article I. 第 I 条 + Chapter 1 第 1 章 + Section 1.01 第 1.01 節 Multiple Pages 複数ページ Name (A–Z) 名前(A–Z) Name: 名前: diff --git a/crates/ui-egui/src/i18n/ko.tsv b/crates/ui-egui/src/i18n/ko.tsv index ac3bca891f..d8de36366b 100644 --- a/crates/ui-egui/src/i18n/ko.tsv +++ b/crates/ui-egui/src/i18n/ko.tsv @@ -562,6 +562,10 @@ Modify… 수정… Move or Resize 이동 또는 크기 조정 Multilevel List 다단계 목록 + Multilevel List Library 다단계 목록 라이브러리 + Article I. 제 I 조 + Chapter 1 제 1 장 + Section 1.01 제 1.01 절 Multiple Pages 여러 페이지 Name (A–Z) 이름순(A–Z) Name: 이름: diff --git a/crates/ui-egui/src/i18n/pl.tsv b/crates/ui-egui/src/i18n/pl.tsv index 7fc07fbc02..d416d69948 100644 --- a/crates/ui-egui/src/i18n/pl.tsv +++ b/crates/ui-egui/src/i18n/pl.tsv @@ -431,6 +431,10 @@ More templates → Więcej szablonów → Move or Resize Przenieś lub zmień rozmiar Multilevel List Lista wielopoziomowa + Multilevel List Library Biblioteka list wielopoziomowych + Article I. Artykuł I. + Chapter 1 Rozdział 1 + Section 1.01 Sekcja 1.01 Multiple Pages Wiele stron Name (A–Z) Nazwa (A–Z) Name: Nazwa: diff --git a/crates/ui-egui/src/i18n/pt-br.tsv b/crates/ui-egui/src/i18n/pt-br.tsv index 4ab9294fb5..d40fa3bb07 100644 --- a/crates/ui-egui/src/i18n/pt-br.tsv +++ b/crates/ui-egui/src/i18n/pt-br.tsv @@ -569,6 +569,10 @@ Modify… Modificar… Move or Resize Mover ou redimensionar Multilevel List Lista de vários níveis + Multilevel List Library Biblioteca de listas de vários níveis + Article I. Artigo I. + Chapter 1 Capítulo 1 + Section 1.01 Seção 1.01 Multiple Pages Várias páginas Name (A–Z) Nome (A–Z) Name: Nome: diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv index b549ff6fbb..50500425fc 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -442,6 +442,10 @@ Modify Style Изменить стиль Modify… Изменить… Multilevel List Многоуровневый список + Multilevel List Library Библиотека многоуровневых списков + Article I. Статья I. + Chapter 1 Глава 1 + Section 1.01 Раздел 1.01 Multiple Pages Несколько страниц Name (A–Z) Имя (А–Я) Name: Имя: diff --git a/crates/ui-egui/src/i18n/sr-latn.tsv b/crates/ui-egui/src/i18n/sr-latn.tsv index 64654587be..e80d2e36d1 100644 --- a/crates/ui-egui/src/i18n/sr-latn.tsv +++ b/crates/ui-egui/src/i18n/sr-latn.tsv @@ -616,6 +616,10 @@ Modify… Izmeni… Move or Resize Pomeri ili promeni veličinu Multilevel List Višenivoska lista + Multilevel List Library Biblioteka višenivoskih lista + Article I. Član I. + Chapter 1 Poglavlje 1 + Section 1.01 Odeljak 1.01 Multiple Pages Više strana Name (A–Z) Naziv (A–Š) Name: Naziv: diff --git a/crates/ui-egui/src/i18n/sr.tsv b/crates/ui-egui/src/i18n/sr.tsv index e2aec00164..2111d1ebcb 100644 --- a/crates/ui-egui/src/i18n/sr.tsv +++ b/crates/ui-egui/src/i18n/sr.tsv @@ -620,6 +620,10 @@ Modify… Измени… Move or Resize Помери или промени величину Multilevel List Вишенивоска листа + Multilevel List Library Библиотека вишенивоских листа + Article I. Члан I. + Chapter 1 Поглавље 1 + Section 1.01 Одељак 1.01 Multiple Pages Више страна Name (A–Z) Назив (А–Ш) Name: Назив: diff --git a/crates/ui-egui/src/i18n/sv.tsv b/crates/ui-egui/src/i18n/sv.tsv index adedea7f6c..0ed9b66959 100644 --- a/crates/ui-egui/src/i18n/sv.tsv +++ b/crates/ui-egui/src/i18n/sv.tsv @@ -574,6 +574,10 @@ More templates → Fler mallar → Move or Resize Flytta eller ändra storlek Multilevel List Lista med flera nivåer + Multilevel List Library Bibliotek med listor med flera nivåer + Article I. Artikel I. + Chapter 1 Kapitel 1 + Section 1.01 Avsnitt 1.01 Multiple Pages Flera sidor Name (A–Z) Namn (A–Ö) Name: Namn: diff --git a/crates/ui-egui/src/i18n/tr.tsv b/crates/ui-egui/src/i18n/tr.tsv index 85de2ce6af..1ae4d1fa40 100644 --- a/crates/ui-egui/src/i18n/tr.tsv +++ b/crates/ui-egui/src/i18n/tr.tsv @@ -440,6 +440,10 @@ Modify Style Stili Değiştir Modify… Değiştir… Multilevel List Çok Düzeyli Liste + Multilevel List Library Çok Düzeyli Liste Kitaplığı + Article I. Madde I. + Chapter 1 Bölüm 1 + Section 1.01 Kısım 1.01 Multiple Pages Birden Çok Sayfa Name (A–Z) Ad (A–Z) Name: Ad: diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index 33676227d2..a29fd94bea 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -583,6 +583,10 @@ Modify… Змінити… Move or Resize Перемістити або змінити розмір Multilevel List Багаторівневий список + Multilevel List Library Бібліотека багаторівневих списків + Article I. Стаття I. + Chapter 1 Глава 1 + Section 1.01 Розділ 1.01 Multiple Pages Кілька сторінок Name (A–Z) Назва (А–Я) Name: Назва: diff --git a/crates/ui-egui/src/i18n/vi.tsv b/crates/ui-egui/src/i18n/vi.tsv index 9f024b4341..efdfe92290 100644 --- a/crates/ui-egui/src/i18n/vi.tsv +++ b/crates/ui-egui/src/i18n/vi.tsv @@ -507,6 +507,10 @@ More templates → Xem thêm mẫu → Move or Resize Di chuyển hoặc đổi kích thước Multilevel List Danh sách đa cấp + Multilevel List Library Thư viện danh sách đa cấp + Article I. Điều I. + Chapter 1 Chương 1 + Section 1.01 Mục 1.01 Multiple Pages Nhiều trang Name (A–Z) Tên (A–Z) Name: Tên: diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv index adf421d6bc..217e48fbdd 100644 --- a/crates/ui-egui/src/i18n/zh-hans.tsv +++ b/crates/ui-egui/src/i18n/zh-hans.tsv @@ -580,6 +580,10 @@ Modify… 修改… Move or Resize 移动或调整大小 Multilevel List 多级列表 + Multilevel List Library 多级列表库 + Article I. 第 I 条 + Chapter 1 第 1 章 + Section 1.01 第 1.01 节 Multiple Pages 多页 Name (A–Z) 名称(A–Z) Name: 名称: diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv index 4489c5119b..367e29907f 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -569,6 +569,10 @@ Modify… 修改… Move or Resize 移動或調整大小 Multilevel List 多層次清單 + Multilevel List Library 多層次清單庫 + Article I. 第 I 條 + Chapter 1 第 1 章 + Section 1.01 第 1.01 節 Multiple Pages 多頁 Name (A–Z) 名稱(A–Z) Name: 名稱: diff --git a/crates/ui-egui/src/ribbon.rs b/crates/ui-egui/src/ribbon.rs index 088378a5d3..cd70833092 100644 --- a/crates/ui-egui/src/ribbon.rs +++ b/crates/ui-egui/src/ribbon.rs @@ -1081,6 +1081,23 @@ fn home(app: &mut WordApp, ui: &mut Ui) { mi(ui, app, "1. 1.1. 1.1.1.", "para.multilevel", json!({"kind": "legal"})); mi(ui, app, "I. A. 1. a.", "para.multilevel", json!({"kind": "outline"})); ui.separator(); + // Lists linked to the heading styles: the headings carry the numbers. Each item + // reads as its first three levels, number then (translated) style name. + for (numbers, k) in [ + (["1", "1.1", "1.1.1"], "headings"), + (["Article I.", "Section 1.01", "(a)"], "article"), + (["I.", "A.", "1."], "outlineHeadings"), + (["Chapter 1", "", ""], "chapter"), + ] { + let text = numbers + .iter() + .zip(["Heading 1", "Heading 2", "Heading 3"]) + .map(|(n, h)| if n.is_empty() { tl!(h).to_string() } else { format!("{} {}", tl!(n), tl!(h)) }) + .collect::>() + .join(" / "); + mi_text(ui, app, &text, "list.multilevelPreset", json!({"preset": k})); + } + ui.separator(); mi(ui, app, "Define New Multilevel List…", "list.define", json!({})); ui.separator(); for lv in 0..5u64 { diff --git a/docs/gaps.md b/docs/gaps.md index 17b17cd7d0..48a9e88d1c 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -1,6 +1,6 @@ # Where WordCraft falls short of Word -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-18 · **Change:** trivial (legacy outline, shadow, emboss and imprint text effects drawn, #2534; previously trivial: #8 narrowed: Indonesian proofing from the installed Hunspell dictionary, #498; previously trivial: glossary/building blocks closed; previously trivial: Split Cells and Convert Text to Table follow the Word check, #2592; previously trivial: Language dialog keeps inherited proofing language and no-proof choices (#2717); previously trivial: #29: a copied equation also goes in the MathML clipboard formats, #1297; previously trivial: VML freeforms read and kept on save; previously trivial: consistent preferred cell widths; previously trivial: multi-paragraph field instructions no longer add blank lines; previously trivial: page-break-only list paragraphs hide their labels; previously trivial: floating-table overlap ignores empty line-break lines; previously trivial: copies keep their formatting on the clipboard as HTML, #1523; previously trivial: sample-text formulas on Enter, #1067; previously trivial: #29: copied equations as MathML, OMML open as #1297, #816; previously trivial: the Font list no longer keeps every previewed font in memory, #848; previously trivial: table column and row markers on the rulers, #700; previously trivial: table borders and the resize handle drag on the page, #217; previously trivial: formatting revisions landed, #41; previously trivial: Draw Table and Eraser landed, #303; previously minor: beta distance restated: ~15 points, ~150–230 h; previously: first version; every known shortfall from the 2026-10-10 re-measure, ranked) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-18 · **Change:** trivial (numbered headings, #1793; previously trivial: legacy outline, shadow, emboss and imprint text effects drawn, #2534; previously trivial: #8 narrowed: Indonesian proofing from the installed Hunspell dictionary, #498; previously trivial: glossary/building blocks closed; previously trivial: Split Cells and Convert Text to Table follow the Word check, #2592; previously trivial: Language dialog keeps inherited proofing language and no-proof choices (#2717); previously trivial: #29: a copied equation also goes in the MathML clipboard formats, #1297; previously trivial: VML freeforms read and kept on save; previously trivial: consistent preferred cell widths; previously trivial: multi-paragraph field instructions no longer add blank lines; previously trivial: page-break-only list paragraphs hide their labels; previously trivial: floating-table overlap ignores empty line-break lines; previously trivial: copies keep their formatting on the clipboard as HTML, #1523; previously trivial: sample-text formulas on Enter, #1067; previously trivial: #29: copied equations as MathML, OMML open as #1297, #816; previously trivial: the Font list no longer keeps every previewed font in memory, #848; previously trivial: table column and row markers on the rulers, #700; previously trivial: table borders and the resize handle drag on the page, #217; previously trivial: formatting revisions landed, #41; previously trivial: Draw Table and Eraser landed, #303; previously minor: beta distance restated: ~15 points, ~150–230 h; previously: first version; every known shortfall from the 2026-10-10 re-measure, ranked) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4 > **Printing update:** 2026-10-12 · direct desktop printing through craft-print; details below. @@ -99,6 +99,7 @@ The Language dialog reads effective character formatting, including document def | 2026-10-16 | trivial | #7: Split Cells and Convert Text to Table dialogs (#2592), 58 modal dialogs (matches ui-parity.md) | | 2026-10-16 | trivial | #8 narrowed: Indonesian proofing (id-ID spelling from the installed Hunspell dictionary, generic checks, #498) | | 2026-10-16 | trivial | Legacy outline, shadow, emboss and imprint text effects drawn (#2534) | +| 2026-10-15 | trivial | Numbered headings (#1793): heading-linked Multilevel List presets; numbering keeps heading styles. Not a ranked gap; style gallery previews without numbers remain | | 2026-10-14 | trivial | Row table border exceptions fixed; other row table exceptions remain unsupported | | 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/docs/parity-checklist.md b/docs/parity-checklist.md index 0f22c6d717..8a9375bcc3 100644 --- a/docs/parity-checklist.md +++ b/docs/parity-checklist.md @@ -2,20 +2,20 @@ 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. -**487 of 502 catalog features have commands (97.0%).** +**490 of 505 catalog features have commands (97.0%).** | Tab | Live | Total | |---|---|---| | File | 22 | 22 | | Quick Access | 5 | 5 | | Title Bar | 1 | 1 | -| Home | 102 | 103 | +| Home | 103 | 104 | | Insert | 47 | 53 | | Draw | 10 | 11 | | Design | 10 | 10 | | Layout | 22 | 22 | | References | 25 | 25 | -| Mailings | 17 | 17 | +| Mailings | 19 | 19 | | Review | 29 | 31 | | View | 25 | 29 | | Developer | 11 | 11 | diff --git a/docs/typing-parity.md b/docs/typing-parity.md index 4f53c5c37b..593d35d808 100644 --- a/docs/typing-parity.md +++ b/docs/typing-parity.md @@ -1,6 +1,6 @@ # Typing parity with Word -> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-18 · **Change:** trivial (reversing an automatic capital adds an exception, #1857; previously trivial: smart quotes open after guillemets and low quotes and nest; previously trivial: Enter on an empty list item numbered by its paragraph style moves it up a level, #2143; previously trivial: Backspace after a section break and Delete before it remove the break, also on the last paragraph, #2127; previously trivial: a table inserted in right-to-left text is right to left, so Tab moves left, #2634; previously trivial: Shift+Delete cuts the selection on every platform, #1669; previously trivial: Select All then Delete removes a table the document starts with, #1198; previously trivial: Alt+numpad character codes, #961; previously trivial: Down and Up from a line start beside a table go into it, #1007; previously trivial: sample-text formulas on Enter, #1067; previously trivial: Track Changes: Accept All after a tracked delete from a paragraph end keeps the next paragraph; Reject All after typing into an empty paragraph keeps it (#864); previously minor: Correct TWo INitial CApitals, cAPS LOCK and table-cell capitals, #544; previously trivial: complex-script caret and Backspace, #604; Move Paragraph Up/Down, Promote/Demote, Go Back and Alt+X keys, #343) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4 +> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-18 · **Change:** trivial (a heading-list preset keeps Heading styles and makes other paragraphs Heading 1, as in Word, #1793; previously trivial: numbered headings: a heading-linked Multilevel List numbers new headings by their style; numbering keeps a heading a heading, #1793; previously trivial: reversing an automatic capital adds an exception, #1857; previously trivial: smart quotes open after guillemets and low quotes and nest; previously trivial: Enter on an empty list item numbered by its paragraph style moves it up a level, #2143; previously trivial: Backspace after a section break and Delete before it remove the break, also on the last paragraph, #2127; previously trivial: a table inserted in right-to-left text is right to left, so Tab moves left, #2634; previously trivial: Shift+Delete cuts the selection on every platform, #1669; previously trivial: Select All then Delete removes a table the document starts with, #1198; previously trivial: Alt+numpad character codes, #961; previously trivial: Down and Up from a line start beside a table go into it, #1007; previously trivial: sample-text formulas on Enter, #1067; previously trivial: Track Changes: Accept All after a tracked delete from a paragraph end keeps the next paragraph; Reject All after typing into an empty paragraph keeps it (#864); previously minor: Correct TWo INitial CApitals, cAPS LOCK and table-cell capitals, #544; previously trivial: complex-script caret and Backspace, #604; Move Paragraph Up/Down, Promote/Demote, Go Back and Alt+X keys, #343) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4 What Word does when you type, observed black-box (same keystrokes typed into Word and WordCraft, results read back paragraph by paragraph) and pinned by tests in @@ -40,6 +40,12 @@ formatting use the existing editing primitives. Tests: `sample_text_formulas_exp | `* ` `- ` `1. ` `1) ` `a. ` `a) ` `A. ` `i. ` `I. ` + text | bullet, dash bullet, 1. 1) a. a) A. i. I. lists | `list_autoformat_triggers` | | `> ` `-> ` `=> ` + text | bulleted list with an arrow bullet (➢ ➔ ⇨; Word takes them from a symbol font, we use the Unicode arrows) ¹ | `arrow_bullets` | | `1. ` after other text | a new list restarting at 1 (never continues an earlier list) | `typing_1_dot_starts_numbering_again` | +| Heading 1 `One`, Heading 2 `Two`, Heading 1 `Three`, the caret in `Three`, Multilevel List › the "1 / 1.1 / 1.1.1" heading list, then at the end Enter, Heading 2, `Four` Enter `Body` | One = 1, Two = 1.1, Three = 2, styles kept; the new Heading 2 is numbered 2.1 by itself (the heading styles carry the list); `Body` (Normal) stays unnumbered | `a_new_heading_after_a_heading_linked_list_is_numbered` | +| Numbering on a Heading 1 | "1. One", still Heading 1 | `numbering_a_heading_keeps_the_heading_style` | +| The "1 / 1.1 / 1.1.1" heading list with a caret or a selection | paragraphs already in a Heading style keep their style and level; every other paragraph style (Normal, Quote, Title…) becomes Heading 1. One H1 / Body / Two H2 → 1, Body H1 "2", Two H2 "2.1"; Two H2 + Three H3 with no Heading 1 above → 1.1, 1.1.1; caret in a Heading 2 → stays Heading 2 "1.1"; Normal alone → Heading 1 "1" (checked in Word for Mac, 2026-10-16) | `a_heading_preset_keeps_headings_and_makes_other_paragraphs_heading_1`, `a_heading_preset_on_a_lone_normal_paragraph_makes_it_heading_1` | +| Undo after a heading list | one step: paragraph styles and the heading styles' numbers come back | `undo_takes_a_heading_preset_back_in_one_step` | +| A paragraph whose numbering was switched off, then Heading 2 from a heading-linked list | numbered (1.1) by the style | `applying_a_numbered_heading_style_numbers_the_paragraph` | +| `1. ` typed in a Heading 1 (also `a) ` in a Heading 2, `1. ` in a Subtitle) | numbered, the style kept; only body text becomes List Paragraph (checked in Word for Mac, 2026-10-15) | `typing_1_dot_in_a_heading_keeps_the_heading_style` | | ⌘Z right after an AutoFormat | undoes only the AutoFormat (`* ` comes back as text) | `undo_right_after_autoformat_undoes_only_the_autoformat` | ## Undo @@ -230,6 +236,9 @@ Word. | 2026-10-15 | trivial | Enter on an empty nested list item whose numbering comes from its paragraph style (List Number 2) moves it up a level instead of adding another item (#2143) | | 2026-10-18 | trivial | Smart quotes open after `«` `‹` `„` `‚` and after the other kind of opening quote, so nested quotes curl correctly | | 2026-10-15 | minor | AutoCorrect: undoing the capital's own step (or backspacing it and retyping) adds an exception; "Automatically add words to list" switches (#1857) | +| 2026-10-16 | trivial | #1793: a heading-list preset keeps paragraphs already in a Heading style at their level and makes other paragraphs Heading 1 (rechecked in Word for Mac); undo test | +| 2026-10-16 | trivial | #1793: a heading-linked Multilevel List preset makes every selected paragraph its first level (Heading 1), checked in Word for Mac; `1. ` in a heading checked in Word; a single Heading 2 becoming Heading 1 is unchecked | +| 2026-10-15 | trivial | Numbered headings (#1793): a heading-linked Multilevel List numbers headings through their styles, so a new Heading 2 after the list is numbered by itself; Numbering and `1. ` keep a heading a heading; applying a numbered heading style numbers the paragraph | | 2026-10-14 | trivial | Shift+Delete cuts the selection, and does nothing without one, on macOS and Linux too, as in Word for Windows (#1669); Shift+Backspace already erased (#707) | | 2026-10-13 | trivial | Select All then Delete or Backspace removes a table the document starts with (#1198) | | 2026-10-12 | trivial | Editing keys: Down and Up from the start of a line beside a table go into it instead of skipping it (#1007) | diff --git a/docs/ui-parity.md b/docs/ui-parity.md index 192beeb86c..290d77212a 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -1,6 +1,6 @@ # UI parity with Microsoft Word -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-18 · **Change:** trivial (Navigation Find exposes Results, #2096; previously trivial: the tab strip never draws tabs over Comments / Editing / Share, #2026; previously trivial: Columns on selected text, #2024; previously trivial: Different First Page headers reachable from page 1, #2826; previously trivial: a copied equation also goes in the clipboard's MathML formats, #1297; previously trivial: Read Mode keeps the keyboard, shows whole columns and keeps text clear of the page-turn bar, #1772); previously trivial: font list entries of tall-ascent fonts sit in their row, not cut off, #2570); previously trivial: Split Cells and Convert Text to Table dialogs follow Word's merged-cell limits and Initial column width, #2592; previously trivial: a new source's Tag is made like Word's, three letters of the author and two digits of the year with a number added when taken, and an edit keeps the Tag, #2094; previously trivial: Web Layout text fills the window width, #1876; previously trivial: Language dialog keeps inherited proofing language and no-proof choices (#2717)); previously trivial: comment balloons readable in the dark theme, #2045; previously trivial: Recently Used Fonts section at the top of the font list, #2068; previously trivial: web preferences kept between visits, #2132; previously trivial: dialog close control: a Windows-style box at the title bar's right end, the whole box clickable, red on hover, #1885); previously trivial (Modify Table Style formats every table style part, #714); previously trivial: a click on the ribbon chrome hands the keyboard to the page in the same frame, #1093 follow-up; previously trivial: Page Setup vertical alignment, #1320; previously trivial: the app opens on the Home tab every launch, as in Word; previously trivial: no document behind the Start screen, #1129; previously trivial: equation pictures share a few textures, #1002; previously trivial: selection bar in the left margin, #856; previously trivial: table column and row markers on the rulers, #700; previously trivial: table borders and the resize handle drag on the page, #217; previously major: first version; ribbon, dialogs, shortcuts and on-canvas interaction measured from `crates/ui-egui` and the command registry) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-18 · **Change:** trivial (numbered headings, #1793; previously trivial: Navigation Find exposes Results, #2096; previously trivial: the tab strip never draws tabs over Comments / Editing / Share, #2026; previously trivial: Columns on selected text, #2024; previously trivial: Different First Page headers reachable from page 1, #2826; previously trivial: a copied equation also goes in the clipboard's MathML formats, #1297; previously trivial: Read Mode keeps the keyboard, shows whole columns and keeps text clear of the page-turn bar, #1772); previously trivial: font list entries of tall-ascent fonts sit in their row, not cut off, #2570); previously trivial: Split Cells and Convert Text to Table dialogs follow Word's merged-cell limits and Initial column width, #2592; previously trivial: a new source's Tag is made like Word's, three letters of the author and two digits of the year with a number added when taken, and an edit keeps the Tag, #2094; previously trivial: Web Layout text fills the window width, #1876; previously trivial: Language dialog keeps inherited proofing language and no-proof choices (#2717)); previously trivial: comment balloons readable in the dark theme, #2045; previously trivial: Recently Used Fonts section at the top of the font list, #2068; previously trivial: web preferences kept between visits, #2132; previously trivial: dialog close control: a Windows-style box at the title bar's right end, the whole box clickable, red on hover, #1885); previously trivial (Modify Table Style formats every table style part, #714); previously trivial: a click on the ribbon chrome hands the keyboard to the page in the same frame, #1093 follow-up; previously trivial: Page Setup vertical alignment, #1320; previously trivial: the app opens on the Home tab every launch, as in Word; previously trivial: no document behind the Start screen, #1129; previously trivial: equation pictures share a few textures, #1002; previously trivial: selection bar in the left margin, #856; previously trivial: table column and row markers on the rulers, #700; previously trivial: table borders and the resize handle drag on the page, #217; previously major: first version; ribbon, dialogs, shortcuts and on-canvas interaction measured from `crates/ui-egui` and the command registry) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4 How WordCraft looks and feels next to Word: ribbon, dialogs, panes, keyboard, mouse and touch. Typing behaviour has its own checklist ([`typing-parity.md`](typing-parity.md)). All assets are our @@ -80,6 +80,9 @@ Find selects the Results tab, including when it prefills selected text. Editing | 2026-10-15 | trivial | Layout › Columns works on the selected text, as in Word: section breaks around it, the rest of the document unchanged; More Columns… adds "Selected text" to Apply to and picks it when text is selected (#2024) | | 2026-10-15 | trivial | Navigation Find and query edits expose Results (#2096) | | 2026-10-15 | trivial | AutoCorrect Options › Exceptions: "Automatically add words to list" switches for First Letter and INitial CAps (#1857) | +| 2026-10-16 | trivial | Multilevel List heading presets: every selected paragraph takes the first level (Heading 1), as Word for Mac does; headings outside the selection keep their styles and levels (#1793) | +| 2026-10-16 | trivial | Multilevel List heading presets: selected paragraphs already in a Heading style keep their style and level, every other selected paragraph becomes Heading 1, as Word for Mac does (#1793) | +| 2026-10-15 | trivial | Multilevel List gallery: four lists linked to Heading 1–9 (1 / 1.1 / 1.1.1; Article I. / Section 1.01 / (a); I. / A. / 1.; Chapter 1), `list.multilevelPreset`; headings keep their styles and new headings are numbered (#1793). Style gallery previews don't show the numbers yet | | 2026-10-13 | trivial | New/Modify Table Style: No Border on a part that inherits its lines hides them; emptying the font box makes the part's font inherited again (#1288) | | 2026-10-14 | trivial | Tab stops edited on the ruler: corner kind selector, click to set, drag to move or off to clear, per-kind markers mirrored for RTL; each gesture one `para.tabs` call and one undo step; Rulers 85% → 90% | | 2026-10-12 | trivial | Tables on the page: one selected cell (Select Cell, or a click along its left edge) is a cell selection when its border is dragged: the border moves in its row only (#962) | From 963df12a11351de3248c175654dabcb567326e5e Mon Sep 17 00:00:00 2001 From: divyanshu2074 <43274510+divyanshu2074@users.noreply.github.com> Date: Sun, 18 Oct 2026 13:52:31 +0530 Subject: [PATCH 06/38] fix(docx): trim marker id, bound bookmarks map, and support bookmarkEnd name fallback (#2252) Co-authored-by: Brandon Thomas --- crates/docx/src/read/story.rs | 14 +++++++++++--- crates/docx/tests/fixtures.rs | 22 ++++++++++++++++++++++ 2 files changed, 33 insertions(+), 3 deletions(-) diff --git a/crates/docx/src/read/story.rs b/crates/docx/src/read/story.rs index d4977fb3a3..cafed444f4 100644 --- a/crates/docx/src/read/story.rs +++ b/crates/docx/src/read/story.rs @@ -295,17 +295,25 @@ impl Reader<'_> { } fn marker(&mut self, e: &El) -> Option { - let id = e.attr("w:id").unwrap_or("").to_string(); + let id = e.attr("w:id").map(str::trim).unwrap_or("").to_string(); match e.name.as_str() { "w:bookmarkStart" => { - let name = e.attr("w:name").unwrap_or("").to_string(); + if self.bookmarks.len() >= 100_000 { + return None; + } + let name = e.attr("w:name").map(str::trim).unwrap_or("").to_string(); if name.is_empty() || name.len() > 256 { return None; } self.bookmarks.insert(id, name.clone()); Some(InlineObject::BookmarkStart { name }) } - "w:bookmarkEnd" => self.bookmarks.get(&id).map(|n| InlineObject::BookmarkEnd { name: n.clone() }), + "w:bookmarkEnd" => self + .bookmarks + .get(&id) + .cloned() + .or_else(|| e.attr("w:name").map(str::trim).filter(|n| !n.is_empty() && n.len() <= 256).map(str::to_string)) + .map(|name| InlineObject::BookmarkEnd { name }), "w:commentRangeStart" => self.comment_id(&id).map(|id| InlineObject::CommentStart { id }), "w:permStart" => { // Bounded: a hostile file can't grow the map without end. diff --git a/crates/docx/tests/fixtures.rs b/crates/docx/tests/fixtures.rs index 91119a0cb1..ae35c203e9 100644 --- a/crates/docx/tests/fixtures.rs +++ b/crates/docx/tests/fixtures.rs @@ -2974,3 +2974,25 @@ fn shape_dashes_import_and_save() { } } } + +#[test] +fn bookmark_end_name_and_id_trim() { + let body = r#" + + Hello + + + "#; + let doc = wordcraft_docx::read(&docx(body, &[], &[])).unwrap(); + let p = doc.body.first().unwrap().as_para().unwrap(); + let names: Vec<(&'static str, String)> = p + .objects + .iter() + .filter_map(|o| match o { + InlineObject::BookmarkStart { name } => Some(("start", name.clone())), + InlineObject::BookmarkEnd { name } => Some(("end", name.clone())), + _ => None, + }) + .collect(); + assert_eq!(names, vec![("start", "section_intro".into()), ("end", "section_intro".into()), ("end", "standalone_target".into())]); +} From 6311f9d4117b7209b1396fde1627da3888373738 Mon Sep 17 00:00:00 2001 From: FredApps <153173854+FredApps@users.noreply.github.com> Date: Sun, 18 Oct 2026 10:22:50 +0200 Subject: [PATCH 07/38] AutoCorrect: replace the double-hyphen arrow (#2223) (#2263) Co-authored-by: FredApps Co-authored-by: Brandon Thomas --- ROADMAP.md | 4 +++- crates/engine/src/cmd/tools.rs | 1 + crates/engine/src/tests_typing.rs | 33 +++++++++++++++++++++++++++++++ docs/typing-parity.md | 7 ++++++- 4 files changed, 43 insertions(+), 2 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index a14ef92057..36a700a2e0 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -2,7 +2,7 @@ **Stage: alpha** · next: beta, ~15 points (60% → 75% ready for real work) and ~150–230 h away -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-18 · **Change:** trivial (numbered headings, #1793; previously trivial: Navigation Find exposes Results, #2096; previously trivial: progress log: Columns on selected text, #2024; previously trivial: progress log: legacy outline, shadow, emboss and imprint text effects drawn, #2534; previously trivial: progress log: Indonesian proofing, #498; previously trivial: progress log: DOCX glossary documents (building blocks); previously trivial: progress log: Read Mode keeps the keyboard and shows whole, unobscured columns, #1772); previously trivial: progress log: font list entries of tall-ascent fonts no longer cut off, #2570); previously trivial: progress log: Split Cells and Convert Text to Table dialogs follow the Word check, #2592; previously trivial: a new source's Tag is made like Word's, three letters of the author and two digits of the year with a number added when taken, and an edit keeps the Tag, #2094; previously trivial: Web Layout fills the window width, #1876; previously trivial: progress log: Different First Page headers reachable from page 1, #2826; previously trivial: Language dialog keeps inherited proofing language and no-proof choices (#2717); previously trivial: progress log: web preferences kept between visits, #2132; previously trivial: progress log: undrawable private-use list bullets show the level's default bullet, #1683); previously trivial: table style editor formats every part, #714; previously trivial: VML freeforms read and kept on save; previously trivial: progress log: consistent preferred cell widths; previously trivial: multi-paragraph field instructions no longer add blank lines; previously trivial: page-break-only list paragraphs hide their labels; previously trivial: progress log: grouped `flowChartOr` shapes draw their circle and crossing spokes; previously trivial: progress log: copies keep their formatting in other programs, #1523; previously trivial: progress log: privacy options on save, #1062; previously trivial: progress log: equation pictures in theme colours on Windows, #1002; previously trivial: symbol fonts, #776; previously trivial: the save prompt answers from the keyboard, #861; previously trivial: table column and row markers on the rulers, #700; previously trivial: table borders and the resize handle drag on the page, #217; previously trivial: formatting revisions landed, #41; previously trivial: Draw tab ink, #307, and Draw Table and Eraser, #303, landed: catalog 402/434; previously minor: readiness table with hours per audience; full number back to the additive weighted sum, 60%; mainstream and essentials numbers added; alpha gate checked; previously major: full re-measure against Word for Mac 16.113.4; restructured to the craftrules progress-docs standard) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-18 · **Change:** trivial (`-->` AutoCorrect replacement, #2223; previously trivial: numbered headings, #1793; previously trivial: Navigation Find exposes Results, #2096; previously trivial: progress log: Columns on selected text, #2024; previously trivial: progress log: legacy outline, shadow, emboss and imprint text effects drawn, #2534; previously trivial: progress log: Indonesian proofing, #498; previously trivial: progress log: DOCX glossary documents (building blocks); previously trivial: progress log: Read Mode keeps the keyboard and shows whole, unobscured columns, #1772); previously trivial: progress log: font list entries of tall-ascent fonts no longer cut off, #2570); previously trivial: progress log: Split Cells and Convert Text to Table dialogs follow the Word check, #2592; previously trivial: a new source's Tag is made like Word's, three letters of the author and two digits of the year with a number added when taken, and an edit keeps the Tag, #2094; previously trivial: Web Layout fills the window width, #1876; previously trivial: progress log: Different First Page headers reachable from page 1, #2826; previously trivial: Language dialog keeps inherited proofing language and no-proof choices (#2717); previously trivial: progress log: web preferences kept between visits, #2132; previously trivial: progress log: undrawable private-use list bullets show the level's default bullet, #1683); previously trivial: table style editor formats every part, #714; previously trivial: VML freeforms read and kept on save; previously trivial: progress log: consistent preferred cell widths; previously trivial: multi-paragraph field instructions no longer add blank lines; previously trivial: page-break-only list paragraphs hide their labels; previously trivial: progress log: grouped `flowChartOr` shapes draw their circle and crossing spokes; previously trivial: progress log: copies keep their formatting in other programs, #1523; previously trivial: progress log: privacy options on save, #1062; previously trivial: progress log: equation pictures in theme colours on Windows, #1002; previously trivial: symbol fonts, #776; previously trivial: the save prompt answers from the keyboard, #861; previously trivial: table column and row markers on the rulers, #700; previously trivial: table borders and the resize handle drag on the page, #217; previously trivial: formatting revisions landed, #41; previously trivial: Draw tab ink, #307, and Draw Table and Eraser, #303, landed: catalog 402/434; previously minor: readiness table with hours per audience; full number back to the additive weighted sum, 60%; mainstream and essentials numbers added; alpha gate checked; previously major: full re-measure against Word for Mac 16.113.4; restructured to the craftrules progress-docs standard) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4 > **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-17 · **Change:** trivial (progress log: pasted blockquotes are indented paragraphs, #1270; previously trivial: progress log: print-area strings translated in every catalog; previously trivial: progress log: comments readable in the dark theme, #2045; previously trivial: progress log: ODT sections, #1379; previously trivial: progress log: table deletes, Change Case and Unlink Fields respect Track Changes and locked content controls, #1329; previously trivial: progress log: rounded rectangle corners, #1476; previously trivial: progress log: Save As type list, #1399; previously trivial: progress log: privacy options on save, #1062; previously trivial: progress log: equation pictures in theme colours on Windows, #1002; previously trivial: symbol fonts, #776; previously trivial: the save prompt answers from the keyboard, #861; previously trivial: table column and row markers on the rulers, #700; previously trivial: table borders and the resize handle drag on the page, #217; previously trivial: formatting revisions landed, #41; previously trivial: Draw tab ink, #307, and Draw Table and Eraser, #303, landed: catalog 402/434; previously minor: readiness table with hours per audience; full number back to the additive weighted sum, 60%; mainstream and essentials numbers added; alpha gate checked; previously major: full re-measure against Word for Mac 16.113.4; restructured to the craftrules progress-docs standard) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4 > **Printing update:** 2026-10-12 · direct desktop printing through craft-print; details below. @@ -137,6 +137,7 @@ Ranked, to beta (detail and remaining milestones in [docs/roadmap.md](docs/roadm - 2026-10-15: Find opens Navigation on Results; editing the query restores Results after manual Headings/Pages navigation. Typed and selected-text searches retain document contents (#2096). | Date | What landed | +| 2026-10-15 | AutoCorrect recognizes `-->` as `→` (#2223), respecting replacement settings and undo without changing paragraph-start arrow bullets. | | 2026-10-15 | Numbered headings (#1793): the Multilevel List gallery offers four lists linked to Heading 1–9 (1 / 1.1 / 1.1.1, Article I. / Section 1.01 / (a), I. / A. / 1., Chapter 1; `list.multilevelPreset`). The heading styles carry the numbers, so paragraphs already in a Heading style keep their style and level, any other selected paragraph (Normal, Quote, Title) becomes Heading 1 (checked in Word), a new Heading 2 is numbered 2.1 by itself and body text stays unnumbered; Numbering or typing `1. ` keeps a heading a heading | | 2026-10-15 | Reversing an automatic sentence capital (Undo, or Backspace and retype) adds the word before it to AutoCorrect's First Letter exceptions, as Word does; "Automatically add words to list" switches for First Letter and INitial CAps (#1857). | | 2026-10-15 | Dialog close control: a Windows-style box at the title bar's right end, the whole box clickable, red on hover, in both themes (#1885) | @@ -449,6 +450,7 @@ Ranked, to beta (detail and remaining milestones in [docs/roadmap.md](docs/roadm | 2026-10-15 | trivial | Progress log: Columns on selected text (#2024) | | 2026-10-15 | trivial | Navigation Find and query edits expose Results (#2096) | | 2026-10-15 | trivial | Progress log: numbered headings (#1793) | +| 2026-10-15 | trivial | Add the missing `-->` → `→` AutoCorrect entry (#2223), with typing, undo and preference regression coverage; existing arrow bullets unchanged | | 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/engine/src/cmd/tools.rs b/crates/engine/src/cmd/tools.rs index 2b919fe30c..7020738f73 100644 --- a/crates/engine/src/cmd/tools.rs +++ b/crates/engine/src/cmd/tools.rs @@ -48,6 +48,7 @@ pub const AUTOCORRECT: &[(&str, &str)] = &[ ("(tm)", "™"), ("...", "…"), ("->", "→"), + ("-->", "→"), ("<-", "←"), ("=>", "⇒"), (":)", "☺"), diff --git a/crates/engine/src/tests_typing.rs b/crates/engine/src/tests_typing.rs index a30da9a1b1..910df273cd 100644 --- a/crates/engine/src/tests_typing.rs +++ b/crates/engine/src/tests_typing.rs @@ -356,6 +356,39 @@ fn smart_quotes_nested_and_opening_delimiters() { assert_eq!(s.doc.plain_text(StoryRef::Body), "«‘french’» „‘german’“"); } +/// #2223: the double-hyphen arrow is a replacement, not the existing arrow bullet. +#[test] +fn double_hyphen_arrow_autocorrect_preserves_typing_scope_and_undo() { + let mut s = typed("go --> "); + assert_eq!(s.doc.plain_text(StoryRef::Body), "Go → "); + assert_eq!(paras(&s)[0].1, None); + run(&mut s, "edit.undo", json!({})); + assert_eq!(s.doc.plain_text(StoryRef::Body), "Go --> "); + let s = typed("go --> here"); + assert_eq!(s.doc.plain_text(StoryRef::Body), "Go → here"); + let s = typed("--> "); + assert_eq!(s.doc.plain_text(StoryRef::Body), "→ "); + assert_eq!(paras(&s)[0].1, None); +} + +#[test] +fn double_hyphen_arrow_respects_autocorrect_preferences() { + for prefs in [json!({"enabled": false}), json!({"replaceText": false}), json!({"delete": "-->"})] { + let mut s = s(); + run(&mut s, "tools.autocorrect", prefs); + typ(&mut s, "go --> here"); + assert!(s.doc.plain_text(StoryRef::Body).ends_with(" --> here")); + } + let mut s = s(); + run(&mut s, "review.language", json!({"lang": "pl-PL", "default": true})); + typ(&mut s, "go --> here"); + assert_eq!(s.doc.plain_text(StoryRef::Body), "Go → here"); + let mut s = self::s(); + run(&mut s, "tools.autocorrect", json!({"add": {"from": "-->", "to": "custom"}})); + typ(&mut s, "go --> here"); + assert_eq!(s.doc.plain_text(StoryRef::Body), "Go custom here"); +} + /// #835: the English replacement list applies only to English text. Polish "i" ("and") stays /// lowercase; symbols still change; English and untagged text still get "I". #[test] diff --git a/docs/typing-parity.md b/docs/typing-parity.md index 593d35d808..f2be5dae4f 100644 --- a/docs/typing-parity.md +++ b/docs/typing-parity.md @@ -1,6 +1,6 @@ # Typing parity with Word -> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-18 · **Change:** trivial (a heading-list preset keeps Heading styles and makes other paragraphs Heading 1, as in Word, #1793; previously trivial: numbered headings: a heading-linked Multilevel List numbers new headings by their style; numbering keeps a heading a heading, #1793; previously trivial: reversing an automatic capital adds an exception, #1857; previously trivial: smart quotes open after guillemets and low quotes and nest; previously trivial: Enter on an empty list item numbered by its paragraph style moves it up a level, #2143; previously trivial: Backspace after a section break and Delete before it remove the break, also on the last paragraph, #2127; previously trivial: a table inserted in right-to-left text is right to left, so Tab moves left, #2634; previously trivial: Shift+Delete cuts the selection on every platform, #1669; previously trivial: Select All then Delete removes a table the document starts with, #1198; previously trivial: Alt+numpad character codes, #961; previously trivial: Down and Up from a line start beside a table go into it, #1007; previously trivial: sample-text formulas on Enter, #1067; previously trivial: Track Changes: Accept All after a tracked delete from a paragraph end keeps the next paragraph; Reject All after typing into an empty paragraph keeps it (#864); previously minor: Correct TWo INitial CApitals, cAPS LOCK and table-cell capitals, #544; previously trivial: complex-script caret and Backspace, #604; Move Paragraph Up/Down, Promote/Demote, Go Back and Alt+X keys, #343) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4 +> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-18 · **Change:** trivial (`-->` AutoCorrect replacement, #2223; previously trivial: a heading-list preset keeps Heading styles and makes other paragraphs Heading 1, as in Word, #1793; previously trivial: numbered headings: a heading-linked Multilevel List numbers new headings by their style; numbering keeps a heading a heading, #1793; previously trivial: reversing an automatic capital adds an exception, #1857; previously trivial: smart quotes open after guillemets and low quotes and nest; previously trivial: Enter on an empty list item numbered by its paragraph style moves it up a level, #2143; previously trivial: Backspace after a section break and Delete before it remove the break, also on the last paragraph, #2127; previously trivial: a table inserted in right-to-left text is right to left, so Tab moves left, #2634; previously trivial: Shift+Delete cuts the selection on every platform, #1669; previously trivial: Select All then Delete removes a table the document starts with, #1198; previously trivial: Alt+numpad character codes, #961; previously trivial: Down and Up from a line start beside a table go into it, #1007; previously trivial: sample-text formulas on Enter, #1067; previously trivial: Track Changes: Accept All after a tracked delete from a paragraph end keeps the next paragraph; Reject All after typing into an empty paragraph keeps it (#864); previously minor: Correct TWo INitial CApitals, cAPS LOCK and table-cell capitals, #544; previously trivial: complex-script caret and Backspace, #604; Move Paragraph Up/Down, Promote/Demote, Go Back and Alt+X keys, #343) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4 What Word does when you type, observed black-box (same keystrokes typed into Word and WordCraft, results read back paragraph by paragraph) and pinned by tests in @@ -90,6 +90,10 @@ Windows/Linux keys are given first, macOS second. ## AutoCorrect and AutoFormat as you type +The `-->` replacement produces `→` when completed with a space (#2223), including +at paragraph start without creating a list. It respects AutoCorrect preferences, user +entries and undo. The existing `-> ` paragraph-start arrow bullet is unchanged. + Applied when a word is finished by a space, punctuation **or Enter** (`enter_finishes_a_word_for_autocorrect`): - First letter of a sentence (paragraph or list item start, after `. ! ?`) is capitalised; not @@ -239,6 +243,7 @@ Word. | 2026-10-16 | trivial | #1793: a heading-list preset keeps paragraphs already in a Heading style at their level and makes other paragraphs Heading 1 (rechecked in Word for Mac); undo test | | 2026-10-16 | trivial | #1793: a heading-linked Multilevel List preset makes every selected paragraph its first level (Heading 1), checked in Word for Mac; `1. ` in a heading checked in Word; a single Heading 2 becoming Heading 1 is unchecked | | 2026-10-15 | trivial | Numbered headings (#1793): a heading-linked Multilevel List numbers headings through their styles, so a new Heading 2 after the list is numbered by itself; Numbering and `1. ` keep a heading a heading; applying a numbered heading style numbers the paragraph | +| 2026-10-15 | trivial | Add the missing `-->` → `→` AutoCorrect entry (#2223), with typing, undo and preference regression coverage; existing arrow bullets unchanged | | 2026-10-14 | trivial | Shift+Delete cuts the selection, and does nothing without one, on macOS and Linux too, as in Word for Windows (#1669); Shift+Backspace already erased (#707) | | 2026-10-13 | trivial | Select All then Delete or Backspace removes a table the document starts with (#1198) | | 2026-10-12 | trivial | Editing keys: Down and Up from the start of a line beside a table go into it instead of skipping it (#1007) | From 6fefeb72e666ce5c6ca06c8bca0be1ab085f1943 Mon Sep 17 00:00:00 2001 From: dhruvLearner8 <83899022+dhruvLearner8@users.noreply.github.com> Date: Sun, 18 Oct 2026 02:23:02 -0600 Subject: [PATCH 08/38] Hover tip on a table of contents link says Current Document (#2177) (#2281) --- crates/ui-egui/src/canvas.rs | 19 ++++++++++++++++++- 1 file changed, 18 insertions(+), 1 deletion(-) diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index ec1e4e7527..764a292c67 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -2495,7 +2495,8 @@ fn mouse(app: &mut WordApp, ui: &Ui, resp: &egui::Response, rects: &[Rect], layo && let Some(link) = link_under(app, layout, page, x, y, &pos) { let key = if cfg!(target_os = "macos") { "⌘" } else { "Ctrl" }; - let tip = format!("{link}\n{}", crate::i18n::fmt(tl!("{key}+Click to follow link"), &[("key", key)])); + let target = if is_generated_bookmark_link(&link) { tl!("Current Document").to_string() } else { link }; + let tip = format!("{target}\n{}", crate::i18n::fmt(tl!("{key}+Click to follow link"), &[("key", key)])); egui::Tooltip::always_open(ui.ctx().clone(), ui.layer_id(), egui::Id::new("link_tip"), egui::PopupAnchor::Pointer).show(|ui| { ui.label(tip); }); @@ -3169,6 +3170,12 @@ fn link_under(app: &WordApp, layout: &DocLayout, page: usize, x: f32, y: f32, po }) } +/// A link to a hidden bookmark (`#_Toc…`, `#_Ref…`): a name the program made, which nobody +/// chose or sees, so the hover tip says where it goes instead of showing it (#2177). +fn is_generated_bookmark_link(link: &str) -> bool { + link.strip_prefix("#_").is_some() +} + /// Whether Ctrl/⌘+click may hand a document's hyperlink to the system: web and email links only, /// so a document can't launch `file:` paths, programs or custom-scheme handlers. fn is_followable_link(url: &str) -> bool { @@ -3203,6 +3210,16 @@ mod tests { egui::ColorImage::from_rgba_unmultiplied([img.width as usize, img.height as usize], &img.to_straight()) } + /// #2177: only a link to a program-made hidden bookmark hides its name in the hover tip. + #[test] + fn hover_tip_hides_generated_bookmark_names_only() { + assert!(is_generated_bookmark_link("#_Toc123456")); + assert!(is_generated_bookmark_link("#_Ref42")); + assert!(!is_generated_bookmark_link("#Chapter1")); + assert!(!is_generated_bookmark_link("https://example.com/_x")); + assert!(!is_generated_bookmark_link("")); + } + #[test] fn page_image_keeps_every_pixel_the_straight_alpha_round_trip_gave() { // Every premultiplied pixel there is: a channel from 0 up to its alpha, for each alpha. From 8701f4e28d218eadf725f240fc08bea4a9c473b4 Mon Sep 17 00:00:00 2001 From: Debarghaya Mitra <175645893+GhostMitra@users.noreply.github.com> Date: Sun, 18 Oct 2026 13:53:13 +0530 Subject: [PATCH 09/38] Theme: default to system appearance (#718) * Theme: default to system appearance Sets Appearance::System as the default variant for ThemePreference, so fresh installs and unconfigured preference files follow the host OS appearance dynamically. * contributors: add the missing blank line Co-Authored-By: Claude Opus 5.5 * contributors: drop the duplicate entry (already listed on main) Co-Authored-By: Claude Opus 5.5 * Web prefs test: junk storage keeps the new default (System) appearance Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: Brandon Thomas Co-authored-by: Claude Opus 5.5 --- apps/wordcraft-web/src/prefs.rs | 2 +- crates/ui-egui/src/lib.rs | 4 ++-- crates/ui-egui/src/theme.rs | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/apps/wordcraft-web/src/prefs.rs b/apps/wordcraft-web/src/prefs.rs index bf5ec46d56..232688c14b 100644 --- a/apps/wordcraft-web/src/prefs.rs +++ b/apps/wordcraft-web/src/prefs.rs @@ -82,7 +82,7 @@ mod tests { for junk in [None, Some(""), Some("not json"), Some("[1,2]"), Some("{\"theme\":42}")] { let mut a = app(); restore(&mut a, junk); - assert_eq!(a.ui.theme, wordcraft_ui_egui::theme::Appearance::Light); + assert_eq!(a.ui.theme, wordcraft_ui_egui::theme::Appearance::System, "junk keeps the default appearance"); } } } diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index b662f775a2..9e0f72cbb9 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -6063,7 +6063,7 @@ mod tests { #[test] fn interface_theme_migrates_and_survives_a_restart() { use theme::Appearance; - for (saved, want) in [(r#"{"dark": false}"#, Appearance::Light), (r#"{"dark": true}"#, Appearance::Dark), ("{}", Appearance::Light)] { + for (saved, want) in [(r#"{"dark": false}"#, Appearance::Light), (r#"{"dark": true}"#, Appearance::Dark), ("{}", Appearance::System)] { let mut a = app(); a.apply_prefs(serde_json::from_str(saved).unwrap()); assert_eq!(a.ui.theme, want, "{saved}"); @@ -6077,7 +6077,7 @@ mod tests { assert_eq!(b.ui.theme, Appearance::System); // An unknown saved value keeps the rest of the preferences. let odd: UiState = serde_json::from_str(r#"{"theme": "sepia", "tab": "Insert"}"#).unwrap(); - assert_eq!((odd.theme, odd.tab.as_str()), (Appearance::Light, "Insert")); + assert_eq!((odd.theme, odd.tab.as_str()), (Appearance::System, "Insert")); } /// #475, #822: the interface size is kept between runs; junk saved values read as Auto and diff --git a/crates/ui-egui/src/theme.rs b/crates/ui-egui/src/theme.rs index a14b94f989..b9c12394f8 100644 --- a/crates/ui-egui/src/theme.rs +++ b/crates/ui-egui/src/theme.rs @@ -14,9 +14,9 @@ pub const APP_INK: Color32 = Color32::from_rgb(0x2B, 0x47, 0xB5); #[derive(Clone, Copy, Debug, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize)] #[serde(from = "String", into = "String")] pub enum Appearance { - #[default] Light, Dark, + #[default] System, } From 47c2eda22cf45f439de92bd4a0d8b5df37ced503 Mon Sep 17 00:00:00 2001 From: FredApps <153173854+FredApps@users.noreply.github.com> Date: Sun, 18 Oct 2026 10:23:24 +0200 Subject: [PATCH 10/38] Focus the Insert Hyperlink address box (#1610) (#1642) * chore: reserve draft implementation for #1610 * fix(ui): focus hyperlink address when opening the dialog (#1610) --- crates/ui-egui/src/dialogs.rs | 16 +++++++++++--- crates/ui-egui/src/lib.rs | 39 +++++++++++++++++++++++++++++++++++ 2 files changed, 52 insertions(+), 3 deletions(-) diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index ca6cbba33c..dd71ceb6ba 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -269,6 +269,8 @@ pub enum Dialog { Link { url: String, text: String, + /// Focus and select the address when the dialog first opens. + focus: bool, /// "Place in This Document" instead of "Existing File or Web Page". place: bool, /// The document's places (`link.targets`), read when that tab is first shown. @@ -1008,7 +1010,9 @@ impl Dialog { valign: sp.valign, } } - "link" => Dialog::Link { url: "https://".into(), text: app.session.selected_text(), place: false, targets: None, pick: None }, + "link" => { + Dialog::Link { url: "https://".into(), text: app.session.selected_text(), place: false, targets: None, pick: None, focus: true } + } "bookmark" => Dialog::Bookmark { name: bookmark_at_caret(&app.session), by_location: false, hidden: false, deleted: false }, "wordCount" => Dialog::WordCount { stats: app.session.run("review.wordCount", &json!({})).unwrap_or_default() }, "zoom" => Dialog::Zoom { percent: (app.session.view.zoom * 100.0).round() }, @@ -2011,7 +2015,7 @@ fn body(app: &mut WordApp, ui: &mut Ui, d: &mut Dialog) -> bool { } ok || cancel } - Dialog::Link { url, text, place, targets, pick } => { + Dialog::Link { url, text, place, targets, pick, focus } => { // Word's "Link to:" choices; the places are read once, when that tab is first shown. ui.horizontal(|ui| { ui.label(tl!("Link to:")); @@ -2027,7 +2031,13 @@ fn body(app: &mut WordApp, ui: &mut Ui, d: &mut Dialog) -> bool { ui.end_row(); if !*place { ui.label(tl!("Address:")); - ui.text_edit_singleline(url); + let mut out = egui::TextEdit::singleline(url).id(egui::Id::new("link_address")).show(ui); + if std::mem::take(focus) { + out.response.request_focus(); + let all = egui::text::CCursorRange::two(egui::text::CCursor::new(0), egui::text::CCursor::new(url.chars().count())); + out.state.cursor.set_char_range(Some(all)); + out.state.store(ui.ctx(), out.response.id); + } ui.end_row(); } }); diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 9e0f72cbb9..42eafa2687 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -2981,6 +2981,45 @@ mod tests { assert!(a.session.doc.plain_text(wordcraft_doc::StoryRef::Body).is_empty(), "nothing was typed into the document"); } + /// #1610: opening a hyperlink must direct typing and paste into its address. + #[test] + fn hyperlink_shortcut_focuses_address_and_preserves_document() { + let ctx = egui::Context::default(); + let mut a = app(); + a.run("text.insert", json!({"text": "Keep this text"})).unwrap(); + a.run("select.text", json!({"text": "Keep this text"})).unwrap(); + let frame = |a: &mut WordApp, events: Vec| { + ctx.run_ui(egui::RawInput { events, ..Default::default() }, |ui| { + a.logic(ui.ctx()); + a.ui(ui); + }) + .drop_without_applying_deltas(); + }; + for _ in 0..3 { + frame(&mut a, Vec::new()); + } + let key = |key, modifiers| egui::Event::Key { key, physical_key: None, pressed: true, repeat: false, modifiers }; + for (event, expected) in [ + (egui::Event::Paste("https://example.com".into()), "https://example.com"), + (egui::Event::Text("https://example.org".into()), "https://example.org"), + ] { + frame(&mut a, vec![key(egui::Key::K, egui::Modifiers::COMMAND)]); + for _ in 0..2 { + frame(&mut a, Vec::new()); + } + assert!(ctx.memory(|m| m.has_focus(egui::Id::new("link_address"))), "Address has focus"); + frame(&mut a, vec![event]); + let Some(dialogs::Dialog::Link { url, text, .. }) = &a.dialog else { panic!("no Link dialog") }; + assert_eq!(url, expected, "input replaces the initial address"); + assert_eq!(text, "Keep this text"); + assert_eq!(a.session.doc.plain_text(wordcraft_doc::StoryRef::Body), "Keep this text"); + frame(&mut a, vec![key(egui::Key::Escape, egui::Modifiers::NONE)]); + frame(&mut a, Vec::new()); + assert!(a.dialog.is_none(), "Cancel closes the dialog"); + assert_eq!(a.session.selected_text(), "Keep this text", "Cancel preserves selection"); + } + } + /// Issue #139: Ctrl+wheel over the page didn't zoom. #[test] fn ctrl_wheel_over_canvas_zooms() { From c2b97f11804ef4981162bba4aee41cc41ae55de4 Mon Sep 17 00:00:00 2001 From: Hamza Naveed <45464935+Hamza0101@users.noreply.github.com> Date: Sun, 18 Oct 2026 13:23:34 +0500 Subject: [PATCH 11/38] DOCX: read every VML shape in a `w:pict`, and a `v:group`'s members (#1662) * DOCX: read every VML shape in a `w:pict`, and a `v:group`'s members (#1643) `read_vml` took `pict.els().find(..)`, the first `v:*` child that is not a `v:shapetype`, and read only that one. So a `w:pict` holding several shapes kept the first and dropped the rest, and a `v:group` was read as a single shape -- or, with no text box in it, as nothing at all -- losing every member. Both are gone after a save, since saving writes the model back as DrawingML. Read all of them instead: - `read_vml_all` walks every `v:*` child of a `w:pict` and emits one object each; `read_vml` keeps returning the first for the two places that show a single drawing (an OLE object's picture, a `mc:Fallback` preview). - a `v:group` becomes an `InlineObject::Group`, its members mapped from the space `coordsize`/`coordorigin` give it onto the group's own box, nested groups flattened into it -- what `group_members` already does for a DrawingML `wpg:wgp`, so the model, the renderer and the writer need no change. Nesting is bounded at `MAX_VML_GROUP_DEPTH`, members at `MAX_GROUP_CHILDREN`. - `read_vml_shape` is the old per-shape body, taking the group's scale so a member's `width`/`height` (and a `v:line`'s `from`/`to`) come through in the group's coordinates. `boxes()` in the text-box tests now asks for `text_boxes()` rather than `text_box()`, which only reports an object that is itself a box and so could not see one inside a group; its assertions are unchanged. * DOCX: cover a `v:group` holding several text boxes (#1643) @KNUFFIXX reported the same loss from the other end: an inline `v:group` with two `_x0000_t202` boxes side by side came through a convert as box A only, in both a tall-B and a too-short-B variant, against Word reading both. A text box is the one member a group used to keep -- `vml_textbox` recurses to the first box that has content -- so this case looked half-working rather than broken, and it is worth pinning separately from the shape cases. On `main` the group collapses to a single `Shape { kind: TextBox, w: 400, h: 100 }` wearing the group's own size and holding box A's story; here both boxes read, keep their own stories and positions, and survive a save. Built in code from the reported fixture rather than committing the .docx. * DOCX: cover the shapetype lookup for a group's member (#1643) Word writes each `v:shapetype` once, at its first use, as a child of the `w:pict` -- so a group's member refers to one that is its parent's sibling, not its own. Reading all shapes means `read_vml_shape` is now called for a member as well as for a top-level shape, and both need the picture to find it, which is why `pict` is threaded through the group walk. Two tests: a member with no geometry and no colours of its own, taking its diamond from the shapetype's `o:spt` and its fill from its `fillcolor`, and the same for a shape outside a group, which is the case the lookup was written for. --- crates/docx/src/read/story.rs | 138 ++++++++++++++-- crates/docx/tests/text_boxes.rs | 5 +- crates/docx/tests/vml_shapes.rs | 272 ++++++++++++++++++++++++++++++++ 3 files changed, 402 insertions(+), 13 deletions(-) create mode 100644 crates/docx/tests/vml_shapes.rs diff --git a/crates/docx/src/read/story.rs b/crates/docx/src/read/story.rs index cafed444f4..967b757041 100644 --- a/crates/docx/src/read/story.rs +++ b/crates/docx/src/read/story.rs @@ -30,6 +30,8 @@ const MAX_TABLE_DEPTH: usize = 24; const MAX_FIELD_DEPTH: usize = 32; /// Deepest text-box-in-text-box nesting. const MAX_STORY_DEPTH: usize = 4; +/// Deepest `v:group` nesting followed (hostile files). +const MAX_VML_GROUP_DEPTH: usize = 8; /// Longest field code we keep (Zotero's citation codes embed item data as JSON). const MAX_INSTR: usize = 2 * 1024 * 1024; /// Longest legacy form field definition (`w:ffData`) we keep. @@ -670,7 +672,7 @@ impl Reader<'_> { } } "w:pict" => { - if let Some(o) = self.read_vml(sc, k, rels) { + for o in self.read_vml_all(sc, k, rels) { self.emit_obj(sc, pb, o, props); } } @@ -1407,20 +1409,125 @@ impl Reader<'_> { self.doc.add_part(PartKind::TextBox, blocks) } - /// Legacy VML pictures, text boxes, lines, rectangles, ovals and preset shapes (best effort). + /// Every legacy VML drawing in one `w:pict`, in document order: a `w:pict` may hold any + /// number of shapes (ECMA-376 Part 4, VML), and each one is its own object. A `v:group` + /// becomes an [`InlineObject::Group`]. + fn read_vml_all(&mut self, sc: &mut StoryCtx, pict: &El, rels: &Rels) -> Vec { + let mut out = Vec::new(); + for e in pict.els().filter(|e| e.name.starts_with("v:") && e.local() != "shapetype") { + if out.len() >= wordcraft_doc::para::MAX_GROUP_CHILDREN { + break; + } + if sc.in_hf + && let Some(wm) = vml_watermark(e) + { + // The document's watermark (the first one found); drawn on every page, not as + // a shape, so it is not one of the objects this picture contributes. + self.doc.settings.watermark.get_or_insert(wm); + continue; + } + let o = if e.local() == "group" { self.read_vml_group(sc, pict, e, rels) } else { self.read_vml_shape(sc, pict, e, rels, None) }; + out.extend(o); + } + out + } + + /// The first VML drawing in `pict`, for the places that show a single one: an OLE object's + /// picture and a `mc:Fallback` preview. fn read_vml(&mut self, sc: &mut StoryCtx, pict: &El, rels: &Rels) -> Option { - let shape = pict.els().find(|e| e.name.starts_with("v:") && e.local() != "shapetype")?; - if sc.in_hf - && let Some(wm) = vml_watermark(shape) - { - // The document's watermark (the first one found); drawn on every page, not as a shape. - self.doc.settings.watermark.get_or_insert(wm); + self.read_vml_all(sc, pict, rels).into_iter().next() + } + + /// A `v:group`: its own box from its `style`, its members in the space `coordsize` and + /// `coordorigin` give it, nested groups flattened into it as [`Self::group_members`] does for + /// DrawingML. `None` when it has no member we can show. `pict` is the picture the group sits + /// in, where a member's `v:shapetype` is written. + fn read_vml_group(&mut self, sc: &mut StoryCtx, pict: &El, grp: &El, rels: &Rels) -> Option { + let style = grp.attr("style").unwrap_or(""); + let len = |k: &str| vml_style(style, k).and_then(|v| measure(v, 1.0)).unwrap_or(0.0).clamp(0.0, crate::units::MAX_LEN_PT); + let (w, h) = (len("width"), len("height")); + let float = vml_float(grp, style); + // The members' space; without a usable `coordsize` it is the group's own box. + let (ch_w, ch_h) = vml_coordpair(grp, "coordsize").filter(|(cw, ch)| *cw > 0.0 && *ch > 0.0).unwrap_or((w.max(1.0), h.max(1.0))); + let (cx, cy) = vml_coordpair(grp, "coordorigin").unwrap_or((0.0, 0.0)); + let mut children = Vec::new(); + self.vml_group_members(sc, pict, grp, rels, (-cx, -cy, 1.0, 1.0), 0, &mut children); + if children.is_empty() { return None; } + Some(InlineObject::Group { w, h, float, ch_w, ch_h, children, canvas: None }) + } + + /// The members of VML group `g` into `out`, mapped into the outermost group's space by `t` + /// (x0, y0, sx, sy: outer = x0 + inner × sx). A member is placed by `left`/`top` in that + /// space, except a `v:line`, which carries its own offset in `from`. + fn vml_group_members( + &mut self, + sc: &mut StoryCtx, + pict: &El, + g: &El, + rels: &Rels, + t: (f32, f32, f32, f32), + depth: usize, + out: &mut Vec, + ) { + let (x0, y0, sx, sy) = t; + let max = crate::units::MAX_LEN_PT; + let fit = |v: f32, lo: f32| wordcraft_geom::finite(v).clamp(lo, max); + for e in g.els() { + if out.len() >= wordcraft_doc::para::MAX_GROUP_CHILDREN { + return; + } + if !e.name.starts_with("v:") || e.local() == "shapetype" { + continue; + } + let style = e.attr("style").unwrap_or(""); + let num = |k: &str| vml_style(style, k).and_then(|v| measure(v, 1.0)).map_or(0.0, wordcraft_geom::finite); + let ((ox, oy), (ew, eh)) = ((num("left"), num("top")), (num("width"), num("height"))); + if e.local() == "group" { + if depth >= MAX_VML_GROUP_DEPTH { + continue; + } + // Its members' space maps onto its own box. + let (cw, ch) = vml_coordpair(e, "coordsize").filter(|(cw, ch)| *cw > 0.0 && *ch > 0.0).unwrap_or((ew.max(1.0), eh.max(1.0))); + let (cox, coy) = vml_coordpair(e, "coordorigin").unwrap_or((0.0, 0.0)); + let (kx, ky) = (ew / cw, eh / ch); + let nt = (x0 + (ox - cox * kx) * sx, y0 + (oy - coy * ky) * sy, sx * kx, sy * ky); + self.vml_group_members(sc, pict, e, rels, nt, depth + 1, out); + continue; + } + let Some(mut obj) = self.read_vml_shape(sc, pict, e, rels, Some((sx, sy))) else { continue }; + // A member is placed by `x`/`y`, so a line's own offset comes off the member itself. + let (dx, dy) = match obj.float_mut() { + Some(f) => { + let d = (f.x, f.y); + (f.x, f.y) = (0.0, 0.0); + d + } + None => (0.0, 0.0), + }; + out.push(wordcraft_doc::para::GroupChild { x: fit(x0 + (ox + dx) * sx, -max), y: fit(y0 + (oy + dy) * sy, -max), obj }); + } + } + + /// One legacy VML picture, text box, line, rectangle, oval or preset shape (best effort). + /// `pict` is the picture it sits in, where its `v:shapetype` is written -- for a group's + /// member too, since Word writes each shapetype once, beside the group rather than inside + /// it. `grp` scales a member from its group's space, and leaves placing it to the caller. + fn read_vml_shape(&mut self, sc: &mut StoryCtx, pict: &El, shape: &El, rels: &Rels, grp: Option<(f32, f32)>) -> Option { let style = shape.attr("style").unwrap_or(""); let len = |k: &str| vml_style(style, k).and_then(|v| measure(v, 1.0)).unwrap_or(0.0).clamp(0.0, crate::units::MAX_LEN_PT); - let (mut w, mut h) = (len("width"), len("height")); - let mut float = vml_float(shape, style); + let (sx, sy) = grp.unwrap_or((1.0, 1.0)); + let (mut w, mut h) = ((len("width") * sx).min(crate::units::MAX_LEN_PT), (len("height") * sy).min(crate::units::MAX_LEN_PT)); + // A member of a group never floats: the group does, and places it. + let mut float = match grp { + Some(_) => { + let mut f = Float { hidden: vml_style(style, "visibility").is_some_and(|v| v.eq_ignore_ascii_case("hidden")), ..Float::default() }; + f.set_spin(vml_spin(style)); + f + } + None => vml_float(shape, style), + }; if let Some(img) = shape.find("v:imagedata") { let id = img.attr("r:id").or_else(|| img.attr("r:pict"))?; let media = self.media_for(rels, id)?; @@ -1540,7 +1647,7 @@ impl Reader<'_> { }; let ((x0, y0), (x1, y1)) = (pt("from"), pt("to")); let max = crate::units::MAX_LEN_PT; - (w, h) = ((x1 - x0).abs().min(max), (y1 - y0).abs().min(max)); + (w, h) = (((x1 - x0).abs() * sx).min(max), ((y1 - y0).abs() * sy).min(max)); float.x = (float.x + x0.min(x1)).clamp(-max, max); float.y = (float.y + y0.min(y1)).clamp(-max, max); // Drawn top-left to bottom-right; flipped for the other directions. @@ -1800,6 +1907,15 @@ fn vml_spt(shape: &El, shapetype: Option<&El>, type_id: Option<&str>) -> Option< /// The value of property `key` in a VML `style` attribute (CSS declarations; the last one wins). /// The first `v:textbox` under `e` holding text, with its `w:txbxContent` (depth-first, bounded /// like `El::find`): in a group, a member's margins go with that member's text. +/// A VML `coordsize="w,h"` or `coordorigin="x,y"` pair (ECMA-376 Part 4 §14.1.2): the member +/// space of a group, in its own unitless coordinates. +fn vml_coordpair(e: &El, name: &str) -> Option<(f32, f32)> { + let v = e.attr(name)?; + let (x, y) = v.split_once(',')?; + let n = |s: &str| measure(s.trim(), 1.0).map(wordcraft_geom::finite); + Some((n(x)?, n(y)?)) +} + fn vml_textbox(e: &El, depth: usize) -> Option<(&El, &El)> { if depth > crate::xml::MAX_DEPTH { return None; diff --git a/crates/docx/tests/text_boxes.rs b/crates/docx/tests/text_boxes.rs index 7efe7b0405..1e3ac2590c 100644 --- a/crates/docx/tests/text_boxes.rs +++ b/crates/docx/tests/text_boxes.rs @@ -24,10 +24,11 @@ fn document_xml(bytes: &[u8]) -> String { s } -/// The text boxes' stories in body order. +/// The text boxes' stories in body order, a group's members among them +/// (`text_boxes`, not `text_box`, which only sees an object that is itself a box). fn boxes(d: &Document) -> Vec { let mut out = Vec::new(); - d.objects_in_reading_order(&mut |o| out.extend(o.text_box())); + d.objects_in_reading_order(&mut |o| out.extend(o.text_boxes())); out.retain(|id| d.parts.get(id).is_some_and(|p| p.kind == PartKind::TextBox)); out } diff --git a/crates/docx/tests/vml_shapes.rs b/crates/docx/tests/vml_shapes.rs new file mode 100644 index 0000000000..fa2a1e044a --- /dev/null +++ b/crates/docx/tests/vml_shapes.rs @@ -0,0 +1,272 @@ +//! Legacy VML drawings in a `w:pict`: every shape in one, and `v:group` members in the space +//! `coordsize`/`coordorigin` give them. Fixtures are hand-written from ECMA-376 Part 4 +//! (VML, §14.1 `v:group`, `coordsize`, `coordorigin`; §14.2 shapes). + +use std::io::Write; + +use wordcraft_doc::para::ShapeKind; +use wordcraft_doc::{Document, InlineObject, Paragraph}; + +fn zip(entries: &[(&str, &[u8])]) -> Vec { + let mut zw = zip::ZipWriter::new(std::io::Cursor::new(Vec::new())); + for (name, bytes) in entries { + zw.start_file(*name, zip::write::SimpleFileOptions::default()).unwrap(); + zw.write_all(bytes).unwrap(); + } + zw.finish().unwrap().into_inner() +} + +/// A document whose body is `body`. +fn read_body(body: &str) -> Document { + let root = r#""#; + let doc = format!( + r#"{body}"# + ); + wordcraft_docx::read(&zip(&[("_rels/.rels", root.as_bytes()), ("word/document.xml", doc.as_bytes())])).unwrap() +} + +fn paras(d: &Document) -> Vec<&Paragraph> { + d.body.iter().filter_map(|b| b.as_para()).collect() +} + +/// The objects of the body's first paragraph. +fn objects(d: &Document) -> &[InlineObject] { + ¶s(d).first().expect("a paragraph").objects +} + +fn pict(inner: &str) -> String { + format!(r#"{inner}"#) +} + +fn kinds(objs: &[InlineObject]) -> Vec { + objs.iter() + .map(|o| match o { + InlineObject::Shape { kind, .. } => *kind, + o => panic!("{o:?}"), + }) + .collect() +} + +#[test] +fn every_shape_in_a_pict_is_read() { + // A `w:pict` may hold any number of shapes; each one is its own object. A `v:shapetype` is + // a definition, not a shape, and is not one of them. + let d = read_body(&pict(concat!( + r#""#, + r#""#, + r#""#, + r#""#, + ))); + let objs = objects(&d); + assert_eq!(kinds(objs), [ShapeKind::Rectangle, ShapeKind::Ellipse, ShapeKind::RoundedRectangle], "all three, in document order"); + let size = |o: &InlineObject| match o { + InlineObject::Shape { w, h, .. } => (*w, *h), + o => panic!("{o:?}"), + }; + assert_eq!(objs.iter().map(size).collect::>(), [(10.0, 20.0), (30.0, 40.0), (50.0, 60.0)], "each keeps its own size"); +} + +#[test] +fn a_vml_group_keeps_its_members() { + // `coordsize` is the members' space, mapped onto the group's own box; `left`/`top` place a + // member in that space. + let d = read_body(&pict(concat!( + r#""#, + r#""#, + r#""#, + r#""#, + ))); + let InlineObject::Group { w, h, ch_w, ch_h, children, canvas, .. } = &objects(&d)[0] else { panic!("{:?}", objects(&d)) }; + assert_eq!((*w, *h, *ch_w, *ch_h), (100.0, 50.0, 1000.0, 500.0), "its own box, and its members' space"); + assert_eq!(*canvas, None, "a group is not a Drawing Canvas"); + assert_eq!(children.len(), 2, "both members"); + assert_eq!((children[0].x, children[0].y), (0.0, 0.0)); + assert_eq!((children[1].x, children[1].y), (600.0, 100.0), "placed in the members' space, not scaled"); + assert_eq!(kinds(&children.iter().map(|c| c.obj.clone()).collect::>()), [ShapeKind::Rectangle, ShapeKind::Ellipse]); +} + +#[test] +fn a_vml_group_shifts_its_members_by_its_coordorigin() { + // `coordorigin` is the top-left of the members' space, so a member's offset is from there. + let d = read_body(&pict(concat!( + r#""#, + r#""#, + r#""#, + r#""#, + ))); + let InlineObject::Group { ch_w, children, .. } = &objects(&d)[0] else { panic!() }; + assert_eq!(*ch_w, 1000.0); + assert_eq!((children[0].x, children[0].y), (0.0, 0.0), "a member at the origin sits at the group's corner"); + assert_eq!((children[1].x, children[1].y), (500.0, 500.0)); +} + +#[test] +fn nested_vml_groups_are_flattened_into_the_outer_one() { + // An inner group's members' space maps onto the inner group's own box, which is itself in + // the outer group's space: 500 wide in a 1000-wide space inside a 1000-wide box halves it. + let d = read_body(&pict(concat!( + r#""#, + r#""#, + r#""#, + r#""#, + r#""#, + r#""#, + ))); + let InlineObject::Group { children, .. } = &objects(&d)[0] else { panic!() }; + assert_eq!(children.len(), 2, "the inner group's member comes into the outer group"); + assert_eq!((children[0].x, children[0].y), (100.0, 100.0)); + // 400 + 200 × (500/1000) = 500; 400 + 600 × 0.5 = 700. + assert_eq!((children[1].x, children[1].y), (500.0, 700.0), "mapped through the inner group"); + let InlineObject::Shape { w, h, .. } = children[1].obj else { panic!() }; + assert_eq!((w, h), (50.0, 50.0), "and scaled by it"); +} + +#[test] +fn a_vml_group_with_no_member_we_can_show_is_left_out() { + // Only a shape definition, and a shape we draw nothing for (no stroke, no fill). + let d = read_body(&pict(concat!( + r#""#, + r#""#, + r#""#, + r#""#, + ))); + assert!(objects(&d).is_empty(), "nothing to show: {:?}", objects(&d)); +} + +#[test] +fn deeply_nested_vml_groups_stop_at_the_depth_bound() { + // A hostile file nests groups without end; reading one must not run away. + let depth = 64; + let mut inner = r#""#.to_string(); + for _ in 0..depth { + inner = format!(r#"{inner}"#); + } + let d = read_body(&pict(&format!(r#"{inner}"#))); + // Past the bound nothing is read, so the outermost group has no member and is left out. + assert!(objects(&d).is_empty(), "{:?}", objects(&d)); +} + +#[test] +fn a_vml_group_survives_being_saved() { + let body = pict(concat!( + r#""#, + r#""#, + r#""#, + r#""#, + )); + let d = read_body(&body); + let InlineObject::Group { children, .. } = &objects(&d)[0] else { panic!("{:?}", objects(&d)) }; + assert_eq!(children.len(), 2, "read with both its members to begin with"); + let back = wordcraft_docx::read(&wordcraft_docx::write(&d).unwrap()).unwrap(); + assert_eq!(objects(&back), objects(&d), "saved as DrawingML and read again, it is the same group"); +} + +#[test] +fn a_pict_of_a_rect_a_text_box_and_a_group_all_come_through() { + // The three shapes of issue #1643, which a round trip dropped the last of. The group's + // member gives its size in points, not in the group's own coordinates, as Word writes it. + let d = read_body(&format!( + "{}{}{}", + pict(r##""##), + pict( + r#"boxed"# + ), + pict(concat!( + r#""#, + r##""##, + r#""#, + )), + )); + let ps = paras(&d); + assert_eq!(ps.len(), 3, "a paragraph each"); + assert_eq!(kinds(&ps[0].objects), [ShapeKind::Rectangle]); + assert_eq!(kinds(&ps[1].objects), [ShapeKind::TextBox]); + let InlineObject::Group { children, .. } = &ps[2].objects[0] else { panic!("{:?}", ps[2].objects) }; + assert_eq!(kinds(&children.iter().map(|c| c.obj.clone()).collect::>()), [ShapeKind::Rectangle], "the green rectangle is read"); + let InlineObject::Shape { fill, .. } = children[0].obj else { panic!() }; + assert_eq!(fill, Some(wordcraft_doc::Rgb(0x00, 0xff, 0x00)), "with its colour"); + // And it is still there after a save, which is what the round trip lost. + let back = wordcraft_docx::read(&wordcraft_docx::write(&d).unwrap()).unwrap(); + assert_eq!(paras(&back).iter().map(|p| p.objects.len()).collect::>(), [1, 1, 1]); + let InlineObject::Group { children, .. } = ¶s(&back)[2].objects[0] else { panic!() }; + assert_eq!(children.len(), 1, "the group keeps its member"); +} + +/// The text of text box story `id`, its paragraphs joined by newlines. +fn part_text(d: &Document, id: u32) -> String { + d.parts.get(&id).map(|p| p.blocks.iter().filter_map(|b| b.as_para()).map(|p| p.plain_text()).collect::>().join("\n")).unwrap_or_default() +} + +#[test] +fn a_group_of_several_text_boxes_keeps_every_one() { + // Reported on #1643 by @KNUFFIXX, whose fixture is a `_x0000_t202` pair side by side in one + // inline `v:group`: only box A came through a convert. `vml_textbox` recurses to the first + // box with content, so before this every later box in a group was dropped -- and a text box + // is the one member a group used to keep, which is why this case looked half-working. + let shape = |left: u32, height: u32, label: &str| { + let paras: String = (1..=3).map(|n| format!(r#"{label} line {n}"#)).collect(); + format!( + r##"{paras}"## + ) + }; + // The control fixture, and the overflow one where box B is too short for its text; the + // height of a box must not decide whether it is read. + for (height_b, label_b) in [(1600, "Box B (tall)"), (400, "Box B (short)")] { + let d = read_body(&pict(&format!( + concat!( + r##""##, + r##"{}{}"##, + ), + shape(100, 1600, "Box A (tall)"), + shape(4200, height_b, label_b), + ))); + let InlineObject::Group { ch_w, ch_h, children, .. } = &objects(&d)[0] else { panic!("{:?}", objects(&d)) }; + assert_eq!((*ch_w, *ch_h), (8000.0, 2000.0), "the group's member space"); + assert_eq!(children.len(), 2, "both boxes, with box B {height_b} high"); + assert_eq!((children[0].x, children[1].x), (100.0, 4200.0), "side by side"); + + let stories: Vec = children.iter().filter_map(|c| c.obj.text_box()).collect(); + assert_eq!(stories.len(), 2, "each box has its own story"); + assert_ne!(stories[0], stories[1]); + assert_eq!(part_text(&d, stories[0]), "Box A (tall) line 1\nBox A (tall) line 2\nBox A (tall) line 3"); + assert_eq!(part_text(&d, stories[1]), format!("{label_b} line 1\n{label_b} line 2\n{label_b} line 3")); + + // Saved and read again -- the step the report loses the text at. + let back = wordcraft_docx::read(&wordcraft_docx::write(&d).unwrap()).unwrap(); + let InlineObject::Group { children: again, .. } = &objects(&back)[0] else { panic!() }; + let back_stories: Vec = again.iter().filter_map(|c| c.obj.text_box()).collect(); + assert_eq!(back_stories.len(), 2, "both boxes survive the save"); + assert_eq!(part_text(&back, back_stories[0]), part_text(&d, stories[0])); + assert_eq!(part_text(&back, back_stories[1]), part_text(&d, stories[1])); + } +} + +#[test] +fn a_group_member_finds_its_shapetype_beside_the_group() { + // Word writes each `v:shapetype` once, at its first use, as a child of the `w:pict` -- so a + // group's member refers to one that is its *parent's* sibling, not its own. The member here + // carries no geometry and no colours of its own: its diamond shape comes from the + // shapetype's `o:spt` (4) and its fill from the shapetype's `fillcolor`. + let d = read_body(&pict(concat!( + r##""##, + r#""#, + r##""##, + r#""#, + ))); + let InlineObject::Group { children, .. } = &objects(&d)[0] else { panic!("{:?}", objects(&d)) }; + assert_eq!(children.len(), 1, "the member is read"); + let InlineObject::Shape { kind, fill, .. } = children[0].obj else { panic!("{:?}", children[0].obj) }; + assert_eq!(kind, ShapeKind::Diamond, "the preset comes from the shapetype's o:spt"); + assert_eq!(fill, Some(wordcraft_doc::Rgb(0x00, 0x00, 0xff)), "and the fill from its fillcolor"); +} + +#[test] +fn a_top_level_shape_still_finds_its_shapetype() { + // The same lookup for a shape that is not in a group, which is what it was written for. + let d = read_body(&pict(concat!( + r##""##, + r##""##, + ))); + let InlineObject::Shape { kind, fill, .. } = objects(&d)[0] else { panic!("{:?}", objects(&d)) }; + assert_eq!((kind, fill), (ShapeKind::Diamond, Some(wordcraft_doc::Rgb(0x00, 0xff, 0x00)))); +} From 9acc34f22c262e705ee613f7dd979fd99d1ae1f5 Mon Sep 17 00:00:00 2001 From: Aleksander Kutycki <74899889+olekkut@users.noreply.github.com> Date: Sun, 18 Oct 2026 10:23:54 +0200 Subject: [PATCH 12/38] i18n(pl): citation style names, audit coverage and attribution (#750) (#1720) * i18n(pl): citation style names, audit coverage and attribution (#750) Follow-up to #871. - pl.tsv: add the 12 citation style names (APA, Chicago, GOST, ISO 690, ...) that hu.tsv and bg.tsv already translate. - tests.rs: Polish also checked against the citation styles; Italian and Hungarian may differ only by the obsolete "Style: APA". - audit.rs: Polish joins the catalogs that must cover every looked-up string. - ATTRIBUTION.md: credit Aleksander Kutycki (@olekkut) with Gemini 3.8 Flash for pl.tsv. - contributors/people.toml: add [people.olekkut] entry. Co-Authored-By: Gemini 3.8 Flash * i18n(pl): one-line equation gallery pieces (Large, Limit and, Log) so Polish matches Italian Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: Gemini 3.8 Flash Co-authored-by: Brandon Thomas Co-authored-by: Claude Opus 5.5 --- ATTRIBUTION.md | 2 +- contributors/people.toml | 5 +++++ crates/ui-egui/src/i18n/audit.rs | 8 ++++---- crates/ui-egui/src/i18n/pl.tsv | 15 +++++++++++++++ crates/ui-egui/src/i18n/tests.rs | 8 ++++++++ 5 files changed, 33 insertions(+), 5 deletions(-) diff --git a/ATTRIBUTION.md b/ATTRIBUTION.md index 14b58afcdc..e0dca6dfb0 100644 --- a/ATTRIBUTION.md +++ b/ATTRIBUTION.md @@ -67,7 +67,7 @@ Generated-in-code art is original and has no file to list: the UI icon set (`cra | craft-fonts (optional build input, not files in this repo): BIZ UDPGothic, BIZ UDMincho, Shippori Mincho, Noto Sans CJK SC, Noto Sans Arabic | see https://github.com/storytold/craft-fonts/blob/main/ATTRIBUTION.md | embedded only when built with `CRAFT_FONTS_DIR` (all official releases) | OFL-1.1 | Japanese and Chinese UI, Japanese document fallback | | `crates/ui-egui/src/i18n/uk.tsv` | @dmatviichuk with Codex | Original translation from the English interface labels in this repository; no other product localisation copied | MIT OR Apache-2.0 | Ukrainian interface catalog; spelling checked with external VESUM and ULIF dictionaries, neither bundled | | `crates/ui-egui/src/i18n/et.tsv` | @henno with Codex | Original translation from the English interface labels in this repository; no other product localisation copied | MIT OR Apache-2.0 | Estonian interface catalog | -| `crates/ui-egui/src/i18n/pl.tsv` | Konrad Witkowski with Claude (AI agent) | Original translation from the English interface labels in this repository; no other product localisation copied | MIT OR Apache-2.0 | Polish interface catalog; not yet reviewed by a second native speaker | +| `crates/ui-egui/src/i18n/pl.tsv` | Konrad Witkowski with Claude (AI agent); Aleksander Kutycki (@olekkut) with Gemini 3.8 Flash | Original translation from the English interface labels in this repository; no other product localisation copied | MIT OR Apache-2.0 | Polish interface catalog; not yet reviewed by a second native speaker | | `crates/ui-egui/src/i18n/pt-br.tsv` | @edurbs with ZCode | Original translation from the English interface labels in this repository; no other product localisation copied | MIT OR Apache-2.0 | Brazilian Portuguese interface catalog; spelling checked with the external pt_BR Hunspell dictionary, not bundled | | `crates/ui-egui/src/i18n/cs.tsv` | @DanielKnourek with Antigravity for VS Code | Original translation from the English interface labels in this repository; no other product localisation copied | MIT OR Apache-2.0 | Czech interface catalog; review suggestions by @Losminek | | `crates/ui-egui/src/i18n/de.tsv` | @arekown with Kimi | Original translation from the English interface labels in this repository; no other product localisation copied | MIT OR Apache-2.0 | German interface catalog | diff --git a/contributors/people.toml b/contributors/people.toml index 6a405d053c..a61c954952 100644 --- a/contributors/people.toml +++ b/contributors/people.toml @@ -135,3 +135,8 @@ sync_github_name = false real_name = "Andrej Škvorc" display_name = "Andrej Škvorc" sync_github_name = false + +[people.olekkut] +real_name = "Aleksander Kutycki" +display_name = "Aleksander Kutycki" +sync_github_name = true diff --git a/crates/ui-egui/src/i18n/audit.rs b/crates/ui-egui/src/i18n/audit.rs index 9c326d5640..f356fb3dcc 100644 --- a/crates/ui-egui/src/i18n/audit.rs +++ b/crates/ui-egui/src/i18n/audit.rs @@ -333,9 +333,9 @@ fn untranslated_interface_text() { complete_catalogs_have_every_string_the_interface_looked_up(pseudo::looked_up()); } -/// Russian, Hungarian, Turkish, Bulgarian, Italian, Swedish and Croatian cover everything the audit saw -/// translated, plus the equation gallery's labels (shown in menus the audit doesn't open one by -/// one). Numbered style names go through a template. +/// Russian, Hungarian, Turkish, Bulgarian, Italian, Swedish, Croatian and Polish cover everything the +/// audit saw translated, plus the equation gallery's labels (shown in menus the audit doesn't open +/// one by one). Numbered style names go through a template. fn complete_catalogs_have_every_string_the_interface_looked_up(mut keys: std::collections::BTreeSet) { use wordcraft_engine::math_gallery::{BUILT_INS, STRUCTURES, SYMBOL_SETS}; keys.extend(BUILT_INS.iter().map(|(_, label, _)| label.to_string())); @@ -379,7 +379,7 @@ fn complete_catalogs_have_every_string_the_interface_looked_up(mut keys: std::co // translate: its placeholder names aren't words the interface shows. let words = |s: &str| english_words(&s.split('{').map(|p| p.split_once('}').map_or(p, |(_, rest)| rest)).collect::()); let mut report = String::new(); - for code in ["ru", "hu", "tr", "bg", "it", "sv", "hr"] { + for code in ["ru", "hu", "tr", "bg", "it", "sv", "hr", "pl"] { let lang = super::Lang::from_code(code).unwrap_or(super::Lang::EN); let missing: Vec<&String> = keys.iter().filter(|s| words(s) && !super::has(lang, s) && !numbered(s) && !same(s) && !script_error(s)).collect(); diff --git a/crates/ui-egui/src/i18n/pl.tsv b/crates/ui-egui/src/i18n/pl.tsv index d416d69948..965bf5a477 100644 --- a/crates/ui-egui/src/i18n/pl.tsv +++ b/crates/ui-egui/src/i18n/pl.tsv @@ -2575,6 +2575,21 @@ preview Title Tytuł Suggested Sugerowane Set Proofing Language… Ustaw język sprawdzania… Language Preferences… Preferencje językowe… + APA APA + Chicago Chicago + GB/T 7714 GB/T 7714 + GOST (name sort) GOST (sortowanie według nazwiska) + GOST (title sort) GOST (sortowanie według tytułu) + Harvard (Anglia) Harvard (Anglia) + IEEE IEEE + ISO 690 (author-date) ISO 690 (autor–data) + ISO 690 (numerical) ISO 690 (numeryczny) + MLA MLA + SIST02 SIST02 + Turabian Turabian + Large Duży + Limit and Granica i + Log Logarytm Printer Drukarka Select a printer Wybierz drukarkę Refresh printers Odśwież drukarki diff --git a/crates/ui-egui/src/i18n/tests.rs b/crates/ui-egui/src/i18n/tests.rs index e23fa4552e..f43f1b216a 100644 --- a/crates/ui-egui/src/i18n/tests.rs +++ b/crates/ui-egui/src/i18n/tests.rs @@ -989,11 +989,17 @@ fn polish_covers_every_key_of_every_other_catalog() { let polish = keys(lang("pl").0.source); // Italian and Hungarian still carry "Style: APA", which the interface no longer shows; // Hungarian and Bulgarian also name the citation styles, and Bulgarian has a few one-line + // pieces of gallery labels. Polish matches every other catalog, including the built-in style + // names, citation styles and equation gallery. for other in Lang::all().filter(|l| *l != Lang::EN && !matches!(l.code(), "pl" | "it" | "hu" | "bg" | "vi" | "sv" | "hr")) { let mut missing: Vec = keys(other.0.source).difference(&polish).cloned().collect(); missing.sort(); assert!(missing.is_empty(), "pl lacks {}'s {missing:?}", other.code()); } + for code in ["it", "hu"] { + let missing: Vec = keys(lang(code).0.source).difference(&polish).cloned().collect(); + assert_eq!(missing, ["Style: APA"], "pl vs {code}"); + } let pl = lang("pl"); assert_eq!(tr(pl, "File"), "Plik"); assert_eq!(tr(pl, "Home"), "Start"); @@ -1002,6 +1008,8 @@ fn polish_covers_every_key_of_every_other_catalog() { assert_eq!(tr(pl, "Save"), "Zapisz"); assert_eq!(tr(pl, "Spelling & Grammar"), "Pisownia i gramatyka"); assert_eq!(tr(pl, "Polish"), "Polski"); + assert_eq!(tr(pl, "GOST (name sort)"), "GOST (sortowanie według nazwiska)"); + assert_eq!(tr(pl, "ISO 690 (author-date)"), "ISO 690 (autor–data)"); assert_eq!(tr(pl, "unknown future label"), "unknown future label"); assert_eq!(fmt(tr(pl, "Exported {path}"), &[("path", "draft-{words}.docx")]), "Wyeksportowano: draft-{words}.docx"); assert_eq!(fmt(tr(pl, "Page {page} of {pages}"), &[("page", "3"), ("pages", "9")]), "Strona 3 z 9"); From 211d6c0c063614bdb8ece7599be0f2abca1827cf Mon Sep 17 00:00:00 2001 From: mbaksa <116127013+mbaksa@users.noreply.github.com> Date: Sun, 18 Oct 2026 10:24:05 +0200 Subject: [PATCH 13/38] UI: Font dropdown list height doesn't update when filter results increase (#1732) * Fix searchable combo box height when filter results increase * Clean up unnecessary blank lines --- crates/ui-egui/src/widgets.rs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/crates/ui-egui/src/widgets.rs b/crates/ui-egui/src/widgets.rs index 32013e0ae6..7d0440b2b9 100644 --- a/crates/ui-egui/src/widgets.rs +++ b/crates/ui-egui/src/widgets.rs @@ -847,7 +847,9 @@ fn list_rows<'a>( ) -> (Option, f32, f32) { let mut out = None; let row_h = if preview.is_some() { COMBO_PREVIEW_ROW_H } else { ui.spacing().interact_size.y }; - let mut area = egui::ScrollArea::vertical().max_height(420.0); + let desired_h = (n as f32 * (row_h + ui.spacing().item_spacing.y)).min(420.0); + ui.set_min_height(desired_h); + let mut area = egui::ScrollArea::vertical().max_height(desired_h); if let (Some(h), Some((offset, view_h))) = (highlight, scroll) { let top = h as f32 * (row_h + ui.spacing().item_spacing.y); let bottom = top + row_h; From 1f0aeb4626f981ffb5102597391c9403fc369c49 Mon Sep 17 00:00:00 2001 From: Dmitrii Khlapov Date: Sun, 18 Oct 2026 10:24:15 +0200 Subject: [PATCH 14/38] Ink to Shape: two-stroke arrows and closed-triangle heads (gap 12) (#1778) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * ink-arrows-2: WIP * ink-arrows-2: Ink to Shape makes two-stroke arrows and closed-triangle heads Co-Authored-By: Claude Opus 5.5 * ink-arrows-2: review fixes — recognise each stroke once, drawing-order pairing, arrow rect Co-Authored-By: Claude Opus 5.5 (1M context) --------- Co-authored-by: Dmitrii Khlapov <19991457+dkhlapov@users.noreply.github.com> Co-authored-by: Claude Opus 5.5 --- ROADMAP.md | 2 + crates/engine/src/cmd/draw.rs | 313 +++++++++++++++++++++++-- crates/engine/src/cmd/ink_shape.rs | 353 ++++++++++++++++++++++++++--- docs/gaps.md | 3 +- docs/hardware-parity.md | 5 +- 5 files changed, 624 insertions(+), 52 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 36a700a2e0..e04ac0fb2e 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -172,6 +172,7 @@ Ranked, to beta (detail and remaining milestones in [docs/roadmap.md](docs/roadm | 2026-10-14 | Grouped `flowChartOr` shapes draw their circle and crossing spokes, as in Word | | 2026-10-14 | Deleting a comment removes its anchors from headers, footers, notes and text boxes too, not just the body, and deletes the whole thread below it, replies to replies included; no anchor or reply is left pointing at a deleted comment (#1345) | | 2026-10-14 | Legacy DOC DIB conversion checks the BMP header and copy before allocation, uses the caller's decode allowance, and caps PNG output while encoding; a BMP fallback is kept when it fits. A generated 1-pixel regression covers 57/58/512-byte limits. Resource-exhaustion consequences remain unmeasured. Related: #1334 and #1554 | +| 2026-10-15 | Draw: Ink to Shape makes two-stroke arrows and arrows with a closed triangle head. A line plus a separate head stroke (an open V or a small closed triangle, drawn after the line or before it) whose apex sits at one of the line's ends becomes one straight arrow connector, and the head stroke goes, in one undo step; this works while drawing with Ink to Shape on and on lasso-selected strokes with Convert to Shapes. A shaft with a small triangle drawn round its tip in the same stroke is an arrow too; a triangle without a shaft stays a triangle. Our own design (Word for Mac has no Ink to Shape). | | 2026-10-13 | Justified vertical page alignment (#1320): a section set to Justified spreads each page's paragraphs and tables from the top margin to the bottom one, as Word does, including the last page and pages ending in a manual page break. DOCX `w:vAlign="both"` opens as Justified (it showed centred) and saves back; `layout.verticalAlign` takes `justify`, and Page Setup has a Vertical alignment box | | 2026-10-13 | macOS shortcuts as in Word for Mac: ⌘⇧S Save As, ⌘⌥E Insert Endnote, ⌘⌥A New Comment, ⌃H Replace, ⌘⌥R collapse the ribbon | | 2026-10-13 | The status bar flags a proofing language whose Hunspell dictionary isn't installed (Polish, Portuguese): "Polish (no dictionary)", with a tooltip naming the dictionary and where it is looked up (#1167) | @@ -464,6 +465,7 @@ Ranked, to beta (detail and remaining milestones in [docs/roadmap.md](docs/roadm | 2026-10-14 | trivial | Floating-table overlap checks use visible line content; empty line-break lines no longer move a table | | 2026-10-14 | trivial | Progress log: deleting a comment leaves no dangling anchors or replies (#1345) | | 2026-10-14 | minor | Legacy DOC DIB conversion byte limits only; related #1334/#1554, stage and readiness estimates unchanged | +| 2026-10-15 | trivial | Progress log: Ink to Shape two-stroke and closed-head arrows | | 2026-10-13 | trivial | Progress log: justified vertical page alignment (#1320) | | 2026-10-14 | trivial | Progress log: file names that aren't valid Unicode (#1440) | | 2026-10-13 | trivial | Behind Text pictures and shapes drag over overlapping text in the body and headers, selected or not, while ordinary clicks keep editing text; In Front of Text drag covered too | diff --git a/crates/engine/src/cmd/draw.rs b/crates/engine/src/cmd/draw.rs index f445228f92..c94ccdf0f6 100644 --- a/crates/engine/src/cmd/draw.rs +++ b/crates/engine/src/cmd/draw.rs @@ -22,7 +22,7 @@ use wordcraft_doc::{CharProps, Pos, StoryRef}; use wordcraft_geom::Rect; use wordcraft_layout::{DocLayout, Placed}; -use super::ink_shape::{InkShape, recognise}; +use super::ink_shape::{ArrowParts, InkShape, arrow_parts, pair_parts, recognise}; use super::pos_json; use crate::{CmdError, CmdResult, CommandSpec, Session, p}; @@ -461,9 +461,26 @@ fn stroke(s: &mut Session, v: &Value) -> CmdResult { extra: Default::default(), }; let at = place(s, &layout, obj, page, (fx, fy), (first[0], first[1]))?; - // Ink to Shape: a stroke that looks like a shape becomes it (in the same undo step). - let shape = if s.view.draw.ink_to_shape && tool != InkTool::Highlighter { convert_at(s, &at)? } else { None }; - Ok(json!({"pos": pos_json(&at), "page": page, "rect": [fx, fy, fw, fh], "shape": shape})) + // Ink to Shape: a stroke that looks like a shape becomes it, and a line and a head drawn as two + // strokes become one arrow (in the same undo step). + let (at, shape, rect) = if s.view.draw.ink_to_shape && tool != InkTool::Highlighter { + match pair_with_neighbour(s, page, &at)? { + Some(arrow) => { + // The arrow is the line drawn earlier (or just now): its frame, not the stroke's. + s.touch(); + let r = s.layout().object(&arrow, page).map(|o| o.rect); + let rect = r.map_or([fx, fy, fw, fh], |r| [r.x, r.y, r.w, r.h]); + (arrow, Some("arrow"), rect) + } + None => { + let shape = convert_at(s, &at)?; + (at, shape, [fx, fy, fw, fh]) + } + } + } else { + (at, None, [fx, fy, fw, fh]) + }; + Ok(json!({"pos": pos_json(&at), "page": page, "rect": rect, "shape": shape})) } /// Insert floating `obj` with its top-left at `(x, y)` on `page`, anchored to the body paragraph @@ -676,28 +693,141 @@ fn lasso_color(s: &mut Session, v: &Value) -> CmdResult { // --- Ink to Shape -------------------------------------------------------------------------- -/// Turn the lasso-selected strokes that look like shapes into those shapes (one undo step). +/// Turn the lasso-selected strokes that look like shapes into those shapes, and a selected line +/// and arrowhead drawn as two strokes into one arrow (one undo step). fn convert_selection(s: &mut Session, _: &Value) -> CmdResult { let sel = lasso_required(s)?; - let mut converted = Vec::new(); - for pos in &sel.items { + let layout = s.layout(); + let pieces = arrow_pieces(s, &layout, sel.page, |pos, _| sel.items.contains(pos)); + let (mut used, mut heads, mut converted): (Vec, Vec, Vec<(Pos, &'static str)>) = (Vec::new(), Vec::new(), Vec::new()); + // Each piece was recognised once; pairing them is cheap geometry, even for a crowded page. + for line in pieces.iter().filter(|k| k.parts.line.is_some()) { + for head in pieces.iter().filter(|k| !k.parts.heads.is_empty()) { + if line.pos == head.pos || used.contains(&line.pos) || used.contains(&head.pos) || !near(line.rect, head.rect) { + continue; + } + if let Some(arrow) = pair_parts(&line.parts, &head.parts) { + set_shape(s, &line.pos, &local_to(&arrow, line.rect))?; + used.extend([line.pos.clone(), head.pos.clone()]); + heads.push(head.pos.clone()); + converted.push((line.pos.clone(), "arrow")); + } + } + } + for pos in sel.items.iter().filter(|p| !used.contains(p)) { if let Some(name) = convert_at(s, pos)? { - converted.push(json!({"pos": pos_json(pos), "shape": name})); + converted.push((pos.clone(), name)); } } if converted.is_empty() { return Err(CmdError::Failed("none of the selected strokes looks like a shape".into())); } - keep_lasso(s, sel); + // The heads went into their arrows; last first, so the earlier ones keep their place. + heads.sort(); + for pos in heads.iter().rev() { + delete_object(s, pos)?; + } + let items = sel.items.iter().filter(|p| !heads.contains(p)).map(|p| after_deleting(p, &heads)).collect(); + let converted: Vec = converted.iter().map(|(p, name)| json!({"pos": pos_json(&after_deleting(p, &heads)), "shape": name})).collect(); + keep_lasso(s, LassoSelection { items, ..sel }); Ok(json!({"count": converted.len(), "converted": converted})) } +/// Where `pos` is once the objects at `deleted` (none of them `pos`) are gone. +fn after_deleting(pos: &Pos, deleted: &[Pos]) -> Pos { + let before = deleted.iter().filter(|d| d.story == pos.story && d.path == pos.path && d.off < pos.off).count(); + Pos { off: pos.off.saturating_sub(before.saturating_mul(OBJ.len_utf8())), ..pos.clone() } +} + +/// A stroke (or an Ink to Shape line or triangle) on a page that may be one half of a two-stroke +/// arrow: where it is, when it was drawn (ink's drawing order) and what it can be in an arrow. +struct ArrowPiece { + pos: Pos, + rect: Rect, + order: Option, + parts: ArrowParts, +} + +/// Whether two objects laid out at `a` and `b` are close enough to make an arrow (a cheap test +/// before recognising them). +fn near(a: Rect, b: Rect) -> bool { + let reach = 0.2 * a.w.max(a.h).max(b.w).max(b.h); + reach.is_finite() && a.expand(reach).intersects(&b.expand(reach)) +} + +/// The pieces of two-stroke arrows on `page` that `keep` (given the position and laid-out rect) +/// accepts: unturned, unflipped pen and pencil strokes, and the open two-point lines and closed +/// triangles Ink to Shape makes. `keep` runs first, so only kept pieces are recognised. +fn arrow_pieces(s: &Session, layout: &DocLayout, page: usize, keep: impl Fn(&Pos, &Rect) -> bool) -> Vec { + let Some(pg) = layout.pages.get(page) else { return Vec::new() }; + let mut out = Vec::new(); + for it in &pg.items { + let Placed::Object { rect, spin, story, path, off, .. } = it else { continue }; + let pos = Pos { story: *story, path: path.clone(), off: *off }; + if !spin.is_identity() || !keep(&pos, rect) { + continue; + } + let Some(InlineObject::Shape { kind, freeform: Some(f), float, .. }) = s.doc.para_at(&pos).and_then(|p| p.object_at(*off)) else { continue }; + let Some(fp) = f.paths.first().filter(|_| f.paths.len() == 1) else { continue }; + let pen = matches!(f.ink, Some(InkTool::Pen | InkTool::Pencil)) && !s.view.hide_ink; + let shape = f.ink.is_none() && *kind == ShapeKind::Freeform && matches!((fp.pts.len(), fp.closed), (2, false) | (3, true)); + if !(pen || shape) || float.rot != 0.0 || float.flip_h || float.flip_v { + continue; + } + let Some((mut pts, closed)) = f.placed(rect.x, rect.y, rect.w, rect.h).into_iter().next() else { continue }; + if closed && let Some(&first) = pts.first() { + pts.push(first); + } + out.push(ArrowPiece { pos, rect: *rect, order: f.order, parts: arrow_parts(&pts) }); + } + out +} + +/// Ink to Shape while drawing: when the stroke just drawn at `new` and another on the page are a +/// line and an arrowhead (either drawn first), make the line an arrow and drop the head. Returns +/// where the arrow is. +fn pair_with_neighbour(s: &mut Session, page: usize, new: &Pos) -> Result, CmdError> { + s.touch(); // lay out the new stroke where it was just placed + let layout = s.layout(); + let Some(mine) = layout.object(new, page).map(|o| o.rect) else { return Ok(None) }; + // Only the new stroke and its neighbours are recognised, not the whole page. + let mut pieces = arrow_pieces(s, &layout, page, |pos, rect| pos == new || near(mine, *rect)); + let Some(at) = pieces.iter().position(|k| k.pos == *new) else { return Ok(None) }; + let me = pieces.swap_remove(at); + // The most recently drawn first (ink records its drawing order; shapes and ink from files + // don't, and come after, latest on the page first), so the winner doesn't depend on layout. + pieces.sort_by(|a, b| b.order.cmp(&a.order).then_with(|| b.pos.cmp(&a.pos))); + for other in &pieces { + for (line, head) in [(other, &me), (&me, other)] { + if let Some(arrow) = pair_parts(&line.parts, &head.parts) { + set_shape(s, &line.pos, &local_to(&arrow, line.rect))?; + delete_object(s, &head.pos)?; + return Ok(Some(after_deleting(&line.pos, std::slice::from_ref(&head.pos)))); + } + } + } + Ok(None) +} + +/// `shape` (in page points) in the frame of the object laid out at `rect`. +fn local_to(shape: &InkShape, rect: Rect) -> InkShape { + let m = |[x, y]: [f32; 2]| [x - rect.x, y - rect.y]; + match shape { + InkShape::Arrow { a, b, head_at_end, head_at_start } => { + InkShape::Arrow { a: m(*a), b: m(*b), head_at_end: *head_at_end, head_at_start: *head_at_start } + } + InkShape::Line { a, b } => InkShape::Line { a: m(*a), b: m(*b) }, + InkShape::Rectangle { x, y, w, h } => InkShape::Rectangle { x: x - rect.x, y: y - rect.y, w: *w, h: *h }, + InkShape::Ellipse { x, y, w, h } => InkShape::Ellipse { x: x - rect.x, y: y - rect.y, w: *w, h: *h }, + InkShape::Polygon(c) => InkShape::Polygon(c.iter().copied().map(m).collect()), + } +} + /// Replace the ink stroke at `pos` by the shape it looks like, in its colour and thickness and /// where it was drawn. `None` (nothing changed) when it isn't a pen or pencil stroke, it is /// turned or flipped, or it doesn't look like a shape. fn convert_at(s: &mut Session, pos: &Pos) -> Result, CmdError> { - let para = s.doc.para_mut(pos.story, &pos.path)?; - let Some(InlineObject::Shape { kind, w, h, fill, float, freeform, extra, .. }) = para.object_at_mut(pos.off) else { return Ok(None) }; + let Some(InlineObject::Shape { w, h, float, freeform, .. }) = s.doc.para_at(pos).and_then(|p| p.object_at(pos.off)) else { return Ok(None) }; let Some(f) = freeform.as_deref().filter(|f| matches!(f.ink, Some(InkTool::Pen | InkTool::Pencil)) && f.paths.len() == 1) else { return Ok(None); }; @@ -707,13 +837,24 @@ fn convert_at(s: &mut Session, pos: &Pos) -> Result, CmdErr // The stroke in its frame's own points (the frame may have been resized since). let Some((pts, _)) = f.placed(0.0, 0.0, *w, *h).into_iter().next() else { return Ok(None) }; let Some(found) = recognise(&pts) else { return Ok(None) }; + set_shape(s, pos, &found)?; + Ok(Some(found.name())) +} + +/// Make the drawing at `pos` the shape `found` (given in the drawing's own frame), keeping its +/// colour and thickness, where it is. +fn set_shape(s: &mut Session, pos: &Pos, found: &InkShape) -> Result<(), CmdError> { + let para = s.doc.para_mut(pos.story, &pos.path)?; + let Some(InlineObject::Shape { kind, w, h, fill, float, freeform, extra, .. }) = para.object_at_mut(pos.off) else { + return Err(CmdError::Failed("no drawing there".into())); + }; let [bx, by, bw, bh] = found.bounds(); let (bw, bh) = (bw.max(1.0), bh.max(1.0)); let outline = |pts: Vec<[f32; 2]>, closed: bool| { Some(Arc::new(Freeform { w: bw, h: bh, paths: vec![FreePath { pts, closed }], ..Default::default() }.sanitized())) }; let local = |c: &[[f32; 2]]| c.iter().map(|[x, y]| [x - bx, y - by]).collect::>(); - let (new_kind, geometry) = match &found { + let (new_kind, geometry) = match found { InkShape::Rectangle { .. } => (ShapeKind::Rectangle, None), InkShape::Ellipse { .. } => (ShapeKind::Ellipse, None), InkShape::Line { a, b } => (ShapeKind::Freeform, outline(local(&[*a, *b]), false)), @@ -735,7 +876,7 @@ fn convert_at(s: &mut Session, pos: &Pos) -> Result, CmdErr float.x += bx; float.y += by; para.touch(); - Ok(Some(found.name())) + Ok(()) } // --- Ink Replay ---------------------------------------------------------------------------- @@ -1017,6 +1158,152 @@ mod tests { assert!(extra.arrow_end && !extra.arrow_start && float.flip_h, "{extra:?} {float:?}"); } + /// Every drawing in the body, in document order. + fn drawings(s: &Session) -> Vec { + s.doc + .body + .iter() + .filter_map(|b| b.as_para()) + .flat_map(|p| p.objects.iter()) + .filter(|o| matches!(o, InlineObject::Shape { .. })) + .cloned() + .collect() + } + + /// A straight stroke from (100, 300) to (300, 300). + fn shaft_stroke() -> Vec<[f32; 2]> { + (0..=40).map(|i| [100.0 + i as f32 * 5.0, 300.0]).collect() + } + + #[test] + fn ink_to_shape_turns_a_line_and_a_head_drawn_after_it_into_one_arrow_in_one_undo_step() { + let mut s = session(); + s.run("draw.inkToShape", &json!({"value": true})).unwrap(); + let r = s.run("draw.stroke", &json!({"page": 0, "points": shaft_stroke(), "color": "C00000", "width": 2})).unwrap(); + assert_eq!(r["shape"], "line"); + let steps = s.undo_depth(); + // An open V at the right-hand end, drawn as a second stroke. + let head = [[274.0, 285.0], [287.0, 292.5], [300.0, 300.0], [287.0, 307.5], [274.0, 315.0]]; + let r = s.run("draw.stroke", &json!({"page": 0, "points": head})).unwrap(); + assert_eq!(r["shape"], "arrow"); + assert_eq!(s.undo_depth(), steps + 1, "paired in one undo step"); + assert!(ink(&s).is_empty(), "the head stroke is gone"); + let all = drawings(&s); + let [InlineObject::Shape { kind, w, stroke, stroke_width, extra, float, .. }] = all.as_slice() else { panic!("one arrow: {all:?}") }; + assert_eq!((*kind, *stroke, *stroke_width), (ShapeKind::StraightConnector, Some(wordcraft_doc::Rgb(0xC0, 0, 0)), 2.0)); + assert!(extra.arrow_end && !extra.arrow_start && !float.flip_h && !float.flip_v, "{extra:?} {float:?}"); + assert!((w - 200.0).abs() < 1.0, "{w}"); + let at: wordcraft_doc::Pos = serde_json::from_value(r["pos"].clone()).unwrap(); + assert!(matches!(s.doc.para_at(&at).and_then(|p| p.object_at(at.off)), Some(InlineObject::Shape { kind: ShapeKind::StraightConnector, .. }))); + let rect = s.layout().object(&at, 0).unwrap().rect; + assert!((rect.x - 100.0).abs() < 1.0 && (rect.y - 300.0).abs() < 1.0, "where the line was: {rect:?}"); + let got: Vec = r["rect"].as_array().unwrap().iter().map(|v| v.as_f64().unwrap() as f32).collect(); + assert!( + got.len() == 4 && [rect.x, rect.y, rect.w, rect.h].iter().zip(&got).all(|(a, b)| (a - b).abs() < 0.5), + "the arrow's rect, not the deleted head's: {got:?} vs {rect:?}" + ); + // It survives a DOCX save and open. + let back = wordcraft_docx::read(&wordcraft_docx::write(&s.doc).unwrap()).unwrap(); + let again: Vec<_> = back.body.iter().filter_map(|b| b.as_para()).flat_map(|p| p.objects.iter()).collect(); + assert!( + again + .iter() + .any(|o| matches!(o, InlineObject::Shape { kind: ShapeKind::StraightConnector, extra, .. } if extra.arrow_end && !extra.arrow_start)), + "{again:?}" + ); + // Undo brings back the line, without the head. + s.run("edit.undo", &json!({})).unwrap(); + let all = drawings(&s); + let [InlineObject::Shape { kind: ShapeKind::Freeform, extra, .. }] = all.as_slice() else { panic!("the line: {all:?}") }; + assert!(!extra.arrow_end); + assert!(ink(&s).is_empty()); + } + + #[test] + fn ink_to_shape_pairs_a_closed_triangle_head_at_the_start_and_a_head_drawn_first() { + let mut s = session(); + s.run("draw.inkToShape", &json!({"value": true})).unwrap(); + s.run("draw.stroke", &json!({"page": 0, "points": shaft_stroke()})).unwrap(); + // A closed triangle at the left end, where the line began: the arrow points left. + let tri = [[100.0, 300.0], [113.0, 292.5], [126.0, 285.0], [126.0, 300.0], [126.0, 315.0], [113.0, 307.5], [100.0, 300.0]]; + let r = s.run("draw.stroke", &json!({"page": 0, "points": tri})).unwrap(); + assert_eq!(r["shape"], "arrow"); + let all = drawings(&s); + let [InlineObject::Shape { kind: ShapeKind::StraightConnector, extra, float, .. }] = all.as_slice() else { panic!("one arrow: {all:?}") }; + assert!(extra.arrow_end && float.flip_h && !float.flip_v, "tip at the left: {extra:?} {float:?}"); + // Head first (it stays ink on its own), then the shaft that ends at its apex. + let mut s = session(); + s.run("draw.inkToShape", &json!({"value": true})).unwrap(); + let r = s.run("draw.stroke", &json!({"page": 0, "points": [[274.0, 285.0], [300.0, 300.0], [274.0, 315.0]]})).unwrap(); + assert!(r["shape"].is_null()); + let r = s.run("draw.stroke", &json!({"page": 0, "points": shaft_stroke()})).unwrap(); + assert_eq!(r["shape"], "arrow"); + assert!(ink(&s).is_empty()); + let all = drawings(&s); + let [InlineObject::Shape { kind: ShapeKind::StraightConnector, extra, float, .. }] = all.as_slice() else { panic!("one arrow: {all:?}") }; + assert!(extra.arrow_end && !float.flip_h, "{extra:?} {float:?}"); + // A V far from the line's ends stays ink, and the line stays a line. + let mut s = session(); + s.run("draw.inkToShape", &json!({"value": true})).unwrap(); + s.run("draw.stroke", &json!({"page": 0, "points": shaft_stroke()})).unwrap(); + let r = s.run("draw.stroke", &json!({"page": 0, "points": [[174.0, 385.0], [200.0, 400.0], [174.0, 415.0]]})).unwrap(); + assert!(r["shape"].is_null()); + assert_eq!(ink(&s).len(), 1); + assert!(drawings(&s).iter().all(|o| !matches!(o, InlineObject::Shape { kind: ShapeKind::StraightConnector, .. }))); + } + + #[test] + fn converting_a_lasso_selection_pairs_a_line_and_a_head_stroke_into_an_arrow() { + let mut s = session(); + s.run("draw.stroke", &json!({"page": 0, "points": shaft_stroke(), "color": "0070C0"})).unwrap(); + s.run("draw.stroke", &json!({"page": 0, "points": [[274.0, 285.0], [300.0, 300.0], [274.0, 315.0]]})).unwrap(); + s.run("draw.stroke", &json!({"page": 0, "points": rect_stroke(100.0, 400.0, 120.0, 60.0)})).unwrap(); + assert_eq!(ink(&s).len(), 3); + let steps = s.undo_depth(); + s.run("draw.lasso", &json!({"page": 0, "points": [[80, 260], [340, 260], [340, 480], [80, 480]]})).unwrap(); + let r = s.run("draw.inkToShape", &json!({})).unwrap(); + let names: Vec<&str> = r["converted"].as_array().unwrap().iter().filter_map(|c| c["shape"].as_str()).collect(); + assert!(names.contains(&"arrow") && names.contains(&"rectangle"), "{names:?}"); + assert!(ink(&s).is_empty(), "the head stroke went into the arrow"); + let all = drawings(&s); + assert_eq!(all.len(), 2, "{all:?}"); + let arrow = all.iter().find(|o| matches!(o, InlineObject::Shape { kind: ShapeKind::StraightConnector, .. })); + let Some(InlineObject::Shape { extra, stroke, .. }) = arrow else { panic!("{all:?}") }; + assert!(extra.arrow_end && *stroke == Some(wordcraft_doc::Rgb(0, 0x70, 0xC0))); + // The selection still holds what's left: deleting it removes both shapes. + s.run("draw.lassoDelete", &json!({})).unwrap(); + assert!(drawings(&s).is_empty()); + s.run("edit.undo", &json!({})).unwrap(); + assert_eq!(drawings(&s).len(), 2); + s.run("edit.undo", &json!({})).unwrap(); + assert_eq!(ink(&s).len(), 3, "one undo step brings all three strokes back"); + assert_eq!(s.undo_depth(), steps); + } + + #[test] + fn converting_a_crowded_lasso_selection_recognises_each_stroke_a_bounded_number_of_times() { + let mut s = session(); + s.run("draw.stroke", &json!({"page": 0, "points": shaft_stroke()})).unwrap(); + s.run("draw.stroke", &json!({"page": 0, "points": [[274.0, 285.0], [300.0, 300.0], [274.0, 315.0]]})).unwrap(); + // A stack of 40 lines close together: every pair is near, none makes an arrow. + let n = 40; + for i in 0..n { + let y = 420.0 + i as f32 * 2.0; + let pts: Vec<[f32; 2]> = (0..=40).map(|k| [100.0 + k as f32 * 5.0, y]).collect(); + s.run("draw.stroke", &json!({"page": 0, "points": pts})).unwrap(); + } + s.run("draw.lasso", &json!({"page": 0, "points": [[80, 260], [340, 260], [340, 560], [80, 560]]})).unwrap(); + super::super::ink_shape::RECOGNISED.with(|c| c.set(0)); + let r = s.run("draw.inkToShape", &json!({})).unwrap(); + let calls = super::super::ink_shape::RECOGNISED.with(|c| c.get()); + let strokes = n + 2; + assert!(calls <= 2 * strokes, "{calls} recognitions for {strokes} strokes"); + let names: Vec<&str> = r["converted"].as_array().unwrap().iter().filter_map(|c| c["shape"].as_str()).collect(); + assert_eq!(names.iter().filter(|n| **n == "arrow").count(), 1, "{names:?}"); + assert_eq!(names.iter().filter(|n| **n == "line").count(), n, "{names:?}"); + assert!(ink(&s).is_empty()); + } + #[test] fn ink_replay_plays_strokes_in_the_order_they_were_drawn() { let mut s = session(); diff --git a/crates/engine/src/cmd/ink_shape.rs b/crates/engine/src/cmd/ink_shape.rs index c65b37c930..1923ea2cab 100644 --- a/crates/engine/src/cmd/ink_shape.rs +++ b/crates/engine/src/cmd/ink_shape.rs @@ -4,8 +4,10 @@ //! between its ends; a closed one is an ellipse when its points sit on the ellipse inscribed in //! its bounds (low RMS of the normalised radius), else a polygon from its corners (the closed //! path simplified with Ramer–Douglas–Peucker, near-straight and crowded corners merged) when -//! that polygon stays close to every point. An arrow is a line-like shaft plus a head of two barbs -//! drawn in the same stroke (shaft first or head first). Anything else (scribbles, curves, letters) stays ink. +//! that polygon stays close to every point. An arrow is a line-like shaft plus a head drawn in the +//! same stroke (shaft first or head first): two barbs, or a small closed triangle round the tip. +//! [`pair_arrow`] makes one arrow of two strokes: a line, and a head (an open V or a small closed +//! triangle) whose apex sits at one of its ends. Anything else (scribbles, curves, letters) stays ink. use wordcraft_doc::freeform::MAX_POINTS; @@ -55,15 +57,17 @@ const MIN_SIZE: f32 = 6.0; /// Most corners a polygon may have. pub const MAX_CORNERS: usize = 8; +#[cfg(test)] +thread_local! { + /// How many times [`recognise`] ran on this thread (tests check the work stays bounded). + pub static RECOGNISED: std::cell::Cell = const { std::cell::Cell::new(0) }; +} + /// The shape `pts` draws, if it is one. pub fn recognise(pts: &[[f32; 2]]) -> Option { - // Finite points only, without repeats. - let mut p: Vec<[f32; 2]> = Vec::with_capacity(pts.len().min(MAX_POINTS)); - for q in pts.iter().take(MAX_POINTS).filter(|[x, y]| x.is_finite() && y.is_finite() && x.abs() < 1e6 && y.abs() < 1e6) { - if p.last() != Some(q) { - p.push(*q); - } - } + #[cfg(test)] + RECOGNISED.with(|c| c.set(c.get().saturating_add(1))); + let p = clean(pts); let (first, last) = (*p.first()?, *p.last()?); if p.len() < 2 { return None; @@ -83,8 +87,10 @@ pub fn recognise(pts: &[[f32; 2]]) -> Option { return Some(InkShape::Line { a: first, b: last }); } } - // An arrow: a shaft and a head with both barbs, drawn in one stroke (either end first). - if let Some(arrow) = arrow(&p).or_else(|| arrow(&p.iter().rev().copied().collect::>())) { + // An arrow: a shaft and a head (both barbs, or a closed triangle), drawn in one stroke + // (either end first). + let rev: Vec<[f32; 2]> = p.iter().rev().copied().collect(); + if let Some(arrow) = arrow(&p).or_else(|| arrow(&rev)).or_else(|| closed_arrow(&p)).or_else(|| closed_arrow(&rev)) { return Some(arrow); } // Other shapes are closed: the stroke ends near where it began and goes around. @@ -126,27 +132,7 @@ const BARB_ANGLE: (f32, f32) = (15.0, 75.0); /// similar angles and lengths. (A stroke drawn head first is tried reversed by the caller.) fn arrow(p: &[[f32; 2]]) -> Option { let tail = *p.first()?; - // The tip: start at the first point about as far from the start as any (a return that - // overshoots it by a point or two must not move it), then follow the stroke on while it still - // moves away from the start (densely sampled strokes have shaft points inside that window). - // The first barb heads back, so the walk stops at the real tip and never reaches an overshoot; - // jitter of under half a point doesn't stop it early. - let dmax = p.iter().map(|q| dist(tail, *q)).fold(0.0, f32::max); - let mut ti = p.iter().position(|q| dist(tail, *q) >= dmax - (0.03 * dmax).max(2.0))?; - let mut best = dist(tail, *p.get(ti)?); - for (i, q) in p.iter().enumerate().skip(ti + 1) { - let d = dist(tail, *q); - if d >= best { - (ti, best) = (i, d); - } else if d < best - 0.5 { - break; - } - } - let tip = *p.get(ti)?; - let shaft = dist(tail, tip); - if shaft < MIN_SIZE || p.get(..=ti)?.iter().any(|q| seg_distance(tail, tip, *q) > (0.06 * shaft).max(1.5)) { - return None; - } + let (ti, tip, shaft) = shaft_tip(p)?; // The head: the stroke after the tip leaves it twice, once for each barb. let head = p.get(ti..)?; let reach = head.iter().map(|q| dist(tip, *q)).fold(0.0, f32::max); @@ -172,20 +158,166 @@ fn arrow(p: &[[f32; 2]]) -> Option { } let &[(l1, b1), (l2, b2)] = barbs.as_slice() else { return None }; // The pen returns to the tip between the barbs. - if gap > 0.35 * l1.min(l2) { + if gap > 0.35 * l1.min(l2) || shaft <= 0.0 { return None; } - // Each barb as a vector from the tip, against the direction back along the shaft. + head_fits(tail, tip, tip, b1, b2).then_some(InkShape::Arrow { a: tail, b: tip, head_at_end: true, head_at_start: false }) +} + +/// An arrow with a closed triangle head, drawn shaft first: the shaft runs from the start to the +/// tip like [`arrow`]'s, then the pen goes round a small triangle (tip, one barb end, the other) +/// and ends back at the tip. (Drawn head first, the caller tries it reversed.) +fn closed_arrow(p: &[[f32; 2]]) -> Option { + let tail = *p.first()?; + let (ti, tip, _) = shaft_tip(p)?; + let head = p.get(ti..)?; + // The corners: the point farthest from the tip, then the one farthest from that side. + let b1 = head.iter().copied().fold(tip, |m, q| if dist(tip, q) > dist(tip, m) { q } else { m }); + let b2 = head.iter().copied().fold(tip, |m, q| if seg_distance(tip, b1, q) > seg_distance(tip, b1, m) { q } else { m }); + let (l1, l2) = (dist(tip, b1), dist(tip, b2)); + if l1.min(l2) <= 0.0 || dist(*head.last()?, tip) > 0.35 * l1.min(l2) { + return None; + } + // Every point of the head is on the triangle. + let tri = [tip, b1, b2]; + if head.iter().any(|q| polygon_distance(&tri, *q) > (0.15 * l1).max(1.5)) { + return None; + } + head_fits(tail, tip, tip, b1, b2).then_some(InkShape::Arrow { a: tail, b: tip, head_at_end: true, head_at_start: false }) +} + +/// Whether a head whose barbs run from `apex` to `b1` and `b2` fits a shaft from `tail` to `tip`: +/// barbs 5–50 % of the shaft, each 15–75° off the way back along it, on opposite sides of it, +/// and of similar length and angle. +fn head_fits(tail: [f32; 2], tip: [f32; 2], apex: [f32; 2], b1: [f32; 2], b2: [f32; 2]) -> bool { + let shaft = dist(tail, tip); + let (l1, l2) = (dist(apex, b1), dist(apex, b2)); + if !(shaft > 0.0 && l1 > 0.0 && l2 > 0.0) { + return false; + } + // Each barb as a vector from the apex, against the direction back along the shaft. let back = [tail[0] - tip[0], tail[1] - tip[1]]; let angle_side = |e: [f32; 2], len: f32| { - let u = [e[0] - tip[0], e[1] - tip[1]]; + let u = [e[0] - apex[0], e[1] - apex[1]]; let angle = ((back[0] * u[0] + back[1] * u[1]) / (shaft * len)).clamp(-1.0, 1.0).acos().to_degrees(); (angle, back[0] * u[1] - back[1] * u[0]) }; let ((a1, s1), (a2, s2)) = (angle_side(b1, l1), angle_side(b2, l2)); let ok = |l: f32, a: f32| (BARB_LEN.0 * shaft..=BARB_LEN.1 * shaft).contains(&l) && (BARB_ANGLE.0..=BARB_ANGLE.1).contains(&a); let similar = l1.max(l2) <= 2.5 * l1.min(l2) && (a1 - a2).abs() <= 35.0; - (ok(l1, a1) && ok(l2, a2) && s1 * s2 < 0.0 && similar).then_some(InkShape::Arrow { a: tail, b: tip, head_at_end: true, head_at_start: false }) + ok(l1, a1) && ok(l2, a2) && s1 * s2 < 0.0 && similar +} + +/// How far a two-stroke arrow's head apex may sit from the line's end, as a fraction of the line. +const PAIR_REACH: f32 = 0.12; + +/// What a stroke can be in a two-stroke arrow, worked out once (one recognition): the ends of the +/// straight line it is, if it is one, and the ways it can be an arrowhead on its own. +#[derive(Debug, Clone, Default, PartialEq)] +pub struct ArrowParts { + pub line: Option<([f32; 2], [f32; 2])>, + pub heads: Vec<[[f32; 2]; 3]>, +} + +/// `pts` as a possible half of a two-stroke arrow (see [`pair_parts`]). +pub fn arrow_parts(pts: &[[f32; 2]]) -> ArrowParts { + let p = clean(pts); + let found = recognise(&p); + let line = match found { + Some(InkShape::Line { a, b }) => Some((a, b)), + _ => None, + }; + ArrowParts { line, heads: heads(&p, found.as_ref()) } +} + +/// One arrow from two strokes: `line`, a straight stroke, and `head`, an open V (one barb end, +/// the apex, the other end) or a small closed triangle, whose apex is at one end of the line and +/// whose barbs point back along it (the single-stroke head limits). The arrow runs from the +/// line's other end to that end; `None` when they don't make one. +pub fn pair_arrow(line: &[[f32; 2]], head: &[[f32; 2]]) -> Option { + pair_parts(&arrow_parts(line), &arrow_parts(head)) +} + +/// [`pair_arrow`] on strokes already recognised by [`arrow_parts`]: cheap geometry only. +pub fn pair_parts(line: &ArrowParts, head: &ArrowParts) -> Option { + let (a, b) = line.line?; + let mut best: Option<(f32, InkShape)> = None; + for &[apex, b1, b2] in &head.heads { + for (tail, tip) in [(a, b), (b, a)] { + let off = dist(apex, tip); + let near = off <= (PAIR_REACH * dist(tail, tip)).max(3.0); + if near && best.as_ref().is_none_or(|(d, _)| off < *d) && head_fits(tail, tip, apex, b1, b2) { + best = Some((off, InkShape::Arrow { a: tail, b: tip, head_at_end: true, head_at_start: false })); + } + } + } + best.map(|(_, arrow)| arrow) +} + +/// The ways the cleaned points `p` (which recognise as `found`) can be an arrowhead on their own, +/// as `[apex, barb end, barb end]`: each corner of a closed triangle, or the corner of an open V +/// whose two arms are straight. +fn heads(p: &[[f32; 2]], found: Option<&InkShape>) -> Vec<[[f32; 2]; 3]> { + if let Some(InkShape::Polygon(c)) = found + && let &[x, y, z] = c.as_slice() + { + return vec![[x, y, z], [y, z, x], [z, x, y]]; + } + let (Some(&f), Some(&l)) = (p.first(), p.last()) else { return Vec::new() }; + // The apex: the point farthest from the chord between the ends. + let Some((ai, apex)) = p.iter().copied().enumerate().fold(None, |m: Option<(usize, [f32; 2])>, (i, q)| match m { + Some((_, b)) if seg_distance(f, l, b) >= seg_distance(f, l, q) => m, + _ => Some((i, q)), + }) else { + return Vec::new(); + }; + let straight = |a: [f32; 2], b: [f32; 2], arm: Option<&[[f32; 2]]>| { + let len = dist(a, b); + len >= 1.0 && arm.is_some_and(|arm| arm.iter().all(|q| seg_distance(a, b, *q) <= (0.12 * len).max(1.5))) + }; + if ai == 0 || ai + 1 >= p.len() || !straight(f, apex, p.get(..=ai)) || !straight(apex, l, p.get(ai..)) { + return Vec::new(); + } + vec![[apex, f, l]] +} + +/// Finite, not absurdly far points only (at most [`MAX_POINTS`]), without repeats. +fn clean(pts: &[[f32; 2]]) -> Vec<[f32; 2]> { + let mut p: Vec<[f32; 2]> = Vec::with_capacity(pts.len().min(MAX_POINTS)); + for q in pts.iter().take(MAX_POINTS).filter(|[x, y]| x.is_finite() && y.is_finite() && x.abs() < 1e6 && y.abs() < 1e6) { + if p.last() != Some(q) { + p.push(*q); + } + } + p +} + +/// A shaft drawn from the start of `p`: the index of its tip, the tip and the shaft's length; +/// `None` when it is too short or not straight. +fn shaft_tip(p: &[[f32; 2]]) -> Option<(usize, [f32; 2], f32)> { + let tail = *p.first()?; + // The tip: start at the first point about as far from the start as any (a return that + // overshoots it by a point or two must not move it), then follow the stroke on while it still + // moves away from the start (densely sampled strokes have shaft points inside that window). + // The first barb heads back, so the walk stops at the real tip and never reaches an overshoot; + // jitter of under half a point doesn't stop it early. + let dmax = p.iter().map(|q| dist(tail, *q)).fold(0.0, f32::max); + let mut ti = p.iter().position(|q| dist(tail, *q) >= dmax - (0.03 * dmax).max(2.0))?; + let mut best = dist(tail, *p.get(ti)?); + for (i, q) in p.iter().enumerate().skip(ti + 1) { + let d = dist(tail, *q); + if d >= best { + (ti, best) = (i, d); + } else if d < best - 0.5 { + break; + } + } + let tip = *p.get(ti)?; + let shaft = dist(tail, tip); + if shaft < MIN_SIZE || p.get(..=ti)?.iter().any(|q| seg_distance(tail, tip, *q) > (0.06 * shaft).max(1.5)) { + return None; + } + Some((ti, tip, shaft)) } /// Left, top, width and height of `pts` (zero for none). @@ -686,4 +818,153 @@ mod tests { let b = tip_of(&stroke); assert!(b.is_some_and(|b| dist(b, tip) < 1.0), "overshoot head first: {b:?}"); } + + /// A shaft from `tail` to `tip`, then a closed triangle head drawn round the tip in the same + /// stroke (tip, one barb end, the other, back to the tip); `n` shaft points, `m` per side. + fn closed_arrow_n(tail: [f32; 2], tip: [f32; 2], barb: f32, spread_deg: f32, n: usize, m: usize) -> Vec<[f32; 2]> { + let [b1, b2] = barb_ends(tail, tip, barb, spread_deg); + let mut pts = line(tail, tip, n); + pts.extend(line(tip, b1, m)); + pts.extend(line(b1, b2, m)); + pts.extend(line(b2, tip, m)); + pts.push(tip); + pts + } + + /// The ends of a head's two barbs at `tip`, `barb` long, `spread_deg` either side of the shaft. + fn barb_ends(tail: [f32; 2], tip: [f32; 2], barb: f32, spread_deg: f32) -> [[f32; 2]; 2] { + let back = (tail[1] - tip[1]).atan2(tail[0] - tip[0]); + let end = |sign: f32| { + let a = back + sign * spread_deg.to_radians(); + [tip[0] + barb * a.cos(), tip[1] + barb * a.sin()] + }; + [end(1.0), end(-1.0)] + } + + #[test] + fn arrows_with_a_closed_triangle_head_in_one_stroke_are_recognised_both_ways() { + for (tail, tip) in + [([20.0, 100.0], [180.0, 100.0]), ([180.0, 100.0], [20.0, 100.0]), ([100.0, 180.0], [100.0, 20.0]), ([150.0, 120.0], [30.0, 30.0])] + { + for stroke in [closed_arrow_n(tail, tip, 25.0, 30.0, 24, 5), closed_arrow_n(tail, tip, 20.0, 25.0, dist(tail, tip).round() as usize, 15)] + { + let Some(InkShape::Arrow { a, b, head_at_end, head_at_start }) = recognise(&stroke) else { + panic!("{tail:?}->{tip:?}: {:?}", recognise(&stroke)) + }; + assert!(head_at_end && !head_at_start); + assert!(dist(a, tail) < 2.0 && dist(b, tip) < 2.0, "{a:?} {b:?} for {tail:?} {tip:?}"); + // The loop round the head first, then the shaft: the same arrow. + let mut rev = stroke; + rev.reverse(); + let Some(InkShape::Arrow { a, b, .. }) = recognise(&rev) else { panic!("head first {tail:?}->{tip:?}: {:?}", recognise(&rev)) }; + assert!(dist(a, tail) < 2.0 && dist(b, tip) < 2.0, "head first {a:?} {b:?}"); + } + } + // The head size limits hold: 5 to 50 % of a 160 pt shaft, 15 to 75 degrees. + let (tail, tip) = ([20.0, 100.0], [180.0, 100.0]); + let is_arrow = |l: f32, deg: f32| matches!(recognise(&closed_arrow_n(tail, tip, l, deg, 24, 5)), Some(InkShape::Arrow { .. })); + assert!(is_arrow(12.0, 30.0) && is_arrow(70.0, 30.0) && is_arrow(25.0, 20.0) && is_arrow(25.0, 70.0)); + assert!(!is_arrow(6.0, 30.0) && !is_arrow(90.0, 30.0) && !is_arrow(25.0, 8.0) && !is_arrow(25.0, 85.0)); + } + + #[test] + fn triangles_without_a_shaft_stay_triangles() { + // Closed, and closed with the pen running on past the start along the first side. + let tri = around(&[[0.0, 80.0], [45.0, 0.0], [90.0, 80.0]], 15); + assert_eq!(recognise(&tri).map(|s| s.name()), Some("triangle")); + let mut over = tri.clone(); + over.extend(line([0.0, 80.0], [45.0, 0.0], 15).into_iter().skip(1).take(8)); + assert!(!matches!(recognise(&over), Some(InkShape::Arrow { .. })), "{:?}", recognise(&over)); + // A long thin triangle is not a shaft with a head. + let thin = around(&[[0.0, 0.0], [160.0, 10.0], [0.0, 20.0]], 20); + assert!(!matches!(recognise(&thin), Some(InkShape::Arrow { .. })), "{:?}", recognise(&thin)); + // A shaft with a triangle as big as the shaft is not an arrow. + let (tail, tip) = ([20.0, 100.0], [180.0, 100.0]); + assert!(!matches!(recognise(&closed_arrow_n(tail, tip, 150.0, 30.0, 24, 5)), Some(InkShape::Arrow { .. }))); + // Hostile points around a closed head never panic. + let mut junk = closed_arrow_n(tail, tip, 25.0, 30.0, 24, 5); + junk.insert(30, [f32::NAN, f32::INFINITY]); + assert!(matches!(recognise(&junk), Some(InkShape::Arrow { .. }))); + } + + /// A head drawn on its own: an open V (one barb end, the apex, the other end) or a closed + /// triangle (apex, barb end, barb end, apex), `m` points per side. + fn v_head(apex: [f32; 2], [b1, b2]: [[f32; 2]; 2], m: usize) -> Vec<[f32; 2]> { + let mut pts = line(b1, apex, m); + pts.extend(line(apex, b2, m)); + pts.push(b2); + pts + } + + fn triangle_head(apex: [f32; 2], [b1, b2]: [[f32; 2]; 2], m: usize) -> Vec<[f32; 2]> { + let mut pts = line(apex, b1, m); + pts.extend(line(b1, b2, m)); + pts.extend(line(b2, apex, m)); + pts.push(apex); + pts + } + + #[test] + fn a_head_stroke_at_a_line_end_pairs_into_an_arrow() { + for (tail, tip) in [([20.0, 100.0], [180.0, 100.0]), ([180.0, 160.0], [40.0, 30.0]), ([100.0, 20.0], [100.0, 180.0])] { + let shaft = line(tail, tip, 40).into_iter().chain([tip]).collect::>(); + let ends = barb_ends(tail, tip, 25.0, 30.0); + for (what, head) in [("V", v_head(tip, ends, 8)), ("triangle", triangle_head(tip, ends, 8)), ("bare V", vec![ends[0], tip, ends[1]])] { + let Some(InkShape::Arrow { a, b, head_at_end, head_at_start }) = pair_arrow(&shaft, &head) else { + panic!("{what} {tail:?}->{tip:?}: {:?}", pair_arrow(&shaft, &head)) + }; + assert!(head_at_end && !head_at_start); + assert!(dist(a, tail) < 1.0 && dist(b, tip) < 1.0, "{what}: {a:?} {b:?}"); + // Drawn the other way round, the head is still found at its end. + let mut rev = head.clone(); + rev.reverse(); + assert!(matches!(pair_arrow(&shaft, &rev), Some(InkShape::Arrow { b, .. }) if dist(b, tip) < 1.0), "{what} reversed"); + let mut back = shaft.clone(); + back.reverse(); + assert!(matches!(pair_arrow(&back, &head), Some(InkShape::Arrow { b, .. }) if dist(b, tip) < 1.0), "{what}, shaft drawn tip first"); + } + // A triangle given as its corners (an Ink to Shape triangle), apex anywhere in the ring. + let [b1, b2] = barb_ends(tail, tip, 25.0, 30.0); + assert!(matches!(pair_arrow(&shaft, &[b1, b2, tip, b1]), Some(InkShape::Arrow { b, .. }) if dist(b, tip) < 1.0), "corners"); + // An apex a few points short of the end still pairs. + let d = [(tip[0] - tail[0]) / dist(tail, tip), (tip[1] - tail[1]) / dist(tail, tip)]; + let near = [tip[0] - 6.0 * d[0] + 3.0 * d[1], tip[1] - 6.0 * d[1] - 3.0 * d[0]]; + assert!(pair_arrow(&shaft, &v_head(near, barb_ends(tail, near, 25.0, 30.0), 8)).is_some(), "near the end"); + } + } + + #[test] + fn heads_away_from_the_end_the_wrong_way_or_the_wrong_size_dont_pair() { + let (tail, tip) = ([20.0, 100.0], [180.0, 100.0]); + let shaft = line(tail, tip, 40).into_iter().chain([tip]).collect::>(); + let head_at = |apex: [f32; 2], barb: f32, deg: f32| v_head(apex, barb_ends(tail, apex, barb, deg), 8); + // Far from either end: mid-shaft, and 40 pt past the tip. + assert_eq!(pair_arrow(&shaft, &head_at([100.0, 100.0], 25.0, 30.0)), None); + assert_eq!(pair_arrow(&shaft, &head_at([220.0, 100.0], 25.0, 30.0)), None); + assert_eq!(pair_arrow(&shaft, &head_at([180.0, 140.0], 25.0, 30.0)), None); + // Pointing back at the shaft (barbs forward), too big, too small, too narrow, too wide. + let forward = v_head(tip, barb_ends([340.0, 100.0], tip, 25.0, 30.0), 8); + assert_eq!(pair_arrow(&shaft, &forward), None); + for (barb, deg) in [(100.0, 30.0), (5.0, 30.0), (25.0, 5.0), (25.0, 85.0)] { + assert_eq!(pair_arrow(&shaft, &head_at(tip, barb, deg)), None, "barb {barb} at {deg} degrees"); + } + // Lopsided: both barbs on one side. + assert_eq!(pair_arrow(&shaft, &[[155.0, 87.0], tip, [158.0, 80.0]]), None); + // The "line" must be a line, the head a head. + let ends = barb_ends(tail, tip, 25.0, 30.0); + let zig: Vec<[f32; 2]> = (0..9).map(|i| [20.0 + i as f32 * 20.0, if i % 2 == 0 { 100.0 } else { 120.0 }]).collect(); + assert_eq!(pair_arrow(&zig, &v_head(tip, ends, 8)), None); + assert_eq!(pair_arrow(&shaft, &[[150.0, 80.0], [170.0, 120.0], [175.0, 80.0], [180.0, 120.0]]), None, "a scribble"); + assert_eq!(pair_arrow(&shaft, &around(&[[150.0, 60.0], [190.0, 60.0], [190.0, 140.0], [150.0, 140.0]], 8)), None, "a box"); + // Hostile input never panics. + let mut junk = v_head(tip, ends, 8); + junk.insert(2, [f32::NAN, 1.0]); + junk.push([f32::INFINITY, 0.0]); + assert!(pair_arrow(&shaft, &junk).is_some()); + assert_eq!(pair_arrow(&[], &[]), None); + assert_eq!(pair_arrow(&shaft, &[[180.0, 100.0]]), None); + let huge: Vec<[f32; 2]> = (0..MAX_POINTS * 2).map(|i| [(i % 977) as f32, ((i * 31) % 541) as f32]).collect(); + let _ = pair_arrow(&huge, &huge); + let _ = pair_arrow(&shaft, &huge); + } } diff --git a/docs/gaps.md b/docs/gaps.md index 48a9e88d1c..f03ad321cc 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -35,7 +35,7 @@ Document renaming: desktop renaming requires a filesystem that supports hard lin | 8 | **Proofing covers English, Polish, Portuguese, Italian, Croatian, Turkish, Russian, Czech and Indonesian only**; no dictionaries, grammar or hyphenation for the other ~42 languages Word proofs (text in them is left unmarked) | | `crates/proof` `ProofLang::PROOFED`: English, Polish, Portuguese (pt-BR via the installed VERO dictionary, #797), Italian (it-IT via the installed Hunspell dictionary), Croatian (hr-HR, 2026-10-13), Turkish (tr_TR spelling, grammar, TDK hyphenation, 2026-10-13) Russian (the installed `ru_RU` dictionary, 2026-10-12) and Indonesian (the installed `id_ID` dictionary, #498, 2026-10-16), a language per run; Bulgarian has hyphenation only (#826); open PRs add Danish (#723), Russian (#894) and Czech (#1048); Word ships 120 proofing tools; issues #25, #40, #100 open | Every writer outside English, Polish, Portuguese, Italian, Croatian, Turkish, Russian, Czech and Indonesian | 20–40 | [localization-parity.md](localization-parity.md) | | 10 | **Objects**: text in a freely rotated text box (free rotation and flips landed, #332; text direction, alignment and linked text boxes landed, #369; contour wrap and wrap side landed, #354), shape effects, WordArt 3-D effects and connector routing around shapes (WordArt effects and warps and glued connectors landed, #416), dragging members inside a Drawing Canvas (the canvas landed, #344) | | [parity-checklist.md](parity-checklist.md) (Shape Format 11/11, Layout 22/22); group and ungroup landed (#267) | Newsletters, flyers, reports with diagrams | 20–30 | [target-app-parity.md](target-app-parity.md) | | 11 | **Interface languages**: 3 of the 12 key languages missing (hi, ar, id) and pt-PT (French landed, #729; Vietnamese, #1434; Korean, #1216; 21 interface languages in `crates/ui-egui/src/i18n`); open PRs for Arabic (#737, #1042, #1032), Indonesian (#572) and pt-PT (#1028); no mirrored RTL interface yet; ~5% of strings still English in the translated catalogs | | [localization-parity.md](localization-parity.md) | Most of the world's writers | 50–80 | [localization-parity.md](localization-parity.md) | -| 12 | **Draw tab / ink**: pens, pencil, highlighter, eraser, Select and Hide Ink landed (#307); Lasso Select, Add Pen, Ink to Shape and Ink Replay landed (#395); Ink to Shape also recognises single-stroke arrows (a line plus a V head drawn in one stroke becomes a straight connector with an arrowhead); missing: Ink to Math, two-stroke arrows and arrows with a closed triangle head, pressure, rendering/editing Word's own ink (drawing-level and direct run-level InkML content parts now survive DOCX save) | | [parity-checklist.md](parity-checklist.md) (Draw 10/11) | Pen and tablet users | 4–8 | [hardware-parity.md](hardware-parity.md) | +| 12 | **Draw tab / ink**: pens, pencil, highlighter, eraser, Select and Hide Ink landed (#307); Lasso Select, Add Pen, Ink to Shape and Ink Replay landed (#395); Ink to Shape also recognises single-stroke arrows (a line plus a V head drawn in one stroke becomes a straight connector with an arrowhead); two-stroke arrows (a line and a separate V or closed-triangle head) and one-stroke arrows with a closed triangle head landed (#1778); missing: Ink to Math, pressure, rendering/editing Word's own ink (drawing-level and direct run-level InkML content parts now survive DOCX save) | | [parity-checklist.md](parity-checklist.md) (Draw 10/11) | Pen and tablet users | 4–8 | [hardware-parity.md](hardware-parity.md) | | 13 | **Charts and SmartArt editing** (editing charts and SmartArt from Word, more chart types and formatting) | | `insert.chart` and Chart Design basics landed (#409): WordCraft's own charts are editable; charts from Word stay read-only (no embedded-workbook editing), no combo/3D/radar/stock charts (charts of those types show a placeholder with their title and type, #998), axis or series formatting; Insert › SmartArt landed (#497) for graphics WordCraft makes | Reports and business documents | 35–55 | [target-app-parity.md](target-app-parity.md) | | 14 | **`.doc` can't be written; Word 6/95 files refused** | | `crates/docbin` is read-only | Users exchanging files with old Word or legacy systems | 25–40 | [file-format-parity.md](file-format-parity.md) | | 15 | **References depth**: no EndNote/Mendeley desktop integration (bibliography sources are saved in DOCX, #790; Zotero landed, #189) | | #790 stores sources in `customXml` and reads them back; `crates/zotero` | Students and researchers | 3–6 | [target-app-parity.md](target-app-parity.md) | @@ -111,6 +111,7 @@ The Language dialog reads effective character formatting, including document def | 2026-10-14 | trivial | A list paragraph containing only a page break has no visible label; it still advances the list counter. Ordinary empty list items keep their label. | | 2026-10-14 | trivial | Field report: grouped `flowChartOr` omitted its crossing spokes; the preset now draws them; wider DOCX corpus work remains open | | 2026-10-14 | trivial | Floating-table overlap checks use visible line content; empty line-break lines no longer move a table | +| 2026-10-15 | trivial | #12: Ink to Shape pairs a line with a separately drawn V or closed-triangle head, and recognises one-stroke arrows with a closed triangle head | | 2026-10-13 | trivial | Gap #3: Behind Text pictures and shapes drag over text in the body and headers, selected or not; a click without movement still edits the text | | 2026-10-13 | trivial | Row 1: the watermark is saved to DOCX headers (#1290) | | 2026-10-13 | trivial | Row 1: Word's text watermark in DOCX headers is drawn (#1290) | diff --git a/docs/hardware-parity.md b/docs/hardware-parity.md index c2e5164d59..4b1dc03e1e 100644 --- a/docs/hardware-parity.md +++ b/docs/hardware-parity.md @@ -1,6 +1,6 @@ # Hardware parity with Microsoft Word -> **Last reviewed:** 2026-10-12 · **Last updated:** 2026-10-14 · **Change:** trivial (Pen / stylus row: ink, eraser and Ink to Shape incl. arrows landed; previously trivial: a window too big for its screen shrinks to fit when it opens, #1215; previously trivial: 32-bit Windows starts on OpenGL; previously major: first version) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4 and Word for Windows +> **Last reviewed:** 2026-10-12 · **Last updated:** 2026-10-15 · **Change:** trivial (Pen / stylus row: two-stroke and closed-head arrows; previously trivial: Pen / stylus row: ink, eraser and Ink to Shape incl. arrows landed; previously trivial: a window too big for its screen shrinks to fit when it opens, #1215; previously trivial: 32-bit Windows starts on OpenGL; previously major: first version) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4 and Word for Windows > **Printing update:** 2026-10-12 · direct desktop printing through craft-print; details below. @@ -14,7 +14,7 @@ pens, microphones (Dictate) and speakers (Read Aloud), and displays. |---|---|---|---|---|---|---|---| | Printers: native print dialog, printer choice, copies, duplex, page ranges, tray, scaling | ✅ (`WordPDE.plugin` print-dialog extension on Mac) | 🟡 Direct CUPS printing; printer, copies, collation, duplex, ranges and grayscale | 🟡 Direct spooler printing; printer, copies, collation, duplex, ranges and grayscale | 🟡 Direct spooler printing; printer, copies, collation, duplex, ranges and grayscale | ✅ browser print dialog (#209) | 35% | 5–8 | | GPU rendering, HiDPI/Retina, pixel-aligned text | ✅ | ✅ wgpu + vello_cpu rasteriser, whole-pixel text (#110, #165) | ✅ DirectX 12 default (#50), OpenGL when DirectX 12 has no GPU driver (#316) or on 32-bit; startup crash on Intel UHD (#170) | ✅ (soft text on KDE at some zooms, #140) | ✅ | 80% | 3–5 (bugs) | -| Pen / stylus: ink, pressure, eraser, ink to shape/math | ✅ (Draw tab, `InkRender.bundle`) | 🟡 ink, stroke eraser, Ink to Shape incl. arrows (#307, #395); pressure and Ink to Math missing | 🟡 same | 🟡 same | 🟡 same | 50% | (Draw tab, 15–25) | +| Pen / stylus: ink, pressure, eraser, ink to shape/math | ✅ (Draw tab, `InkRender.bundle`) | 🟡 ink, stroke eraser, Ink to Shape incl. one- and two-stroke arrows with open or closed heads (#307, #395); pressure and Ink to Math missing | 🟡 same | 🟡 same | 🟡 same | 50% | (Draw tab, 15–25) | | Multiple monitors, per-monitor DPI, remembered window position, a window that fits its screen | ✅ | ✅ (#38, #76, #1215) | ✅ (winit DPI fix pending, #155) | ✅ | — | 85% | 1 | | Microphone: Dictate | ✅ (cloud speech service) | ❌ | ❌ | ❌ | ❌ | 0% | 8–15 + owner (speech model choice) | | Speakers: Read Aloud | ✅ | ✅ system speech, speed, sentence skip (#190) | ✅ | 🟡 | 🟡 | 75% | 1–2 | @@ -25,6 +25,7 @@ pens, microphones (Dictate) and speakers (Read Aloud), and displays. | Date | Change | Summary | |---|---|---| +| 2026-10-15 | trivial | Pen / stylus row: Ink to Shape also makes two-stroke arrows and arrows with a closed triangle head | | 2026-10-14 | trivial | Pen / stylus row corrected: ink, eraser and Ink to Shape (with arrows) work; pressure and Ink to Math missing | | 2026-10-13 | trivial | A window too big for its screen shrinks to fit when it opens (#1215): eframe sized the first window to the whole monitor, frame and taskbar not counted, so on a laptop screen its bottom, with the end of the scroll bar, was past the screen edge. A window saved that size is fitted on its next launch too | | 2026-10-12 | trivial | 32-bit Windows starts on OpenGL: DirectX 12 graphics pipeline creation returns "The parameter is incorrect" and the app exited before the window appeared | From c5a9c954f760aeeec7d84dc3e12b7db1750a7602 Mon Sep 17 00:00:00 2001 From: Dmitrii Khlapov Date: Sun, 18 Oct 2026 10:24:34 +0200 Subject: [PATCH 15/38] Track Changes Options: Moves (move tracking, Moved from/to marks and colours) (gap 5) (#1779) * track-move-options: WIP * track-move-options: Moves options in Track Changes Options and w:doNotTrackMoves Co-Authored-By: Claude Opus 5.5 (1M context) * track-move-options: Moved from Hidden leaves the layout; per-revision hiding Co-Authored-By: Claude Opus 5.5 (1M context) * track-move-options: Word's mark lists, (none) moves are plain, no borders on collapsed paragraphs Co-Authored-By: Claude Opus 5.5 (1M context) * track-move-options: hr and sv translations for Moved from:/Moved to: Co-Authored-By: Claude Sonnet 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/lib.rs | 5 + crates/docx/src/read/mod.rs | 1 + crates/docx/src/write/mod.rs | 3 + crates/docx/src/write/settings_merge.rs | 1 + crates/docx/tests/settings_merge.rs | 30 +++ crates/engine/src/cmd/moves.rs | 23 ++- crates/engine/src/cmd/review.rs | 259 +++++++++++++++++++++++- crates/engine/src/session.rs | 11 +- crates/layout/src/display.rs | 66 ++++-- crates/layout/src/lib.rs | 53 ++++- crates/layout/src/para.rs | 38 +++- crates/layout/src/table.rs | 2 +- crates/layout/src/tests.rs | 50 ++++- crates/ui-egui/src/dialogs_lists.rs | 151 +++++++++++--- crates/ui-egui/src/i18n/bg.tsv | 2 + crates/ui-egui/src/i18n/cs.tsv | 2 + crates/ui-egui/src/i18n/de.tsv | 2 + crates/ui-egui/src/i18n/es.tsv | 2 + crates/ui-egui/src/i18n/et.tsv | 2 + crates/ui-egui/src/i18n/fr.tsv | 2 + crates/ui-egui/src/i18n/hr.tsv | 2 + crates/ui-egui/src/i18n/hu.tsv | 2 + crates/ui-egui/src/i18n/it.tsv | 2 + crates/ui-egui/src/i18n/ja.tsv | 2 + crates/ui-egui/src/i18n/pl.tsv | 2 + crates/ui-egui/src/i18n/pt-br.tsv | 2 + crates/ui-egui/src/i18n/ru.tsv | 2 + crates/ui-egui/src/i18n/sr-latn.tsv | 2 + crates/ui-egui/src/i18n/sr.tsv | 2 + crates/ui-egui/src/i18n/sv.tsv | 2 + crates/ui-egui/src/i18n/tr.tsv | 2 + crates/ui-egui/src/i18n/uk.tsv | 2 + crates/ui-egui/src/i18n/vi.tsv | 2 + crates/ui-egui/src/i18n/zh-hans.tsv | 2 + crates/ui-egui/src/i18n/zh-hant.tsv | 2 + docs/file-format-parity.md | 5 +- docs/gaps.md | 3 +- docs/ui-parity.md | 1 + 39 files changed, 668 insertions(+), 78 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index e04ac0fb2e..28d0aa821f 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -162,6 +162,7 @@ Ranked, to beta (detail and remaining milestones in [docs/roadmap.md](docs/roadm | 2026-10-12 | Table formulas: cell references and ranges (`=B2+B3`, `=SUM(B2:C3)`), arithmetic and comparisons, IF, AND, OR, NOT, TRUE, FALSE, DEFINED and the `\#` number format; ABOVE and LEFT sum the run of numbers next to the cell as Word does; an unknown function or a syntax error is an error instead of a sum; Update Field (F9) recalculates formula fields | | 2026-10-12 | Fields keep `w:dirty` through DOCX saves (#877): a begin marker flagged by a generator that couldn't compute a result (pandoc-style TOC placeholders) keeps the flag on open/save — as a buffered field or a range field — so Word still refreshes it on open; computing a result (Update Field, Update Table) clears it, like Word's F9 | | 2026-10-18 | Different First Page works from page 1 (#2826): Insert › Header and Edit Header with the caret on page 1, and a double-click there while another header is being edited, go into page 1's own header instead of the default one page 1 no longer shows. | +| 2026-10-15 | Track Changes Options › Moves (gap #5): Moved from and Moved to each have a mark and a colour (by default green double strikethrough and double underline, as before and as in Word for Mac), saved per user; `review.trackingOptions` takes `moveFromMark`, `moveFromColor`, `moveToMark`, `moveToColor`, and `trackMoves`, the document's Track moves setting: off, a tracked cut and paste is an ordinary deletion and insertion. DOCX `w:doNotTrackMoves` round-trips | | 2026-10-17 | Language dialog keeps inherited proofing language and no-proof choices (#2717). Opening and accepting unchanged no longer replaces style-derived values with English US and proofing enabled. | | 2026-10-16 | Text with the legacy Outline, Shadow, Emboss or Engrave font effects (`w:outline`, `w:shadow`, `w:emboss`, `w:imprint`) is drawn with them on screen and in PDFs, no longer as plain text: hollow outlined letters, or a grey copy behind the letters; the PDF keeps the text (#2534) | | 2026-10-14 | Consistent preferred cell widths override a stale table grid when both totals agree, for fixed and AutoFit tables. Conflicting widths, spans and percentages remain unchanged. | @@ -438,6 +439,7 @@ Ranked, to beta (detail and remaining milestones in [docs/roadmap.md](docs/roadm | 2026-10-18 | trivial | Progress log: Different First Page headers reachable from page 1 (#2826) | | 2026-10-16 | trivial | Progress log: Web Layout fills the window width (#1876) | | 2026-10-17 | trivial | Progress log: DOCX glossary documents (building blocks) | +| 2026-10-15 | trivial | Progress log: move options in Track Changes Options (gap #5) | | 2026-10-17 | trivial | Language dialog keeps inherited proofing language and no-proof choices (#2717); readiness estimates unchanged | | 2026-10-17 | trivial | Explicit DrawingML preset/custom outline dashes survive import/save and paint in raster/PDF, scaled to the effective stroke width even for zero-width paths. Synthetic controls; theme-inherited dashes and line caps remain unverified. | | 2026-10-15 | trivial | `wordcraft-cli convert --update-fields` explicitly refreshes unlocked fields before conversion; ordinary conversion keeps cached results. Field refresh can change pagination | diff --git a/crates/doc/src/lib.rs b/crates/doc/src/lib.rs index fb3c50e350..c016400393 100644 --- a/crates/doc/src/lib.rs +++ b/crates/doc/src/lib.rs @@ -488,6 +488,10 @@ pub struct CoreProps { #[serde(default, rename_all = "camelCase")] pub struct Settings { pub track_changes: bool, + /// Record a tracked cut and paste of a sentence or paragraph as a move (Track moves); off + /// when the file has `w:doNotTrackMoves` (ECMA-376 §17.15.1.29), and then it is an + /// ordinary deletion and insertion. + pub track_moves: bool, /// Default tab stop interval, points. pub default_tab: f32, pub even_odd_headers: bool, @@ -654,6 +658,7 @@ impl Default for Settings { fn default() -> Self { Settings { track_changes: false, + track_moves: true, default_tab: 36.0, even_odd_headers: false, mirror_margins: false, diff --git a/crates/docx/src/read/mod.rs b/crates/docx/src/read/mod.rs index d82e6d33e4..7ade359b6c 100644 --- a/crates/docx/src/read/mod.rs +++ b/crates/docx/src/read/mod.rs @@ -1088,6 +1088,7 @@ impl Reader<'_> { s.sum_paragraph_spacing = k.children("w:doNotUseHTMLParagraphAutoSpacing").any(on_off); } "w:trackRevisions" => s.track_changes = on_off(k), + "w:doNotTrackMoves" => s.track_moves = !on_off(k), "w:defaultTabStop" => { if let Some(v) = tw(k, "w:val").filter(|v| *v > 0.0) { s.default_tab = v.clamp(1.0, 1584.0); diff --git a/crates/docx/src/write/mod.rs b/crates/docx/src/write/mod.rs index 7ac86e9d53..02ef90256e 100644 --- a/crates/docx/src/write/mod.rs +++ b/crates/docx/src/write/mod.rs @@ -1005,6 +1005,9 @@ fn settings_xml(doc: &Document, footnotes: bool, endnotes: bool, template_rel: O if s.track_changes { w.empty("w:trackRevisions", &[]); } + if !s.track_moves { + w.empty("w:doNotTrackMoves", &[]); + } if let Some(p) = &s.protection { let mut a: Vec<(&str, &str)> = Vec::new(); if p != "none" { diff --git a/crates/docx/src/write/settings_merge.rs b/crates/docx/src/write/settings_merge.rs index 84dfa25eca..b19bde32a0 100644 --- a/crates/docx/src/write/settings_merge.rs +++ b/crates/docx/src/write/settings_merge.rs @@ -21,6 +21,7 @@ const OWNED: &[&str] = &[ "w:attachedTemplate", "w:linkStyles", "w:trackRevisions", + "w:doNotTrackMoves", "w:documentProtection", "w:defaultTabStop", "w:autoHyphenation", diff --git a/crates/docx/tests/settings_merge.rs b/crates/docx/tests/settings_merge.rs index 875a4f34cd..c725b5c2d3 100644 --- a/crates/docx/tests/settings_merge.rs +++ b/crates/docx/tests/settings_merge.rs @@ -133,3 +133,33 @@ fn settings_written_by_wordcraft_keep_no_original() { assert!(!back.passthrough.contains_key("wordcraft:settings.orig"), "{:?}", back.passthrough.keys()); assert_eq!(text(&save(&back), "word/settings.xml"), text(&save(&doc), "word/settings.xml")); } + +/// Gap #5: `w:doNotTrackMoves` (ECMA-376 §17.15.1.29) turns Track moves off; it is read, written +/// back right after `w:trackRevisions` (in a merged part and a generated one), and left out +/// while moves are tracked. +#[test] +fn do_not_track_moves_round_trips() { + let original = SETTINGS.replace("", ""); + let mut doc = wordcraft_docx::read(&with_settings(&original)).unwrap(); + assert!(!doc.settings.track_moves); + let s = text(&save(&doc), "word/settings.xml"); + assert_eq!(s.matches("").count(), 1, "{s}"); + assert!(pos(&s, "") < pos(&s, "") && pos(&s, "") < pos(&s, "", r#""#); + assert!(wordcraft_docx::read(&with_settings(&off)).unwrap().settings.track_moves); + + // A document of our own: absent by default, written when off, and read back. + let mut doc = wordcraft_doc::Document::new(); + assert!(doc.settings.track_moves); + assert!(!text(&save(&doc), "word/settings.xml").contains("doNotTrackMoves")); + doc.settings.track_moves = false; + let s = text(&save(&doc), "word/settings.xml"); + assert!(s.contains(""), "{s}"); + assert!(!wordcraft_docx::read(&wordcraft_docx::write(&doc).unwrap()).unwrap().settings.track_moves); +} diff --git a/crates/engine/src/cmd/moves.rs b/crates/engine/src/cmd/moves.rs index 5d0133c44e..4e23cf8ef2 100644 --- a/crates/engine/src/cmd/moves.rs +++ b/crates/engine/src/cmd/moves.rs @@ -53,7 +53,7 @@ fn in_use(s: &Session, rev: u32) -> bool { /// done) when it isn't such a cut. pub fn cut_tracked(s: &mut Session) -> Result { let (a, b) = s.sel.ordered(); - if !s.doc.settings.track_changes || a == b || a.story != b.story || !move_sized(&s.clipboard_text) { + if !s.doc.settings.track_changes || !s.doc.settings.track_moves || a == b || a.story != b.story || !move_sized(&s.clipboard_text) { return Ok(false); } s.doc.revisions.push(Revision::new(RevisionKind::Delete, s.author.clone(), now_iso())); @@ -71,7 +71,7 @@ pub fn cut_tracked(s: &mut Session) -> Result { /// returns the revision for the pasted text (the destination). `None` = an ordinary insertion. pub fn take_paste(s: &mut Session, frag: &Fragment) -> Option { let m = s.pending_move.take()?; - if m.author != s.author || frag.plain_text() != m.text || !in_use(s, m.rev) { + if !s.doc.settings.track_moves || m.author != s.author || frag.plain_text() != m.text || !in_use(s, m.rev) { return None; } let src = s.doc.revisions.get(m.rev as usize).filter(|r| r.kind == RevisionKind::Delete && r.move_name.is_none())?; @@ -240,6 +240,25 @@ mod tests { assert_eq!(k, ["delete", "insert"]); } + /// Gap #5: with Track moves off (`w:doNotTrackMoves`), a tracked cut and paste of a + /// paragraph is an ordinary deletion and insertion, with no move names. + #[test] + fn with_track_moves_off_a_cut_and_paste_is_a_deletion_and_an_insertion() { + let mut s = Session::new(Document::new()); + s.run("document.setText", &json!({"text": "First one.\nSecond one.\nThird one."})).unwrap(); + s.run("review.trackChanges", &json!({"value": true})).unwrap(); + s.run("review.trackingOptions", &json!({"trackMoves": false})).unwrap(); + s.run("select.range", &json!({"anchor": {"block": 1, "off": 0}, "focus": {"block": 2, "off": 0}})).unwrap(); + s.run("edit.cut", &json!({})).unwrap(); + s.run("caret.set", &json!({"pos": {"block": 0, "off": 0}})).unwrap(); + s.run("edit.paste", &json!({})).unwrap(); + assert_eq!(texts(&s), ["Second one.", "First one.", "Second one.", "Third one."]); + assert!(s.doc.revisions.iter().all(|r| r.move_name.is_none()), "{:?}", s.doc.revisions); + let k = kinds(&mut s); + assert!(k.iter().all(|(k, m)| (k == "insert" || k == "delete") && m.is_null()), "{k:?}"); + assert!(k.iter().any(|(k, _)| k == "insert") && k.iter().any(|(k, _)| k == "delete"), "{k:?}"); + } + #[test] fn accepting_one_end_of_a_move_accepts_both() { let mut s = moved(); diff --git a/crates/engine/src/cmd/review.rs b/crates/engine/src/cmd/review.rs index f51ae6ce42..00cf0ed7df 100644 --- a/crates/engine/src/cmd/review.rs +++ b/crates/engine/src/cmd/review.rs @@ -45,7 +45,7 @@ pub fn specs() -> Vec { .pure(), CommandSpec::new("review.trackingOptions", "Track Changes Options", "Review › Tracking", tracking_options) .params( - r#"{"comments"?: bool, "ink"?: bool, "insertionsDeletions"?: bool, "formatting"?: bool, "balloons"?: "revisions|inline|commentsAndFormatting", "commentDisplay"?: "margin|selected", "insertMark"?: "underline|doubleUnderline|bold|italic|strikethrough|colorOnly|none", "insertColor"?: "byAuthor" | "RRGGBB", "deleteMark"?: "strikethrough|doubleStrikethrough|hidden|caret|hash|underline|colorOnly", "deleteColor"?: "byAuthor" | "RRGGBB", "changedLines"?: "outside|left|right|none", "changedLinesColor"?: "auto" | "RRGGBB", "trackFormatting"?: bool} → the options (omit all to read them)"#, + r#"{"comments"?: bool, "ink"?: bool, "insertionsDeletions"?: bool, "formatting"?: bool, "balloons"?: "revisions|inline|commentsAndFormatting", "commentDisplay"?: "margin|selected", "insertMark"?: "underline|doubleUnderline|bold|italic|strikethrough|colorOnly|none", "insertColor"?: "byAuthor" | "RRGGBB", "deleteMark"?: "strikethrough|doubleStrikethrough|hidden|caret|hash|underline|doubleUnderline|bold|italic|colorOnly|none", "deleteColor"?: "byAuthor" | "RRGGBB", "changedLines"?: "outside|left|right|none", "changedLinesColor"?: "auto" | "RRGGBB", "trackFormatting"?: bool, "moveFromMark"?: (as deleteMark; none = plain text, no colour), "moveFromColor"?: "byAuthor" | "RRGGBB", "moveToMark"?: (as insertMark; none = plain text, no colour), "moveToColor"?: "byAuthor" | "RRGGBB", "trackMoves"?: bool (the document's setting; off = a cut and paste is a deletion and an insertion, not a move)} → the options (omit all to read them)"#, ) .pure(), CommandSpec::new("review.wordCount", "Word Count", "Review › Proofing", word_count) @@ -982,18 +982,37 @@ fn tracking_options(s: &mut Session, v: &Value) -> CmdResult { if let Some(x) = pick("deleteMark") { m.delete_mark = DeleteMark::parse(x).ok_or_else(|| CmdError::Params("unknown `deleteMark`".into()))?; } + if let Some(x) = pick("moveFromMark") { + m.move_from_mark = DeleteMark::parse(x).ok_or_else(|| CmdError::Params("unknown `moveFromMark`".into()))?; + } + if let Some(x) = pick("moveToMark") { + m.move_to_mark = InsertMark::parse(x).ok_or_else(|| CmdError::Params("unknown `moveToMark`".into()))?; + } if let Some(x) = pick("changedLines") { m.changed_lines = ChangeBar::parse(x).ok_or_else(|| CmdError::Params("unknown `changedLines`".into()))?; } m.insert_color = color("insertColor", "byAuthor", m.insert_color)?; m.delete_color = color("deleteColor", "byAuthor", m.delete_color)?; m.changed_lines_color = color("changedLinesColor", "auto", m.changed_lines_color)?; + m.move_from_color = color("moveFromColor", "byAuthor", m.move_from_color)?; + m.move_to_color = color("moveToColor", "byAuthor", m.move_to_color)?; + // Track moves is the document's (`w:doNotTrackMoves`), so changing it is an undo step; the + // command is pure, so it records the step itself, once every parameter has been accepted. + if let Some(b) = p::bool(v, "trackMoves") + && b != s.doc.settings.track_moves + { + s.checkpoint("Track Changes Options"); + s.doc.settings.track_moves = b; + s.touch(); + } if m != s.prefs.markup { s.prefs.markup = m; // Hidden deletions leave the layout. s.relayout(); } - Ok(tracking_json(&s.prefs.markup)) + let mut out = tracking_json(&s.prefs.markup); + out["trackMoves"] = json!(s.doc.settings.track_moves); + Ok(out) } /// The Track Changes Options as `review.trackingOptions` takes them. @@ -1013,6 +1032,10 @@ pub fn tracking_json(m: &wordcraft_layout::display::MarkupOptions) -> Value { "changedLines": m.changed_lines.name(), "changedLinesColor": hex(m.changed_lines_color, "auto"), "trackFormatting": m.track_formatting, + "moveFromMark": m.move_from_mark.name(), + "moveFromColor": hex(m.move_from_color, "byAuthor"), + "moveToMark": m.move_to_mark.name(), + "moveToColor": hex(m.move_to_color, "byAuthor"), }) } @@ -1094,6 +1117,238 @@ mod tests { assert_eq!(s.run("review.trackingOptions", &json!({"commentDisplay": "margin"})).unwrap()["commentDisplay"], "margin"); } + /// Gap #5 (Track Changes Options › Moves): moved text is drawn with the Moved from / Moved + /// to marks and colours, by default green with a double strikethrough where it was and a + /// double underline where it went. + #[test] + fn move_marks_and_colours_change_how_moved_text_is_drawn() { + use wordcraft_doc::Rgb; + use wordcraft_layout::display::{MOVE_COLOR, Stroke}; + let mut s = Session::new(Document::new()); + s.view.proofing = false; + s.run("document.setText", &json!({"text": "First one.\nSecond one.\nThird one."})).unwrap(); + s.run("review.trackChanges", &json!({"value": true})).unwrap(); + s.run("select.range", &json!({"anchor": {"block": 1, "off": 0}, "focus": {"block": 2, "off": 0}})).unwrap(); + s.run("edit.cut", &json!({})).unwrap(); + s.run("caret.set", &json!({"pos": {"block": 0, "off": 0}})).unwrap(); + s.run("edit.paste", &json!({})).unwrap(); + // Horizontal lines (underlines, strikethroughs) with their colour and stroke. + let strokes = |d: &[Draw]| -> Vec<(Rgb, Stroke)> { + d.iter() + .filter_map(|x| if let Draw::Line { y0, y1, color, stroke, .. } = x { (y0 == y1).then_some((*color, *stroke)) } else { None }) + .collect() + }; + + let r = s.run("review.trackingOptions", &json!({})).unwrap(); + assert_eq!( + (r["moveFromMark"].as_str(), r["moveFromColor"].as_str(), r["moveToMark"].as_str(), r["moveToColor"].as_str()), + (Some("doubleStrikethrough"), Some(MOVE_COLOR.hex().as_str()), Some("doubleUnderline"), Some(MOVE_COLOR.hex().as_str())), + ); + let (d, _) = draws(&mut s); + let st = strokes(&d); + assert_eq!( + st.iter().filter(|(c, k)| *c == MOVE_COLOR && *k == Stroke::Double).count(), + 2, + "green double underline and strikethrough: {st:?}" + ); + assert!(glyph_colors(&d).contains(&(MOVE_COLOR, false))); + + // Red single strikethrough where it was; bold blue where it went. + let r = s + .run( + "review.trackingOptions", + &json!({"moveFromMark": "strikethrough", "moveFromColor": "FF0000", "moveToMark": "bold", "moveToColor": "0000FF"}), + ) + .unwrap(); + assert_eq!((r["moveFromMark"].as_str(), r["moveToColor"].as_str()), (Some("strikethrough"), Some("0000FF"))); + let (d, _) = draws(&mut s); + let st = strokes(&d); + assert_eq!(st, vec![(Rgb(0xFF, 0, 0), Stroke::Solid)], "only a red strikethrough is left"); + let g = glyph_colors(&d); + assert!(g.contains(&(Rgb(0, 0, 0xFF), true)) && g.contains(&(Rgb(0xFF, 0, 0), false)), "{g:?}"); + assert!(!g.iter().any(|(c, _)| *c == MOVE_COLOR), "{g:?}"); + + // By author: the insertion and deletion colour of the author. + s.run("review.trackingOptions", &json!({"moveToColor": "byAuthor"})).unwrap(); + let (d, _) = draws(&mut s); + let author = wordcraft_layout::display::revision_color(0); + assert!(glyph_colors(&d).contains(&(author, true)), "{:?}", glyph_colors(&d)); + + // Unknown choices are refused. + assert!(s.run("review.trackingOptions", &json!({"moveToMark": "sparkles"})).is_err()); + assert!(s.run("review.trackingOptions", &json!({"moveFromColor": "green"})).is_err()); + } + + /// Three paragraphs; with Track Changes on, the second cut and pasted before the first, then + /// the Track Changes Options `opts` applied. + fn moved_with(opts: serde_json::Value) -> Session { + let mut s = Session::new(Document::new()); + s.view.proofing = false; + s.run("document.setText", &json!({"text": "First one.\nSecond one.\nThird one."})).unwrap(); + s.run("review.trackChanges", &json!({"value": true})).unwrap(); + s.run("select.range", &json!({"anchor": {"block": 1, "off": 0}, "focus": {"block": 2, "off": 0}})).unwrap(); + s.run("edit.cut", &json!({})).unwrap(); + s.run("caret.set", &json!({"pos": {"block": 0, "off": 0}})).unwrap(); + s.run("edit.paste", &json!({})).unwrap(); + s.run("review.trackingOptions", &opts).unwrap(); + s + } + + /// The caret at body paragraph `block`, offset `off`: (x, top). + fn caret_at(s: &mut Session, block: usize, off: usize) -> (f32, f32) { + let c = s.layout().caret(&wordcraft_doc::Pos::body(block, off)).unwrap(); + (c.x, c.top) + } + + /// Review of gap #5: Moved from = Hidden takes the moved-away paragraph out of the layout, + /// as Hidden does for deletions: the text below closes up as if the move were accepted, the + /// caret can't land inside it, and nothing of it is drawn, not even its ¶. + #[test] + fn hidden_moved_from_text_takes_no_room() { + let mut accepted = moved_with(json!({})); + accepted.run("review.acceptAll", &json!({})).unwrap(); + let third_accepted = caret_at(&mut accepted, 2, 0).1; + + let mut s = moved_with(json!({"moveFromMark": "hidden"})); + assert_eq!(s.doc.body.len(), 4, "the moved-away paragraph is still in the document"); + assert!((caret_at(&mut s, 3, 0).1 - third_accepted).abs() < 0.01, "no blank line where it was"); + // Inside it is the same place as its start and as the paragraph after it. + assert_eq!(caret_at(&mut s, 2, 5), caret_at(&mut s, 2, 0)); + assert_eq!(caret_at(&mut s, 2, 0), caret_at(&mut s, 3, 0)); + let (d, _) = draws(&mut s); + assert_eq!(glyph_text(&d).matches("Second").count(), 1, "{:?}", glyph_text(&d)); + // Formatting marks shown: one ¶ per visible paragraph. + let l = s.layout(); + let opts = DisplayOptions { marks: true, revisions: s.prefs.markup.clone(), ..Default::default() }; + let d = page_display(&s.doc, &l.pages[0], &opts); + assert_eq!(d.iter().filter(|x| matches!(x, Draw::Mark { ch: '¶', .. })).count(), 3); + } + + /// Each "Second" run drawn, in page order (the moved-to paragraph first, then the moved-from + /// one): (colour, bold, italic). + fn seconds(d: &[Draw]) -> Vec<(wordcraft_doc::Rgb, bool, bool)> { + d.iter() + .filter_map(|x| match x { + Draw::Glyphs { text, color, synth_bold, synth_italic, .. } if text.starts_with("Second") => { + Some((*color, *synth_bold, *synth_italic)) + } + _ => None, + }) + .collect() + } + + /// Horizontal lines (underlines and strikethroughs): their strokes. + fn h_strokes(d: &[Draw]) -> Vec { + d.iter().filter_map(|x| if let Draw::Line { y0, y1, stroke, .. } = x { (y0 == y1).then_some(*stroke) } else { None }).collect() + } + + /// Change bars: vertical lines left of the body. + fn bars(d: &[Draw], body_x: f32) -> usize { + lines(d).into_iter().filter(|(x0, _, x1, _)| x0 == x1 && *x0 < body_x).count() + } + + /// Gap #5, Word for Mac 16.113.4 (2026-10-15): Moved to = (none) draws the moved text as + /// plain text, no mark and no colour, although the colour stays set; the change bar stays. + #[test] + fn moved_to_none_is_plain_text_with_its_change_bar() { + use wordcraft_layout::display::{MOVE_COLOR, Stroke}; + let mut s = moved_with(json!({})); + let (d, body_x) = draws(&mut s); + let default_bars = bars(&d, body_x); + assert!(default_bars > 0); + let mut s = moved_with(json!({"moveToMark": "none"})); + assert_eq!(s.run("review.trackingOptions", &json!({})).unwrap()["moveToColor"], MOVE_COLOR.hex()); + let (d, body_x) = draws(&mut s); + let sec = seconds(&d); + assert_eq!(sec.first().map(|c| c.0), Some(wordcraft_doc::Rgb::BLACK), "{sec:?}"); + assert_eq!(sec.get(1).map(|c| c.0), Some(MOVE_COLOR), "the moved-from end keeps its colour"); + assert_eq!(h_strokes(&d), vec![Stroke::Double], "only the moved-from strikethrough"); + assert_eq!(bars(&d, body_x), default_bars, "the change bars stay"); + } + + /// Gap #5, Word for Mac 16.113.4: Deletions and Moved from offer (none), Bold, Italic and + /// Double underline too. Moved from (none) is plain text, as Moved to (none); an ordinary + /// deletion marked (none) keeps its colour. + #[test] + fn deletion_marks_none_bold_italic_and_double_underline() { + use wordcraft_layout::display::{MOVE_COLOR, Stroke}; + let plain = json!({"moveToMark": "none", "moveToColor": "byAuthor"}); + let with = |extra: serde_json::Value| { + let mut o = plain.clone(); + o.as_object_mut().unwrap().extend(extra.as_object().unwrap().clone()); + moved_with(o) + }; + let mut s = with(json!({"moveFromMark": "none"})); + let (d, _) = draws(&mut s); + assert_eq!(seconds(&d).get(1).map(|c| c.0), Some(wordcraft_doc::Rgb::BLACK), "{:?}", seconds(&d)); + assert!(h_strokes(&d).is_empty(), "{:?}", h_strokes(&d)); + let mut s = with(json!({"moveFromMark": "bold"})); + let (d, _) = draws(&mut s); + assert_eq!(seconds(&d).get(1), Some(&(MOVE_COLOR, true, false))); + assert!(h_strokes(&d).is_empty()); + let mut s = with(json!({"moveFromMark": "italic"})); + let (d, _) = draws(&mut s); + assert_eq!(seconds(&d).get(1), Some(&(MOVE_COLOR, false, true))); + let mut s = with(json!({"moveFromMark": "doubleUnderline"})); + let (d, _) = draws(&mut s); + assert_eq!(h_strokes(&d), vec![Stroke::Double]); + let r = s.run("review.trackingOptions", &json!({"deleteMark": "none"})).unwrap(); + assert_eq!((r["deleteMark"].as_str(), r["moveFromMark"].as_str()), (Some("none"), Some("doubleUnderline"))); + + // An ordinary deletion marked (none): no line, the author's colour. + let mut s = Session::new(Document::new()); + s.view.proofing = false; + s.run("document.setText", &json!({"text": "keep gone"})).unwrap(); + s.run("review.trackChanges", &json!({"value": true})).unwrap(); + s.run("select.range", &json!({"anchor": {"block": 0, "off": 5}, "focus": {"block": 0, "off": 9}})).unwrap(); + s.run("text.delete", &json!({})).unwrap(); + s.run("review.trackingOptions", &json!({"deleteMark": "none"})).unwrap(); + let (d, _) = draws(&mut s); + assert!(h_strokes(&d).is_empty()); + let gone = d.iter().find_map(|x| if let Draw::Glyphs { text, color, .. } = x { text.contains("gone").then_some(*color) } else { None }); + assert_eq!(gone, Some(wordcraft_layout::display::revision_color(0))); + } + + /// Review of gap #5: Deletions = Hidden hides deletions, not moved-away text, which follows + /// Moved from (left at its default: shown, green, double strikethrough) and keeps its room, + /// as in Word for Mac 16.113.4 (user-checked 2026-10-15). + #[test] + fn hidden_deletions_leave_moved_from_text_shown() { + use wordcraft_layout::display::{MOVE_COLOR, Stroke}; + let mut accepted = moved_with(json!({})); + accepted.run("review.acceptAll", &json!({})).unwrap(); + let third_accepted = caret_at(&mut accepted, 2, 0).1; + let mut s = moved_with(json!({"deleteMark": "hidden"})); + assert!(caret_at(&mut s, 3, 0).1 > third_accepted + 1.0, "the moved-away paragraph keeps its line"); + let (d, _) = draws(&mut s); + assert_eq!(glyph_text(&d).matches("Second").count(), 2, "{:?}", glyph_text(&d)); + let struck = d.iter().any(|x| matches!(x, Draw::Line { color, stroke: Stroke::Double, y0, y1, .. } if *color == MOVE_COLOR && y0 == y1)); + assert!(struck, "green double lines"); + } + + /// Gap #5: Track moves is a document setting (`w:doNotTrackMoves`): turning it off is one + /// undo step, and setting it to what it is adds none. + #[test] + fn track_moves_is_a_document_setting_with_one_undo_step() { + let mut s = Session::new(Document::new()); + assert_eq!(s.run("review.trackingOptions", &json!({})).unwrap()["trackMoves"], true); + assert!(s.doc.settings.track_moves); + assert!(!s.can_undo()); + assert_eq!(s.run("review.trackingOptions", &json!({"trackMoves": true})).unwrap()["trackMoves"], true); + assert!(!s.can_undo(), "no change, no undo step"); + assert_eq!(s.run("review.trackingOptions", &json!({"trackMoves": false})).unwrap()["trackMoves"], false); + assert!(!s.doc.settings.track_moves && s.dirty); + s.run("edit.undo", &json!({})).unwrap(); + assert!(s.doc.settings.track_moves); + assert!(!s.can_undo()); + // A refused parameter changes nothing. + assert!(s.run("review.trackingOptions", &json!({"trackMoves": false, "moveToMark": "sparkles"})).is_err()); + assert!(s.doc.settings.track_moves); + // Documents saved before the setting existed keep tracking moves. + let old: wordcraft_doc::Settings = serde_json::from_value(json!({"trackChanges": true})).unwrap(); + assert!(old.track_moves); + } + /// #1055: the caret's own comment is the one whose range holds it, not any in its paragraph. #[test] fn comment_range_at_caret_needs_the_caret_inside_the_range() { diff --git a/crates/engine/src/session.rs b/crates/engine/src/session.rs index cfbb9f97f9..14a48e4dbd 100644 --- a/crates/engine/src/session.rs +++ b/crates/engine/src/session.rs @@ -402,7 +402,7 @@ pub struct Session { document_id: u64, cache: LayoutCache, /// (revision, width, read height, view, read line spacing, layout, proofing, outline, - /// (deletions left out, body deletions in balloons), numeral display). + /// (deletions left out, deletions marked Hidden, body deletions in balloons), numeral display). #[allow(clippy::type_complexity)] layout: Option<( u64, @@ -413,7 +413,7 @@ pub struct Session { Arc, bool, Option, - (bool, bool), + (bool, wordcraft_layout::HideDeleted, bool), NumeralMode, )>, /// Picture edits: edited media key → original media key (Reset Picture). @@ -787,8 +787,8 @@ impl Session { let spacing = if self.view.read_mode { self.view.read_line_spacing.map(f32::to_bits) } else { None }; // Deletions in balloons depend on the view (the Comments pane, Read Mode, side by side), so // a view change that moves them in or out of the text lays it out again. - let hide_deleted = !self.view.show_markup || self.prefs.markup.hides_deletions(); - let deleted = (hide_deleted, self.revisions_in_balloons()); + let hide_deleted = !self.view.show_markup; + let deleted = (hide_deleted, self.prefs.markup.hidden_deletions(), self.revisions_in_balloons()); let outline = (mode == ViewMode::Outline).then(|| self.view.outline.view()); if let Some((r, w, h, m, sp, l, pf, o, hd, n)) = &self.layout && *r == self.rev @@ -810,7 +810,8 @@ impl Session { read_line_spacing: spacing.map(f32::from_bits), show_hidden: self.view.marks || self.prefs.marks.hidden, hide_deleted, - deletions_in_balloons: deleted.1, + hide_marked: deleted.1, + deletions_in_balloons: deleted.2, proofing: self.view.proofing, numeral: self.view.numeral.resolve(SYSTEM_LOCALE.get().map(String::as_str)), comment_balloons: false, diff --git a/crates/layout/src/display.rs b/crates/layout/src/display.rs index f431444b77..a6f4eced31 100644 --- a/crates/layout/src/display.rs +++ b/crates/layout/src/display.rs @@ -231,6 +231,11 @@ named! { Hash = "hash", Underline = "underline", ColorOnly = "colorOnly", + /// No mark (an ordinary deletion keeps its colour; moved-away text is plain, as in Word). + None = "none", + Bold = "bold", + Italic = "italic", + DoubleUnderline = "doubleUnderline", } } @@ -294,6 +299,14 @@ pub struct MarkupOptions { pub changed_lines_color: Option, /// Record formatting changes while tracking. pub track_formatting: bool, + /// How the place moved text was taken from is marked (Moves › Moved from). + pub move_from_mark: DeleteMark, + /// `None` = by author. + pub move_from_color: Option, + /// How moved text is marked where it went (Moves › Moved to). + pub move_to_mark: InsertMark, + /// `None` = by author. + pub move_to_color: Option, } impl Default for MarkupOptions { @@ -312,14 +325,28 @@ impl Default for MarkupOptions { changed_lines: ChangeBar::default(), changed_lines_color: None, track_formatting: true, + move_from_mark: DeleteMark::DoubleStrikethrough, + move_from_color: Some(MOVE_COLOR), + move_to_mark: InsertMark::DoubleUnderline, + move_to_color: Some(MOVE_COLOR), } } } impl MarkupOptions { - /// Tracked deletions take no room: they are hidden, or insertions and deletions aren't shown. - pub fn hides_deletions(&self) -> bool { - !self.insertions_deletions || self.delete_mark == DeleteMark::Hidden + /// Whether a tracked deletion takes no room with markup shown: insertions and deletions + /// aren't shown, or its mark is Hidden. Text a move took away (`moved_from`) follows Moved + /// from, other deletions follow Deletions, so Deletions = Hidden leaves moved-away text shown + /// (Word for Mac's behaviour for that combination is unverified: this is the one place to + /// change it). + pub fn hides_deletion(&self, moved_from: bool) -> bool { + let mark = if moved_from { self.move_from_mark } else { self.delete_mark }; + !self.insertions_deletions || mark == DeleteMark::Hidden + } + + /// The deletions the layout leaves out with markup shown ([`crate::LayoutOptions::hide_marked`]). + pub fn hidden_deletions(&self) -> crate::HideDeleted { + crate::HideDeleted { plain: self.hides_deletion(false), moved_from: self.hides_deletion(true) } } } @@ -845,28 +872,32 @@ fn lines( let rc = &st.rc; let m = &opts.revisions; // Without markup a deletion is never drawn as ordinary text, even in a layout that - // kept it; nor when deletions are hidden. - if rc.del.is_some() && (!opts.markup || m.hides_deletions()) { + // kept it; nor when its mark is Hidden. + if rc.del.is_some() && (!opts.markup || m.hides_deletion(doc.move_name(rc.del).is_some())) { continue; } let shown = opts.markup && m.insertions_deletions; let del = rc.del.filter(|_| shown); let ins = rc.ins.filter(|_| shown && del.is_none()); let author = |r: u32| revision_color(doc.revisions.get(r as usize).map(|v| author_index(doc, &v.author)).unwrap_or(0)); - // Moved text: green, double strikethrough where it was, double underline where it went. + // Moved text: the Moves marks and colours (by default green, double strikethrough + // where it was, double underline where it went). let moved_to = doc.move_name(ins).is_some(); let moved_from = del.is_some() && doc.move_name(del).is_some(); + // Moved text marked (none) is plain text, its colour setting aside (Word for Mac). + let plain_move = (moved_from && m.move_from_mark == DeleteMark::None) || (moved_to && m.move_to_mark == InsertMark::None); let color = match (ins, del) { - (_, Some(_)) if moved_from => MOVE_COLOR, - (Some(_), _) if moved_to => MOVE_COLOR, + _ if plain_move => text_color(&rc.color, rc.shading.or(rc.highlight)), + (_, Some(r)) if moved_from => m.move_from_color.unwrap_or_else(|| author(r)), + (Some(r), _) if moved_to => m.move_to_color.unwrap_or_else(|| author(r)), (Some(r), _) => m.insert_color.unwrap_or_else(|| author(r)), (None, Some(r)) => m.delete_color.unwrap_or_else(|| author(r)), _ => ink(&rc.color, rc.shading.or(rc.highlight)), }; // The highlight or shading drawn under this run (as filled in the backgrounds pass). let text_bg = rc.highlight.or(rc.shading); - let ins_mark = ins.map(|_| if moved_to { InsertMark::DoubleUnderline } else { m.insert_mark }); - let del_mark = del.map(|_| if moved_from { DeleteMark::DoubleStrikethrough } else { m.delete_mark }); + let ins_mark = ins.map(|_| if moved_to { m.move_to_mark } else { m.insert_mark }); + let del_mark = del.map(|_| if moved_from { m.move_from_mark } else { m.delete_mark }); if del_mark == Some(DeleteMark::Caret) { // A caret where the text was, instead of the text. if let Some(cx) = line.cl_left(run_start).map(|c| x + c) { @@ -904,8 +935,8 @@ fn lines( g.0 = gid; } } - let synth_bold = st.synth_bold || ins_mark == Some(InsertMark::Bold); - let synth_italic = st.synth_italic || ins_mark == Some(InsertMark::Italic); + let synth_bold = st.synth_bold || ins_mark == Some(InsertMark::Bold) || del_mark == Some(DeleteMark::Bold); + let synth_italic = st.synth_italic || ins_mark == Some(InsertMark::Italic) || del_mark == Some(DeleteMark::Italic); // Text effects draw the letters from their outlines; the glyphs stay, invisible, for // the PDF's text. Not for colour glyphs (no outlines), top-to-bottom text with upright // characters (their outlines would lie on their side), `#` deletion marks, or WordArt @@ -953,7 +984,7 @@ fn lines( let thick = (st.size / 18.0).max(0.5); let underline = match (ins_mark, del_mark) { (Some(InsertMark::Underline), _) | (_, Some(DeleteMark::Underline)) => Underline::Single, - (Some(InsertMark::DoubleUnderline), _) => Underline::Double, + (Some(InsertMark::DoubleUnderline), _) | (_, Some(DeleteMark::DoubleUnderline)) => Underline::Double, _ => rc.underline, }; let double = rc.double_strike || del_mark == Some(DeleteMark::DoubleStrikethrough); @@ -1154,7 +1185,10 @@ fn lines( _ => {} } } - if line.end == LineEnd::Para && marks.paragraphs { + // A deleted paragraph mark that isn't shown (no markup, or its mark is Hidden) draws no ¶. + let mark_gone = + para.is_some_and(|p| p.mark.del.is_some() && (!opts.markup || opts.revisions.hides_deletion(doc.move_name(p.mark.del).is_some()))); + if line.end == LineEnd::Para && marks.paragraphs && !mark_gone { // The mark follows the line's visual end, past every glyph, even where the // logically last text runs against the paragraph's direction. let ex = x + line.visual_end_x(); @@ -1163,8 +1197,10 @@ fn lines( let mx = if line.rtl { ex - 1.0 - size * 0.6 } else { ex + 1.0 }; // A tracked (inserted or deleted) paragraph mark is drawn in its author's colour. let m = &opts.revisions; + let by_author = |r: u32| revision_color(doc.revisions.get(r as usize).map(|v| author_index(doc, &v.author)).unwrap_or(0)); let color = para.filter(|_| opts.markup && m.insertions_deletions).and_then(|p| match (p.mark.ins, p.mark.del) { - (ins, del) if doc.move_name(del).is_some() || (del.is_none() && doc.move_name(ins).is_some()) => Some(MOVE_COLOR), + (_, Some(r)) if doc.move_name(Some(r)).is_some() => Some(m.move_from_color.unwrap_or_else(|| by_author(r))), + (Some(r), None) if doc.move_name(Some(r)).is_some() => Some(m.move_to_color.unwrap_or_else(|| by_author(r))), (_, Some(r)) => Some( m.delete_color .unwrap_or_else(|| revision_color(doc.revisions.get(r as usize).map(|v| author_index(doc, &v.author)).unwrap_or(0))), diff --git a/crates/layout/src/lib.rs b/crates/layout/src/lib.rs index 3ff6484361..6a0f150b42 100644 --- a/crates/layout/src/lib.rs +++ b/crates/layout/src/lib.rs @@ -149,6 +149,10 @@ pub struct LayoutOptions { pub show_hidden: bool, /// Final text ("No Markup"): tracked deletions take no space and draw nothing. pub hide_deleted: bool, + /// Tracked deletions that take no space and draw nothing although markup is shown: those + /// Track Changes Options mark Hidden (deletions, or moved-away text). `hide_deleted` and + /// `deletions_in_balloons` leave every deletion out whatever this says. + pub hide_marked: HideDeleted, /// Show revisions in balloons: tracked deletions in the body take no space and draw nothing /// (the editor shows them in balloons beside the page). Headers, footers, notes and text /// boxes get no balloons, so their deletions stay inline. @@ -186,9 +190,29 @@ impl Default for ProofOptions { } impl LayoutOptions { - /// Whether tracked deletions in `story` take no space and draw nothing. - pub fn hides_deleted(&self, story: StoryRef) -> bool { - self.hide_deleted || (self.deletions_in_balloons && story == StoryRef::Body) + /// Which tracked deletions in `story` take no space and draw nothing. + pub fn hides_deleted(&self, story: StoryRef) -> HideDeleted { + if self.hide_deleted || (self.deletions_in_balloons && story == StoryRef::Body) { HideDeleted::ALL } else { self.hide_marked } + } +} + +/// Which tracked deletions are left out of the layout: ordinary ones, and the text a tracked +/// move took away (the source of a move, `w:moveFrom`). +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)] +pub struct HideDeleted { + pub plain: bool, + pub moved_from: bool, +} + +impl HideDeleted { + pub const ALL: HideDeleted = HideDeleted { plain: true, moved_from: true }; + /// Whether any deletion is left out. + pub fn any(self) -> bool { + self.plain || self.moved_from + } + /// Whether text deleted by revision `del` (`None`: not deleted) is left out. + pub fn hides(self, doc: &Document, del: Option) -> bool { + del.is_some() && if doc.move_name(del).is_some() { self.moved_from } else { self.plain } } } @@ -475,7 +499,7 @@ struct Key { read_spacing: Option, grid: (u32, u32, u32, bool), fit: bool, - hide_deleted: bool, + hide_deleted: HideDeleted, /// STYLEREF results in headers and footers ([`FieldCtx::styleref_key`]). sref: u64, } @@ -545,6 +569,7 @@ fn env_hash(doc: &Document, opts: &LayoutOptions) -> u64 { s, opts.show_hidden, opts.hide_deleted, + opts.hide_marked, opts.deletions_in_balloons, opts.proofing, opts.numeral, @@ -644,7 +669,7 @@ struct Ctx<'a> { grid: para::Grid, /// Whether tracked deletions are left out in the story being laid out /// ([`LayoutOptions::hides_deleted`]): the body's, or the story of the [`layout_box`] at work. - hide_deleted: bool, + hide_deleted: HideDeleted, /// Whether body text (or its notes) laid out so far shows a page total (`NUMPAGES`, /// `SECTIONPAGES`), which is only known once the whole body is paginated. uses_totals: bool, @@ -701,7 +726,7 @@ impl Ctx<'_> { fields: &self.fields, show_hidden: false, field_codes: false, - hide_deleted: false, + hide_deleted: Default::default(), table: None, proofing: None, numeral: self.opts.numeral, @@ -868,14 +893,20 @@ impl Ctx<'_> { /// (`Float::hidden`, whatever is shown). `table_chr` is the /// cell's table-style character formatting ([`para::CellText::chr`]), resolved as the paragraph is /// laid out with (`StyleSheet::resolve_char_in`). -fn left_out_objects(doc: &Document, p: &Paragraph, table_chr: Option<&CharProps>, show_hidden: bool, hide_deleted: bool) -> impl Fn(usize) -> bool { +fn left_out_objects( + doc: &Document, + p: &Paragraph, + table_chr: Option<&CharProps>, + show_hidden: bool, + hide_deleted: HideDeleted, +) -> impl Fn(usize) -> bool { let mut left = Vec::new(); // No text left out with hidden text shown and markup on (hidden drawings still are, below). - if (hide_deleted || !show_hidden) && !p.objects.is_empty() { + if (hide_deleted.any() || !show_hidden) && !p.objects.is_empty() { let style = p.props.style.as_deref(); let is_left = |c: &CharProps| { let rc = doc.styles.resolve_char_in(style, table_chr, c); - para::left_out(&rc, show_hidden, hide_deleted) + para::left_out(doc, &rc, show_hidden, hide_deleted) }; // Runs and objects are both in order: one walk finds each object's run (past the last run, // the paragraph mark, as `props_of_char` gives), resolving a run's formatting only once. @@ -906,7 +937,7 @@ fn left_out_objects(doc: &Document, p: &Paragraph, table_chr: Option<&CharProps> /// section or (footnotes) on each page. `pages` gives the page each footnote was placed on by an /// earlier pass (empty: numbered on). With `hide_deleted`, notes whose reference mark is a /// tracked deletion are left out, as in the final text. -fn note_info(doc: &Document, hide_deleted: bool, pages: &HashMap) -> HashMap { +fn note_info(doc: &Document, hide_deleted: HideDeleted, pages: &HashMap) -> HashMap { use wordcraft_doc::section::NoteRestart; let sections = doc.sections(); let opts: Vec<[wordcraft_doc::section::NoteOptions; 2]> = @@ -962,7 +993,7 @@ fn note_info(doc: &Document, hide_deleted: bool, pages: &HashMap) -> /// Note part ids → numbers, numbered on (see [`note_info`]). #[cfg(test)] -fn note_numbers(doc: &Document, hide_deleted: bool) -> HashMap { +fn note_numbers(doc: &Document, hide_deleted: HideDeleted) -> HashMap { note_info(doc, hide_deleted, &HashMap::new()).into_iter().map(|(id, n)| (id, n.n)).collect() } diff --git a/crates/layout/src/para.rs b/crates/layout/src/para.rs index 030bdab4e9..82e851033e 100644 --- a/crates/layout/src/para.rs +++ b/crates/layout/src/para.rs @@ -405,8 +405,9 @@ pub struct ParaEnv<'a> { pub show_hidden: bool, /// Show field codes instead of results ([`crate::LayoutOptions::field_codes`]). pub field_codes: bool, - /// Leave tracked deletions out, like hidden text (the final, "No Markup" text). - pub hide_deleted: bool, + /// Which tracked deletions to leave out, like hidden text (all of them in the final, "No + /// Markup" text). + pub hide_deleted: crate::HideDeleted, /// The table style's formatting for text in this paragraph's cell (`None` outside tables). pub table: Option<&'a CellText>, /// Check spelling (and grammar, as the options say) for squiggles; `None` = no squiggles. @@ -1249,8 +1250,8 @@ impl Builder<'_> { /// Whether text with this formatting is left out of the layout: hidden text unless it is shown, and /// tracked deletions in the final text. It takes no room, draws nothing and is no place to break. -pub(crate) fn left_out(rc: &ResolvedChar, show_hidden: bool, hide_deleted: bool) -> bool { - (rc.hidden && !show_hidden) || (rc.del.is_some() && hide_deleted) +pub(crate) fn left_out(doc: &Document, rc: &ResolvedChar, show_hidden: bool, hide_deleted: crate::HideDeleted) -> bool { + (rc.hidden && !show_hidden) || hide_deleted.hides(doc, rc.del) } /// Lay out one paragraph. @@ -1319,7 +1320,7 @@ pub fn layout_para(p: &Paragraph, env: &ParaEnv) -> ParaLayout { let rc = resolve(props); runs.push(ProofRun::of(range.clone(), &rc)); let Some(text) = p.text.get(range.clone()) else { continue }; - if left_out(&rc, b.env.show_hidden, b.env.hide_deleted) { + if left_out(doc, &rc, b.env.show_hidden, b.env.hide_deleted) { match left.last_mut() { Some(last) if last.end == range.start => last.end = range.end, _ => left.push(range.clone()), @@ -1328,7 +1329,7 @@ pub fn layout_para(p: &Paragraph, env: &ParaEnv) -> ParaLayout { let g = b.glyphs.len() as u32; // Word still prints the note of a hidden reference mark (and numbers it); a deleted // reference is gone from the final text, note and all. - let keeps_notes = !(rc.del.is_some() && b.env.hide_deleted); + let keeps_notes = !b.env.hide_deleted.hides(doc, rc.del); for (i, c) in text.char_indices() { let mut note = None; if c == OBJ { @@ -1652,6 +1653,9 @@ pub fn layout_para(p: &Paragraph, env: &ParaEnv) -> ParaLayout { }; let auto = env.doc.settings.auto_hyphenation && !pl.rp.suppress_hyphens; pl.hyph_after = hyphenation_points(&text, to_para, &pl, auto, env.doc.settings.hyphenate_caps, &runs); + // A paragraph whose text and mark are all a deletion the layout leaves out (a deleted + // paragraph in the final text, or moved-away text marked Hidden) is gone: it takes no room. + let gone = env.hide_deleted.hides(doc, mark_rc.del) && (p.text.is_empty() || left.first() == Some(&(0..p.text.len()))); pl.left_out = left; for k in pl.hyph_after.clone() { if let Some(c) = pl.clusters.get_mut(k as usize) { @@ -1659,9 +1663,29 @@ pub fn layout_para(p: &Paragraph, env: &ParaEnv) -> ParaLayout { } } break_lines(&mut pl, env, mark_style, env.label.as_ref().map(|(_, l)| l)); + if gone { + collapse(&mut pl); + } pl } +/// Lays a paragraph out as nothing: no height, no spacing, no label, its lines (kept for the +/// caret and hit testing) all at its top with no height. +fn collapse(pl: &mut ParaLayout) { + for l in &mut pl.lines { + l.top = 0.0; + l.height = 0.0; + l.baseline = 0.0; + } + pl.height = 0.0; + pl.rp.space_before = 0.0; + pl.rp.space_after = 0.0; + pl.label = None; + // Its shading and borders would be drawn across the page at no height. + pl.rp.shading = None; + pl.rp.borders = None; +} + /// Next tab stop after `x` (column coordinates): explicit stops, the hanging-indent implicit stop, /// then default stops. fn next_tab(x: f32, tabs: &[TabStop], default_tab: f32, hanging_at: Option) -> TabStop { @@ -2805,7 +2829,7 @@ mod label_spacing_tests { width: 100.0, label: Some(("•".into(), level.clone())), show_hidden: false, - hide_deleted: false, + hide_deleted: Default::default(), table: None, proofing: None, field_codes: false, diff --git a/crates/layout/src/table.rs b/crates/layout/src/table.rs index d279aaa771..8b91754eef 100644 --- a/crates/layout/src/table.rs +++ b/crates/layout/src/table.rs @@ -193,7 +193,7 @@ pub fn measure_table_columns(doc: &wordcraft_doc::Document, t: &Table) -> Vec<(f fields: &fields, show_hidden: false, field_codes: false, - hide_deleted: false, + hide_deleted: Default::default(), table: text.as_ref(), proofing: None, // Measurement uses a fixed environment (like show_hidden/proofing above). diff --git a/crates/layout/src/tests.rs b/crates/layout/src/tests.rs index 5936339ed1..e9fd94efcd 100644 --- a/crates/layout/src/tests.rs +++ b/crates/layout/src/tests.rs @@ -3246,7 +3246,7 @@ fn footnotes_inside_text_boxes_are_numbered_and_placed() { let b = l.caret(&d.start_of(StoryRef::Part(after))).unwrap(); assert_eq!((a.page, b.page), (0, 0)); assert!(a.top > 600.0 && a.top < b.top, "{a:?} {b:?}"); - let nums = note_numbers(&d, false); + let nums = note_numbers(&d, HideDeleted::default()); assert_eq!((nums.get(&in_box), nums.get(&after)), (Some(&1), Some(&2))); } @@ -4207,9 +4207,9 @@ fn objects_left_out_through_the_table_style_formatting() { p.insert_object(1, rect_shape(10.0, 10.0, Default::default()), &Default::default()).unwrap(); let d = Document::from_text(""); let hidden = wordcraft_doc::CharProps { hidden: Some(true), ..Default::default() }; - assert!(!left_out_objects(&d, &p, None, false, false)(0)); - assert!(left_out_objects(&d, &p, Some(&hidden), false, false)(0)); - assert!(!left_out_objects(&d, &p, Some(&hidden), true, false)(0), "hidden text shown"); + assert!(!left_out_objects(&d, &p, None, false, HideDeleted::default())(0)); + assert!(left_out_objects(&d, &p, Some(&hidden), false, HideDeleted::default())(0)); + assert!(!left_out_objects(&d, &p, Some(&hidden), true, HideDeleted::default())(0), "hidden text shown"); } #[test] @@ -5530,7 +5530,7 @@ fn footnotes_restart_on_each_page_and_section() { d.settings.footnote_pr = NoteProps::default(); d.para_mut(StoryRef::Body, &Path::top(49)).unwrap().section = Some(Box::new(SectionProps::default())); d.last_section.footnote_pr = NoteProps { num_restart: Some(NoteRestart::EachSect), num_start: Some(5), ..Default::default() }; - let nums = note_numbers(&d, false); + let nums = note_numbers(&d, HideDeleted::default()); assert_eq!((nums[&a], nums[&b], nums[&c]), (1, 2, 5)); } @@ -7381,6 +7381,46 @@ fn justified_multi_column_pages_stay_at_the_top() { assert_eq!(last(PageVAlign::Justify), last(PageVAlign::Top)); } +/// Gap #5 review: a paragraph deleted whole (text and mark) takes no room in the final text, and +/// neither does moved-away text marked Hidden; the paragraph after it sits where it would without +/// it, and a deletion that isn't left out keeps its line. +#[test] +fn a_paragraph_left_out_whole_takes_no_room() { + let mut d = Document::from_text("One\nGone\nThree"); + d.format_range(&Pos::body(1, 0), &Pos::body(2, 0), &|c| c.del = Some(0)).unwrap(); + let three_top = + |d: &Document, opts: &LayoutOptions, block: usize| layout(d, &mut LayoutCache::new(), opts).caret(&Pos::body(block, 0)).unwrap().top; + let without = three_top(&Document::from_text("One\nThree"), &LayoutOptions::default(), 1); + let fin = LayoutOptions { hide_deleted: true, ..Default::default() }; + assert!((three_top(&d, &fin, 2) - without).abs() < 0.01, "no blank line in the final text"); + assert!(three_top(&d, &LayoutOptions::default(), 2) > without + 1.0, "with markup it keeps its line"); + // Only moved-away text hidden: an ordinary deletion keeps its line. + let moves = LayoutOptions { hide_marked: HideDeleted { plain: false, moved_from: true }, ..Default::default() }; + assert!(three_top(&d, &moves, 2) > without + 1.0); + let plain = LayoutOptions { hide_marked: HideDeleted { plain: true, moved_from: false }, ..Default::default() }; + assert!((three_top(&d, &plain, 2) - without).abs() < 0.01); +} + +/// Gap #5 review: a paragraph left out whole draws no shading and no borders (they would be +/// lines across the page at its zero height). +#[test] +fn a_paragraph_left_out_whole_draws_no_borders_or_shading() { + let mut d = Document::from_text("One\nGone\nThree"); + let line = Some(Border::single(1.0)); + d.format_paragraphs(&Pos::body(1, 0), &Pos::body(1, 0), &|p| { + p.borders = Some(Borders { top: line, left: line, bottom: line, right: line, ..Borders::default() }); + p.shading = Some(wordcraft_doc::Rgb(0xEE, 0xEE, 0xEE)); + }) + .unwrap(); + d.format_range(&Pos::body(1, 0), &Pos::body(2, 0), &|c| c.del = Some(0)).unwrap(); + let decorations = |hide_deleted: bool| { + let l = layout(&d, &mut LayoutCache::new(), &LayoutOptions { hide_deleted, ..Default::default() }); + l.pages[0].items.iter().filter(|i| matches!(i, Placed::Fill { .. } | Placed::Rule { .. })).count() + }; + assert_eq!(decorations(false), 5, "shading and four borders with markup"); + assert_eq!(decorations(true), 0, "none in the final text"); +} + #[test] fn row_borders_stay_on_row() { let mut d = Document::from_text("before\nafter"); diff --git a/crates/ui-egui/src/dialogs_lists.rs b/crates/ui-egui/src/dialogs_lists.rs index 24baf130a4..ac6b8d0f3d 100644 --- a/crates/ui-egui/src/dialogs_lists.rs +++ b/crates/ui-egui/src/dialogs_lists.rs @@ -361,6 +361,11 @@ pub struct TrackForm { /// Hex, empty = automatic. pub changed_lines_color: String, pub track_formatting: bool, + pub move_from_mark: String, + /// Hex, empty = by author. + pub move_from_color: String, + pub move_to_mark: String, + pub move_to_color: String, } impl TrackForm { @@ -383,6 +388,10 @@ impl TrackForm { changed_lines: s("changedLines"), changed_lines_color: c("changedLinesColor"), track_formatting: b("trackFormatting"), + move_from_mark: s("moveFromMark"), + move_from_color: c("moveFromColor"), + move_to_mark: s("moveToMark"), + move_to_color: c("moveToColor"), } } @@ -403,6 +412,10 @@ impl TrackForm { "changedLines": self.changed_lines, "changedLinesColor": or(&self.changed_lines_color, "auto"), "trackFormatting": self.track_formatting, + "moveFromMark": self.move_from_mark, + "moveFromColor": or(&self.move_from_color, "byAuthor"), + "moveToMark": self.move_to_mark, + "moveToColor": or(&self.move_to_color, "byAuthor"), }) } } @@ -451,6 +464,44 @@ fn color(ui: &mut Ui, theme: &[wordcraft_doc::Rgb], value: &mut String, none_lab }); } +/// How insertions (and moved text where it went) can be marked: (value, label). +const INSERT_MARKS: &[(&str, &str)] = &[ + ("underline", "Underline"), + ("doubleUnderline", "Double Underline"), + ("bold", "Bold"), + ("italic", "Italic"), + ("strikethrough", "Strikethrough"), + ("colorOnly", "Color only"), + ("none", "None"), +]; + +/// How deletions (and the place moved text was taken from) can be marked, in Word for Mac's +/// order: (value, label). +const DELETE_MARKS: &[(&str, &str)] = &[ + ("none", "(none)"), + ("colorOnly", "Color only"), + ("bold", "Bold"), + ("italic", "Italic"), + ("underline", "Underline"), + ("doubleUnderline", "Double Underline"), + ("strikethrough", "Strikethrough"), + ("hidden", "Hidden"), + ("caret", "^"), + ("hash", "#"), + ("doubleStrikethrough", "Double Strikethrough"), +]; + +/// How moved text is marked where it went, in Word for Mac's order: (value, label). +const MOVE_TO_MARKS: &[(&str, &str)] = &[ + ("none", "(none)"), + ("colorOnly", "Color only"), + ("bold", "Bold"), + ("italic", "Italic"), + ("underline", "Underline"), + ("doubleUnderline", "Double Underline"), + ("strikethrough", "Strikethrough"), +]; + /// Track Changes Options. Returns true to close. pub fn track_options(app: &mut WordApp, ui: &mut Ui, f: &mut TrackForm) -> bool { let theme = app.session.doc.settings.theme_colors.clone(); @@ -482,37 +533,11 @@ pub fn track_options(app: &mut WordApp, ui: &mut Ui, f: &mut TrackForm) -> bool heading(ui, "Advanced"); egui::Grid::new("tco_advanced").num_columns(3).spacing(vec2(10.0, 6.0)).show(ui, |ui| { ui.label(tl!("Insertions:")); - choice( - ui, - "tco_ins", - &mut f.insert_mark, - &[ - ("underline", "Underline"), - ("doubleUnderline", "Double Underline"), - ("bold", "Bold"), - ("italic", "Italic"), - ("strikethrough", "Strikethrough"), - ("colorOnly", "Color only"), - ("none", "None"), - ], - ); + choice(ui, "tco_ins", &mut f.insert_mark, INSERT_MARKS); color(ui, &theme, &mut f.insert_color, "By author", None); ui.end_row(); ui.label(tl!("Deletions:")); - choice( - ui, - "tco_del", - &mut f.delete_mark, - &[ - ("strikethrough", "Strikethrough"), - ("doubleStrikethrough", "Double Strikethrough"), - ("hidden", "Hidden"), - ("caret", "^"), - ("hash", "#"), - ("underline", "Underline"), - ("colorOnly", "Color only"), - ], - ); + choice(ui, "tco_del", &mut f.delete_mark, DELETE_MARKS); color(ui, &theme, &mut f.delete_color, "By author", None); ui.end_row(); ui.label(tl!("Changed lines:")); @@ -525,6 +550,17 @@ pub fn track_options(app: &mut WordApp, ui: &mut Ui, f: &mut TrackForm) -> bool color(ui, &theme, &mut f.changed_lines_color, "Automatic", Some(wordcraft_layout::display::CHANGE_BAR)); ui.end_row(); }); + heading(ui, "Moves"); + egui::Grid::new("tco_moves").num_columns(3).spacing(vec2(10.0, 6.0)).show(ui, |ui| { + ui.label(tl!("Moved from:")); + choice(ui, "tco_move_from", &mut f.move_from_mark, DELETE_MARKS); + ui.push_id("tco_move_from_color", |ui| color(ui, &theme, &mut f.move_from_color, "By author", None)); + ui.end_row(); + ui.label(tl!("Moved to:")); + choice(ui, "tco_move_to", &mut f.move_to_mark, MOVE_TO_MARKS); + ui.push_id("tco_move_to_color", |ui| color(ui, &theme, &mut f.move_to_color, "By author", None)); + ui.end_row(); + }); ui.add_space(4.0); ui.checkbox(&mut f.track_formatting, tl!("Track formatting")); let (ok, cancel) = buttons(ui, tl!("OK")); @@ -599,4 +635,63 @@ mod tests { b.apply_prefs(saved); assert_eq!(b.session.prefs.markup.changed_lines, wordcraft_layout::display::ChangeBar::Right); } + + /// Gap #5: the Moves rows (Moved from / Moved to: mark and colour) start at green double + /// strikethrough and double underline, and OK applies them. There is no Track moves + /// checkbox (as in Word for Mac), so OK leaves the document's setting alone. + #[test] + fn track_changes_options_moves_rows_apply_marks_and_colours() { + use wordcraft_layout::display::{DeleteMark, InsertMark, MOVE_COLOR}; + let mut a = app(); + a.session.run("review.trackingOptions", &json!({"trackMoves": false})).unwrap(); + a.run("review.trackingOptions", json!({})).unwrap(); + let Some(Dialog::TrackOptions { form }) = &mut a.dialog else { panic!("dialog") }; + assert_eq!( + (form.move_from_mark.as_str(), form.move_from_color.clone(), form.move_to_mark.as_str(), form.move_to_color.clone()), + ("doubleStrikethrough", MOVE_COLOR.hex(), "doubleUnderline", MOVE_COLOR.hex()) + ); + form.move_from_mark = "strikethrough".into(); + form.move_from_color = "FF0000".into(); + form.move_to_mark = "bold".into(); + form.move_to_color = String::new(); + let params = form.params(); + assert!(params.get("trackMoves").is_none(), "{params}"); + a.dialog = None; + a.run("review.trackingOptions", params).unwrap(); + let m = &a.session.prefs.markup; + assert_eq!((m.move_from_mark, m.move_from_color), (DeleteMark::Strikethrough, Some(wordcraft_doc::Rgb(0xFF, 0, 0)))); + assert_eq!((m.move_to_mark, m.move_to_color), (InsertMark::Bold, None), "empty = by author"); + assert!(!a.session.doc.settings.track_moves, "the document setting is untouched"); + } + + /// Gap #5, Word for Mac 16.113.4 (2026-10-15): the Deletions and Moved from lists, and the + /// Moved to list, in Word's order; every choice is one `review.trackingOptions` takes. + #[test] + fn track_changes_options_mark_lists_follow_word() { + use wordcraft_layout::display::{DeleteMark, InsertMark}; + let values = |l: &[(&str, &str)]| l.iter().map(|(v, _)| v.to_string()).collect::>(); + assert_eq!( + values(super::DELETE_MARKS), + [ + "none", + "colorOnly", + "bold", + "italic", + "underline", + "doubleUnderline", + "strikethrough", + "hidden", + "caret", + "hash", + "doubleStrikethrough" + ] + ); + assert_eq!(values(super::MOVE_TO_MARKS), ["none", "colorOnly", "bold", "italic", "underline", "doubleUnderline", "strikethrough"]); + assert!(super::DELETE_MARKS.iter().all(|(v, _)| DeleteMark::parse(v).is_some())); + assert!(super::MOVE_TO_MARKS.iter().chain(super::INSERT_MARKS).all(|(v, _)| InsertMark::parse(v).is_some())); + let mut a = app(); + for (v, _) in super::DELETE_MARKS { + a.run("review.trackingOptions", json!({"deleteMark": v, "moveFromMark": v})).unwrap(); + } + } } diff --git a/crates/ui-egui/src/i18n/bg.tsv b/crates/ui-egui/src/i18n/bg.tsv index 9047f9f154..e80e7ac85c 100644 --- a/crates/ui-egui/src/i18n/bg.tsv +++ b/crates/ui-egui/src/i18n/bg.tsv @@ -2215,6 +2215,8 @@ Math AutoCorrect Автокорекция на математически изрази More >> Още >> Moves Премествания + Moved from: Преместено от: + Moved to: Преместено в: New document Нов документ No changes (read only) Без промени (само за четене) Only allow this kind of editing: Разрешаване само на този вид редактиране: diff --git a/crates/ui-egui/src/i18n/cs.tsv b/crates/ui-egui/src/i18n/cs.tsv index 21ae7e517b..c9c01432db 100644 --- a/crates/ui-egui/src/i18n/cs.tsv +++ b/crates/ui-egui/src/i18n/cs.tsv @@ -2108,6 +2108,8 @@ preview Title Ukázka Math AutoCorrect Automatické opravy matematiky More >> Více >> Moves Přesuny + Moved from: Přesunuto odtud: + Moved to: Přesunuto sem: New document Nový dokument No changes (read only) Žádné změny (jen pro čtení) Only allow this kind of editing: Povolit pouze tento typ úprav: diff --git a/crates/ui-egui/src/i18n/de.tsv b/crates/ui-egui/src/i18n/de.tsv index f37087f736..9656f3be91 100644 --- a/crates/ui-egui/src/i18n/de.tsv +++ b/crates/ui-egui/src/i18n/de.tsv @@ -1531,6 +1531,8 @@ Math AutoCorrect Math. AutoKorrektur More >> Mehr >> Moves Verschiebungen + Moved from: Verschoben von: + Moved to: Verschoben nach: New document Neues Dokument No changes (read only) Keine Änderungen (schreibgeschützt) Only allow this kind of editing: Nur diese Art der Bearbeitung zulassen: diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index 329640b13a..633e4c062d 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -1528,6 +1528,8 @@ Math AutoCorrect Autocorrección matemática More >> Más >> Moves Movimientos + Moved from: Movido desde: + Moved to: Movido a: New document Documento nuevo No changes (read only) Sin cambios (solo lectura) Only allow this kind of editing: Permitir solo este tipo de edición: diff --git a/crates/ui-egui/src/i18n/et.tsv b/crates/ui-egui/src/i18n/et.tsv index 930099c0a8..6708762074 100644 --- a/crates/ui-egui/src/i18n/et.tsv +++ b/crates/ui-egui/src/i18n/et.tsv @@ -1507,6 +1507,8 @@ Math AutoCorrect Matemaatika automaatparandus More >> Rohkem >> Moves Teisaldused + Moved from: Teisaldatud kohast: + Moved to: Teisaldatud kohta: New document Uus dokument No changes (read only) Muudatusi pole (kirjutuskaitstud) Only allow this kind of editing: Luba ainult seda tüüpi muutmist: diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv index 85bcb83450..e59db976c1 100644 --- a/crates/ui-egui/src/i18n/fr.tsv +++ b/crates/ui-egui/src/i18n/fr.tsv @@ -1413,6 +1413,8 @@ Math AutoCorrect Correction automatique mathématique More >> Plus >> Moves Déplacements + Moved from: Déplacé depuis : + Moved to: Déplacé vers : New document Nouveau document No changes (read only) Aucune modification (lecture seule) Only allow this kind of editing: Autoriser uniquement ce type de modification : diff --git a/crates/ui-egui/src/i18n/hr.tsv b/crates/ui-egui/src/i18n/hr.tsv index dbdfd641e6..1206d37d53 100644 --- a/crates/ui-egui/src/i18n/hr.tsv +++ b/crates/ui-egui/src/i18n/hr.tsv @@ -2652,3 +2652,5 @@ preview Title Naslov The measurement must be between {min} and {max}. Mjera mora biti između {min} i {max}. Recently Used Fonts Nedavno korišteni fontovi All Fonts Svi fontovi + Moved from: Premješteno iz: + Moved to: Premješteno u: diff --git a/crates/ui-egui/src/i18n/hu.tsv b/crates/ui-egui/src/i18n/hu.tsv index 7567e1b6c1..c676374a98 100644 --- a/crates/ui-egui/src/i18n/hu.tsv +++ b/crates/ui-egui/src/i18n/hu.tsv @@ -1868,6 +1868,8 @@ Math AutoCorrect Matematikai automatikus javítás More >> Több >> Moves Áthelyezések + Moved from: Áthelyezve innen: + Moved to: Áthelyezve ide: New document Új dokumentum No changes (read only) Nincs módosítás (csak olvasható) Only allow this kind of editing: Csak ez a fajta szerkesztés engedélyezett: diff --git a/crates/ui-egui/src/i18n/it.tsv b/crates/ui-egui/src/i18n/it.tsv index 01b0c3c73e..1e3735300b 100644 --- a/crates/ui-egui/src/i18n/it.tsv +++ b/crates/ui-egui/src/i18n/it.tsv @@ -1598,6 +1598,8 @@ Math AutoCorrect Correzione automatica formule More >> Altro >> Moves Spostamenti + Moved from: Spostato da: + Moved to: Spostato in: New document Nuovo documento No changes (read only) Nessuna modifica (sola lettura) Only allow this kind of editing: Consenti solo questo tipo di modifica: diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index 1692407caf..c6da9c47bb 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -1488,6 +1488,8 @@ Math AutoCorrect 数式オートコレクト More >> オプション >> Moves 移動 + Moved from: 移動元: + Moved to: 移動先: New document 新規文書 No changes (read only) 変更不可 (読み取り専用) Only allow this kind of editing: 次の種類の編集のみを許可: diff --git a/crates/ui-egui/src/i18n/pl.tsv b/crates/ui-egui/src/i18n/pl.tsv index 965bf5a477..f9273e023f 100644 --- a/crates/ui-egui/src/i18n/pl.tsv +++ b/crates/ui-egui/src/i18n/pl.tsv @@ -1892,6 +1892,8 @@ Move Up Przenieś w górę Move with Text Przenoś z tekstem Moves Przeniesienia + Moved from: Przeniesiono z: + Moved to: Przeniesiono do: New comments and tracked changes are recorded under this name and these initials. Nowe komentarze i śledzone zmiany są zapisywane pod tą nazwą i tymi inicjałami. New document Nowy dokument New word Nowy wyraz diff --git a/crates/ui-egui/src/i18n/pt-br.tsv b/crates/ui-egui/src/i18n/pt-br.tsv index d40fa3bb07..af16b70f64 100644 --- a/crates/ui-egui/src/i18n/pt-br.tsv +++ b/crates/ui-egui/src/i18n/pt-br.tsv @@ -1500,6 +1500,8 @@ Math AutoCorrect Autocorreção de matemática More >> Mais >> Moves Movimentações + Moved from: Movido de: + Moved to: Movido para: New document Novo documento No changes (read only) Sem alterações (somente leitura) Only allow this kind of editing: Permitir somente este tipo de edição: diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv index 50500425fc..76a642b874 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -2090,6 +2090,8 @@ preview Title Пример Math AutoCorrect Автозамена математическими символами More >> Больше >> Moves Перемещения + Moved from: Перемещено из: + Moved to: Перемещено в: New document Новый документ No changes (read only) Без изменений (только чтение) Only allow this kind of editing: Разрешить только такой способ редактирования: diff --git a/crates/ui-egui/src/i18n/sr-latn.tsv b/crates/ui-egui/src/i18n/sr-latn.tsv index e80d2e36d1..1b4c36ebbf 100644 --- a/crates/ui-egui/src/i18n/sr-latn.tsv +++ b/crates/ui-egui/src/i18n/sr-latn.tsv @@ -1498,6 +1498,8 @@ Math AutoCorrect Automatska ispravka matematike More >> Više >> Moves Premeštanja + Moved from: Premešteno odavde: + Moved to: Premešteno ovde: New document Novi dokument No changes (read only) Bez izmena (samo za čitanje) Only allow this kind of editing: Dozvoli samo ovu vrstu uređivanja: diff --git a/crates/ui-egui/src/i18n/sr.tsv b/crates/ui-egui/src/i18n/sr.tsv index 2111d1ebcb..0c10922f2f 100644 --- a/crates/ui-egui/src/i18n/sr.tsv +++ b/crates/ui-egui/src/i18n/sr.tsv @@ -1502,6 +1502,8 @@ Math AutoCorrect Аутоматска исправка математике More >> Више >> Moves Премештања + Moved from: Премештено одавде: + Moved to: Премештено овде: New document Нови документ No changes (read only) Без измена (само за читање) Only allow this kind of editing: Дозволи само ову врсту уређивања: diff --git a/crates/ui-egui/src/i18n/sv.tsv b/crates/ui-egui/src/i18n/sv.tsv index 0ed9b66959..5244422436 100644 --- a/crates/ui-egui/src/i18n/sv.tsv +++ b/crates/ui-egui/src/i18n/sv.tsv @@ -2667,3 +2667,5 @@ preview Title Titel The measurement must be between {min} and {max}. Måttet måste vara mellan {min} och {max}. Recently Used Fonts Senast använda typsnitt All Fonts Alla typsnitt + Moved from: Flyttat från: + Moved to: Flyttat till: diff --git a/crates/ui-egui/src/i18n/tr.tsv b/crates/ui-egui/src/i18n/tr.tsv index 1ae4d1fa40..46170eb538 100644 --- a/crates/ui-egui/src/i18n/tr.tsv +++ b/crates/ui-egui/src/i18n/tr.tsv @@ -2092,6 +2092,8 @@ preview Title Örnek Math AutoCorrect Matematik Otomatik Düzelt More >> Daha fazla >> Moves Taşımalar + Moved from: Taşındı (kaynak): + Moved to: Taşındı (hedef): New document Yeni belge No changes (read only) Değişiklik yok (salt okunur) Only allow this kind of editing: Yalnızca bu tür düzenlemeye izin ver: diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index a29fd94bea..1b0e14f1b2 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -1500,6 +1500,8 @@ Math AutoCorrect Математичне автовиправлення More >> Більше >> Moves Переміщення + Moved from: Переміщено звідси: + Moved to: Переміщено сюди: New document Новий документ No changes (read only) Без змін (лише читання) Only allow this kind of editing: Дозволити лише такий тип редагування: diff --git a/crates/ui-egui/src/i18n/vi.tsv b/crates/ui-egui/src/i18n/vi.tsv index efdfe92290..351d7ee1d4 100644 --- a/crates/ui-egui/src/i18n/vi.tsv +++ b/crates/ui-egui/src/i18n/vi.tsv @@ -2208,6 +2208,8 @@ Math AutoCorrect Tự động sửa ký hiệu toán More >> Mở rộng >> Moves Nội dung đã di chuyển + Moved from: Đã di chuyển từ: + Moved to: Đã di chuyển đến: New document Tài liệu mới No changes (read only) Không cho phép thay đổi (chỉ đọc) Only allow this kind of editing: Chỉ cho phép kiểu chỉnh sửa sau: diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv index 217e48fbdd..ad295688d8 100644 --- a/crates/ui-egui/src/i18n/zh-hans.tsv +++ b/crates/ui-egui/src/i18n/zh-hans.tsv @@ -1500,6 +1500,8 @@ Math AutoCorrect 数学符号自动更正 More >> 更多 >> Moves 移动 + Moved from: 移动自: + Moved to: 移动到: New document 新文档 No changes (read only) 不允许任何更改(只读) Only allow this kind of editing: 仅允许此类编辑: diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv index 367e29907f..032bef0e90 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -1489,6 +1489,8 @@ Math AutoCorrect 數學自動校正 More >> 更多 >> Moves 移動 + Moved from: 移動自: + Moved to: 移動到: New document 新文件 No changes (read only) 不允許任何變更 (唯讀) Only allow this kind of editing: 僅允許此類編輯: diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index a39b7b0137..37036dd534 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -1,6 +1,6 @@ # File-format parity with Microsoft Word -> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-18 · **Change:** trivial (legacy outline, shadow, emboss and imprint text effects drawn, #2534; previously trivial: DOCX glossary documents (building blocks) read, kept and written; previously trivial: cached field results keep their visible run formatting; previously trivial: cached REF results retain hyperlink destinations; previously trivial: CLI conversions can explicitly refresh fields; previously trivial: a copied equation also goes in the clipboard's MathML formats, #1297; previously trivial: a pasted HTML `
` is an indented paragraph, not the Quote style, #1270); previously trivial: every table style part round-trips, #714; previously trivial: VML freeforms read and kept on save; previously trivial: multi-paragraph field instructions no longer add blank lines; previously trivial: `flowChartOr` draws its circle and cross spokes, as in Word; previously trivial: RTF: Mac OS Roman and DOS text, #1653; previously trivial: VML text boxes keep their internal margins, fit to their text and vertical alignment; hidden drawings not shown; previously trivial: PDF export draws horizontal and vertical lines inside groups, #1474; previously trivial: symbol-font text exported as Unicode, #776; previously trivial: text-box margins, Resize shape to fit text and freeforms in groups, #743; previously trivial: formatted paste from the system clipboard's HTML and RTF, #687; previously trivial: independent complex-script formatting inheritance and round-trip regression tests; previously trivial: import/export fixes for TXT, RTF, ODT, HTML and LaTeX, #433–#440; previously major: first version; every format Word reads or writes, measured from the readers and writers on origin/main) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4, plus Word for Windows' extra formats +> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-18 · **Change:** trivial (`w:doNotTrackMoves` round-trips and turns move tracking off; previously trivial: legacy outline, shadow, emboss and imprint text effects drawn, #2534; previously trivial: DOCX glossary documents (building blocks) read, kept and written; previously trivial: cached field results keep their visible run formatting; previously trivial: cached REF results retain hyperlink destinations; previously trivial: CLI conversions can explicitly refresh fields; previously trivial: a copied equation also goes in the clipboard's MathML formats, #1297; previously trivial: a pasted HTML `
` is an indented paragraph, not the Quote style, #1270); previously trivial: every table style part round-trips, #714; previously trivial: VML freeforms read and kept on save; previously trivial: multi-paragraph field instructions no longer add blank lines; previously trivial: `flowChartOr` draws its circle and cross spokes, as in Word; previously trivial: RTF: Mac OS Roman and DOS text, #1653; previously trivial: VML text boxes keep their internal margins, fit to their text and vertical alignment; hidden drawings not shown; previously trivial: PDF export draws horizontal and vertical lines inside groups, #1474; previously trivial: symbol-font text exported as Unicode, #776; previously trivial: text-box margins, Resize shape to fit text and freeforms in groups, #743; previously trivial: formatted paste from the system clipboard's HTML and RTF, #687; previously trivial: independent complex-script formatting inheritance and round-trip regression tests; previously trivial: import/export fixes for TXT, RTF, ODT, HTML and LaTeX, #433–#440; previously major: first version; every format Word reads or writes, measured from the readers and writers on origin/main) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4, plus Word for Windows' extra formats Every format Word opens or saves, what WordCraft does with it, and how it's tested. Fidelity is the share of a typical real-world file's content and formatting that survives (read: shows correctly; @@ -74,6 +74,8 @@ Measured by grepping `crates/docx/src/read` and `src/write` for the OOXML elemen | Footnotes, endnotes, comments | ✅ | ✅ | The document's own note separators and continuation notice (`w:type` `separator`, `continuationSeparator`, `continuationNotice`) round-trip as ids -1, 0 and 1 (#352); note options (`w:pos`, `w:numFmt`, `w:numStart`, `w:numRestart`) round-trip in the settings and in each `w:sectPr` (#385); `commentsExtended` written; `commentsIds` (durable ids) and `commentsExtensible` (UTC dates) read and regenerated consistently with the written paragraph ids; `people.xml` carried verbatim | | Tracked insertions and deletions (`w:ins`/`w:del`), paragraph-mark revisions | ✅ | ✅ | #125, #244 | | Formatting revisions (`w:rPrChange`, `w:pPrChange`, `w:sectPrChange`, `w:tblPrChange`, `w:trPrChange`, `w:tcPrChange`, `w:numberingChange`) | ✅ | ✅ | #41: kept on open and save, schema order (change element last); changes inside `styles.xml` are dropped. RTF/ODT ignore them | +| Move tracking (`w:moveFrom`/`w:moveTo`, `w:moveFromRangeStart/End`, `w:moveToRangeStart/End`), moved paragraph marks | ✅ | ✅ | #400: both ends linked by the range name; ranges rewritten around each moved stretch (ids renumbered); a move missing an end or a range end opens as a plain deletion/insertion. `w:doNotTrackMoves` (Track moves off) round-trips; with it a tracked cut and paste records a plain deletion and insertion. ODT/PDF export the result (moved-away text hidden like deletions); RTF writes a move as a plain tracked deletion and insertion | +| Fields (`w:fldChar`, `w:fldSimple`), TOC, cross-references, `ADDIN` citations | ✅ | ✅ | Field codes Word supports but we don't evaluate keep their cached result. Imported TOCs preserve actual result boundaries, formatted entries and nested fields without relying on TOC styles. `EQ \o` (overstrike, enclosed characters) is drawn; enclosed characters are written as Word stores them, their style in the code's run sizes (#297); a cached picture (`INCLUDEPICTURE`) stays as the content of a range field (#465) | | Fields (`w:fldChar`, `w:fldSimple`), TOC, cross-references, `ADDIN` citations | ✅ | ✅ | Field codes Word supports but we don't evaluate keep their cached result. Textual `REF` fields with `\h` export internal PDF links without refreshing cached text; existing enclosing links take precedence. `wordcraft-cli convert --update-fields` explicitly runs field updates before saving; ordinary conversion preserves caches and locked fields stay unchanged. Instruction-only paragraphs carry hidden marks instead of blank lines; an empty result paragraph keeps its line. Imported TOCs preserve actual result boundaries, formatted entries and nested fields without relying on TOC styles. `EQ \o` (overstrike, enclosed characters) is drawn; enclosed characters are written as Word stores them, their style in the code's run sizes (#297); a cached picture (`INCLUDEPICTURE`) stays as the content of a range field (#465) | | Move tracking (`w:moveFrom`/`w:moveTo`, `w:moveFromRangeStart/End`, `w:moveToRangeStart/End`), moved paragraph marks | ✅ | ✅ | #400: both ends linked by the range name; ranges rewritten around each moved stretch (ids renumbered); a move missing an end or a range end opens as a plain deletion/insertion. ODT/PDF export the result (moved-away text hidden like deletions); RTF writes a move as a plain tracked deletion and insertion | | Content controls (`w:sdt`) | ✅ | ✅ | Block, inline, row and cell level, nested; title, tag, id, lock, placeholder, temporary, rich/plain text, `w14:checkbox`, combo box, drop-down list, date, picture, building block gallery, `w15` repeating sections; other `w:sdtPr` children (data binding, appearance) kept verbatim (#381). A TOC's control is rebuilt by the TOC. Replace All respects content-edit and control-deletion locks and refuses a batch before making any edit | @@ -223,6 +225,7 @@ Imported text fields use the first nonempty cached result run's formatting on th | 2026-10-16 | trivial | Cached text fields retain result-run formatting instead of field-begin formatting. | | 2026-10-17 | trivial | Glossary document (building blocks) read into the model, kept verbatim while unchanged and written from the model once edited; split from the `w:altChunk`/sub-documents row | | 2026-10-16 | trivial | Legacy `w:outline`, `w:shadow`, `w:emboss` and `w:imprint` run effects drawn on screen and in PDFs (#2534) | +| 2026-10-15 | trivial | `w:doNotTrackMoves` read and written (Track moves off: a tracked cut and paste is a deletion and an insertion) | | 2026-10-13 | trivial | A cell's explicit top vertical alignment (`w:vAlign w:val="top"`) is kept on read and write and beats a table style that centres or bottom-aligns (#1288) | | 2026-10-14 | trivial | Row table border exceptions survive DOCX saves | | 2026-10-12 | trivial | Fields keep `w:dirty` through DOCX round trips — on buffered fields and range-field begin markers alike — and computing a result (Update Field, Update Table) clears it like Word's F9, so a generator's placeholder TOC still refreshes when Word opens the file until WordCraft replaces the placeholder (#877) | diff --git a/docs/gaps.md b/docs/gaps.md index f03ad321cc..1f70387ed1 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -28,7 +28,7 @@ Document renaming: desktop renaming requires a filesystem that supports hard lin | 3 | **Crashes and freezes in the field**: startup crash on Intel UHD Graphics (#170, open), a crash on macOS 12.7.6 Intel (#979, open) (lag on GPUs without a DirectX 12 driver fixed, #316; 32-bit Windows exited at launch because DirectX 12 graphics pipeline creation failed, now OpenGL), freezes (#59 Thai fonts and #31 font selector, both open; #77 closed), broken shape tool (#142, open); the Windows 11 no-write-without-admin (#218) and unmovable images (#82) reports are closed (pictures and shapes in Behind Text drag through the text over them, #1313); memory growth on every font change fixed (#848: the font list no longer loads each family it previews) | B | 197 open issues (2026-10-12), among them the crash and freeze reports #170, #979, #59 and #31 | Anyone hit loses work or trust; the never-crash standard covers panics, not hangs or GPU driver faults | 20–30 | [target-app-parity.md](target-app-parity.md) (Stability) | | 4 | **Pagination differs from Word**: Aptos has no metric-matched substitute; legacy compatibility options only partially covered; no page-by-page comparison harness | B | Legacy same-style cell spacing preserved for measured modes 11/12/14, with mode 15 and non-cell controls; Contextual spacing corrected in body/header probes and repeated table-cell runs; [layout-parity.md](layout-parity.md); [typing-parity.md](typing-parity.md) Known gaps; stray labels on page-break-only list paragraphs fixed multi-paragraph field-code blank lines fixed; Arial Unicode MS no longer selects monospace substitutes | Page counts and line breaks differ, which matters for forms, legal and academic work | 30–50 + owner (font) | [layout-parity.md](layout-parity.md) | | 2 | **Remaining native ink forms are dropped on open and lost on save, silently**; OLE objects show only their picture | B | Drawing-level native ink, its optional picture fallback, and direct run-level content parts are preserved; ink nested in groups/canvases remains unsupported. Drawing canvases are read and written (#344); group shapes (`wpg`) read and written (#267). Charts and SmartArt are drawn (#292) and, with OLE objects, written back with all their parts on save (#319) | Documents with diagrams drawn in Word or pen notes lose content without warning | 4–8 to preserve remaining native ink forms; 45–70 to render and edit charts and SmartArt (#13) | [file-format-parity.md](file-format-parity.md) | -| 5 | **Content controls** landed (#381): `w:sdt` kept, shown and edited (Developer tab, Design Mode, Properties). Move tracking landed (#400): `w:moveFrom`/`w:moveTo` with their named ranges round-trip, a tracked cut and paste of a paragraph or sentence records a move, either end accepts/rejects both, green double strike/underline markup and "Moved from/to" balloons; still missing: drag-and-drop moves (no text drag yet) and move options in Track Changes Options. Formatting revisions landed (#41): `w:rPrChange`/`w:pPrChange`/`w:tblPrChange`/`w:trPrChange`/`w:tcPrChange`/`w:sectPrChange`/`w:numberingChange` round-trip, are recorded while tracking, accepted/rejected and shown in balloons; still missing: tracked style-definition changes and table/section changes made in WordCraft. Opaque custom XML backing stores now survive saves; binding evaluation remains absent. Replace All now checks content-control locks before editing any match; a refused batch preserves the document and undo history. | B | List-label pitch corrected in synthetic metric controls; Issues #41, #400; source grep | Legal and editorial review workflows; templates with form controls | 5–8 | [file-format-parity.md](file-format-parity.md) | +| 5 | **Content controls** landed (#381): `w:sdt` kept, shown and edited (Developer tab, Design Mode, Properties). Move tracking landed (#400): `w:moveFrom`/`w:moveTo` with their named ranges round-trip, a tracked cut and paste of a paragraph or sentence records a move, either end accepts/rejects both, green double strike/underline markup and "Moved from/to" balloons; Track Changes Options has the Moves rows (Moved from / Moved to mark and colour) and `w:doNotTrackMoves` (Track moves off: a cut and paste is a deletion and an insertion) round-trips; still missing: drag-and-drop moves (no text drag yet). Formatting revisions landed (#41): `w:rPrChange`/`w:pPrChange`/`w:tblPrChange`/`w:trPrChange`/`w:tcPrChange`/`w:sectPrChange`/`w:numberingChange` round-trip, are recorded while tracking, accepted/rejected and shown in balloons; still missing: tracked style-definition changes and table/section changes made in WordCraft. Opaque custom XML backing stores now survive saves; binding evaluation remains absent. Replace All now checks content-control locks before editing any match; a refused batch preserves the document and undo history. | B | List-label pitch corrected in synthetic metric controls; Issues #41, #400; source grep | Legal and editorial review workflows; templates with form controls | 5–8 | [file-format-parity.md](file-format-parity.md) | | 6 | **Print dialog depth**: desktop printer choice, copies, collation, duplex and grayscale submit through craft-print; File › Print retains preview, page ranges and odd/even (#641); driver tray/options and pages per sheet remain; web prints through the browser (#209) | B | `ui.print` → `apps/wordcraft/src/print.rs`; issue #15 | Everyone who prints | 5–8 | [hardware-parity.md](hardware-parity.md) | | 7 | **Dialog depth**: 58 modal dialogs vs Word's ~100 (Split Cells and Convert Text to Table match Word's observed limits and defaults) | B | `Dialog` enum in `crates/ui-egui/src/dialogs.rs` | Power users reach for dialogs the ribbon alone doesn't cover | 11–19 | [ui-parity.md](ui-parity.md) | | 9 | **Password-protected `.doc` files** (AES) can't be opened; password-protected `.docx` opens and saves (agile encryption, #55 closed by #281) | | `crates/docx/src/crypt.rs`; `crates/docbin/src/lib.rs` opens XOR/RC4/RC4 CryptoAPI `.doc` with a password (#1079, untested on Word-written files; AES `.doc` refused) | Corporate and legal users with old files | 3–5 | [file-format-parity.md](file-format-parity.md) | @@ -91,6 +91,7 @@ The Language dialog reads effective character formatting, including document def | Date | Change | Summary | |---|---|---| | 2026-10-17 | trivial | Glossary/building blocks closed (DOCX glossary read, kept and written); `w:altChunk` and sub-documents remain | +| 2026-10-15 | trivial | #5 narrowed: Track Changes Options › Moves (Moved from / Moved to marks and colours) and Track moves (`w:doNotTrackMoves`) landed; drag-and-drop moves remain | | 2026-10-17 | trivial | Language dialog keeps inherited proofing language and no-proof choices (#2717); readiness estimates unchanged | | 2026-10-17 | trivial | Explicit DrawingML preset/custom outline dashes survive import/save and paint in raster/PDF, scaled to the effective stroke width even for zero-width paths. Synthetic controls; theme-inherited dashes and line caps remain unverified. | | 2026-10-15 | trivial | `wordcraft-cli convert --update-fields` explicitly refreshes unlocked fields before conversion; ordinary conversion keeps cached results. Field refresh can change pagination | diff --git a/docs/ui-parity.md b/docs/ui-parity.md index 290d77212a..3486a8e10b 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -70,6 +70,7 @@ Find selects the Results tab, including when it prefills selected text. Editing | 2026-10-15 | trivial | Font box (ribbon and mini toolbar): a Recently Used Fonts section at the top of the list holds the fonts applied from the box, the mini toolbar or the Font dialog, most recent first, once each, ten at most, kept between runs in `ui.json`; All Fonts follows; the sections show while nothing is typed and a query lists its matches only; Up/Down walk the recent entries then the list, Enter applies the highlighted one; a recent font no longer installed stays listed (#2068) | | 2026-10-16 | trivial | Web Layout text fills the window width and re-wraps once a resize settles or at once on a zoom, instead of a fixed 800 pt column; Draft and Outline keep their 800 pt column (#1876) | | 2026-10-18 | trivial | Different First Page: double-clicking page 1's header while editing another one goes into page 1's own; Insert › Header/Footer/Page Number and Edit Header/Footer use the header the caret's page shows (first, even or default) instead of always the default (#2826) | +| 2026-10-15 | trivial | Track Changes Options › Moves: Moved from and Moved to rows (mark and colour; green double strikethrough and double underline by default, as in Word for Mac, which has no Track moves checkbox). `review.trackingOptions {"moveFromMark", "moveFromColor", "moveToMark", "moveToColor"}`, saved per user; `"trackMoves"` sets the document's setting. Mark lists in Word for Mac's order; Deletions and Moved from gain (none), Bold, Italic and Double underline; moved text marked (none) is plain text | | 2026-10-17 | trivial | Language dialog keeps inherited proofing language and no-proof choices (#2717); readiness estimates unchanged | | 2026-10-16 | trivial | Create Source: automatic Tag like Word's (`Smi20`, `Kim001`); Edit Source keeps the Tag and citations show the new Author and Year (#2094; Word for Mac 16.113.4, user-checked) | | 2026-10-18 | trivial | Split Cells merged-cell limits and equal parts, Convert Text to Table Initial column width, both from the Word check (#2592) | From 6d83d13257825fabd9eb203041bd3e0b39d8f38e Mon Sep 17 00:00:00 2001 From: Dmitrii Khlapov Date: Sun, 18 Oct 2026 10:24:53 +0200 Subject: [PATCH 16/38] Update Field works in headers, footers, notes and text boxes; printing updates every field (#1706) (#1780) * #1706: WIP * #1706: Update Fields refreshes fields in headers, footers and text boxes Co-Authored-By: Claude Opus 5.5 # Conflicts: # ROADMAP.md # Conflicts: # ROADMAP.md * #1706: review fixes Revision-history row, update_fields doc comment, test covers body field and locked header field. Co-Authored-By: Claude Opus 5.5 * #1706: update the fields of the part being edited, as in Word Word check: F9 with the caret in the body leaves header fields alone; with the caret in a header it updates that header. SEQ, citations and TOC run only from the body. Co-Authored-By: Claude Opus 5.5 * #1706: printing updates every field in the document references.updateFields takes {"all"?: bool}: the body (with SEQ, citations, TOC) plus every part story, wherever the caret is. Printing passes it. Co-Authored-By: Claude Opus 5.5 * #1706: ROADMAP status line after rebase Co-Authored-By: Claude Opus 5.5 * #1706: tests build the field form value added on main Co-Authored-By: Claude Opus 5.5 * #1706: the test's fields take main's new dirty flag Co-Authored-By: Claude Opus 5.5 (1M context) --------- Co-authored-by: Dmitrii Khlapov <19991457+dkhlapov@users.noreply.github.com> Co-authored-by: Claude Opus 5.5 Co-authored-by: Brandon Thomas --- ROADMAP.md | 3 + crates/engine/src/cmd/references.rs | 112 ++++++++++++++++++++++++++-- crates/ui-egui/src/printing.rs | 2 +- 3 files changed, 109 insertions(+), 8 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 28d0aa821f..42b49c97d0 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -185,6 +185,7 @@ Ranked, to beta (detail and remaining milestones in [docs/roadmap.md](docs/roadm | 2026-10-13 | Cut copies to the system clipboard however it is run (#1265): the ribbon, the context menu and the native menu cut deleted the words without copying them (only the Cmd+X event wrote the clipboard, so Paste re-inserted the previous copy); Copy/Cut publication is centralised in one sink and every entry point is tested | | 2026-10-13 | Insert › Bookmark deletes bookmarks (#1236): the dialog is laid out as Word's, with Add, Delete and Go To beside the list, Sort by Name or Location and Hidden bookmarks; it opens on the bookmark around the caret, Delete and Go To keep it open, and Go To selects the bookmark's text. `insert.deleteBookmark {name}` removes both ends of a bookmark, also one across paragraphs (adding a bookmark under a name in use left such a one's end behind) | | 2026-10-12 | Password-protected Word 97-2003 `.doc` files open after a password prompt (XOR obfuscation, RC4, RC4 CryptoAPI; wrong passwords are rejected by the stored verifier, #1079); written from the public specs and tested on synthetic files only, AES-encrypted `.doc` files are still refused | +| 2026-10-14 | Update Field works inside headers, footers, notes and text boxes, on the part being edited, as in Word (#1706); with the caret in the body it still leaves them alone, and printing updates every field in the document | | 2026-10-12 | Comments can show only for the selected text (#1055): Track Changes Options › Comments › "Only for the selected text" drops the margin column, and the comment under the caret, or the clicked one, floats beside the page (over its edge in a narrow window). The default stays "In the margin", as in Word; `review.trackingOptions {"commentDisplay"}` for agents | | 2026-10-12 | Clicking inside a selected comment's balloon moves the caret in its text (#1013); the click used to leave the balloon, so the caret could only be moved with the arrow keys. A press on any balloon now belongs to the balloon, not the page behind it | | 2026-10-12 | Page background pictures and textures (Design › Page Color › Fill Effects) are drawn (tiled or stretched) and kept on save instead of being dropped from the package (#999); PDF export paints them too | @@ -454,6 +455,8 @@ Ranked, to beta (detail and remaining milestones in [docs/roadmap.md](docs/roadm | 2026-10-15 | trivial | Navigation Find and query edits expose Results (#2096) | | 2026-10-15 | trivial | Progress log: numbered headings (#1793) | | 2026-10-15 | trivial | Add the missing `-->` → `→` AutoCorrect entry (#2223), with typing, undo and preference regression coverage; existing arrow bullets unchanged | +| 2026-10-15 | trivial | Progress log: Update Field updates the part being edited, as in Word (#1706) | +| 2026-10-14 | trivial | Progress log: Update Fields reaches headers, footers and text boxes (#1706) | | 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/engine/src/cmd/references.rs b/crates/engine/src/cmd/references.rs index fdc5042e36..d77cad0e3a 100644 --- a/crates/engine/src/cmd/references.rs +++ b/crates/engine/src/cmd/references.rs @@ -74,11 +74,12 @@ pub fn specs() -> Vec { CommandSpec::new("references.caption", "Insert Caption", "References › Captions", caption) .params(r#"{"label"?: "Figure|Table|Equation|any label", "text"?: string, "position"?: "below|above", "excludeLabel"?: bool, "format"?: "ARABIC|alphabetic|ALPHABETIC|roman|ROMAN", "chapter"?: heading level 1-9, "separator"?: "-|.|:|—|–"}"#), // F9: update fields; the selected ones that showed their code show their result again. - CommandSpec::new("references.updateFields", "Update Field", "References", |s, _| { - update_fields(s)?; + CommandSpec::new("references.updateFields", "Update Field", "References", |s, v| { + update_fields_in(s, v.get("all").and_then(Value::as_bool).unwrap_or(false))?; super::fields::show_results(s)?; sel_result(s) }) + .params(r#"{"all"?: bool (the body and every header, footer, note and text box, wherever the caret is; default: the story the caret is in)}"#) .key("F9"), ] } @@ -861,9 +862,22 @@ fn has_switch(instr: &str, sw: char) -> bool { }) } -/// Update fields in the body: dates, SEQ numbering, cross-references, TOC. +/// Update fields of the story being edited, as Word's F9 does: dates, REF/PAGEREF, TITLE, AUTHOR +/// and NUMWORDS in the body, or in the header, footer, note or text box the caret is in (the body's +/// fields stay as they are then). SEQ numbering, citations and TOC are body-only, so they run only +/// when the caret is in the body. pub fn update_fields(s: &mut Session) -> Result<(), CmdError> { - update_seq(s)?; + update_fields_in(s, false) +} + +/// [`update_fields`], or with `all` the whole document: the body (with SEQ, citations and TOC) and +/// every part story, wherever the caret is (what printing needs). +pub fn update_fields_in(s: &mut Session, all: bool) -> Result<(), CmdError> { + let caret_story = s.sel.focus.story; + let in_body = all || caret_story == StoryRef::Body; + if in_body { + update_seq(s)?; + } let mut pages: std::collections::HashMap = Default::default(); let formats: Vec = s.doc.sections().into_iter().map(|(_, sec)| sec.page_num_format).collect(); let marks = s.doc.bookmarks(); @@ -879,8 +893,10 @@ pub fn update_fields(s: &mut Session) -> Result<(), CmdError> { .unwrap_or((1, NumFormat::Decimal)); pages.entry(name).or_insert(page_info); } - for path in s.doc.para_paths(StoryRef::Body) { - let Some(p) = s.doc.para(StoryRef::Body, &path) else { continue }; + let stories: Vec = + if all { std::iter::once(StoryRef::Body).chain(s.doc.parts.keys().map(|k| StoryRef::Part(*k))).collect() } else { vec![caret_story] }; + for (story, path) in stories.into_iter().flat_map(|st| s.doc.para_paths(st).into_iter().map(move |p| (st, p))).collect::>() { + let Some(p) = s.doc.para(story, &path) else { continue }; if !p.objects.iter().any(|o| matches!(o, InlineObject::Field { .. })) { continue; } @@ -933,7 +949,7 @@ pub fn update_fields(s: &mut Session) -> Result<(), CmdError> { if updates.is_empty() { continue; } - let para = s.doc.para_mut(StoryRef::Body, &path)?; + let para = s.doc.para_mut(story, &path)?; for (k, r) in updates { if let Some(InlineObject::Field { result, dirty, .. }) = para.objects.get_mut(k) { // A computed result is no longer stale: Word's F9 clears `w:dirty` too. @@ -943,6 +959,9 @@ pub fn update_fields(s: &mut Session) -> Result<(), CmdError> { } para.touch(); } + if !in_body { + return Ok(()); + } super::citations::update_citations(s)?; update_toc(s) } @@ -1198,4 +1217,83 @@ mod tests { assert_eq!(res[3], ("PAGEREF TargetMark \\p".into(), "above".into())); assert_eq!(res[4], ("REF TargetMark \\p".into(), "above".into())); } + + /// A document with a body AUTHOR field and a header holding AUTHOR, NUMWORDS and a locked TITLE, + /// all stale; the author is "Ada". + fn doc_with_header_fields() -> (wordcraft_doc::Document, u32) { + use wordcraft_doc::{CharProps, Paragraph, PartKind}; + let field = |instr: &str, locked: bool| InlineObject::Field { + instr: instr.into(), + result: "stale".into(), + locked, + dirty: false, + code: false, + form: String::new(), + }; + let mut doc = wordcraft_doc::Document::from_text("one two three"); + doc.core.creator = "Ada".into(); + let mut p = Paragraph::new(); + for (instr, locked) in [("AUTHOR", false), ("NUMWORDS", false), ("TITLE", true)] { + let off = p.len(); + p.insert_object(off, field(instr, locked), &CharProps::default()).unwrap(); + } + let body_path = doc.para_paths(StoryRef::Body).remove(0); + doc.para_mut(StoryRef::Body, &body_path).unwrap().insert_object(0, field("AUTHOR", false), &CharProps::default()).unwrap(); + let hdr = doc.add_part(PartKind::Header, vec![std::sync::Arc::new(wordcraft_doc::Block::Para(p))]); + (doc, hdr) + } + + fn story_fields(s: &Session, story: StoryRef) -> Vec<(String, String)> { + let mut out = Vec::new(); + for path in s.doc.para_paths(story) { + for o in &s.doc.para(story, &path).unwrap().objects { + if let InlineObject::Field { instr, result, .. } = o { + out.push((instr.clone(), result.clone())); + } + } + } + out + } + + /// #1706 (Word check): Update Field with the caret in the body leaves a header's fields alone. + #[test] + fn update_fields_in_the_body_leaves_headers_alone() { + let (doc, hdr) = doc_with_header_fields(); + let mut s = Session::new(doc); + s.run("references.updateFields", &json!({})).unwrap(); + assert_eq!(story_fields(&s, StoryRef::Body)[0], ("AUTHOR".to_string(), "Ada".to_string())); + assert!(story_fields(&s, StoryRef::Part(hdr)).iter().all(|(_, r)| r == "stale"), "{:?}", story_fields(&s, StoryRef::Part(hdr))); + } + + /// #1706: with the caret in a header (or other part), Update Field refreshes that part's + /// fields, but not locked ones and not the body's. + #[test] + fn update_fields_in_a_header_updates_that_header() { + let (doc, hdr) = doc_with_header_fields(); + let mut s = Session::new(doc); + let path = s.doc.para_paths(StoryRef::Part(hdr)).remove(0); + s.sel = crate::session::Selection::caret(Pos::new(StoryRef::Part(hdr), path, 0)); + s.run("references.updateFields", &json!({})).unwrap(); + let got = story_fields(&s, StoryRef::Part(hdr)); + assert_eq!(got.len(), 3, "{got:?}"); + assert_eq!(got[0].1, "Ada"); + assert_eq!(got[1].1, s.doc.word_count().to_string()); + assert_eq!(got[2].1, "stale", "a locked field stays as it was"); + assert!(story_fields(&s, StoryRef::Body).iter().all(|(_, r)| r == "stale"), "the body is not touched"); + } + + /// #1706: `all: true` (printing) updates the whole document even with the caret in a header. + #[test] + fn update_fields_all_covers_every_story() { + let (doc, hdr) = doc_with_header_fields(); + let mut s = Session::new(doc); + let path = s.doc.para_paths(StoryRef::Part(hdr)).remove(0); + s.sel = crate::session::Selection::caret(Pos::new(StoryRef::Part(hdr), path, 0)); + s.run("references.updateFields", &json!({"all": true})).unwrap(); + let got = story_fields(&s, StoryRef::Part(hdr)); + assert_eq!((got[0].1.as_str(), got[2].1.as_str()), ("Ada", "stale"), "{got:?}"); + assert_eq!(story_fields(&s, StoryRef::Body)[0].1, "Ada"); + // Junk values fall back to the caret's story. + s.run("references.updateFields", &json!({"all": "yes"})).unwrap(); + } } diff --git a/crates/ui-egui/src/printing.rs b/crates/ui-egui/src/printing.rs index d9c58803b8..24acb80d71 100644 --- a/crates/ui-egui/src/printing.rs +++ b/crates/ui-egui/src/printing.rs @@ -137,7 +137,7 @@ pub fn submit(app: &mut crate::WordApp, params: &serde_json::Value) -> Result Date: Sun, 18 Oct 2026 10:25:04 +0200 Subject: [PATCH 17/38] #1906: content-control Properties OK inherits theme text (#1920) --- crates/ui-egui/src/dialogs_controls.rs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/crates/ui-egui/src/dialogs_controls.rs b/crates/ui-egui/src/dialogs_controls.rs index 9bdc1971b9..ee58270780 100644 --- a/crates/ui-egui/src/dialogs_controls.rs +++ b/crates/ui-egui/src/dialogs_controls.rs @@ -167,7 +167,8 @@ pub fn body(app: &mut WordApp, ui: &mut Ui, f: &mut ControlForm) -> bool { if ui.button(tl!("Cancel")).clicked() || ui.input(|i| i.key_pressed(egui::Key::Escape)) { done = true; } - if ui.button(egui::RichText::new(tl!("OK")).color(egui::Color32::WHITE)).clicked() { + // #1906: no forced label color; the theme button text reads on the theme fill. + if ui.button(tl!("OK")).clicked() { match app.run("developer.properties", f.params()) { Ok(_) => done = true, Err(e) => app.status(e), From bcdad07d007abe93074475947806c4b60c6120d6 Mon Sep 17 00:00:00 2001 From: Kameron Kilchrist Date: Sun, 18 Oct 2026 01:25:59 -0700 Subject: [PATCH 18/38] =?UTF-8?q?macOS:=20dialogs=20put=20Cancel=20left=20?= =?UTF-8?q?of=20the=20default=20button,=20and=20=E2=8C=98.=20cancels=20(#1?= =?UTF-8?q?835)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Mac order, [Cancel] [OK] with the default button rightmost, in every dialog with OK and Cancel; Cmd-period cancels wherever Escape does. Claude-Session: https://claude.ai/code/session_01JTqaRSzgjKv75TzSC55HEY Co-authored-by: Claude Opus 5.5 Co-authored-by: Brandon Thomas --- crates/ui-egui/src/colors.rs | 4 ++-- crates/ui-egui/src/dialogs.rs | 22 ++++++++++++++++++++-- crates/ui-egui/src/dialogs_controls.rs | 7 ++++--- crates/ui-egui/src/dialogs_insert.rs | 5 +++-- crates/ui-egui/src/dialogs_layout.rs | 5 +++-- crates/ui-egui/src/dialogs_notes.rs | 15 ++++++++++++--- crates/ui-egui/src/dialogs_review.rs | 19 +++++++++++++++---- crates/ui-egui/src/dialogs_ribbon.rs | 7 ++++++- crates/ui-egui/src/file_browser.rs | 8 ++++++-- crates/ui-egui/src/zotero.rs | 12 ++++++++---- 10 files changed, 79 insertions(+), 25 deletions(-) diff --git a/crates/ui-egui/src/colors.rs b/crates/ui-egui/src/colors.rs index 8880a97c3d..e5c0d22e3d 100644 --- a/crates/ui-egui/src/colors.rs +++ b/crates/ui-egui/src/colors.rs @@ -510,10 +510,10 @@ fn body(ui: &mut Ui, d: &mut ColorsDialog) -> Option { ui.add_space(8.0); ui.horizontal(|ui| { ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { - if ui.button(tl!("Cancel")).clicked() { + let (okb, cancelb) = crate::dialogs::ok_cancel_responses(ui, |ui| ui.add(crate::widgets::primary_button(ui, tl!("OK"))), tl!("Cancel")); + if cancelb.clicked() { out = Some(false); } - let okb = ui.add(crate::widgets::primary_button(ui, tl!("OK"))); let typing = ui.ctx().memory(|m| m.focused().is_some()); if okb.clicked() || (!typing && ui.input(|i| i.key_pressed(egui::Key::Enter))) { out = Some(true); diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index dd71ceb6ba..6f018a4c7e 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -1431,6 +1431,24 @@ fn page_paper_group(ui: &mut Ui, paper: &mut (f32, f32), landscape: &mut bool, c }); } +/// Cancel key for dialogs: Escape everywhere, plus ⌘. (Command+Period) on macOS. +pub(crate) fn cancel_key_pressed(ui: &Ui) -> bool { + ui.input(|i| i.key_pressed(egui::Key::Escape) || (cfg!(target_os = "macos") && i.modifiers.command && i.key_pressed(egui::Key::Period))) +} + +/// Lays out an OK and a Cancel button right-to-left, in the platform's order: +/// macOS puts Cancel left of the default OK button; Windows and Linux put OK left of Cancel. +/// Returns `(ok, cancel)` responses. Call inside a right-to-left layout. +pub(crate) fn ok_cancel_responses(ui: &mut Ui, draw_ok: impl FnOnce(&mut Ui) -> egui::Response, cancel: &str) -> (egui::Response, egui::Response) { + if cfg!(target_os = "macos") { + let okb = draw_ok(ui); + (okb, ui.button(cancel)) + } else { + let cancelb = ui.button(cancel); + (draw_ok(ui), cancelb) + } +} + pub(crate) fn buttons(ui: &mut Ui, ok: &str) -> (bool, bool) { let mut r = (false, false); ui.add_space(8.0); @@ -1438,10 +1456,10 @@ pub(crate) fn buttons(ui: &mut Ui, ok: &str) -> (bool, bool) { ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { // Enter and Escape belong to the Colors dialog while it is open above this one. let keys = !crate::colors::is_open(ui.ctx()); - if ui.button(tl!("Cancel")).clicked() || (keys && ui.input(|i| i.key_pressed(egui::Key::Escape))) { + let (okb, cancelb) = ok_cancel_responses(ui, |ui| ui.add(crate::widgets::primary_button(ui, ok)), tl!("Cancel")); + if cancelb.clicked() || (keys && cancel_key_pressed(ui)) { r.1 = true; } - let okb = ui.add(crate::widgets::primary_button(ui, ok)); if okb.clicked() || (keys && ui.input(|i| i.key_pressed(egui::Key::Enter))) { r.0 = true; } diff --git a/crates/ui-egui/src/dialogs_controls.rs b/crates/ui-egui/src/dialogs_controls.rs index ee58270780..136d3be469 100644 --- a/crates/ui-egui/src/dialogs_controls.rs +++ b/crates/ui-egui/src/dialogs_controls.rs @@ -164,11 +164,12 @@ pub fn body(app: &mut WordApp, ui: &mut Ui, f: &mut ControlForm) -> bool { ui.add_space(8.0); ui.horizontal(|ui| { ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { - if ui.button(tl!("Cancel")).clicked() || ui.input(|i| i.key_pressed(egui::Key::Escape)) { + // #1906: no forced label color; the theme button text reads on the theme fill. + let (okb, cancelb) = crate::dialogs::ok_cancel_responses(ui, |ui| ui.button(tl!("OK")), tl!("Cancel")); + if cancelb.clicked() || crate::dialogs::cancel_key_pressed(ui) { done = true; } - // #1906: no forced label color; the theme button text reads on the theme fill. - if ui.button(tl!("OK")).clicked() { + if okb.clicked() { match app.run("developer.properties", f.params()) { Ok(_) => done = true, Err(e) => app.status(e), diff --git a/crates/ui-egui/src/dialogs_insert.rs b/crates/ui-egui/src/dialogs_insert.rs index a0181e1f28..61017bf011 100644 --- a/crates/ui-egui/src/dialogs_insert.rs +++ b/crates/ui-egui/src/dialogs_insert.rs @@ -84,10 +84,11 @@ pub(crate) fn buttons(ui: &mut Ui, ok: &str, cancel: &str, ok_enabled: bool) -> ui.add_space(8.0); ui.horizontal(|ui| { ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { - if ui.button(tl!(cancel)).clicked() || ui.input(|i| i.key_pressed(egui::Key::Escape)) { + let (okb, cancelb) = + crate::dialogs::ok_cancel_responses(ui, |ui| ui.add_enabled(ok_enabled, crate::widgets::primary_button(ui, tl!(ok))), tl!(cancel)); + if cancelb.clicked() || crate::dialogs::cancel_key_pressed(ui) { r.1 = true; } - let okb = ui.add_enabled(ok_enabled, crate::widgets::primary_button(ui, tl!(ok))); if okb.clicked() || (ok_enabled && ui.input(|i| i.key_pressed(egui::Key::Enter))) { r.0 = true; } diff --git a/crates/ui-egui/src/dialogs_layout.rs b/crates/ui-egui/src/dialogs_layout.rs index aa946562f6..365fde41b9 100644 --- a/crates/ui-egui/src/dialogs_layout.rs +++ b/crates/ui-egui/src/dialogs_layout.rs @@ -107,10 +107,11 @@ fn buttons(ui: &mut Ui, ok: &str, ok_enabled: bool) -> (bool, bool) { ui.add_space(8.0); ui.horizontal(|ui| { ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { - if ui.button(tl!("Cancel")).clicked() || ui.input(|i| i.key_pressed(egui::Key::Escape)) { + let (okb, cancelb) = + crate::dialogs::ok_cancel_responses(ui, |ui| ui.add_enabled(ok_enabled, crate::widgets::primary_button(ui, tl!(ok))), tl!("Cancel")); + if cancelb.clicked() || crate::dialogs::cancel_key_pressed(ui) { r.1 = true; } - let okb = ui.add_enabled(ok_enabled, crate::widgets::primary_button(ui, tl!(ok))); if okb.clicked() { r.0 = true; } diff --git a/crates/ui-egui/src/dialogs_notes.rs b/crates/ui-egui/src/dialogs_notes.rs index ae69d55927..e321555cf6 100644 --- a/crates/ui-egui/src/dialogs_notes.rs +++ b/crates/ui-egui/src/dialogs_notes.rs @@ -147,10 +147,19 @@ pub fn note_options(app: &mut WordApp, ui: &mut Ui, f: &mut NoteForm) -> bool { ui.add_space(8.0); ui.horizontal(|ui| { ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { - cancel = ui.button(tl!("Cancel")).clicked() || ui.input(|i| i.key_pressed(egui::Key::Escape)); - apply = ui.button(tl!("Apply")).clicked(); let label = if f.endnote { "Insert Endnote" } else { "Insert Footnote" }; - insert = ui.add(crate::widgets::primary_button(ui, tl!(label))).clicked() || ui.input(|i| i.key_pressed(egui::Key::Enter)); + let enter = ui.input(|i| i.key_pressed(egui::Key::Enter)); + if cfg!(target_os = "macos") { + // macOS: [Apply] [Cancel] [Insert], the default button rightmost. + insert = ui.add(crate::widgets::primary_button(ui, tl!(label))).clicked() || enter; + cancel = ui.button(tl!("Cancel")).clicked(); + apply = ui.button(tl!("Apply")).clicked(); + } else { + cancel = ui.button(tl!("Cancel")).clicked(); + apply = ui.button(tl!("Apply")).clicked(); + insert = ui.add(crate::widgets::primary_button(ui, tl!(label))).clicked() || enter; + } + cancel |= crate::dialogs::cancel_key_pressed(ui); }); }); if insert || apply { diff --git a/crates/ui-egui/src/dialogs_review.rs b/crates/ui-egui/src/dialogs_review.rs index a465b61aa7..d59869c020 100644 --- a/crates/ui-egui/src/dialogs_review.rs +++ b/crates/ui-egui/src/dialogs_review.rs @@ -68,10 +68,14 @@ fn buttons(ui: &mut Ui, ok_enabled: bool) -> (bool, bool) { ui.add_space(8.0); ui.horizontal(|ui| { ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { - if ui.button(tl!("Cancel")).clicked() || ui.input(|i| i.key_pressed(egui::Key::Escape)) { + let (okb, cancelb) = crate::dialogs::ok_cancel_responses( + ui, + |ui| ui.add_enabled(ok_enabled, crate::widgets::primary_button(ui, tl!("OK"))), + tl!("Cancel"), + ); + if cancelb.clicked() || crate::dialogs::cancel_key_pressed(ui) { r.1 = true; } - let okb = ui.add_enabled(ok_enabled, crate::widgets::primary_button(ui, tl!("OK"))); if okb.clicked() { r.0 = true; } @@ -658,6 +662,9 @@ fn setup_ui(app: &mut WordApp, ui: &mut Ui, st: &mut RestrictPane) { ui.label(egui::RichText::new(tl!("The password is optional; without it anyone can stop the protection.")).small()); passwords(ui, st, true); ui.horizontal(|ui| { + // macOS puts Cancel before OK. + let mac = cfg!(target_os = "macos"); + let mut cancel = mac && ui.button(tl!("Cancel")).clicked(); if ui.button(tl!("OK")).clicked() { if st.password != st.confirm { st.message = tl!("The passwords don't match.").to_string(); @@ -681,7 +688,8 @@ fn setup_ui(app: &mut WordApp, ui: &mut Ui, st: &mut RestrictPane) { st.confirm = Password::default(); } } - if ui.button(tl!("Cancel")).clicked() { + cancel |= !mac && ui.button(tl!("Cancel")).clicked(); + if cancel { st.asking = false; st.password = Password::default(); st.confirm = Password::default(); @@ -742,10 +750,13 @@ fn enforced_ui(app: &mut WordApp, ui: &mut Ui, st: &mut RestrictPane, mode: &str ui.label(egui::RichText::new(tl!("Type the password to stop the protection.")).small()); passwords(ui, st, false); ui.horizontal(|ui| { + // macOS puts Cancel before OK. + let mac = cfg!(target_os = "macos"); + let cancel_first = mac && ui.button(tl!("Cancel")).clicked(); if ui.button(tl!("OK")).clicked() { stop(app, st); } - if ui.button(tl!("Cancel")).clicked() { + if cancel_first || (!mac && ui.button(tl!("Cancel")).clicked()) { st.asking = false; st.password = Password::default(); } diff --git a/crates/ui-egui/src/dialogs_ribbon.rs b/crates/ui-egui/src/dialogs_ribbon.rs index d25817a681..6384e95ae3 100644 --- a/crates/ui-egui/src/dialogs_ribbon.rs +++ b/crates/ui-egui/src/dialogs_ribbon.rs @@ -275,10 +275,15 @@ fn ribbon_page(app: &mut WordApp, ui: &mut Ui, f: &mut RibbonForm, h: f32) { ui.label(tl!("Name:")); let r = ui.add(egui::TextEdit::singleline(name).desired_width(140.0)); let enter = r.lost_focus() && ui.input(|i| i.key_pressed(egui::Key::Enter)); + // macOS puts Cancel before OK. + let mac = cfg!(target_os = "macos"); + if mac && ui.button(tl!("Cancel")).clicked() { + done = Some(None); + } if ui.button(tl!("OK")).clicked() || enter { done = Some(Some(name.clone())); } - if ui.button(tl!("Cancel")).clicked() { + if !mac && ui.button(tl!("Cancel")).clicked() { done = Some(None); } }); diff --git a/crates/ui-egui/src/file_browser.rs b/crates/ui-egui/src/file_browser.rs index d2a7320682..7d32dc14b1 100644 --- a/crates/ui-egui/src/file_browser.rs +++ b/crates/ui-egui/src/file_browser.rs @@ -439,8 +439,12 @@ fn body(ui: &mut Ui, b: &mut FileBrowser, t: &Tokens) -> Option> let enter = !location_done && ui.input(|i| i.key_pressed(egui::Key::Enter)); ui.horizontal(|ui| { ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { - cancel |= ui.button(tl!("Cancel")).clicked(); - let ok = ui.add(egui::Button::new(egui::RichText::new(tl!(b.ok_label())).color(egui::Color32::WHITE)).fill(APP_COLOR)); + let (ok, cancelb) = crate::dialogs::ok_cancel_responses( + ui, + |ui| ui.add(egui::Button::new(egui::RichText::new(tl!(b.ok_label())).color(egui::Color32::WHITE)).fill(APP_COLOR)), + tl!("Cancel"), + ); + cancel |= cancelb.clicked(); pick |= ok.clicked() || enter; }); }); diff --git a/crates/ui-egui/src/zotero.rs b/crates/ui-egui/src/zotero.rs index 0437df5739..e5c4ef32b2 100644 --- a/crates/ui-egui/src/zotero.rs +++ b/crates/ui-egui/src/zotero.rs @@ -218,10 +218,14 @@ pub fn poll(app: &mut WordApp, ctx: &egui::Context) { pub fn show_alert(app: &mut WordApp, ctx: &egui::Context) { let Some(a) = app.zotero.alert.as_ref() else { return }; // Buttons and the value Zotero expects for each. - let buttons: &[(&str, i64)] = match a.buttons { - 1 => &[("OK", 1), ("Cancel", 0)], - 2 => &[("Yes", 1), ("No", 0)], - 3 => &[("Yes", 2), ("No", 1), ("Cancel", 0)], + // macOS puts the default button last (rightmost), with Cancel just before it. + let buttons: &[(&str, i64)] = match (a.buttons, cfg!(target_os = "macos")) { + (1, false) => &[("OK", 1), ("Cancel", 0)], + (1, true) => &[("Cancel", 0), ("OK", 1)], + (2, false) => &[("Yes", 1), ("No", 0)], + (2, true) => &[("No", 0), ("Yes", 1)], + (3, false) => &[("Yes", 2), ("No", 1), ("Cancel", 0)], + (3, true) => &[("No", 1), ("Cancel", 0), ("Yes", 2)], _ => &[("OK", 1)], }; let mut picked = None; From 31cb2c8714e83a5e799348d4ca25b2db2be4446a Mon Sep 17 00:00:00 2001 From: SlavenDj <53652906+SlavenDj@users.noreply.github.com> Date: Sun, 18 Oct 2026 10:26:19 +0200 Subject: [PATCH 19/38] #1861: searchable Interface Theme command (View, light/dark/system + toggle) (#1868) Co-authored-by: Brandon Thomas --- crates/engine/src/cmd/view.rs | 20 ++++++++++++++++++++ crates/engine/src/tests.rs | 20 ++++++++++++++++++++ crates/ui-egui/src/lib.rs | 33 +++++++++++++++++++++++++++++++++ crates/ui-egui/src/ribbon.rs | 2 +- docs/ui-parity.md | 4 ++-- 5 files changed, 76 insertions(+), 3 deletions(-) diff --git a/crates/engine/src/cmd/view.rs b/crates/engine/src/cmd/view.rs index d79d0a9057..ff29899133 100644 --- a/crates/engine/src/cmd/view.rs +++ b/crates/engine/src/cmd/view.rs @@ -70,6 +70,26 @@ pub fn specs() -> Vec { CommandSpec::new("view.multiplePages", "Multiple Pages", "View › Zoom", |s, _| zoom(s, &json!({"value": "multiplePages"}))).pure(), CommandSpec::new("view.pageWidth", "Page Width", "View › Zoom", |s, _| zoom(s, &json!({"value": "pageWidth"}))).pure(), CommandSpec::new("view.darkMode", "Switch Modes", "View › Dark Mode", |s, v| toggle(s, v, |x| &mut x.dark_mode)).pure(), + // The interface theme itself (light, dark, or follow the OS), next to dark pages (#1861). + // The session can't paint: like split and new windows, this queues a request the front + // end applies to its theme setting (which persists per user). Without a value it toggles + // light and dark, as the manual switch did. + CommandSpec::new("view.interfaceTheme", "Interface Theme", "View › Dark Mode", |s, v| match v.get("value") { + None => { + s.ui_requests.push(json!({"toggle": "interfaceTheme"})); + Ok(json!({"value": "toggle"})) + } + Some(Value::String(name)) => match name.as_str() { + "light" | "dark" | "system" => { + s.ui_requests.push(json!({"interfaceTheme": name})); + Ok(json!({"value": name})) + } + _ => Err(CmdError::Params("interface theme: light|dark|system".into())), + }, + _ => Err(CmdError::Params("interface theme: light|dark|system".into())), + }) + .params(r#"{"value"?: "light" | "dark" | "system"; without it, toggles light and dark}"#) + .pure(), CommandSpec::new("view.stylesPane", "Styles Pane", "Home › Styles", |s, v| toggle(s, v, |x| &mut x.styles_pane)).pure(), CommandSpec::new("view.commentsPane", "Comments Pane", "Review › Comments", |s, v| toggle(s, v, |x| &mut x.comments_pane)).pure(), CommandSpec::new("view.accessibilityPane", "Accessibility Pane", "Review › Accessibility", |s, v| { diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index a5b4f8b3a9..72aaa9cee3 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -4290,6 +4290,26 @@ fn navigation_pane_opens_without_changing_view_mode() { } } +#[test] +fn interface_theme_queues_a_front_end_request_and_rejects_junk() { + // #1861: the session can't paint, so the command asks the front end; the document, + // view state and undo stack are untouched. + let mut s = s(); + let dirty = s.dirty; + for value in ["light", "dark", "system"] { + let r = run(&mut s, "view.interfaceTheme", json!({"value": value})); + assert_eq!(r["value"], value); + assert_eq!(s.ui_requests.last().unwrap()["interfaceTheme"], value); + } + let r = run(&mut s, "view.interfaceTheme", json!({})); + assert_eq!(r["value"], "toggle"); + assert_eq!(s.ui_requests.last().unwrap()["toggle"], "interfaceTheme"); + assert_eq!(s.dirty, dirty); + for bad in [json!({"value": "midnight"}), json!({"value": 3})] { + assert!(s.run("view.interfaceTheme", &bad).is_err(), "{bad}"); + } +} + #[test] fn reading_mode_reflows_per_viewport_without_changing_document_or_export() { let mut s = s(); diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 42eafa2687..052b567feb 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -1299,6 +1299,15 @@ impl WordApp { self.nav_search_focus = true; self.ui.nav_tab = "results".into(); } + // #1861: the registered Interface Theme command drives the same setting as Options. + if let Some(name) = req.get("interfaceTheme").and_then(Value::as_str) + && let Some(a) = theme::Appearance::from_code(name) + { + self.ui.theme = a; + } + if req.get("toggle").and_then(Value::as_str) == Some("interfaceTheme") { + self.ui.theme = if self.ui_is_dark() { theme::Appearance::Light } else { theme::Appearance::Dark }; + } if let Some(what) = req.get("open").and_then(Value::as_str) { match what { "openFile" => self.open_dialog(), @@ -6119,6 +6128,30 @@ mod tests { assert_eq!((odd.theme, odd.tab.as_str()), (Appearance::System, "Insert")); } + /// #1861: the registered Interface Theme command drives the same setting as Options: + /// searchable in the registry, settable, toggling without a value, and surviving a restart. + #[test] + fn interface_theme_command_sets_toggles_and_persists() { + use theme::Appearance; + let mut a = app(); + let spec = a.session.registry.get("view.interfaceTheme").expect("registered and searchable"); + assert_eq!((spec.label, spec.location), ("Interface Theme", "View › Dark Mode")); + a.run("view.interfaceTheme", json!({"value": "dark"})).unwrap(); + assert_eq!(a.ui.theme, Appearance::Dark); + a.run("view.interfaceTheme", json!({"value": "system"})).unwrap(); + assert_eq!(a.ui.theme, Appearance::System); + a.run("view.interfaceTheme", json!({"value": "light"})).unwrap(); + a.run("view.interfaceTheme", json!({})).unwrap(); + assert_eq!(a.ui.theme, Appearance::Dark, "no value toggles light to dark"); + a.run("view.interfaceTheme", json!({})).unwrap(); + assert_eq!(a.ui.theme, Appearance::Light, "and back"); + assert!(a.run("view.interfaceTheme", json!({"value": "sepia"})).is_err()); + let saved = serde_json::to_string(&a.prefs()).unwrap(); + let mut b = app(); + b.apply_prefs(serde_json::from_str(&saved).unwrap()); + assert_eq!(b.ui.theme, Appearance::Light); + } + /// #475, #822: the interface size is kept between runs; junk saved values read as Auto and /// out-of-range ones are clamped, without losing the other preferences. #[test] diff --git a/crates/ui-egui/src/ribbon.rs b/crates/ui-egui/src/ribbon.rs index cd70833092..6b4afd9c41 100644 --- a/crates/ui-egui/src/ribbon.rs +++ b/crates/ui-egui/src/ribbon.rs @@ -2108,7 +2108,7 @@ fn view(app: &mut WordApp, ui: &mut Ui) { menu_button(ui, app, "interfaceTheme", Some("Interface\nTheme"), "Interface Theme", true, "", |ui, app| { for a in crate::theme::Appearance::ALL { if ui.add(egui::Button::selectable(app.ui.theme == a, tl!(a.label())).min_size(vec2(200.0, 0.0))).clicked() { - let _ = app.run("ui.theme", json!({"value": a.code()})); + let _ = app.run("view.interfaceTheme", json!({"value": a.code()})); ui.close(); } } diff --git a/docs/ui-parity.md b/docs/ui-parity.md index 3486a8e10b..c6229559cc 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -46,8 +46,7 @@ Document title opens Rename Document; the account badge opens Options › Genera | Reading Mode | Existing Read Mode ribbon button opens a read-only, viewport-column layout with animated horizontal paging, transient width/text/spacing controls, heading navigation (Navigation pane) and resize anchoring; print/export layout unchanged. The arrows, Page Up/Down and Escape work straight after entering it; only whole columns show, centred, at any width, text size or window size; the columns end above the page-turn bar (#1772) | reading-optimized view and navigation | 75% | 1–2 | | Status bar (page x of y, words, language, view buttons, zoom slider) | ✅ (the language is the caret's, by name; a click sets the proofing language, or opens Set Proofing Language… for any other) | ✅ | 85% | — | | Caret motion: keys and typing glide the caret to its new place (100 ms, `cubic-bezier(0, 0, 0, 1)`), the blink fades (530 ms on and off, 100 ms fades); clicks and scripted moves don't glide. File › Options › Advanced › Animation (`ui.caretAnimation`) turns it off. The caret is black or white by contrast with what lies under it (paper, page colour, dark page, shading, highlight), never by the UI theme (#299) | ✅ as measured from Word for Windows (`caret_glide.rs`); zoom levels other than 100%, modifier keys and off-screen moves not measured | ✅ on Windows; Word for Mac not measured | 90% | 1–2 | -| Interface themes (light, dark, system #249, follows OS changes live #311), dark page separate from the interface theme (#194, #312); on a dark page highlights and character shading are dimmed, keeping their hue, so the text on them stays readable; coloured text below 4.5:1 on them is drawn white, and underlines and strikethroughs follow the same rule (#1252) | ✅ | ✅ | 90% | — | -| Interface themes (light, dark, system #249, follows OS changes live #311), dark page separate from the interface theme (#194, #312) | ✅ | ✅ | 90% | — | +| Interface themes (light, dark, system #249, follows OS changes live #311), dark page separate from the interface theme (#194, #312); on a dark page highlights and character shading are dimmed, keeping their hue, so the text on them stays readable; coloured text below 4.5:1 on them is drawn white, and underlines and strikethroughs follow the same rule (#1252); Interface Theme as a searchable View command (light/dark/system, toggle without a value, #1861) | ✅ | ✅ | 90% | — | | Interface size (File › Options › General: Auto, which keeps the system display scale and shows the size it draws at, or an absolute 75–300 %, so "100%" is one screen pixel per point; `ui.interfaceSize`) (#475, #822). A size from before #822 (on top of the display scale) becomes the absolute size it drew at. On the web the browser's zoom is part of the display scale, so a chosen size cancels browser zoom (Ctrl+plus/minus no longer resize the interface); Auto follows it | ✅ | system display scaling only | 100% | — | | Animation effects (File › Options › Accessibility: Use system setting, On, Off; `ui.animations`). Off: a wheel notch scrolls at once; Ctrl+wheel zooms in whole steps, each drawn finished in its own frame (a notch at once; touchpad pinches and high-resolution wheels in steps of about 10% rather than gliding); menus, buttons and the scroll to the caret neither fade nor glide | ✅ "Use system setting" reads Windows (Animation effects), macOS (Reduce motion), the Linux/BSD desktops (settings portal: the cross-desktop reduced-motion preference, GNOME's switch, KDE's animation speed; the GTK and KDE settings files where no portal answers) and browsers (`prefers-reduced-motion`), and follows a change while running. Run on Windows and against a stand-in portal on Linux; not yet tried on a Mac or a real GNOME or KDE session | not compared | 90% | 0.5–1 | | Full screen (#980): View › Window › Full Screen (`view.fullScreen`), F11 (⌃⌘F on macOS) toggles OS-level full screen, Escape leaves it, leaving gives back the earlier window size; the window manager's or macOS's own full screen keeps it in step. In the browser it's off (the browser's own F11) | ✅ | Word for Mac: View › Enter Full Screen (⌃⌘F); Word for Windows: Full Screen Mode hides the ribbon only | 100% | — | @@ -219,3 +218,4 @@ labels unambiguous. Those extra-number assignments have not been compared with W | 2026-10-12 | trivial | Recipient List dialog: Filter and Sort section (conditions joined by And/Or, three sort keys, merge order column), .xls in the picker (#778) | | 2026-10-13 | trivial | Double-clicking the top or bottom margin of a page with no header or footer creates the one that page shows and edits it, as in Word; one left empty is taken away again on leaving; `insert.editHeader`/`insert.editFooter {"page"}` for agents (#960); making one is an undo step (`insert.createHeader`/`insert.createFooter`), refused read-only, linked from the first section | | 2026-10-15 | trivial | Narrow windows: the tab strip keeps the room of Comments / Editing / Share and gives way in order (tab padding, Comments and Editing labels, Share label, plain tabs into », active and contextual labels cut with …, last the active tab into »); File never leaves the strip. Public sources say Word keeps those buttons in view and shrinks the tabs; the » menu is ours (#2026) | +| 2026-10-15 | trivial | Interface Theme as a searchable View › Dark Mode command (light/dark/system, toggles light and dark without a value), driving the same setting as Options; ribbon menu rewired to it (#1861) | From af90833305c2c2e9fabea101d90af55cc4e6e10a Mon Sep 17 00:00:00 2001 From: SlavenDj <53652906+SlavenDj@users.noreply.github.com> Date: Sun, 18 Oct 2026 10:26:30 +0200 Subject: [PATCH 20/38] #1902: clipboard and styles pane actions return focus to the document (#1914) --- crates/ui-egui/src/panes.rs | 35 +++++++++++++++++++++++++++++++++++ 1 file changed, 35 insertions(+) diff --git a/crates/ui-egui/src/panes.rs b/crates/ui-egui/src/panes.rs index 942c5ef1e2..8752a595a5 100644 --- a/crates/ui-egui/src/panes.rs +++ b/crates/ui-egui/src/panes.rs @@ -244,6 +244,7 @@ fn clipboard(app: &mut WordApp, ui: &mut Ui) { } if ui.add_enabled(!items.is_empty(), egui::Button::new(tl!("Clear All"))).clicked() { let _ = app.run("edit.clearClipboard", json!({})); + app.canvas.want_focus = true; } }); ui.add_space(4.0); @@ -267,6 +268,7 @@ fn clipboard(app: &mut WordApp, ui: &mut Ui) { ui.with_layout(egui::Layout::right_to_left(egui::Align::Min), |ui| { if ui.small_button(tl!("Delete")).clicked() { let _ = app.run("edit.deleteClipboardItem", json!({"index": i})); + app.canvas.want_focus = true; } }); }); @@ -336,10 +338,12 @@ fn styles(app: &mut WordApp, ui: &mut Ui) { } if ui.button(tl!("Update to Match Selection")).clicked() { let _ = app.run("styles.updateToMatch", json!({"style": id})); + app.canvas.want_focus = true; ui.close(); } if ui.button(tl!("Delete")).clicked() { let _ = app.run("styles.delete", json!({"style": id})); + app.canvas.want_focus = true; ui.close(); } }); @@ -739,6 +743,37 @@ mod tests { assert_eq!(comment_stamp("2026-10-12"), "2026-10-12"); assert_eq!(comment_stamp(""), ""); } + + /// #1902: Clipboard Delete returns focus to the document like paste does. + #[test] + #[cfg(not(target_arch = "wasm32"))] + fn clipboard_delete_returns_focus_to_the_document() { + use egui_kittest::kittest::Queryable; + + let mut app = crate::WordApp::new(wordcraft_engine::Session::new(wordcraft_doc::Document::from_text("Hello")), crate::Services::default()); + app.run("select.all", serde_json::json!({})).unwrap(); + app.run("edit.copy", serde_json::json!({})).unwrap(); + assert_eq!(app.session.clip_history.items().len(), 1); + app.run("edit.clipboardPane", serde_json::json!({"value": true})).unwrap(); + let mut h = egui_kittest::Harness::builder().with_size(egui::vec2(1440.0, 900.0)).build_ui_state( + |ui, app: &mut crate::WordApp| { + let ctx = ui.ctx().clone(); + app.logic(&ctx); + app.ui(ui); + }, + app, + ); + h.run_steps(4); + // Focus the button for real: a bare accesskit click leaves egui focus empty, and an + // unrelated chrome-click helper would hand focus to the canvas on its own. + h.get_by_label("Delete").focus(); + h.run_steps(2); + h.state_mut().canvas.want_focus = false; + h.get_by_label("Delete").click(); + h.run_steps(4); + assert!(h.state().canvas.focused, "Delete returns focus to the document"); + assert!(h.state().session.clip_history.items().is_empty(), "the item was deleted"); + } } #[cfg(test)] From db9d9d488aa800b0e31d5451ab680e0bf226107d Mon Sep 17 00:00:00 2001 From: SlavenDj <53652906+SlavenDj@users.noreply.github.com> Date: Sun, 18 Oct 2026 10:26:41 +0200 Subject: [PATCH 21/38] #1904: bookmark dialog placeholder when no bookmarks (#1917) --- crates/ui-egui/src/dialogs.rs | 32 ++++++++++++++++++++++++++++++++ 1 file changed, 32 insertions(+) diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index 6f018a4c7e..ecf4597187 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -3211,6 +3211,10 @@ fn bookmark(app: &mut WordApp, ui: &mut Ui, name: &mut String, by_location: &mut ui.set_width(232.0); ui.set_height(150.0); egui::ScrollArea::vertical().id_salt("bookmark_list").max_height(150.0).auto_shrink([false, false]).show(ui, |ui| { + if marks.is_empty() { + // #1904: a blank box says nothing; reuse the Field dialog's wording. + ui.label(egui::RichText::new(tl!("The document has no bookmarks yet.")).small().color(t.text_dim)); + } for n in &marks { if ui.selectable_label(*n == *name, n.as_str()).clicked() { *name = n.clone(); @@ -3759,6 +3763,34 @@ mod tests { assert_eq!(h.state().session.doc.bookmarks().len(), 3, "one undo step"); } + /// #1904: with no bookmarks the list says so instead of a blank box. + #[test] + fn the_bookmark_dialog_says_when_there_are_no_bookmarks() { + let mut app = app_with("Alpha beta gamma"); + app.dialog = Dialog::open("bookmark", &mut app); + let mut h = egui_kittest::Harness::builder().with_size(vec2(1440.0, 900.0)).build_ui_state( + |ui, app: &mut WordApp| { + let ctx = ui.ctx().clone(); + app.logic(&ctx); + app.ui(ui); + }, + app, + ); + for _ in 0..4 { + h.step(); + } + let texts: Vec = h + .output() + .shapes + .iter() + .filter_map(|s| match &s.shape { + egui::Shape::Text(t) => Some(t.galley.text().to_string()), + _ => None, + }) + .collect(); + assert!(texts.iter().any(|t| t.contains("The document has no bookmarks yet.")), "placeholder shown"); + } + /// Find, Replace and Go To are one dialog with three tabs (#712). #[test] fn find_replace_and_go_to_are_one_dialog() { From 228a3eae3e9f6dbd530ac17278a8ca3b84a149ea Mon Sep 17 00:00:00 2001 From: SlavenDj <53652906+SlavenDj@users.noreply.github.com> Date: Sun, 18 Oct 2026 10:26:52 +0200 Subject: [PATCH 22/38] #1905: empty keys list shows (none) (#1919) --- crates/ui-egui/src/dialogs_keyboard.rs | 47 ++++++++++++++++++++++++++ 1 file changed, 47 insertions(+) diff --git a/crates/ui-egui/src/dialogs_keyboard.rs b/crates/ui-egui/src/dialogs_keyboard.rs index ddd2cbb0b0..abea4ac6ed 100644 --- a/crates/ui-egui/src/dialogs_keyboard.rs +++ b/crates/ui-egui/src/dialogs_keyboard.rs @@ -106,6 +106,11 @@ pub(crate) fn list(ui: &mut Ui, id: &str, size: egui::Vec2, rows: &[(String, Str egui::Frame::new().stroke(ui.visuals().widgets.noninteractive.bg_stroke).inner_margin(2.0).show(ui, |ui| { ui.set_width(size.x); ui.set_height(size.y); + if rows.is_empty() { + // #1905: an empty list says so instead of a blank box. + ui.centered_and_justified(|ui| ui.label(egui::RichText::new(tl!("(none)")).weak())); + return; + } let row_h = ui.text_style_height(&egui::TextStyle::Body) + 4.0; egui::ScrollArea::vertical().id_salt(id).auto_shrink([false, false]).show_rows(ui, row_h, rows.len(), |ui, range| { ui.with_layout(egui::Layout::top_down_justified(egui::Align::LEFT), |ui| { @@ -289,4 +294,46 @@ mod tests { c.apply_prefs(serde_json::from_str(r#"{"keyboard": {"assigned": {"Mod+Q": "gone.command", "Bad+": 1}, "removed": 7}}"#).unwrap()); assert!(c.session.keymap.is_empty()); } + + /// #1905: a command with no shortcut shows (none) instead of a blank keys box. + #[test] + fn keys_list_without_shortcuts_says_none() { + use super::customize_keyboard; + + let app = || WordApp::new(Session::new(wordcraft_doc::Document::new()), Services::default()); + let mut a = app(); + a.run("tools.customizeKeyboard", json!({})).unwrap(); + let id = a + .session + .registry + .all() + .iter() + .find(|s| a.session.keymap.keys_for(&a.session.registry, s.id).is_empty()) + .map(|s| s.id.to_string()) + .expect("a command with no shortcut"); + let mut form = match a.dialog.take() { + Some(crate::dialogs::Dialog::CustomizeKeyboard { form }) => form, + _ => panic!("expected the Customize Keyboard dialog"), + }; + form.command = id; + let ctx = egui::Context::default(); + crate::theme::install_fonts(&ctx); + crate::theme::apply(&ctx, crate::theme::Appearance::Light); + ctx.run_ui(egui::RawInput::default(), |_| {}).drop_without_applying_deltas(); + let screen = egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(900.0, 700.0)); + let mut output = ctx.run_ui(egui::RawInput { screen_rect: Some(screen), ..Default::default() }, |ui| { + customize_keyboard(&mut a, ui, &mut form); + }); + output.textures_delta.clear(); + a.dialog = Some(crate::dialogs::Dialog::CustomizeKeyboard { form }); + let texts: Vec = output + .shapes + .iter() + .filter_map(|s| match &s.shape { + egui::Shape::Text(t) => Some(t.galley.text().to_string()), + _ => None, + }) + .collect(); + assert!(texts.iter().any(|t| t.contains("(none)")), "empty keys list says (none)"); + } } From eff1444f0856ba23e1aef029bb05b6ae8e116477 Mon Sep 17 00:00:00 2001 From: Dmitrii Khlapov Date: Sun, 18 Oct 2026 10:41:10 +0200 Subject: [PATCH 23/38] Fonts: bounded family caches; math follows font changes (#1354) (#1929) * WIP for #1354 * Fonts: bound the family caches and refresh math resolution on font changes (#1354) Co-Authored-By: Claude Opus 5.5 (1M context) * Fonts: test math font lookup against a really installed and removed font (#1354) Co-Authored-By: Claude Opus 5.5 (1M context) --------- 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 | 5 +- crates/fonts/src/fontdb.rs | 2 +- crates/fonts/src/fontdb/user.rs | 2 +- crates/fonts/src/math.rs | 107 ++++++++++++++++++++++++++++++-- crates/fonts/src/word.rs | 36 +++++++++-- 5 files changed, 137 insertions(+), 15 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 42b49c97d0..2a640f38f0 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -2,7 +2,7 @@ **Stage: alpha** · next: beta, ~15 points (60% → 75% ready for real work) and ~150–230 h away -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-18 · **Change:** trivial (`-->` AutoCorrect replacement, #2223; previously trivial: numbered headings, #1793; previously trivial: Navigation Find exposes Results, #2096; previously trivial: progress log: Columns on selected text, #2024; previously trivial: progress log: legacy outline, shadow, emboss and imprint text effects drawn, #2534; previously trivial: progress log: Indonesian proofing, #498; previously trivial: progress log: DOCX glossary documents (building blocks); previously trivial: progress log: Read Mode keeps the keyboard and shows whole, unobscured columns, #1772); previously trivial: progress log: font list entries of tall-ascent fonts no longer cut off, #2570); previously trivial: progress log: Split Cells and Convert Text to Table dialogs follow the Word check, #2592; previously trivial: a new source's Tag is made like Word's, three letters of the author and two digits of the year with a number added when taken, and an edit keeps the Tag, #2094; previously trivial: Web Layout fills the window width, #1876; previously trivial: progress log: Different First Page headers reachable from page 1, #2826; previously trivial: Language dialog keeps inherited proofing language and no-proof choices (#2717); previously trivial: progress log: web preferences kept between visits, #2132; previously trivial: progress log: undrawable private-use list bullets show the level's default bullet, #1683); previously trivial: table style editor formats every part, #714; previously trivial: VML freeforms read and kept on save; previously trivial: progress log: consistent preferred cell widths; previously trivial: multi-paragraph field instructions no longer add blank lines; previously trivial: page-break-only list paragraphs hide their labels; previously trivial: progress log: grouped `flowChartOr` shapes draw their circle and crossing spokes; previously trivial: progress log: copies keep their formatting in other programs, #1523; previously trivial: progress log: privacy options on save, #1062; previously trivial: progress log: equation pictures in theme colours on Windows, #1002; previously trivial: symbol fonts, #776; previously trivial: the save prompt answers from the keyboard, #861; previously trivial: table column and row markers on the rulers, #700; previously trivial: table borders and the resize handle drag on the page, #217; previously trivial: formatting revisions landed, #41; previously trivial: Draw tab ink, #307, and Draw Table and Eraser, #303, landed: catalog 402/434; previously minor: readiness table with hours per audience; full number back to the additive weighted sum, 60%; mainstream and essentials numbers added; alpha gate checked; previously major: full re-measure against Word for Mac 16.113.4; restructured to the craftrules progress-docs standard) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-18 · **Change:** trivial (progress log: font family caches bounded and math font lookups follow font changes, #1354; previously trivial: `-->` AutoCorrect replacement, #2223; previously trivial: numbered headings, #1793; previously trivial: Navigation Find exposes Results, #2096; previously trivial: progress log: Columns on selected text, #2024; previously trivial: progress log: legacy outline, shadow, emboss and imprint text effects drawn, #2534; previously trivial: progress log: Indonesian proofing, #498; previously trivial: progress log: DOCX glossary documents (building blocks); previously trivial: progress log: Read Mode keeps the keyboard and shows whole, unobscured columns, #1772); previously trivial: progress log: font list entries of tall-ascent fonts no longer cut off, #2570); previously trivial: progress log: Split Cells and Convert Text to Table dialogs follow the Word check, #2592; previously trivial: a new source's Tag is made like Word's, three letters of the author and two digits of the year with a number added when taken, and an edit keeps the Tag, #2094; previously trivial: Web Layout fills the window width, #1876; previously trivial: progress log: Different First Page headers reachable from page 1, #2826; previously trivial: Language dialog keeps inherited proofing language and no-proof choices (#2717); previously trivial: progress log: web preferences kept between visits, #2132; previously trivial: progress log: undrawable private-use list bullets show the level's default bullet, #1683); previously trivial: table style editor formats every part, #714; previously trivial: VML freeforms read and kept on save; previously trivial: progress log: consistent preferred cell widths; previously trivial: multi-paragraph field instructions no longer add blank lines; previously trivial: page-break-only list paragraphs hide their labels; previously trivial: progress log: grouped `flowChartOr` shapes draw their circle and crossing spokes; previously trivial: progress log: copies keep their formatting in other programs, #1523; previously trivial: progress log: privacy options on save, #1062; previously trivial: progress log: equation pictures in theme colours on Windows, #1002; previously trivial: symbol fonts, #776; previously trivial: the save prompt answers from the keyboard, #861; previously trivial: table column and row markers on the rulers, #700; previously trivial: table borders and the resize handle drag on the page, #217; previously trivial: formatting revisions landed, #41; previously trivial: Draw tab ink, #307, and Draw Table and Eraser, #303, landed: catalog 402/434; previously minor: readiness table with hours per audience; full number back to the additive weighted sum, 60%; mainstream and essentials numbers added; alpha gate checked; previously major: full re-measure against Word for Mac 16.113.4; restructured to the craftrules progress-docs standard) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4 > **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-17 · **Change:** trivial (progress log: pasted blockquotes are indented paragraphs, #1270; previously trivial: progress log: print-area strings translated in every catalog; previously trivial: progress log: comments readable in the dark theme, #2045; previously trivial: progress log: ODT sections, #1379; previously trivial: progress log: table deletes, Change Case and Unlink Fields respect Track Changes and locked content controls, #1329; previously trivial: progress log: rounded rectangle corners, #1476; previously trivial: progress log: Save As type list, #1399; previously trivial: progress log: privacy options on save, #1062; previously trivial: progress log: equation pictures in theme colours on Windows, #1002; previously trivial: symbol fonts, #776; previously trivial: the save prompt answers from the keyboard, #861; previously trivial: table column and row markers on the rulers, #700; previously trivial: table borders and the resize handle drag on the page, #217; previously trivial: formatting revisions landed, #41; previously trivial: Draw tab ink, #307, and Draw Table and Eraser, #303, landed: catalog 402/434; previously minor: readiness table with hours per audience; full number back to the additive weighted sum, 60%; mainstream and essentials numbers added; alpha gate checked; previously major: full re-measure against Word for Mac 16.113.4; restructured to the craftrules progress-docs standard) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4 > **Printing update:** 2026-10-12 · direct desktop printing through craft-print; details below. @@ -137,6 +137,8 @@ Ranked, to beta (detail and remaining milestones in [docs/roadmap.md](docs/roadm - 2026-10-15: Find opens Navigation on Results; editing the query restores Results after manual Headings/Pages navigation. Typed and selected-text searches retain document contents (#2096). | Date | What landed | +|---|---| +| 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. | | 2026-10-15 | Numbered headings (#1793): the Multilevel List gallery offers four lists linked to Heading 1–9 (1 / 1.1 / 1.1.1, Article I. / Section 1.01 / (a), I. / A. / 1., Chapter 1; `list.multilevelPreset`). The heading styles carry the numbers, so paragraphs already in a Heading style keep their style and level, any other selected paragraph (Normal, Quote, Title) becomes Heading 1 (checked in Word), a new Heading 2 is numbered 2.1 by itself and body text stays unnumbered; Numbering or typing `1. ` keeps a heading a heading | | 2026-10-15 | Reversing an automatic sentence capital (Undo, or Backspace and retype) adds the word before it to AutoCorrect's First Letter exceptions, as Word does; "Automatically add words to list" switches for First Letter and INitial CAps (#1857). | @@ -457,6 +459,7 @@ Ranked, to beta (detail and remaining milestones in [docs/roadmap.md](docs/roadm | 2026-10-15 | trivial | Add the missing `-->` → `→` AutoCorrect entry (#2223), with typing, undo and preference regression coverage; existing arrow bullets unchanged | | 2026-10-15 | trivial | Progress log: Update Field updates the part being edited, as in Word (#1706) | | 2026-10-14 | trivial | Progress log: Update Fields reaches headers, footers and text boxes (#1706) | +| 2026-10-15 | trivial | Progress log: font family caches bounded; math font resolution follows font changes (#1354) | | 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/fonts/src/fontdb.rs b/crates/fonts/src/fontdb.rs index 7e6c683c75..d73b49e4d2 100644 --- a/crates/fonts/src/fontdb.rs +++ b/crates/fonts/src/fontdb.rs @@ -12,7 +12,7 @@ use skrifa::raw::FileRef; use skrifa::string::StringId; use skrifa::{GlyphId, MetadataProvider}; -mod user; +pub(crate) mod user; pub use user::{FONT_FILE_EXTS, FontFormat, Installed, MAX_FONT_FILE_BYTES, UserFont, UserFontError, decode_font_file, is_font_file_name, sniff}; /// The family used when a requested family is unknown (the UI sans). diff --git a/crates/fonts/src/fontdb/user.rs b/crates/fonts/src/fontdb/user.rs index 3eb6d57d7f..2fb09a43d8 100644 --- a/crates/fonts/src/fontdb/user.rs +++ b/crates/fonts/src/fontdb/user.rs @@ -422,7 +422,7 @@ fn write_unique(dir: &Path, name: &str, bytes: &[u8]) -> std::io::Result>)>); + +fn math_cache() -> &'static Mutex { + static CACHE: OnceLock> = OnceLock::new(); + CACHE.get_or_init(|| Mutex::new((0, HashMap::new()))) +} + /// 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. +/// Remembered until user fonts are added or removed ([`FontDb::generation`]). pub fn resolve_math(family: &str) -> (FaceRef, Option>) { - static CACHE: OnceLock>)>>> = OnceLock::new(); - let cache = CACHE.get_or_init(|| Mutex::new(HashMap::new())); - if let Some(v) = cache.lock().unwrap_or_else(|e| e.into_inner()).get(family) { - return v.clone(); - } + let cache = math_cache(); let db = FontDb::global(); + let generation = db.generation(); + { + let mut c = cache.lock().unwrap_or_else(|e| e.into_inner()); + if c.0 != generation { + *c = (generation, HashMap::new()); + } + if let Some(v) = c.1.get(family) { + return v.clone(); + } + } let mut found = None; for fam in std::iter::once(family).chain(MATH_SUBSTITUTES.iter().copied()) { if !db.has_family(fam) { @@ -322,7 +342,15 @@ pub fn resolve_math(family: &str) -> (FaceRef, Option>) { } } 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()); + if family.len() <= MATH_KEY_MAX { + let mut c = cache.lock().unwrap_or_else(|e| e.into_inner()); + if c.0 == generation { + if c.1.len() >= MATH_CACHE_MAX { + c.1.clear(); + } + c.1.insert(family.to_string(), v.clone()); + } + } v } @@ -330,6 +358,73 @@ pub fn resolve_math(family: &str) -> (FaceRef, Option>) { mod tests { use super::*; + fn cached(family: &str) -> bool { + math_cache().lock().unwrap_or_else(|e| e.into_inner()).1.contains_key(family) + } + + /// `font` (an sfnt) with a minimal valid `MATH` table added: enough for `parse` to accept. + fn with_math_table(font: &[u8]) -> Vec { + let n = u16::from_be_bytes([font[4], font[5]]) as usize; + let mut dir: Vec<[u8; 16]> = (0..n).map(|i| font[12 + i * 16..28 + i * 16].try_into().unwrap()).collect(); + let mut math = vec![0u8, 1, 0, 0, 0, 10, 0, 0, 0, 0]; + math.resize(10 + 8 + VALUE_COUNT * 4 + 2, 0); + let data_start = 12 + (n + 1) * 16; + let end = font.len() + 16; + for e in &mut dir { + let off = u32::from_be_bytes([e[8], e[9], e[10], e[11]]) + 16; + e[8..12].copy_from_slice(&off.to_be_bytes()); + } + let mut entry = [0u8; 16]; + entry[..4].copy_from_slice(b"MATH"); + entry[8..12].copy_from_slice(&(end as u32).to_be_bytes()); + entry[12..16].copy_from_slice(&(math.len() as u32).to_be_bytes()); + let at = dir.iter().position(|e| e[..4] > entry[..4]).unwrap_or(dir.len()); + dir.insert(at, entry); + let mut out = font[..12].to_vec(); + out[4..6].copy_from_slice(&((n + 1) as u16).to_be_bytes()); + // Search fields are only hints for lookups; skrifa binary-searches the directory, so + // keep them consistent. + let entry_selector = (usize::BITS - 1 - (n + 1).leading_zeros()) as u16; + let search_range = (1u16 << entry_selector) * 16; + out[6..8].copy_from_slice(&search_range.to_be_bytes()); + out[8..10].copy_from_slice(&entry_selector.to_be_bytes()); + out[10..12].copy_from_slice(&(((n + 1) as u16) * 16 - search_range).to_be_bytes()); + for e in &dir { + out.extend_from_slice(e); + } + debug_assert_eq!(out.len(), data_start); + out.extend_from_slice(&font[12 + n * 16..]); + out.extend_from_slice(&math); + out + } + + #[test] + fn math_resolution_follows_installed_and_removed_fonts() { + use crate::fontdb::user::tests::renamed; + // 13 characters, as `renamed` requires. + let fam = "Zz Math Gen 1"; + let db = FontDb::global(); + assert_ne!(resolve_math(fam).0.family, fam); + let font = with_math_table(&renamed("SourceSans3-Regular.ttf", fam)); + db.install_font("ZzMathGen.ttf", &font).unwrap(); + let (face, table) = resolve_math(fam); + assert_eq!(face.family, fam, "a math font added after the first lookup must be used"); + assert!(table.is_some()); + db.remove_user_font(fam).unwrap(); + assert_ne!(resolve_math(fam).0.family, fam, "a removed font must stop being used"); + } + + #[test] + fn math_cache_stays_bounded() { + for i in 0..(MATH_CACHE_MAX + 50) { + let _ = resolve_math(&format!("Zz Bound Probe {i}")); + } + assert!(math_cache().lock().unwrap_or_else(|e| e.into_inner()).1.len() <= MATH_CACHE_MAX); + let long = "L".repeat(10_000); + let _ = resolve_math(&long); + assert!(!cached(&long), "absurdly long names must not be cached"); + } + #[test] fn garbage_tables_never_panic() { assert!(parse(&[]).is_none()); diff --git a/crates/fonts/src/word.rs b/crates/fonts/src/word.rs index a2869810b2..69a7034140 100644 --- a/crates/fonts/src/word.rs +++ b/crates/fonts/src/word.rs @@ -310,21 +310,31 @@ fn style_name(bold: bool, italic: bool) -> &'static str { } } +type Memo = (u64, HashMap<(String, bool, bool), Resolved>); + +fn resolve_cache() -> &'static Mutex { + static CACHE: OnceLock> = OnceLock::new(); + CACHE.get_or_init(|| Mutex::new((0, HashMap::new()))) +} + +const FAMILY_CACHE_MAX: usize = 4096; +/// Family names longer than this (bytes) are resolved but never remembered. +const FAMILY_KEY_MAX: usize = 256; + /// Resolve `family` with bold/italic to a face (memoised until user fonts are added or removed, /// [`FontDb::generation`]). pub fn resolve(family: &str, bold: bool, italic: bool) -> Resolved { - type Memo = (u64, HashMap<(String, bool, bool), Resolved>); - static CACHE: OnceLock> = OnceLock::new(); - let cache = CACHE.get_or_init(|| Mutex::new((0, HashMap::new()))); + let cache = resolve_cache(); let db = FontDb::global(); let generation = db.generation(); - let key = (family.to_string(), bold, italic); + let cacheable = family.len() <= FAMILY_KEY_MAX; + let key = (if cacheable { family.to_string() } else { String::new() }, bold, italic); { let mut c = cache.lock().unwrap_or_else(|e| e.into_inner()); if c.0 != generation { *c = (generation, HashMap::new()); } - if let Some(r) = c.1.get(&key) { + if cacheable && let Some(r) = c.1.get(&key) { return *r; } } @@ -341,7 +351,10 @@ pub fn resolve(family: &str, bold: bool, italic: bool) -> Resolved { let face = db.face(&fam, style_name(bold, italic)); let r = Resolved { synth_bold: bold && face.weight < 600.0, synth_italic: italic && !face.italic, substituted, face: FaceRef::of(&face) }; let mut c = cache.lock().unwrap_or_else(|e| e.into_inner()); - if c.0 == generation { + if cacheable && c.0 == generation { + if c.1.len() >= FAMILY_CACHE_MAX { + c.1.clear(); + } c.1.insert(key, r); } r @@ -394,6 +407,17 @@ mod tests { assert_eq!(hhea_line(1000, i16::MIN, 0), (1000.0, 32768.0)); } + #[test] + fn family_cache_stays_bounded() { + for i in 0..(FAMILY_CACHE_MAX + 50) { + let _ = resolve(&format!("Zz Missing Family {i}"), false, false); + } + assert!(resolve_cache().lock().unwrap_or_else(|e| e.into_inner()).1.len() <= FAMILY_CACHE_MAX); + let long = "L".repeat(10_000); + let _ = resolve(&long, false, false); + assert!(!resolve_cache().lock().unwrap_or_else(|e| e.into_inner()).1.keys().any(|k| k.0.len() > 1000)); + } + #[test] fn unknown_family_substitutes() { let r = resolve("Definitely Not A Font 123", false, false); From 6b192f58ce802b9d54e87e040d4bf5242bd5d747 Mon Sep 17 00:00:00 2001 From: NetterSilver <133535268+Silvertree2010@users.noreply.github.com> Date: Sun, 18 Oct 2026 10:41:20 +0200 Subject: [PATCH 24/38] Clamp negative margins before checking they leave text room (#1941) Found while testing crates/engine/src/cmd/page.rs:392 (margins) and :424 (margins_fit) Co-authored-by: Claude Haiku 5.5 --- crates/engine/src/cmd/page.rs | 14 ++++++++------ crates/engine/src/tests.rs | 14 ++++++++++++++ 2 files changed, 22 insertions(+), 6 deletions(-) diff --git a/crates/engine/src/cmd/page.rs b/crates/engine/src/cmd/page.rs index a521fc6103..6fdfa31abe 100644 --- a/crates/engine/src/cmd/page.rs +++ b/crates/engine/src/cmd/page.rs @@ -474,7 +474,9 @@ fn margins(s: &mut Session, v: &Value) -> CmdResult { ) } }; - let gutter = p::f32(v, "gutter"); + // Negative margins count as zero before the fit check, so one can't offset another. + let (t, b, l, r) = (t.max(0.0), b.max(0.0), l.max(0.0), r.max(0.0)); + let gutter = p::f32(v, "gutter").map(|g| g.max(0.0)); let cur = sect(s); if !margins_fit((cur.page_w, cur.page_h), (t, b, l, r), gutter.unwrap_or(cur.gutter)) { return Err(CmdError::Params("margins leave no room for text".into())); @@ -485,12 +487,12 @@ fn margins(s: &mut Session, v: &Value) -> CmdResult { s.doc.settings.mirror_margins = on; } with_sect(s, |x| { - x.margin_top = t.max(0.0); - x.margin_bottom = b.max(0.0); - x.margin_left = l.max(0.0); - x.margin_right = r.max(0.0); + x.margin_top = t; + x.margin_bottom = b; + x.margin_left = l; + x.margin_right = r; if let Some(g) = gutter { - x.gutter = g.max(0.0); + x.gutter = g; } }) } diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 72aaa9cee3..e2a86ec6e7 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -1935,6 +1935,20 @@ fn layout_size_refuses_a_page_its_margins_dont_fit() { assert_eq!((s.doc.last_section.page_w, s.doc.last_section.page_h), (180.0, 180.0), "exactly 36 pt of room is enough"); } +/// A negative margin (or gutter) counts as zero, so it can't offset an oversized one past the +/// text-room check: `layout.margins` refuses and changes nothing. +#[test] +fn layout_margins_negative_cannot_hide_an_oversized_margin() { + let mut s = s(); + run(&mut s, "layout.margins", json!({"preset": "normal"})); + let before = s.doc.last_section.clone(); + for v in [json!({"top": 2000, "bottom": -2000}), json!({"left": 2000, "right": -2000}), json!({"left": 600, "right": 0, "gutter": -600})] { + let e = s.run("layout.margins", &v).unwrap_err().to_string(); + assert!(e.contains("margins leave no room for text"), "{v}: {e}"); + assert_eq!(s.doc.last_section, before, "{v} changes nothing"); + } +} + /// #1433: the smallest page there can be (0.1" wide, no margins, set straight through Page /// Setup's section) still lays out and exports text, a table and two columns. #[test] From 82bf8061180d3ee3f7c9628143acbd98352b5cb6 Mon Sep 17 00:00:00 2001 From: Dmitrii Khlapov Date: Sun, 18 Oct 2026 10:41:39 +0200 Subject: [PATCH 25/38] Web: input-method rewrites erase before retyping, so non-Latin typing no longer repeats (part of #1480) (#1949) * WIP for #1480 * Web: an input method's DeleteSurrounding erases around the caret, so non-Latin typing no longer repeats (#1480) Co-Authored-By: Claude Opus 5.5 (1M context) * Web: input-method rewrites keep formatting and the typing undo group; AutoCorrect resets the web text field (#1480) Co-Authored-By: Claude Opus 5.5 (1M context) --------- 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 | 4 +- crates/engine/src/session.rs | 11 ++ crates/ui-egui/src/canvas.rs | 22 +++- crates/ui-egui/src/keys.rs | 219 ++++++++++++++++++++++++++++++++++- docs/typing-parity.md | 28 ++++- 5 files changed, 277 insertions(+), 7 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 2a640f38f0..f86174f527 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -2,7 +2,7 @@ **Stage: alpha** · next: beta, ~15 points (60% → 75% ready for real work) and ~150–230 h away -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-18 · **Change:** trivial (progress log: font family caches bounded and math font lookups follow font changes, #1354; previously trivial: `-->` AutoCorrect replacement, #2223; previously trivial: numbered headings, #1793; previously trivial: Navigation Find exposes Results, #2096; previously trivial: progress log: Columns on selected text, #2024; previously trivial: progress log: legacy outline, shadow, emboss and imprint text effects drawn, #2534; previously trivial: progress log: Indonesian proofing, #498; previously trivial: progress log: DOCX glossary documents (building blocks); previously trivial: progress log: Read Mode keeps the keyboard and shows whole, unobscured columns, #1772); previously trivial: progress log: font list entries of tall-ascent fonts no longer cut off, #2570); previously trivial: progress log: Split Cells and Convert Text to Table dialogs follow the Word check, #2592; previously trivial: a new source's Tag is made like Word's, three letters of the author and two digits of the year with a number added when taken, and an edit keeps the Tag, #2094; previously trivial: Web Layout fills the window width, #1876; previously trivial: progress log: Different First Page headers reachable from page 1, #2826; previously trivial: Language dialog keeps inherited proofing language and no-proof choices (#2717); previously trivial: progress log: web preferences kept between visits, #2132; previously trivial: progress log: undrawable private-use list bullets show the level's default bullet, #1683); previously trivial: table style editor formats every part, #714; previously trivial: VML freeforms read and kept on save; previously trivial: progress log: consistent preferred cell widths; previously trivial: multi-paragraph field instructions no longer add blank lines; previously trivial: page-break-only list paragraphs hide their labels; previously trivial: progress log: grouped `flowChartOr` shapes draw their circle and crossing spokes; previously trivial: progress log: copies keep their formatting in other programs, #1523; previously trivial: progress log: privacy options on save, #1062; previously trivial: progress log: equation pictures in theme colours on Windows, #1002; previously trivial: symbol fonts, #776; previously trivial: the save prompt answers from the keyboard, #861; previously trivial: table column and row markers on the rulers, #700; previously trivial: table borders and the resize handle drag on the page, #217; previously trivial: formatting revisions landed, #41; previously trivial: Draw tab ink, #307, and Draw Table and Eraser, #303, landed: catalog 402/434; previously minor: readiness table with hours per audience; full number back to the additive weighted sum, 60%; mainstream and essentials numbers added; alpha gate checked; previously major: full re-measure against Word for Mac 16.113.4; restructured to the craftrules progress-docs standard) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-18 · **Change:** trivial (progress log: web typing in non-Latin text no longer repeats, #1480; previously trivial: progress log: font family caches bounded and math font lookups follow font changes, #1354; previously trivial: `-->` AutoCorrect replacement, #2223; previously trivial: numbered headings, #1793; previously trivial: Navigation Find exposes Results, #2096; previously trivial: progress log: Columns on selected text, #2024; previously trivial: progress log: legacy outline, shadow, emboss and imprint text effects drawn, #2534; previously trivial: progress log: Indonesian proofing, #498; previously trivial: progress log: DOCX glossary documents (building blocks); previously trivial: progress log: Read Mode keeps the keyboard and shows whole, unobscured columns, #1772); previously trivial: progress log: font list entries of tall-ascent fonts no longer cut off, #2570); previously trivial: progress log: Split Cells and Convert Text to Table dialogs follow the Word check, #2592; previously trivial: a new source's Tag is made like Word's, three letters of the author and two digits of the year with a number added when taken, and an edit keeps the Tag, #2094; previously trivial: Web Layout fills the window width, #1876; previously trivial: progress log: Different First Page headers reachable from page 1, #2826; previously trivial: Language dialog keeps inherited proofing language and no-proof choices (#2717); previously trivial: progress log: web preferences kept between visits, #2132; previously trivial: progress log: undrawable private-use list bullets show the level's default bullet, #1683); previously trivial: table style editor formats every part, #714; previously trivial: VML freeforms read and kept on save; previously trivial: progress log: consistent preferred cell widths; previously trivial: multi-paragraph field instructions no longer add blank lines; previously trivial: page-break-only list paragraphs hide their labels; previously trivial: progress log: grouped `flowChartOr` shapes draw their circle and crossing spokes; previously trivial: progress log: copies keep their formatting in other programs, #1523; previously trivial: progress log: privacy options on save, #1062; previously trivial: progress log: equation pictures in theme colours on Windows, #1002; previously trivial: symbol fonts, #776; previously trivial: the save prompt answers from the keyboard, #861; previously trivial: table column and row markers on the rulers, #700; previously trivial: table borders and the resize handle drag on the page, #217; previously trivial: formatting revisions landed, #41; previously trivial: Draw tab ink, #307, and Draw Table and Eraser, #303, landed: catalog 402/434; previously minor: readiness table with hours per audience; full number back to the additive weighted sum, 60%; mainstream and essentials numbers added; alpha gate checked; previously major: full re-measure against Word for Mac 16.113.4; restructured to the craftrules progress-docs standard) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4 > **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-17 · **Change:** trivial (progress log: pasted blockquotes are indented paragraphs, #1270; previously trivial: progress log: print-area strings translated in every catalog; previously trivial: progress log: comments readable in the dark theme, #2045; previously trivial: progress log: ODT sections, #1379; previously trivial: progress log: table deletes, Change Case and Unlink Fields respect Track Changes and locked content controls, #1329; previously trivial: progress log: rounded rectangle corners, #1476; previously trivial: progress log: Save As type list, #1399; previously trivial: progress log: privacy options on save, #1062; previously trivial: progress log: equation pictures in theme colours on Windows, #1002; previously trivial: symbol fonts, #776; previously trivial: the save prompt answers from the keyboard, #861; previously trivial: table column and row markers on the rulers, #700; previously trivial: table borders and the resize handle drag on the page, #217; previously trivial: formatting revisions landed, #41; previously trivial: Draw tab ink, #307, and Draw Table and Eraser, #303, landed: catalog 402/434; previously minor: readiness table with hours per audience; full number back to the additive weighted sum, 60%; mainstream and essentials numbers added; alpha gate checked; previously major: full re-measure against Word for Mac 16.113.4; restructured to the craftrules progress-docs standard) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4 > **Printing update:** 2026-10-12 · direct desktop printing through craft-print; details below. @@ -137,6 +137,7 @@ Ranked, to beta (detail and remaining milestones in [docs/roadmap.md](docs/roadm - 2026-10-15: Find opens Navigation on Results; editing the query restores Results after manual Headings/Pages navigation. Typed and selected-text searches retain document contents (#2096). | Date | What landed | +| 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 | 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. | @@ -460,6 +461,7 @@ Ranked, to beta (detail and remaining milestones in [docs/roadmap.md](docs/roadm | 2026-10-15 | trivial | Progress log: Update Field updates the part being edited, as in Word (#1706) | | 2026-10-14 | trivial | Progress log: Update Fields reaches headers, footers and text boxes (#1706) | | 2026-10-15 | trivial | Progress log: font family caches bounded; math font resolution follows font changes (#1354) | +| 2026-10-15 | trivial | Progress log: web typing in non-Latin text no longer repeats (#1480) | | 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/engine/src/session.rs b/crates/engine/src/session.rs index 14a48e4dbd..e8e7613036 100644 --- a/crates/engine/src/session.rs +++ b/crates/engine/src/session.rs @@ -908,6 +908,17 @@ impl Session { core.last_modified_by = self.author.clone(); core.revision = core.revision.saturating_add(1); } + /// Whether typing is still collecting into one undo step (no other command since). + pub fn typing_open(&self) -> bool { + self.typing_open + } + /// Reopen the typing group when the last undo step is typing, so the keys typed next join + /// it: after an input method's rewrite was joined to it (`join_next_undo` closes the group). + pub fn continue_typing(&mut self) { + if self.undo_label() == Some("Typing") { + self.typing_open = true; + } + } /// Close an open typing group (caret moved, other command). pub fn close_typing(&mut self) { self.typing_open = false; diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 764a292c67..4873093495 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -151,6 +151,13 @@ pub struct CanvasState { /// The last key ran an Alt shortcut: the text its key sends (it may arrive a frame later) /// isn't typed. The next key press clears it. pub(crate) swallow_text: bool, + /// An input method's `DeleteSurrounding` just erased text with this formatting: the text it + /// sends next replaces it, keeps the formatting and joins the same undo step + /// (`keys::delete_surrounding`, #1480). + pub(crate) ime_join_text: Option, + /// An automatic change (AutoCorrect, AutoFormat) altered the text around the caret: the + /// input method is asked to start afresh (#1480). + pub(crate) ime_reset: bool, /// Alt+numpad entry in progress: the digits typed while Alt is held (#961). pub(crate) alt_code: String, /// The character an Alt code just typed: if the platform also sends its own composed @@ -232,6 +239,8 @@ impl Default for CanvasState { mini_dismissed: None, pasted: false, swallow_text: false, + ime_join_text: None, + ime_reset: false, alt_code: String::new(), alt_code_echo: None, wheel: Default::default(), @@ -1417,6 +1426,9 @@ pub fn show(app: &mut WordApp, ui: &mut Ui) { } // Caret. let focused = resp.has_focus() || app.canvas.focused; + // After an automatic change, the web's text agent starts afresh (`keys::note_automatic_change`). + // Only there: on X11 an interrupt recreates the input context and fcitx drops later keys (#1169). + let ime_reset = std::mem::take(&mut app.canvas.ime_reset) && cfg!(target_arch = "wasm32") && app.canvas.ime_preedit.is_empty(); // Editing an equation: shade it and draw the caret inside it. let mut math_caret = false; if let Some(m) = app.session.math.clone() @@ -1450,7 +1462,7 @@ pub fn show(app: &mut WordApp, ui: &mut Ui) { rect: cr, cursor_rect: cr, purpose: Default::default(), - should_interrupt_composition: false, + should_interrupt_composition: ime_reset, }); }); } @@ -1488,8 +1500,12 @@ pub fn show(app: &mut WordApp, ui: &mut Ui) { let cr = Rect::from_min_max(pos2(x, y0), pos2(x + 1.0, y1)); if focused { ui.ctx().output_mut(|o| { - o.ime = - Some(egui::output::IMEOutput { rect: cr, cursor_rect: cr, purpose: Default::default(), should_interrupt_composition: false }); + o.ime = Some(egui::output::IMEOutput { + rect: cr, + cursor_rect: cr, + purpose: Default::default(), + should_interrupt_composition: ime_reset, + }); }); } if !app.canvas.ime_preedit.is_empty() { diff --git a/crates/ui-egui/src/keys.rs b/crates/ui-egui/src/keys.rs index acd6b51218..3cce0a8741 100644 --- a/crates/ui-egui/src/keys.rs +++ b/crates/ui-egui/src/keys.rs @@ -239,12 +239,15 @@ pub fn canvas_events(app: &mut WordApp, ctx: &egui::Context) { return; } let alt_codes = alt_codes_supported(ctx); + // `DeleteSurrounding` and the text replacing what it erased arrive in the same frame. + app.canvas.ime_join_text = None; for e in events { // An Alt shortcut (Alt+Shift+D, Alt+F7…) may also send its key's text; it isn't typed. match &e { egui::Event::Key { pressed: true, .. } => { app.canvas.swallow_text = false; app.canvas.alt_code_echo = None; + app.canvas.ime_join_text = None; } egui::Event::Text(_) if std::mem::take(&mut app.canvas.swallow_text) => continue, egui::Event::Text(t) => { @@ -318,14 +321,15 @@ pub fn canvas_events(app: &mut WordApp, ctx: &egui::Context) { if t.chars().all(|c| !c.is_control()) && !t.is_empty() { // Typing with a text box selected types into it. crate::objects::enter_text_box(app); - let _ = app.run("text.insert", json!({"text": t})); + type_text(app, &t); } } + egui::Event::Ime(egui::ImeEvent::DeleteSurrounding { before_chars, after_chars }) => delete_surrounding(app, before_chars, after_chars), egui::Event::Ime(egui::ImeEvent::Preedit { text, .. }) => app.canvas.ime_preedit = text, egui::Event::Ime(egui::ImeEvent::Commit(text)) => { app.canvas.ime_preedit.clear(); if !text.is_empty() { - let _ = app.run("text.insert", json!({"text": text})); + type_text(app, &text); } } egui::Event::Paste(t) => { @@ -534,6 +538,77 @@ fn finish_alt_code(app: &mut WordApp) { } } +/// Typed text (a key's text or an input method's commit). Text replacing what an input method's +/// `DeleteSurrounding` just erased takes the erased text's formatting, never overtypes what +/// follows, and stays in the typing undo group (#1480). +fn type_text(app: &mut WordApp, text: &str) { + if let Some(props) = app.canvas.ime_join_text.take() { + app.session.join_next_undo(); + app.session.pending = Some(props); + let overtype = std::mem::replace(&mut app.session.prefs.overtype, false); + let typed = app.run("text.insert", json!({"text": text})).is_ok(); + app.session.prefs.overtype = overtype; + if typed { + app.session.continue_typing(); + } + } else { + let _ = app.run("text.insert", json!({"text": text})); + } + note_automatic_change(app); +} + +/// AutoCorrect or AutoFormat just changed the text around the caret (its own undo step, which +/// closes the typing group). The web's text agent still holds the text as typed and counts its +/// `DeleteSurrounding` from that, so it is asked to start afresh (`canvas::ime_reset`). +fn note_automatic_change(app: &mut WordApp) { + if !app.session.typing_open() && app.session.undo_label() == Some("AutoFormat") { + app.canvas.ime_reset = true; + } +} + +/// An input method's `DeleteSurrounding`: erase `before` characters (Unicode scalar values, as +/// the input method counts them) before the caret and `after` after it, as Backspace does over a +/// selection. The web build's text agent (eframe's hidden ``) sends it, then the new +/// text, whenever its value changes other than by an addition at its end (#1480): Backspace +/// and corrections from phone keyboards, and Chrome's caret jump. The desktop builds never get +/// it (egui-winit 0.36 doesn't send it). The counts come from outside: they stop at the +/// paragraph's ends, so a paragraph mark or list number is never erased. The erasure joins an +/// open typing group and the text that follows joins it too ([`type_text`]), so one Undo takes +/// back the typing with its correction. +/// With a selection (the agent starts afresh on any click or editing key, so it doesn't send +/// one then) nothing is erased: the text that follows replaces the selection as typing does. +/// Nor inside an equation, whose text the agent's characters went to. +fn delete_surrounding(app: &mut WordApp, before: usize, after: usize) { + let s = &app.session; + // Inside an equation the document's text isn't what the input method typed into. + if !s.sel.is_collapsed() || s.math.is_some() { + return; + } + let f = s.sel.focus.clone(); + let Some(text) = s.doc.para_at(&f).map(|p| p.text.as_str()) else { return }; + let (Some(head), Some(tail)) = (text.get(..f.off), text.get(f.off..)) else { return }; + let start = f.off.saturating_sub(head.chars().rev().take(before).map(char::len_utf8).sum::()); + let end = f.off.saturating_add(tail.chars().take(after).map(char::len_utf8).sum::()); + if start == end { + return; + } + let join = s.typing_open(); + // The formatting typing at the caret would use: that of the erased text (or set for it). + let props = s.typing_props(); + app.session.sel = + wordcraft_engine::Selection { anchor: wordcraft_doc::Pos { off: start, ..f.clone() }, focus: wordcraft_doc::Pos { off: end, ..f.clone() } }; + let caret = f; + if join { + app.session.join_next_undo(); + } + if app.run("text.backspace", json!({})).is_ok() { + app.canvas.ime_join_text = Some(props); + } else { + // Refused (a protected document): leave the caret where it was. + app.session.sel = wordcraft_engine::Selection::caret(caret); + } +} + /// Text arriving right after an Alt code typed `echo` (no key pressed between): whether it was /// the platform's composed character and is handled. The same character isn't typed twice. On /// Windows the text is always the composed character: for a code up to 255 it replaces ours (the @@ -779,4 +854,144 @@ mod tests { alt_frame(&ctx, &mut a, vec![egui::Event::Text("x".into())]); assert_eq!(text(&a), "ą€x"); } + + fn body(a: &WordApp) -> String { + a.session.doc.plain_text(wordcraft_doc::StoryRef::Body).trim_end_matches(['\r', '\n']).to_string() + } + + fn surround(before_chars: usize, after_chars: usize) -> egui::Event { + egui::Event::Ime(egui::ImeEvent::DeleteSurrounding { before_chars, after_chars }) + } + + /// The web's text agent keeps what it typed and, when its value changes other than by an + /// addition at the end, sends `DeleteSurrounding` for the changed tail and then the new text + /// (#1480). The document follows the agent's value instead of repeating it. + #[test] + fn delete_surrounding_from_the_web_text_agent_does_not_repeat_text() { + let (ctx, mut a) = alt_frame_app(); + // The trace from #1480: the agent's caret jumped to 0 after `м`, so it reported `иabм` + // and `риabм` as its whole new tail. + alt_frame(&ctx, &mut a, vec![egui::Event::Text("a".into())]); + alt_frame(&ctx, &mut a, vec![egui::Event::Text("b".into())]); + alt_frame(&ctx, &mut a, vec![egui::Event::Text("м".into())]); + alt_frame(&ctx, &mut a, vec![surround(3, 0), egui::Event::Text("иabм".into())]); + alt_frame(&ctx, &mut a, vec![surround(4, 0), egui::Event::Text("риabм".into())]); + assert_eq!(body(&a), "риabм"); + // A correction (`teh` -> `the`): erase three characters, type the replacement. + alt_frame(&ctx, &mut a, vec![egui::Event::Text(" teh".into())]); + alt_frame(&ctx, &mut a, vec![surround(3, 0), egui::Event::Text("the".into())]); + assert_eq!(body(&a), "риabм the"); + // A plain deletion (Android Backspace): no text follows, or an empty one. + alt_frame(&ctx, &mut a, vec![surround(1, 0), egui::Event::Text(String::new())]); + assert_eq!(body(&a), "риabм th"); + alt_frame(&ctx, &mut a, vec![surround(2, 0)]); + assert_eq!(body(&a), "риabм "); + } + + /// `after_chars` erases after the caret; both counts are characters, not bytes. + #[test] + fn delete_surrounding_erases_characters_on_both_sides_of_the_caret() { + let (ctx, mut a) = alt_frame_app(); + a.run("text.insert", json!({"text": "мир дом"})).unwrap(); + for _ in 0..3 { + a.run("caret.left", json!({})).unwrap(); + } + // The caret is after `мир `: two before (`р `) and one after (`д`). + alt_frame(&ctx, &mut a, vec![surround(2, 1)]); + assert_eq!(body(&a), "миом"); + alt_frame(&ctx, &mut a, vec![egui::Event::Text("X".into())]); + assert_eq!(body(&a), "миXом", "the caret stays where the deletion was"); + } + + /// The counts come from outside: larger than the paragraph they stop at its ends, never + /// joining paragraphs or panicking. + #[test] + fn delete_surrounding_stays_inside_the_paragraph() { + let (ctx, mut a) = alt_frame_app(); + a.run("document.setText", json!({"text": "one\ntwo\nthree"})).unwrap(); + a.run("caret.docStart", json!({})).unwrap(); + // After the `t` of `two`. + for _ in 0..5 { + a.run("caret.right", json!({})).unwrap(); + } + alt_frame(&ctx, &mut a, vec![surround(usize::MAX, usize::MAX)]); + assert_eq!(a.session.doc.plain_text(wordcraft_doc::StoryRef::Body).trim_end(), "one\n\nthree"); + alt_frame(&ctx, &mut a, vec![surround(5, 5), egui::Event::Text("2".into())]); + assert_eq!(a.session.doc.plain_text(wordcraft_doc::StoryRef::Body).trim_end(), "one\n2\nthree"); + } + + /// The agent's rewrite belongs to the typing it corrects: one Undo takes back the typing + /// and its correction together, not a half-erased word. + #[test] + fn delete_surrounding_joins_the_typing_undo_step() { + let (ctx, mut a) = alt_frame_app(); + alt_frame(&ctx, &mut a, vec![egui::Event::Text("teh".into())]); + alt_frame(&ctx, &mut a, vec![surround(3, 0), egui::Event::Text("the".into())]); + assert_eq!(body(&a), "the"); + // Typing goes on in the same group, not one step per keystroke. + alt_frame(&ctx, &mut a, vec![egui::Event::Text(" more".into())]); + alt_frame(&ctx, &mut a, vec![surround(4, 0), egui::Event::Text("mores".into())]); + alt_frame(&ctx, &mut a, vec![egui::Event::Text("t".into())]); + assert_eq!(body(&a), "the morest"); + assert_eq!(a.session.undo_label(), Some("Typing")); + a.run("edit.undo", json!({})).unwrap(); + assert_eq!(body(&a), ""); + } + + /// The rewritten text keeps the formatting of the text it replaces: a bold `helo` corrected + /// to `hello` stays bold, as typing over a word does. + #[test] + fn delete_surrounding_keeps_the_replaced_texts_formatting() { + let (ctx, mut a) = alt_frame_app(); + a.run("text.insert", json!({"text": "plain "})).unwrap(); + a.run("format.bold", json!({})).unwrap(); + alt_frame(&ctx, &mut a, vec![egui::Event::Text("helo".into())]); + alt_frame(&ctx, &mut a, vec![surround(4, 0), egui::Event::Text("hello".into())]); + assert_eq!(body(&a), "plain hello"); + let f = a.session.sel.focus.clone(); + let para = a.session.doc.para_at(&f).expect("a paragraph"); + let bold: Vec = para.text.char_indices().map(|(i, _)| para.props_of_char(i).bold == Some(true)).collect(); + assert_eq!(bold, [false, false, false, false, false, false, true, true, true, true, true], "{bold:?}"); + } + + /// Overtype replaces characters after the caret when typing, but the text an input method + /// sends to replace what it erased only replaces that. + #[test] + fn delete_surrounding_text_does_not_overtype() { + let (ctx, mut a) = alt_frame_app(); + a.run("text.insert", json!({"text": "texXY"})).unwrap(); + for _ in 0..2 { + a.run("caret.left", json!({})).unwrap(); + } + a.run("file.options", json!({"overtype": true})).unwrap(); + alt_frame(&ctx, &mut a, vec![surround(3, 0), egui::Event::Text("Texas".into())]); + assert_eq!(body(&a), "TexasXY"); + } + + /// A key pressed between the erasure and the text ends the input method's rewrite: the text + /// is ordinary typing, its own undo step. + #[test] + fn delete_surrounding_rewrite_ends_at_a_key_press() { + let (ctx, mut a) = alt_frame_app(); + alt_frame(&ctx, &mut a, vec![egui::Event::Text("ab".into())]); + let key = egui::Event::Key { key: Key::F20, physical_key: None, pressed: true, repeat: false, modifiers: Modifiers::NONE }; + alt_frame(&ctx, &mut a, vec![surround(1, 0), key, egui::Event::Text("X".into())]); + assert_eq!(body(&a), "aX"); + a.run("edit.undo", json!({})).unwrap(); + assert_eq!(body(&a), "a"); + } + + /// An automatic change while typing (AutoCorrect `(c)` -> `©`) changes the text behind the + /// web's text agent, so its later counts would be wrong: the page asks it to start afresh. + #[test] + fn automatic_change_asks_the_input_method_to_start_afresh() { + let (ctx, mut a) = alt_frame_app(); + alt_frame(&ctx, &mut a, vec![egui::Event::Text("Word".into())]); + alt_frame(&ctx, &mut a, vec![egui::Event::Text(" ".into())]); + assert!(!a.canvas.ime_reset, "plain typing keeps the agent's text"); + alt_frame(&ctx, &mut a, vec![egui::Event::Text("(c)".into())]); + alt_frame(&ctx, &mut a, vec![egui::Event::Text(" ".into())]); + assert_eq!(body(&a), "Word \u{a9} "); + assert!(a.canvas.ime_reset, "AutoCorrect changed the text"); + } } diff --git a/docs/typing-parity.md b/docs/typing-parity.md index f2be5dae4f..99d04cc139 100644 --- a/docs/typing-parity.md +++ b/docs/typing-parity.md @@ -1,6 +1,6 @@ # Typing parity with Word -> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-18 · **Change:** trivial (`-->` AutoCorrect replacement, #2223; previously trivial: a heading-list preset keeps Heading styles and makes other paragraphs Heading 1, as in Word, #1793; previously trivial: numbered headings: a heading-linked Multilevel List numbers new headings by their style; numbering keeps a heading a heading, #1793; previously trivial: reversing an automatic capital adds an exception, #1857; previously trivial: smart quotes open after guillemets and low quotes and nest; previously trivial: Enter on an empty list item numbered by its paragraph style moves it up a level, #2143; previously trivial: Backspace after a section break and Delete before it remove the break, also on the last paragraph, #2127; previously trivial: a table inserted in right-to-left text is right to left, so Tab moves left, #2634; previously trivial: Shift+Delete cuts the selection on every platform, #1669; previously trivial: Select All then Delete removes a table the document starts with, #1198; previously trivial: Alt+numpad character codes, #961; previously trivial: Down and Up from a line start beside a table go into it, #1007; previously trivial: sample-text formulas on Enter, #1067; previously trivial: Track Changes: Accept All after a tracked delete from a paragraph end keeps the next paragraph; Reject All after typing into an empty paragraph keeps it (#864); previously minor: Correct TWo INitial CApitals, cAPS LOCK and table-cell capitals, #544; previously trivial: complex-script caret and Backspace, #604; Move Paragraph Up/Down, Promote/Demote, Go Back and Alt+X keys, #343) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4 +> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-18 · **Change:** trivial (an input method's request to erase text around the caret is honoured, so web typing in Cyrillic and other non-Latin text no longer repeats, #1480; previously trivial: `-->` AutoCorrect replacement, #2223; previously trivial: a heading-list preset keeps Heading styles and makes other paragraphs Heading 1, as in Word, #1793; previously trivial: numbered headings: a heading-linked Multilevel List numbers new headings by their style; numbering keeps a heading a heading, #1793; previously trivial: reversing an automatic capital adds an exception, #1857; previously trivial: smart quotes open after guillemets and low quotes and nest; previously trivial: Enter on an empty list item numbered by its paragraph style moves it up a level, #2143; previously trivial: Backspace after a section break and Delete before it remove the break, also on the last paragraph, #2127; previously trivial: a table inserted in right-to-left text is right to left, so Tab moves left, #2634; previously trivial: Shift+Delete cuts the selection on every platform, #1669; previously trivial: Select All then Delete removes a table the document starts with, #1198; previously trivial: Alt+numpad character codes, #961; previously trivial: Down and Up from a line start beside a table go into it, #1007; previously trivial: sample-text formulas on Enter, #1067; previously trivial: Track Changes: Accept All after a tracked delete from a paragraph end keeps the next paragraph; Reject All after typing into an empty paragraph keeps it (#864); previously minor: Correct TWo INitial CApitals, cAPS LOCK and table-cell capitals, #544; previously trivial: complex-script caret and Backspace, #604; Move Paragraph Up/Down, Promote/Demote, Go Back and Alt+X keys, #343) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4 What Word does when you type, observed black-box (same keystrokes typed into Word and WordCraft, results read back paragraph by paragraph) and pinned by tests in @@ -210,8 +210,32 @@ Word. `myanmar_typing_steps_by_syllable_and_backspaces_by_sign`, `indic_and_khmer_backspace_removes_one_character`). +## Input methods + +- An input method can ask to erase characters before and after the caret (`DeleteSurrounding`) + and send replacement text. The web build's hidden text field does this whenever its contents + change other than by adding to the end: Backspace and corrections from phone keyboards, and + the Chrome caret jump below. The desktop builds never receive it (egui-winit 0.36 doesn't + send it). WordCraft erases those characters as Backspace erases a selection, stopping at the + paragraph's ends, then types the new text. The new text keeps the erased text's formatting + and never overtypes what follows. Erasing and retyping stay in the open typing undo step, and + typing after them joins it too (#1480, + `keys::tests::delete_surrounding_from_the_web_text_agent_does_not_repeat_text`, + `delete_surrounding_erases_characters_on_both_sides_of_the_caret`, + `delete_surrounding_stays_inside_the_paragraph`, `delete_surrounding_joins_the_typing_undo_step`, + `delete_surrounding_keeps_the_replaced_texts_formatting`, + `delete_surrounding_text_does_not_overtype`, `delete_surrounding_rewrite_ends_at_a_key_press`). +- After AutoCorrect or AutoFormat changes the text as you type, the web build tells its hidden + text field to start afresh. Its later counts then can't reach past the change + (`automatic_change_asks_the_input_method_to_start_afresh`). The reset itself happens in eframe + and hasn't been checked in a browser. + ## Known gaps +- Web (#1480): Chrome has been seen moving the caret of eframe's hidden text field to its + start after the first non-Latin character. Later letters then go in before what was typed + since the field was last cleared, so `hello` then `мир` gives `риhelloм`. The text is no + longer repeated, but the cause of the caret jump hasn't been found and isn't worked around. - Word's body font Aptos isn't installed outside Office; our substitute (Carlito, else Helvetica Neue) is wider, so lines wrap earlier than in Word. Needs a metric-matched open substitute. - Moving paragraphs with Track Changes on isn't recorded as a tracked move; whole table rows @@ -244,6 +268,8 @@ Word. | 2026-10-16 | trivial | #1793: a heading-linked Multilevel List preset makes every selected paragraph its first level (Heading 1), checked in Word for Mac; `1. ` in a heading checked in Word; a single Heading 2 becoming Heading 1 is unchecked | | 2026-10-15 | trivial | Numbered headings (#1793): a heading-linked Multilevel List numbers headings through their styles, so a new Heading 2 after the list is numbered by itself; Numbering and `1. ` keep a heading a heading; applying a numbered heading style numbers the paragraph | | 2026-10-15 | trivial | Add the missing `-->` → `→` AutoCorrect entry (#2223), with typing, undo and preference regression coverage; existing arrow bullets unchanged | +| 2026-10-15 | trivial | Input methods (#1480 review): the rewritten text keeps its formatting, doesn't overtype, keeps the typing undo group; AutoCorrect/AutoFormat reset the web text field; desktop doesn't receive `DeleteSurrounding` | +| 2026-10-15 | trivial | Input methods: `DeleteSurrounding` erases around the caret, so web typing in non-Latin text no longer repeats; Chrome's caret jump in the hidden field is listed as a known gap (#1480) | | 2026-10-14 | trivial | Shift+Delete cuts the selection, and does nothing without one, on macOS and Linux too, as in Word for Windows (#1669); Shift+Backspace already erased (#707) | | 2026-10-13 | trivial | Select All then Delete or Backspace removes a table the document starts with (#1198) | | 2026-10-12 | trivial | Editing keys: Down and Up from the start of a line beside a table go into it instead of skipping it (#1007) | From c9f3da2fc50c718e0ca3534be03fa0fd4825a477 Mon Sep 17 00:00:00 2001 From: Dmitrii Khlapov Date: Sun, 18 Oct 2026 10:41:59 +0200 Subject: [PATCH 26/38] DOCX: mixed Hebrew/Latin keeps its order in Word (runs split by direction, w:rtl and language) (#1543) (#1950) * WIP for #1543 * DOCX: split mixed-direction runs and mark right-to-left pieces w:rtl so Word orders them as WordCraft does (#1543) Co-Authored-By: Claude Opus 5.5 (1M context) * DOCX: right-to-left pieces of split runs say their language (w:lang w:bidi) (#1543) Co-Authored-By: Claude Opus 5.5 (1M context) * DOCX: no phantom tracked formatting on split runs; typed text doesn't inherit run direction (#1543) Co-Authored-By: Claude Opus 5.5 (1M context) * Engine: text typed or inserted anywhere takes its direction from its own letters, not the caret's run (#1543) Co-Authored-By: Claude Opus 5.5 (1M context) --------- 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 --- Cargo.lock | 1 + ROADMAP.md | 4 +- crates/docx/Cargo.toml | 1 + crates/docx/src/write/bidi.rs | 148 +++++++++++++ crates/docx/src/write/mod.rs | 1 + crates/docx/src/write/story.rs | 11 +- crates/docx/tests/bidi_runs.rs | 194 ++++++++++++++++++ .../docx/tests/complex_script_inheritance.rs | 71 ++++++- crates/engine/src/cmd/inspector.rs | 4 +- crates/engine/src/cmd/mod.rs | 4 + crates/engine/src/session.rs | 12 +- crates/engine/src/tests_bidi.rs | 53 +++++ docs/file-format-parity.md | 7 +- docs/typing-parity.md | 10 +- 14 files changed, 510 insertions(+), 11 deletions(-) create mode 100644 crates/docx/src/write/bidi.rs create mode 100644 crates/docx/tests/bidi_runs.rs diff --git a/Cargo.lock b/Cargo.lock index abfba697d8..ba8b474093 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5223,6 +5223,7 @@ dependencies = [ "sha1", "sha2", "thiserror 2.0.21", + "unicode-bidi", "wordcraft-doc", "wordcraft-geom", "zeroize", diff --git a/ROADMAP.md b/ROADMAP.md index f86174f527..3662b5843a 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -2,7 +2,7 @@ **Stage: alpha** · next: beta, ~15 points (60% → 75% ready for real work) and ~150–230 h away -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-18 · **Change:** trivial (progress log: web typing in non-Latin text no longer repeats, #1480; previously trivial: progress log: font family caches bounded and math font lookups follow font changes, #1354; previously trivial: `-->` AutoCorrect replacement, #2223; previously trivial: numbered headings, #1793; previously trivial: Navigation Find exposes Results, #2096; previously trivial: progress log: Columns on selected text, #2024; previously trivial: progress log: legacy outline, shadow, emboss and imprint text effects drawn, #2534; previously trivial: progress log: Indonesian proofing, #498; previously trivial: progress log: DOCX glossary documents (building blocks); previously trivial: progress log: Read Mode keeps the keyboard and shows whole, unobscured columns, #1772); previously trivial: progress log: font list entries of tall-ascent fonts no longer cut off, #2570); previously trivial: progress log: Split Cells and Convert Text to Table dialogs follow the Word check, #2592; previously trivial: a new source's Tag is made like Word's, three letters of the author and two digits of the year with a number added when taken, and an edit keeps the Tag, #2094; previously trivial: Web Layout fills the window width, #1876; previously trivial: progress log: Different First Page headers reachable from page 1, #2826; previously trivial: Language dialog keeps inherited proofing language and no-proof choices (#2717); previously trivial: progress log: web preferences kept between visits, #2132; previously trivial: progress log: undrawable private-use list bullets show the level's default bullet, #1683); previously trivial: table style editor formats every part, #714; previously trivial: VML freeforms read and kept on save; previously trivial: progress log: consistent preferred cell widths; previously trivial: multi-paragraph field instructions no longer add blank lines; previously trivial: page-break-only list paragraphs hide their labels; previously trivial: progress log: grouped `flowChartOr` shapes draw their circle and crossing spokes; previously trivial: progress log: copies keep their formatting in other programs, #1523; previously trivial: progress log: privacy options on save, #1062; previously trivial: progress log: equation pictures in theme colours on Windows, #1002; previously trivial: symbol fonts, #776; previously trivial: the save prompt answers from the keyboard, #861; previously trivial: table column and row markers on the rulers, #700; previously trivial: table borders and the resize handle drag on the page, #217; previously trivial: formatting revisions landed, #41; previously trivial: Draw tab ink, #307, and Draw Table and Eraser, #303, landed: catalog 402/434; previously minor: readiness table with hours per audience; full number back to the additive weighted sum, 60%; mainstream and essentials numbers added; alpha gate checked; previously major: full re-measure against Word for Mac 16.113.4; restructured to the craftrules progress-docs standard) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-18 · **Change:** trivial (progress log: mixed Hebrew/Latin text keeps its order in Word, #1543; previously trivial: progress log: web typing in non-Latin text no longer repeats, #1480; previously trivial: progress log: font family caches bounded and math font lookups follow font changes, #1354; previously trivial: `-->` AutoCorrect replacement, #2223; previously trivial: numbered headings, #1793; previously trivial: Navigation Find exposes Results, #2096; previously trivial: progress log: Columns on selected text, #2024; previously trivial: progress log: legacy outline, shadow, emboss and imprint text effects drawn, #2534; previously trivial: progress log: Indonesian proofing, #498; previously trivial: progress log: DOCX glossary documents (building blocks); previously trivial: progress log: Read Mode keeps the keyboard and shows whole, unobscured columns, #1772); previously trivial: progress log: font list entries of tall-ascent fonts no longer cut off, #2570); previously trivial: progress log: Split Cells and Convert Text to Table dialogs follow the Word check, #2592; previously trivial: a new source's Tag is made like Word's, three letters of the author and two digits of the year with a number added when taken, and an edit keeps the Tag, #2094; previously trivial: Web Layout fills the window width, #1876; previously trivial: progress log: Different First Page headers reachable from page 1, #2826; previously trivial: Language dialog keeps inherited proofing language and no-proof choices (#2717); previously trivial: progress log: web preferences kept between visits, #2132; previously trivial: progress log: undrawable private-use list bullets show the level's default bullet, #1683); previously trivial: table style editor formats every part, #714; previously trivial: VML freeforms read and kept on save; previously trivial: progress log: consistent preferred cell widths; previously trivial: multi-paragraph field instructions no longer add blank lines; previously trivial: page-break-only list paragraphs hide their labels; previously trivial: progress log: grouped `flowChartOr` shapes draw their circle and crossing spokes; previously trivial: progress log: copies keep their formatting in other programs, #1523; previously trivial: progress log: privacy options on save, #1062; previously trivial: progress log: equation pictures in theme colours on Windows, #1002; previously trivial: symbol fonts, #776; previously trivial: the save prompt answers from the keyboard, #861; previously trivial: table column and row markers on the rulers, #700; previously trivial: table borders and the resize handle drag on the page, #217; previously trivial: formatting revisions landed, #41; previously trivial: Draw tab ink, #307, and Draw Table and Eraser, #303, landed: catalog 402/434; previously minor: readiness table with hours per audience; full number back to the additive weighted sum, 60%; mainstream and essentials numbers added; alpha gate checked; previously major: full re-measure against Word for Mac 16.113.4; restructured to the craftrules progress-docs standard) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4 > **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-17 · **Change:** trivial (progress log: pasted blockquotes are indented paragraphs, #1270; previously trivial: progress log: print-area strings translated in every catalog; previously trivial: progress log: comments readable in the dark theme, #2045; previously trivial: progress log: ODT sections, #1379; previously trivial: progress log: table deletes, Change Case and Unlink Fields respect Track Changes and locked content controls, #1329; previously trivial: progress log: rounded rectangle corners, #1476; previously trivial: progress log: Save As type list, #1399; previously trivial: progress log: privacy options on save, #1062; previously trivial: progress log: equation pictures in theme colours on Windows, #1002; previously trivial: symbol fonts, #776; previously trivial: the save prompt answers from the keyboard, #861; previously trivial: table column and row markers on the rulers, #700; previously trivial: table borders and the resize handle drag on the page, #217; previously trivial: formatting revisions landed, #41; previously trivial: Draw tab ink, #307, and Draw Table and Eraser, #303, landed: catalog 402/434; previously minor: readiness table with hours per audience; full number back to the additive weighted sum, 60%; mainstream and essentials numbers added; alpha gate checked; previously major: full re-measure against Word for Mac 16.113.4; restructured to the craftrules progress-docs standard) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4 > **Printing update:** 2026-10-12 · direct desktop printing through craft-print; details below. @@ -137,6 +137,7 @@ Ranked, to beta (detail and remaining milestones in [docs/roadmap.md](docs/roadm - 2026-10-15: Find opens Navigation on Results; editing the query restores Results after manual Headings/Pages navigation. Typed and selected-text searches retain document contents (#2096). | Date | What landed | +| 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 | 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) | @@ -651,3 +652,4 @@ Ranked, to beta (detail and remaining milestones in [docs/roadmap.md](docs/roadm | 2026-10-06 | major | First honest estimate: 88% catalog, ~62% real parity, alpha ~85% of the way | | 2026-10-12 | trivial | Progress log: recipient filter and sort, .xls and legacy-encoding recipient lists (#778) | | 2026-10-15 | trivial | Progress log: dialog close box (#1885) | +| 2026-10-15 | trivial | Progress log: mixed Hebrew/Latin text keeps its order in Word (#1543) | diff --git a/crates/docx/Cargo.toml b/crates/docx/Cargo.toml index f51956f954..8e599b2a23 100644 --- a/crates/docx/Cargo.toml +++ b/crates/docx/Cargo.toml @@ -15,6 +15,7 @@ quick-xml = { workspace = true } zip = { workspace = true } thiserror = { workspace = true } log = { workspace = true } +unicode-bidi = { workspace = true } cfb = { workspace = true } aes = { workspace = true } sha1 = { workspace = true } diff --git a/crates/docx/src/write/bidi.rs b/crates/docx/src/write/bidi.rs new file mode 100644 index 0000000000..abc4eae5a6 --- /dev/null +++ b/crates/docx/src/write/bidi.rs @@ -0,0 +1,148 @@ +//! Run direction for mixed right-to-left and left-to-right text (#1543). +//! +//! WordCraft lays a paragraph out with the Unicode Bidirectional Algorithm (UAX #9) from its +//! text and direction alone, so a run need not say its direction. Word orders a run's characters +//! by the run's `w:rtl` (ECMA-376 Part 1 §17.3.2.30): text in a run without it is laid out left +//! to right, so Hebrew words on either side of a Latin name, and the punctuation between them, +//! change places. A run that doesn't say its direction is therefore split where the resolved +//! level changes parity, and the right-to-left pieces are written `w:rtl`, as Word writes text +//! typed in a right-to-left paragraph. Runs that say their direction, and paragraphs without +//! right-to-left characters, are written as they are. +//! +//! Word gives right-to-left text without a bidi language (`w:lang w:bidi`, §17.3.2.20) its +//! default complex-script language, Arabic, so Hebrew would proof and show as Arabic. The pieces +//! marked `w:rtl` therefore also say their language, unless the run or its styles already do. + +use std::borrow::Cow; +use std::ops::Range; + +use wordcraft_doc::props::CharProps; +use wordcraft_doc::styles::StyleSheet; + +/// The language Word expects for right-to-left text of `c`'s script, for the scripts that have +/// one usual language. +fn script_lang(c: char) -> Option<&'static str> { + match u32::from(c) { + 0x0590..=0x05FF | 0xFB1D..=0xFB4F => Some("he-IL"), + 0x0600..=0x06FF | 0x0750..=0x077F | 0x0870..=0x08FF | 0xFB50..=0xFDFF | 0xFE70..=0xFEFF => Some("ar-SA"), + 0x0700..=0x074F => Some("syr-SY"), + 0x0780..=0x07BF => Some("dv-MV"), + _ => None, + } +} + +/// The language of the first right-to-left letter of `text` with a usual language. +fn text_lang(text: &str) -> Option<&'static str> { + text.chars().find_map(script_lang) +} + +/// A language tag of a right-to-left language (`he-IL`, `yi`, `fa-IR`, `ar-EG`…)? +fn is_rtl_lang(tag: &str) -> bool { + let primary = tag.split(['-', '_']).next().unwrap_or("").to_ascii_lowercase(); + matches!(primary.as_str(), "he" | "iw" | "yi" | "ar" | "fa" | "ur" | "ps" | "sd" | "ug" | "ckb" | "syr" | "dv" | "ku") +} + +/// Does a paragraph of style `para_style` inherit a bidi language from the document defaults or +/// its style chain? (Resolved once per paragraph.) +pub(super) fn para_inherits_lang_bidi(styles: &StyleSheet, para_style: Option<&str>) -> bool { + styles.para_style_props(para_style.unwrap_or("Normal")).1.lang_bidi.is_some() +} + +/// Does `c`'s character style chain give it a bidi language? +pub(super) fn char_style_has_lang_bidi(styles: &StyleSheet, c: &CharProps) -> bool { + c.style.as_deref().is_some_and(|s| styles.chain(s).iter().any(|st| st.chr.lang_bidi.is_some())) +} + +/// Add `rtl` and, when given, the bidi language `lang` to `c` where it doesn't say them. +fn direct(c: &mut CharProps, lang: Option<&String>) { + c.rtl.get_or_insert(true); + if let Some(l) = lang + && c.lang_bidi.is_none() + { + c.lang_bidi = Some(l.clone()); + } +} + +/// `runs` (byte ranges of `text` with their props), split where the bidi level of `text` in a +/// paragraph of direction `rtl_para` changes parity; right-to-left pieces get `rtl: Some(true)` +/// and, unless they have one or `inherits_lang` says it comes from a style, a bidi language. +pub(super) fn direction_spans<'a>( + text: &str, + rtl_para: bool, + runs: impl Iterator, &'a CharProps)>, + inherits_lang: impl Fn(&CharProps) -> bool, +) -> Vec<(Range, Cow<'a, CharProps>)> { + if !wordcraft_doc::bidi::has_rtl(text) { + return runs.map(|(r, p)| (r, Cow::Borrowed(p))).collect(); + } + let level = if rtl_para { unicode_bidi::Level::rtl() } else { unicode_bidi::Level::ltr() }; + let info = unicode_bidi::ParagraphBidiInfo::new(text, Some(level)); + let odd = |at: usize| info.levels.get(at).is_some_and(|l| l.is_rtl()); + let para_lang = text_lang(text); + let mut out = Vec::new(); + for (range, props) in runs { + let Some(run) = text.get(range.clone()).filter(|_| props.rtl.is_none()) else { + out.push((range, Cow::Borrowed(props))); + continue; + }; + let named = props.lang_bidi.is_some() || inherits_lang(props); + let piece = |r: Range, rtl: bool| { + if !rtl { + return (r, Cow::Borrowed(props)); + } + let lang = if named { + None + } else { + let own = props.lang.clone().filter(|l| is_rtl_lang(l)); + own.or_else(|| text.get(r.clone()).and_then(text_lang).or(para_lang).map(str::to_string)) + }; + let mut p = props.clone(); + direct(&mut p, lang.as_ref()); + // A tracked formatting change's "before" copy gets the same, so the change lists + // nothing the author didn't do. + if let Some(ch) = p.fmt_change.as_mut() { + direct(&mut ch.old, lang.as_ref()); + } + (r, Cow::Owned(p)) + }; + let mut start = range.start; + let mut cur: Option = None; + for (i, _) in run.char_indices() { + let at = range.start.saturating_add(i); + let rtl = odd(at); + match cur { + Some(c) if c != rtl => { + out.push(piece(start..at, c)); + start = at; + } + _ => {} + } + cur = Some(rtl); + } + out.push(piece(start..range.end, cur.unwrap_or(false))); + } + out +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn hostile_ranges_never_panic() { + let p = CharProps::default(); + let text = "עברית WordCraft"; + let runs = vec![(0..3, &p), (3..1000, &p), (usize::MAX - 1..usize::MAX, &p), (2..text.len(), &p)]; + let out = direction_spans(text, true, runs.into_iter(), |_| false); + assert_eq!(out.len(), 5, "bad ranges pass through unsplit: {out:?}"); + } + + #[test] + fn languages_by_script() { + assert_eq!(text_lang("(שלום"), Some("he-IL")); + assert_eq!(text_lang("سلام"), Some("ar-SA")); + assert_eq!(text_lang("ܫܠܡܐ"), Some("syr-SY")); + assert_eq!(text_lang(". ,"), None); + assert!(is_rtl_lang("he-IL") && is_rtl_lang("yi") && is_rtl_lang("FA_ir") && !is_rtl_lang("en-US") && !is_rtl_lang("")); + } +} diff --git a/crates/docx/src/write/mod.rs b/crates/docx/src/write/mod.rs index 02ef90256e..0fd312badd 100644 --- a/crates/docx/src/write/mod.rs +++ b/crates/docx/src/write/mod.rs @@ -1,5 +1,6 @@ //! [`Document`] → DOCX. +mod bidi; mod embed; mod glossary; mod settings_merge; diff --git a/crates/docx/src/write/story.rs b/crates/docx/src/write/story.rs index 7e91b46587..ba6142fc11 100644 --- a/crates/docx/src/write/story.rs +++ b/crates/docx/src/write/story.rs @@ -247,7 +247,16 @@ impl Writer<'_> { // Split into pieces with their props. let mut pieces: Vec<(Piece, &CharProps)> = Vec::new(); let mut k = 0usize; - for (range, props) in p.run_ranges() { + // Runs of mixed direction are split so Word orders them as WordCraft does (#1543). + let rtl_para = self.doc.styles.resolve_para(&p.props).bidi; + let styles = &self.doc.styles; + let para_style = p.props.style.as_deref(); + let para_lang = super::bidi::para_inherits_lang_bidi(styles, para_style); + let spans = + super::bidi::direction_spans(&p.text, rtl_para, p.run_ranges(), |c| para_lang || super::bidi::char_style_has_lang_bidi(styles, c)); + for (range, props) in &spans { + let props: &CharProps = props; + let range = range.clone(); let base = range.start; let Some(text) = p.text.get(range) else { continue }; let mut buf = String::new(); diff --git a/crates/docx/tests/bidi_runs.rs b/crates/docx/tests/bidi_runs.rs new file mode 100644 index 0000000000..067918f26b --- /dev/null +++ b/crates/docx/tests/bidi_runs.rs @@ -0,0 +1,194 @@ +//! Runs of mixed right-to-left and left-to-right text (#1543). WordCraft lays a paragraph out with +//! the Unicode Bidirectional Algorithm (UAX #9) from its text and direction alone, while Word +//! orders a run's characters by the run's `w:rtl` (ECMA-376 Part 1 §17.3.2.30): text in a run +//! without it is laid out left to right, so Hebrew words around a Latin name, and the punctuation +//! between them, change places. The writer therefore splits such a run where the resolved +//! direction changes and marks the right-to-left pieces `w:rtl`, as Word does with text typed +//! in a right-to-left paragraph. + +use std::io::Read; + +use wordcraft_doc::props::CharProps; +use wordcraft_doc::{Document, Paragraph, para_block}; + +/// `word/document.xml` of a written package. +fn document_xml(bytes: &[u8]) -> String { + let mut z = zip::ZipArchive::new(std::io::Cursor::new(bytes)).unwrap(); + let mut s = String::new(); + z.by_name("word/document.xml").unwrap().read_to_string(&mut s).unwrap(); + s +} + +/// The runs of the first paragraph of `xml`: (has `w:rtl`, text with `w:br` as `\n`). +fn runs(xml: &str) -> Vec<(bool, String)> { + let body = xml.split("").nth(1).unwrap(); + let para = body.split("").next().unwrap(); + para.split("") + .skip(1) + .map(|r| { + let r = r.split("").next().unwrap(); + let rtl = r.contains(""); + let mut text = String::new(); + for part in r.split('<').skip(1) { + if part.starts_with("w:br/>") { + text.push('\n'); + } else if part.starts_with("w:t ") || part.starts_with("w:t>") { + text.push_str(part.split_once('>').unwrap().1); + } + } + (rtl, text) + }) + .collect() +} + +fn arial() -> CharProps { + CharProps { font: Some("Arial".into()), font_cs: Some("Arial".into()), size: Some(16.0), size_cs: Some(16.0), ..Default::default() } +} + +fn doc_of(text: &str, rtl_para: bool, props: CharProps) -> Document { + let mut p = Paragraph::with_text(text, props); + if rtl_para { + p.props.bidi = Some(true); + } + let mut d = Document::new(); + d.body = vec![para_block(p)]; + d +} + +fn written_runs(d: &Document) -> Vec<(bool, String)> { + runs(&document_xml(&wordcraft_docx::write(d).unwrap())) +} + +fn owned(v: &[(bool, &str)]) -> Vec<(bool, String)> { + v.iter().map(|(r, t)| (*r, (*t).to_string())).collect() +} + +#[test] +fn rtl_paragraph_splits_latin_out_of_rtl_runs() { + let d = doc_of("עברית WordCraft 2026 ואחריה עוד עברית.", true, arial()); + assert_eq!(written_runs(&d), owned(&[(true, "עברית "), (false, "WordCraft 2026"), (true, " ואחריה עוד עברית.")])); +} + +#[test] +fn rtl_paragraph_brackets_email_and_final_stop_take_the_paragraph_direction() { + let d = doc_of("לפני (ABC 123) אחרי; דוא״ל test@example.com.", true, arial()); + assert_eq!(written_runs(&d), owned(&[(true, "לפני ("), (false, "ABC 123"), (true, ") אחרי; דוא״ל "), (false, "test@example.com"), (true, ".")])); +} + +#[test] +fn line_breaks_stay_in_the_split_runs() { + let d = doc_of("שלום עולם.\nעברית WordCraft 2026 ואחריה.", true, arial()); + assert_eq!(written_runs(&d), owned(&[(true, "שלום עולם.\nעברית "), (false, "WordCraft 2026"), (true, " ואחריה.")])); +} + +#[test] +fn split_runs_keep_their_formatting() { + let d = doc_of("עברית WordCraft עברית", true, arial()); + let xml = document_xml(&wordcraft_docx::write(&d).unwrap()); + let body = xml.split("").nth(1).unwrap(); + assert_eq!(body.matches("").count(), 3); + assert_eq!(body.matches(r#""#).count(), 4, "three runs and the mark: {body}"); + assert_eq!(body.matches(r#""#).count(), 4, "{body}"); +} + +#[test] +fn hebrew_inside_an_ltr_paragraph_is_marked_right_to_left() { + let d = doc_of("English with עברית מעורבת inside.", false, CharProps::default()); + assert_eq!(written_runs(&d), owned(&[(false, "English with "), (true, "עברית מעורבת"), (false, " inside.")])); +} + +#[test] +fn text_without_rtl_characters_is_one_run() { + let d = doc_of("Plain English, 2026.", true, arial()); + assert_eq!(written_runs(&d), owned(&[(false, "Plain English, 2026.")])); + let d = doc_of("Plain English, 2026.", false, arial()); + assert_eq!(written_runs(&d), owned(&[(false, "Plain English, 2026.")])); +} + +#[test] +fn explicit_run_direction_is_kept() { + // A run that already says its direction is written as it is, unsplit. + let d = doc_of("עברית WordCraft", true, CharProps { rtl: Some(true), ..arial() }); + assert_eq!(written_runs(&d), owned(&[(true, "עברית WordCraft")])); + let d = doc_of("עברית WordCraft", true, CharProps { rtl: Some(false), ..arial() }); + let xml = document_xml(&wordcraft_docx::write(&d).unwrap()); + assert!(xml.contains(r#""#), "{xml}"); + assert_eq!(runs(&xml).len(), 1); +} + +#[test] +fn mixed_paragraph_round_trips_text_and_direction() { + let text = "לפני (ABC 123) אחרי; דוא״ל test@example.com.\nעברית WordCraft 2026 ואחריה עוד עברית."; + let d = doc_of(text, true, arial()); + let bytes = wordcraft_docx::write(&d).unwrap(); + let r = wordcraft_docx::read(&bytes).unwrap(); + let p = r.body.iter().find_map(|b| b.as_para()).unwrap(); + assert_eq!(p.text, text); + assert_eq!(p.props.bidi, Some(true)); + for run in &p.runs { + assert_eq!(run.props.font.as_deref(), Some("Arial")); + assert_eq!(run.props.size, Some(16.0)); + } + assert!(p.runs.iter().any(|r| r.props.rtl == Some(true))); + // Saving again writes the same runs: the read-back direction is now explicit. + assert_eq!(written_runs(&r), written_runs(&d)); +} + +// Word gives right-to-left text without a `w:lang w:bidi` its default complex-script language +// (Arabic), so its status bar, proofing and hyphenation call Hebrew "Arabic". Right-to-left +// pieces the writer marks say their language (§17.3.2.20), unless the run or a style already does. + +/// `w:bidi` attribute of each run's `w:lang` in the first paragraph: (has `w:rtl`, bidi language). +fn run_langs(xml: &str) -> Vec<(bool, Option)> { + let body = xml.split("").nth(1).unwrap(); + let para = body.split("").next().unwrap(); + para.split("") + .skip(1) + .map(|r| { + let r = r.split("").next().unwrap(); + let lang = r.split(""), lang) + }) + .collect() +} + +fn langs(d: &Document) -> Vec<(bool, Option)> { + run_langs(&document_xml(&wordcraft_docx::write(d).unwrap())) +} + +fn he(rtl: bool) -> (bool, Option) { + (rtl, rtl.then(|| "he-IL".to_string())) +} + +#[test] +fn hebrew_pieces_say_they_are_hebrew() { + let d = doc_of("לפני (ABC 123) אחרי; דוא״ל test@example.com.", true, arial()); + // The final stop has no letters of its own; it takes the paragraph's language. + assert_eq!(langs(&d), vec![he(true), he(false), he(true), he(false), he(true)]); + let d = doc_of("English with עברית מעורבת inside.", false, CharProps::default()); + assert_eq!(langs(&d), vec![he(false), he(true), he(false)]); +} + +#[test] +fn arabic_pieces_say_they_are_arabic() { + let d = doc_of("مرحبا WordCraft بالعالم", true, arial()); + let ar = Some("ar-SA".to_string()); + assert_eq!(langs(&d), vec![(true, ar.clone()), (false, None), (true, ar)]); +} + +#[test] +fn a_language_the_run_already_has_is_kept() { + let d = doc_of("سلام WordCraft دنیا", true, CharProps { lang_bidi: Some("fa-IR".into()), ..arial() }); + let fa = Some("fa-IR".to_string()); + assert_eq!(langs(&d), vec![(true, fa.clone()), (false, fa.clone()), (true, fa)]); + // A right-to-left proofing language on the run (w:val) names the right-to-left text too. + let d = doc_of("שלום WordCraft", true, CharProps { lang: Some("yi".into()), ..arial() }); + assert_eq!(langs(&d), vec![(true, Some("yi".into())), (false, None)]); +} + +#[test] +fn an_inherited_bidi_language_is_not_repeated() { + let mut d = doc_of("سلام WordCraft دنیا", true, arial()); + d.styles.default_chr.lang_bidi = Some("fa-IR".into()); + assert_eq!(langs(&d), vec![(true, None), (false, None), (true, None)]); +} diff --git a/crates/docx/tests/complex_script_inheritance.rs b/crates/docx/tests/complex_script_inheritance.rs index 92e11ea3ce..d86e6f2a1f 100644 --- a/crates/docx/tests/complex_script_inheritance.rs +++ b/crates/docx/tests/complex_script_inheritance.rs @@ -2,7 +2,8 @@ //! Synthetic packages, written here; no external documents or installed fonts are needed. use std::io::Write; -use wordcraft_doc::{Document, Paragraph}; +use wordcraft_doc::props::CharProps; +use wordcraft_doc::{Document, Paragraph, Run}; fn package(run: &str, defaults: &str, styles: &str, paragraph_style: &str) -> Vec { let ns = r#"xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main""#; @@ -29,13 +30,41 @@ fn paragraph(d: &Document) -> &Paragraph { d.body.iter().find_map(|b| b.as_para()).unwrap() } +/// `runs` without run direction and bidi language (in the formatting change too), adjacent equal +/// runs merged. +fn undirected(runs: &[Run]) -> Vec { + let strip = |c: &mut CharProps| { + c.rtl = None; + c.lang_bidi = None; + }; + let mut out: Vec = Vec::new(); + for r in runs { + let mut props = r.props.clone(); + strip(&mut props); + if let Some(ch) = props.fmt_change.as_mut() { + strip(&mut ch.old); + } + match out.last_mut() { + Some(last) if last.props == props => last.len += r.len, + _ => out.push(Run { len: r.len, props }), + } + } + out +} + fn check_generations(bytes: &[u8], check: impl Fn(&Document)) { let mut d = wordcraft_docx::read(bytes).unwrap(); - for _ in 0..3 { + for generation in 0..3 { check(&d); let next = wordcraft_docx::read(&wordcraft_docx::write(&d).unwrap()).unwrap(); assert_eq!(paragraph(&next).text, paragraph(&d).text); - assert_eq!(paragraph(&next).runs, paragraph(&d).runs); + if generation == 0 { + // The first save splits the mixed Hebrew/Latin run by direction and names the + // Hebrew's language (#1543); apart from that nothing changes. + assert_eq!(undirected(¶graph(&next).runs), undirected(¶graph(&d).runs)); + } else { + assert_eq!(paragraph(&next).runs, paragraph(&d).runs); + } assert_eq!(paragraph(&next).props.bidi, Some(true)); d = next; } @@ -133,3 +162,39 @@ fn tracked_formatting_keeps_independent_complex_script_values() { assert_eq!(overlaid.fmt_change, c.fmt_change); }); } + +/// `(w:rtl, w:lang)` elements of a run's current `w:rPr` and of its `w:rPrChange` "before" copy. +fn direction_markup(run: &str) -> (String, String) { + let pick = |rpr: &str| -> String { + rpr.split('<').filter(|t| t.starts_with("w:rtl") || t.starts_with("w:lang")).map(|t| format!("<{t}")).collect::>().join("") + }; + let (now, change) = run.split_once(""#; + let d = wordcraft_docx::read(&package(run, "", "", "")).unwrap(); + let bytes = wordcraft_docx::write(&d).unwrap(); + let mut xml = String::new(); + std::io::Read::read_to_string(&mut zip::ZipArchive::new(std::io::Cursor::new(&bytes)).unwrap().by_name("word/document.xml").unwrap(), &mut xml) + .unwrap(); + let runs: Vec<&str> = xml.split("").skip(1).map(|r| r.split("").next().unwrap()).collect(); + assert!(runs.len() >= 2, "the mixed run is split: {xml}"); + assert!(runs.iter().any(|r| r.contains("")), "{xml}"); + for r in runs { + assert!(r.contains(" ParaProps { fn char_props(s: &Session) -> CharProps { let (a, b) = s.sel.ordered(); if a == b { - return s.typing_props(); + return s.caret_props(); } match s.doc.para_at(&a) { Some(p) if a.off < p.len() => p.props_of_char(a.off).clone(), - _ => s.typing_props(), + _ => s.caret_props(), } } diff --git a/crates/engine/src/cmd/mod.rs b/crates/engine/src/cmd/mod.rs index 8881ebf7f3..7e36ba619d 100644 --- a/crates/engine/src/cmd/mod.rs +++ b/crates/engine/src/cmd/mod.rs @@ -290,6 +290,10 @@ fn insert_text(s: &mut Session, text: &str, enter: bool) -> Result<(), CmdError> delete_selection(s)?; // Typed text is new: never deleted, and not a formatting change of the text around it. props.fmt_change = None; + // Its direction comes from its own letters, as on screen, not from the run it's typed into: + // Latin typed into reopened Hebrew must not be saved as right-to-left (#1543). The DOCX + // writer marks the right-to-left pieces itself. + props.rtl = None; if s.doc.settings.track_changes { props.ins = Some(new_revision(s, RevisionKind::Insert)); props.del = None; diff --git a/crates/engine/src/session.rs b/crates/engine/src/session.rs index e8e7613036..9839501124 100644 --- a/crates/engine/src/session.rs +++ b/crates/engine/src/session.rs @@ -1354,8 +1354,18 @@ impl Session { self.sel.focus.story } - /// Formatting typing would use at the caret. + /// Formatting typing would use at the caret: the caret's, except run direction. New text + /// takes its direction from its own letters, as on screen, so Latin typed or inserted into + /// reopened Hebrew isn't saved as right-to-left; the DOCX writer marks right-to-left pieces + /// itself (#1543). The bidi language stays (it only applies to complex-script text). pub fn typing_props(&self) -> CharProps { + let mut c = self.caret_props(); + c.rtl = None; + c + } + + /// The caret's formatting, as Reveal Formatting shows it. + pub fn caret_props(&self) -> CharProps { if let Some(p) = &self.pending { return p.clone(); } diff --git a/crates/engine/src/tests_bidi.rs b/crates/engine/src/tests_bidi.rs index 4eca74d77a..f6e50fc8dc 100644 --- a/crates/engine/src/tests_bidi.rs +++ b/crates/engine/src/tests_bidi.rs @@ -269,3 +269,56 @@ fn typing_kurdish_in_new_document_advances_caret_smoothly() { assert!(last_x < first_x.unwrap(), "caret should advance leftwards overall"); assert_eq!(props(&s, 0).bidi, Some(true), "paragraph auto-detected as right-to-left"); } + +/// The runs of the first paragraph in a saved DOCX: (has `w:rtl`, text). +fn saved_runs(s: &Session) -> Vec<(bool, String)> { + use std::io::Read; + let bytes = crate::io::save_bytes("x.docx", &s.doc).unwrap(); + let mut xml = String::new(); + zip::ZipArchive::new(std::io::Cursor::new(bytes)).unwrap().by_name("word/document.xml").unwrap().read_to_string(&mut xml).unwrap(); + let para = xml.split("").nth(1).unwrap().split("").next().unwrap().to_string(); + para.split("") + .skip(1) + .map(|r| { + let r = r.split("").next().unwrap(); + let text: String = r.split("").skip(1).filter_map(|t| t.split("").next()).collect(); + (r.contains(""), text) + }) + .collect() +} + +#[test] +fn latin_typed_into_reopened_hebrew_is_saved_left_to_right() { + // #1543: a saved file's Hebrew runs say `w:rtl`. Latin typed next to them after reopening + // takes its direction from its own letters, as on screen, so it's saved in a run of its own + // without `w:rtl` (Word would otherwise lay it out right to left again). + let s = session("לפני אחרי", true); + let bytes = crate::io::save_bytes("x.docx", &s.doc).unwrap(); + let mut s = Session::new(crate::io::open_bytes("x.docx", &bytes).unwrap()); + assert!(s.doc.para(StoryRef::Body, &wordcraft_doc::Path::top(0)).unwrap().runs.iter().all(|r| r.props.rtl == Some(true))); + s.sel = crate::Selection::caret(Pos::new(StoryRef::Body, wordcraft_doc::Path::top(0), "לפני ".len())); + run(&mut s, "text.insert", json!({"text": "Foo Bar 2026 "})); + let runs = saved_runs(&s); + assert!(runs.contains(&(false, "Foo Bar 2026".to_string())), "{runs:?}"); + assert!(runs.iter().all(|(rtl, t)| !rtl || !t.chars().any(|c| c.is_ascii_alphanumeric())), "{runs:?}"); + assert_eq!(runs.iter().map(|(_, t)| t.as_str()).collect::(), "לפני Foo Bar 2026 אחרי"); +} + +#[test] +fn a_latin_link_inserted_into_reopened_hebrew_is_saved_left_to_right() { + // Other insertions start from the caret's formatting too (#1543): a link's Latin text + // inserted between reopened Hebrew words isn't saved as right-to-left. + let s = session("לפני אחרי", true); + let bytes = crate::io::save_bytes("x.docx", &s.doc).unwrap(); + let mut s = Session::new(crate::io::open_bytes("x.docx", &bytes).unwrap()); + s.sel = crate::Selection::caret(Pos::new(StoryRef::Body, wordcraft_doc::Path::top(0), "לפני ".len())); + run(&mut s, "insert.link", json!({"url": "https://example.com", "text": "WordCraft site"})); + let runs = saved_runs(&s); + assert!(runs.contains(&(false, "WordCraft site".to_string())), "{runs:?}"); + // The caret's own formatting, as Reveal Formatting shows it, still says right to left. + s.sel = crate::Selection::caret(Pos::new(StoryRef::Body, wordcraft_doc::Path::top(0), "לפ".len())); + s.pending = None; // moving the caret by hand doesn't drop the link's pending formatting + assert_eq!(s.caret_props().rtl, Some(true)); + assert_eq!(s.typing_props().rtl, None); + assert_eq!(s.typing_props().lang_bidi.as_deref(), Some("he-IL"), "the bidi language stays"); +} diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index 37036dd534..3038c391c8 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -1,6 +1,6 @@ # File-format parity with Microsoft Word -> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-18 · **Change:** trivial (`w:doNotTrackMoves` round-trips and turns move tracking off; previously trivial: legacy outline, shadow, emboss and imprint text effects drawn, #2534; previously trivial: DOCX glossary documents (building blocks) read, kept and written; previously trivial: cached field results keep their visible run formatting; previously trivial: cached REF results retain hyperlink destinations; previously trivial: CLI conversions can explicitly refresh fields; previously trivial: a copied equation also goes in the clipboard's MathML formats, #1297; previously trivial: a pasted HTML `
` is an indented paragraph, not the Quote style, #1270); previously trivial: every table style part round-trips, #714; previously trivial: VML freeforms read and kept on save; previously trivial: multi-paragraph field instructions no longer add blank lines; previously trivial: `flowChartOr` draws its circle and cross spokes, as in Word; previously trivial: RTF: Mac OS Roman and DOS text, #1653; previously trivial: VML text boxes keep their internal margins, fit to their text and vertical alignment; hidden drawings not shown; previously trivial: PDF export draws horizontal and vertical lines inside groups, #1474; previously trivial: symbol-font text exported as Unicode, #776; previously trivial: text-box margins, Resize shape to fit text and freeforms in groups, #743; previously trivial: formatted paste from the system clipboard's HTML and RTF, #687; previously trivial: independent complex-script formatting inheritance and round-trip regression tests; previously trivial: import/export fixes for TXT, RTF, ODT, HTML and LaTeX, #433–#440; previously major: first version; every format Word reads or writes, measured from the readers and writers on origin/main) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4, plus Word for Windows' extra formats +> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-18 · **Change:** trivial (DOCX: mixed right-to-left runs split and marked `w:rtl` with their language, #1543; previously trivial: `w:doNotTrackMoves` round-trips and turns move tracking off; previously trivial: legacy outline, shadow, emboss and imprint text effects drawn, #2534; previously trivial: DOCX glossary documents (building blocks) read, kept and written; previously trivial: cached field results keep their visible run formatting; previously trivial: cached REF results retain hyperlink destinations; previously trivial: CLI conversions can explicitly refresh fields; previously trivial: a copied equation also goes in the clipboard's MathML formats, #1297; previously trivial: a pasted HTML `
` is an indented paragraph, not the Quote style, #1270); previously trivial: every table style part round-trips, #714; previously trivial: VML freeforms read and kept on save; previously trivial: multi-paragraph field instructions no longer add blank lines; previously trivial: `flowChartOr` draws its circle and cross spokes, as in Word; previously trivial: RTF: Mac OS Roman and DOS text, #1653; previously trivial: VML text boxes keep their internal margins, fit to their text and vertical alignment; hidden drawings not shown; previously trivial: PDF export draws horizontal and vertical lines inside groups, #1474; previously trivial: symbol-font text exported as Unicode, #776; previously trivial: text-box margins, Resize shape to fit text and freeforms in groups, #743; previously trivial: formatted paste from the system clipboard's HTML and RTF, #687; previously trivial: independent complex-script formatting inheritance and round-trip regression tests; previously trivial: import/export fixes for TXT, RTF, ODT, HTML and LaTeX, #433–#440; previously major: first version; every format Word reads or writes, measured from the readers and writers on origin/main) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4, plus Word for Windows' extra formats Every format Word opens or saves, what WordCraft does with it, and how it's tested. Fidelity is the share of a typical real-world file's content and formatting that survives (read: shows correctly; @@ -35,7 +35,7 @@ Weights are the share of a Word user's file traffic (estimate). Read/write: ✅ | Format | Weight | Word | WordCraft read | WordCraft write | Fidelity | Hours | How it's tested | Notes | |---|---|---|---|---|---|---|---|---| | `.docx` page background picture/texture (`v:background/v:fill`) | — | R/W | ✅ | ✅ | ~80% | — | `roundtrip.rs::page_background_picture_round_trips`, `crates/render` | Image part and `type`/`o:title` kept; tiled at 96 dpi or stretched; drawn on screen and in PDF; no Fill Effects dialog yet, gradients and patterns are not read | -| `.docx` Word Document (ECMA-376 Transitional) | 70% | R/W | ✅ | ✅ | ~65% | 70–110 | `crates/docx/tests` (roundtrip 34, fixtures 29, malformed 8, bidi 5, citations 5); synthetic fixtures only | Word opens our files. Untested on a real-world corpus. Tracked changes inside a field result stay revisions (the field is kept as a range, #1037). Details below | +| `.docx` Word Document (ECMA-376 Transitional) | 70% | R/W | ✅ | ✅ | ~65% | 70–110 | `crates/docx/tests` (roundtrip 34, fixtures 29, malformed 8, bidi 5, bidi_runs 12, citations 5); synthetic fixtures only | Word opens our files. Untested on a real-world corpus. Tracked changes inside a field result stay revisions (the field is kept as a range, #1037). Details below | | `.docx` Strict Open XML | 1% | R/W | ✅ (namespace mapping in `xml.rs`) | ❌ | ~60% | 2–4 | one fixture | Writing Strict is rare | | `.docm` / `.dotx` / `.dotm` | 3% | R/W | ✅ | ✅ | ~65% | incl. above | `macro_packages.rs` | Macros and signatures kept on save (#172); macros never run | | `.doc` Word 97-2003 (and `.dot`) | 8% | R/W | 🟡 | ❌ | ~60% | 25–40 | `crates/docbin/src/tests.rs`, `tests/drawing.rs` | Spec-based reader ([MS-DOC]): text, formatting, styles, sections, headers/footers, tables, lists, notes, fields, bookmarks; inline and floating pictures (PNG, JPEG, DIB, TIFF, EMF, WMF; Macintosh PICT as a sized placeholder) with crop, alt text, position and wrap; basic shapes, lines, groups and text boxes ([MS-ODRAW]). Table cell shading from every SHD record (`sprmTDefTableShd`, `sprmTDefTableShdRaw` and their 2nd/3rd ranges, with the Word 97 `Shd80` as fallback; a clear pattern shows its background colour); the row-end mark is not a cell, and each table has its own grid and properties (#1380). Horizontally merged rows stored as fewer, wider cells come with #1123. Freeform shapes, z-order and Word 6/95 files are dropped or refused. Password-protected files (XOR obfuscation, RC4, RC4 CryptoAPI) open after a password prompt, from the public specs; not yet tried on a file Word wrote (AES-encrypted ones are refused). No writer. DIB conversion caps BMP bytes (header included) and encoded PNG at the supplied BLIP byte limit, and uses it as the decoder's allocation limit; a BMP fallback is kept if it fits (related #1334 and #1554). | @@ -65,6 +65,8 @@ Measured by grepping `crates/docx/src/read` and `src/write` for the OOXML elemen | Feature | Read | Write | Notes | |---|---|---|---| +| Text, runs, paragraph and character properties, styles, numbering, sections | ✅ | ✅ | Including a Styles gallery for files that mark no style `w:qFormat` (LibreOffice: Word's usual quick styles are added, #1238), complex-script props (`w:rtl`, `w:cs`, `w:szCs`, `w:bCs`…); runs that mix right-to-left and left-to-right text are split on save where the direction changes, and the right-to-left pieces get `w:rtl` and a bidi language (`w:lang w:bidi`, e.g. he-IL), so Word lays Hebrew around Latin names, numbers and e-mail addresses out as WordCraft does and doesn't call it Arabic (#1543, checked in Word for Mac 16.113.4), East Asian fonts kept apart from Latin (#111); independent complex-script formatting inherited through defaults/styles and preserved over repeated saves (`complex_script_inheritance.rs`), indents in characters (`w:firstLineChars`, `w:hangingChars`, `w:leftChars`, `w:rightChars`, #534); every ST_NumberFormat value of `w:numFmt` is kept on save, Chinese/Japanese/Korean, enclosed and full-width formats are drawn, the rest (Hebrew, Arabic, Hindi, Thai…) show decimal (#534). `w:beforeLines`/`w:afterLines` not kept (the points are) | +| Tables incl. floating (`w:tblpPr`), table styles and conditional formatting | ✅ | ✅ | Custom table styles round-trip (#256); right-to-left tables (`w:bidiVisual`) and sections (`w:bidi`) round-trip (#362) | | Text, runs, paragraph and character properties, styles, numbering, sections | ✅ | ✅ | Including complex-script props (`w:rtl`, `w:cs`, `w:szCs`, `w:bCs`…), East Asian fonts kept apart from Latin (#111); independent complex-script formatting inherited through defaults/styles and preserved over repeated saves (`complex_script_inheritance.rs`), indents in characters (`w:firstLineChars`, `w:hangingChars`, `w:leftChars`, `w:rightChars`, #534); every ST_NumberFormat value of `w:numFmt` is kept on save, Chinese/Japanese/Korean, enclosed and full-width formats are drawn, the rest (Hebrew, Arabic, Hindi, Thai…) show decimal (#534). `w:beforeLines`/`w:afterLines` not kept (the points are) | | Tables incl. floating (`w:tblpPr`), table styles and conditional formatting | ✅ | ✅ | Custom table styles round-trip (#256), every `w:tblStylePr` part (wholeTable, firstRow, lastRow, firstCol, lastCol, band1/2Vert, band1/2Horz, ne/nw/se/swCell) with run formatting, `w:jc`, cell borders, shading and `w:vAlign` (#714); a cell's own `w:vAlign w:val="top"` is kept and beats the style's (#1288); right-to-left tables (`w:bidiVisual`) and sections (`w:bidi`) round-trip (#362) | | Text, runs, paragraph and character properties, styles, numbering, sections | ✅ | ✅ | Including a Styles gallery for files that mark no style `w:qFormat` (LibreOffice: Word's usual quick styles are added, #1238), complex-script props (`w:rtl`, `w:cs`, `w:szCs`, `w:bCs`…), East Asian fonts kept apart from Latin (#111); independent complex-script formatting inherited through defaults/styles and preserved over repeated saves (`complex_script_inheritance.rs`), indents in characters (`w:firstLineChars`, `w:hangingChars`, `w:leftChars`, `w:rightChars`, #534); every ST_NumberFormat value of `w:numFmt` is kept on save, Chinese/Japanese/Korean, enclosed and full-width formats are drawn, the rest (Hebrew, Arabic, Hindi, Thai…) show decimal (#534). `w:beforeLines`/`w:afterLines` not kept (the points are) | @@ -330,3 +332,4 @@ Imported text fields use the first nonempty cached result run's formatting on th | 2026-10-12 | trivial | Charts of types WordCraft doesn't draw (radar, stock…) show a placeholder with their title and type (#998) | | 2026-10-13 | trivial | DOCX styles without any `w:qFormat` (LibreOffice) get Word's usual Styles gallery: matching built-in styles are marked quick and missing quick built-ins added (#1238) | | 2026-10-12 | trivial | Recipient lists: legacy .xls workbooks and GBK/Big5/Shift_JIS/UTF-16 CSV/TXT (#778); plain-text import detects legacy encodings too | +| 2026-10-15 | trivial | DOCX: runs mixing right-to-left and left-to-right text are split by direction on save; right-to-left pieces get `w:rtl` and `w:lang w:bidi`, so Word shows the order WordCraft shows (#1543) | diff --git a/docs/typing-parity.md b/docs/typing-parity.md index 99d04cc139..4581f84c66 100644 --- a/docs/typing-parity.md +++ b/docs/typing-parity.md @@ -1,6 +1,6 @@ # Typing parity with Word -> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-18 · **Change:** trivial (an input method's request to erase text around the caret is honoured, so web typing in Cyrillic and other non-Latin text no longer repeats, #1480; previously trivial: `-->` AutoCorrect replacement, #2223; previously trivial: a heading-list preset keeps Heading styles and makes other paragraphs Heading 1, as in Word, #1793; previously trivial: numbered headings: a heading-linked Multilevel List numbers new headings by their style; numbering keeps a heading a heading, #1793; previously trivial: reversing an automatic capital adds an exception, #1857; previously trivial: smart quotes open after guillemets and low quotes and nest; previously trivial: Enter on an empty list item numbered by its paragraph style moves it up a level, #2143; previously trivial: Backspace after a section break and Delete before it remove the break, also on the last paragraph, #2127; previously trivial: a table inserted in right-to-left text is right to left, so Tab moves left, #2634; previously trivial: Shift+Delete cuts the selection on every platform, #1669; previously trivial: Select All then Delete removes a table the document starts with, #1198; previously trivial: Alt+numpad character codes, #961; previously trivial: Down and Up from a line start beside a table go into it, #1007; previously trivial: sample-text formulas on Enter, #1067; previously trivial: Track Changes: Accept All after a tracked delete from a paragraph end keeps the next paragraph; Reject All after typing into an empty paragraph keeps it (#864); previously minor: Correct TWo INitial CApitals, cAPS LOCK and table-cell capitals, #544; previously trivial: complex-script caret and Backspace, #604; Move Paragraph Up/Down, Promote/Demote, Go Back and Alt+X keys, #343) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4 +> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-18 · **Change:** trivial (typed text takes its direction from its own letters, #1543; previously trivial: an input method's request to erase text around the caret is honoured, so web typing in Cyrillic and other non-Latin text no longer repeats, #1480; previously trivial: `-->` AutoCorrect replacement, #2223; previously trivial: a heading-list preset keeps Heading styles and makes other paragraphs Heading 1, as in Word, #1793; previously trivial: numbered headings: a heading-linked Multilevel List numbers new headings by their style; numbering keeps a heading a heading, #1793; previously trivial: reversing an automatic capital adds an exception, #1857; previously trivial: smart quotes open after guillemets and low quotes and nest; previously trivial: Enter on an empty list item numbered by its paragraph style moves it up a level, #2143; previously trivial: Backspace after a section break and Delete before it remove the break, also on the last paragraph, #2127; previously trivial: a table inserted in right-to-left text is right to left, so Tab moves left, #2634; previously trivial: Shift+Delete cuts the selection on every platform, #1669; previously trivial: Select All then Delete removes a table the document starts with, #1198; previously trivial: Alt+numpad character codes, #961; previously trivial: Down and Up from a line start beside a table go into it, #1007; previously trivial: sample-text formulas on Enter, #1067; previously trivial: Track Changes: Accept All after a tracked delete from a paragraph end keeps the next paragraph; Reject All after typing into an empty paragraph keeps it (#864); previously minor: Correct TWo INitial CApitals, cAPS LOCK and table-cell capitals, #544; previously trivial: complex-script caret and Backspace, #604; Move Paragraph Up/Down, Promote/Demote, Go Back and Alt+X keys, #343) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4 What Word does when you type, observed black-box (same keystrokes typed into Word and WordCraft, results read back paragraph by paragraph) and pinned by tests in @@ -194,6 +194,13 @@ Word. right), as in Word, so Tab moves to the cell on the left (the next one in reading order) and Shift+Tab back; in a left-to-right table Tab still moves right (#2634, `tab_in_a_table_inserted_in_right_to_left_text_moves_left`). +- Text typed or inserted (links, fields, symbols, pasted text with merged formatting) takes its + direction from its own letters, not from the run around the caret, keeping only that run's + bidi language: Latin typed into Hebrew from a reopened file is saved as left-to-right text, and + the saved file marks the right-to-left pieces `w:rtl` itself (#1543, + `latin_typed_into_reopened_hebrew_is_saved_left_to_right`). Known display difference: spaces, + digits and punctuation typed into reopened Hebrew text draw in the Latin font, not the + complex-script font, until the file is saved and reopened. ## Layout @@ -299,3 +306,4 @@ Word. | 2026-10-10 | trivial | Status line, revision history and cross-links added (progress-docs standard) | | 2026-10-10 | minor | Track Changes rows: tracked Enter, Backspace and Delete on paragraph marks (#244) | | 2026-10-10 | major | First version: lists, AutoCorrect and AutoFormat as you type, observed against Word (#204) | +| 2026-10-15 | trivial | Text direction: typed and inserted text takes its direction from its own letters, not the caret's run; spaces typed into reopened Hebrew draw in the Latin font until reopened (#1543) | From 3787af0a6709151db9b07fa79de8a39afdbcad77 Mon Sep 17 00:00:00 2001 From: Dmitrii Khlapov Date: Sun, 18 Oct 2026 10:42:17 +0200 Subject: [PATCH 27/38] Rendering and PDF check image limits before allocating (#1321) (#2001) * WIP for #1321 * Images: check size budgets before decoding and cap page rasters (#1321) Co-Authored-By: Claude Opus 5.5 (1M context) * Images: charge decode peak, valid-CRC header tests, cap text-line rasters (#1321) Co-Authored-By: Claude Opus 5.5 (1M context) --------- 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/geom/src/lib.rs | 2 + crates/geom/src/pixels.rs | 65 ++++++++++++++++++++++++++ crates/pdf/src/lib.rs | 16 +++++-- crates/pdf/src/tests.rs | 39 ++++++++++++++++ crates/render/src/lib.rs | 97 +++++++++++++++++++++++++++++++++++---- 6 files changed, 209 insertions(+), 12 deletions(-) create mode 100644 crates/geom/src/pixels.rs diff --git a/ROADMAP.md b/ROADMAP.md index 3662b5843a..fccd2ed30b 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -137,6 +137,7 @@ Ranked, to beta (detail and remaining milestones in [docs/roadmap.md](docs/roadm - 2026-10-15: Find opens Navigation on Results; editing the query restores Results after manual Headings/Pages navigation. Typed and selected-text searches retain document contents (#2096). | Date | What landed | +| 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 | |---|---| @@ -445,6 +446,7 @@ Ranked, to beta (detail and remaining milestones in [docs/roadmap.md](docs/roadm | 2026-10-16 | trivial | Progress log: Web Layout fills the window width (#1876) | | 2026-10-17 | trivial | Progress log: DOCX glossary documents (building blocks) | | 2026-10-15 | trivial | Progress log: move options in Track Changes Options (gap #5) | +| 2026-10-15 | trivial | Progress log: image size limits checked before allocation (#1321) | | 2026-10-17 | trivial | Language dialog keeps inherited proofing language and no-proof choices (#2717); readiness estimates unchanged | | 2026-10-17 | trivial | Explicit DrawingML preset/custom outline dashes survive import/save and paint in raster/PDF, scaled to the effective stroke width even for zero-width paths. Synthetic controls; theme-inherited dashes and line caps remain unverified. | | 2026-10-15 | trivial | `wordcraft-cli convert --update-fields` explicitly refreshes unlocked fields before conversion; ordinary conversion keeps cached results. Field refresh can change pagination | diff --git a/crates/geom/src/lib.rs b/crates/geom/src/lib.rs index 8673b70649..1c8c8590cc 100644 --- a/crates/geom/src/lib.rs +++ b/crates/geom/src/lib.rs @@ -10,6 +10,8 @@ use serde::{Deserialize, Serialize}; mod region; pub use region::Region; +mod pixels; +pub use pixels::{MAX_DECODED_BYTES, MAX_RASTER_PIXELS, decode_fits, fit_pixels, rgba_bytes, rgba_fits}; /// Points per inch. pub const PT_PER_IN: f32 = 72.0; diff --git a/crates/geom/src/pixels.rs b/crates/geom/src/pixels.rs new file mode 100644 index 0000000000..64258a71f8 --- /dev/null +++ b/crates/geom/src/pixels.rs @@ -0,0 +1,65 @@ +//! Pixel-budget arithmetic shared by every crate that decodes or allocates rasters: sizes are +//! checked before anything is allocated, so a hostile header or request cannot exhaust memory. + +/// Bytes a decoded picture may take as RGBA8 (what the `image` crate's default decode budget is). +pub const MAX_DECODED_BYTES: u64 = 512 * 1024 * 1024; + +/// Most pixels one page raster may hold (400 MB of RGBA8); larger requests are scaled down. +pub const MAX_RASTER_PIXELS: u64 = 100_000_000; + +/// Bytes of a `w`×`h` RGBA8 raster, None on overflow. +pub fn rgba_bytes(w: u32, h: u32) -> Option { + (w as u64).checked_mul(h as u64)?.checked_mul(4) +} + +/// Whether a `w`×`h` RGBA8 raster fits `budget` bytes. +pub fn rgba_fits(w: u32, h: u32, budget: u64) -> bool { + w > 0 && h > 0 && rgba_bytes(w, h).is_some_and(|b| b <= budget) +} + +/// Whether decoding a `w`×`h` picture fits `budget` bytes at its peak: the decoder's buffer and +/// the RGBA conversion (or its premultiplied copy) are alive together, so two RGBA buffers. +pub fn decode_fits(w: u32, h: u32, budget: u64) -> bool { + w > 0 && h > 0 && rgba_bytes(w, h).and_then(|b| b.checked_mul(2)).is_some_and(|b| b <= budget) +} + +/// `w`×`h` shrunk (aspect kept, each side at least 1) until it holds at most `max_pixels`. +pub fn fit_pixels(w: u32, h: u32, max_pixels: u64) -> (u32, u32) { + let (w, h) = (w.max(1), h.max(1)); + let max_pixels = max_pixels.max(1); + let area = w as u64 * h as u64; + if area <= max_pixels { + return (w, h); + } + let f = (max_pixels as f64 / area as f64).sqrt(); + let nh = ((h as f64 * f) as u64).clamp(1, h as u64); + let nw = ((w as f64 * f) as u64).clamp(1, w as u64).min(max_pixels / nh).max(1); + let nh = nh.min(max_pixels / nw).max(1); + (nw as u32, nh as u32) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn rgba_budget_boundary() { + assert_eq!(rgba_bytes(16, 8), Some(512)); + assert!(rgba_fits(16, 8, 512)); + assert!(!rgba_fits(16, 8, 511)); + assert!(!rgba_fits(0, 8, 512), "empty is not a picture"); + assert!(decode_fits(16, 8, 1024)); + assert!(!decode_fits(16, 8, 1023), "decode + conversion hold two buffers"); + assert!(!decode_fits(0, 8, u64::MAX)); + } + + #[test] + fn fit_pixels_keeps_normal_sizes_and_shrinks_huge_ones() { + assert_eq!(fit_pixels(612, 792, MAX_RASTER_PIXELS), (612, 792)); + let (w, h) = fit_pixels(16_000, 16_000, 1_000_000); + assert!(w as u64 * h as u64 <= 1_000_000 && w >= 900, "{w}x{h}"); + let (w, h) = fit_pixels(16_000, 1, 100); + assert!(w as u64 * h as u64 <= 100 && h == 1, "{w}x{h}"); + assert_eq!(fit_pixels(0, 5, 10).0, 1); + } +} diff --git a/crates/pdf/src/lib.rs b/crates/pdf/src/lib.rs index e0719ef229..fb3812d954 100644 --- a/crates/pdf/src/lib.rs +++ b/crates/pdf/src/lib.rs @@ -1680,10 +1680,18 @@ fn path_from(b: &kurbo::BezPath) -> Option { /// Decode a picture for embedding (validated with the `image` crate first, so a broken file /// can't fail the whole export). fn load_image(data: &Arc>) -> Option { + load_image_within(data, wordcraft_geom::MAX_DECODED_BYTES) +} + +/// [`load_image`] with an explicit RGBA byte budget (a small one in tests). +fn load_image_within(data: &Arc>, budget: u64) -> Option { let fmt = image::guess_format(data).ok()?; - // The side check below used to run only after a full decode; applying it as a decode limit - // rejects a small file that declares a huge image at header time instead. The default - // allocation cap (512 MB, what `load_from_memory_with_format` applied) stays in force. + // Header-only size check before anything is allocated (decode buffer + RGBA conversion are + // charged); the side limits and the default 512 MB allocation cap stay as a second guard. + let (hw, hh) = image::ImageReader::new(std::io::Cursor::new(data.as_slice())).with_guessed_format().ok()?.into_dimensions().ok()?; + if !wordcraft_geom::decode_fits(hw, hh, budget) { + return None; + } let mut reader = image::ImageReader::new(std::io::Cursor::new(data.as_slice())).with_guessed_format().ok()?; let mut limits = image::Limits::default(); limits.max_image_width = Some(MAX_IMAGE_SIDE); @@ -1697,7 +1705,7 @@ fn load_image(data: &Arc>) -> Option { if fmt == image::ImageFormat::Jpeg && matches!(dec.color(), image::ColorType::L8 | image::ColorType::Rgb8) { return Image::from_jpeg(data.clone().into(), true).ok(); } - Some(Image::from_rgba8(dec.to_rgba8().into_raw(), w, h)) + Some(Image::from_rgba8(dec.into_rgba8().into_raw(), w, h)) } #[cfg(test)] diff --git a/crates/pdf/src/tests.rs b/crates/pdf/src/tests.rs index 1b470745c9..f45c4eb907 100644 --- a/crates/pdf/src/tests.rs +++ b/crates/pdf/src/tests.rs @@ -1461,6 +1461,32 @@ fn right_to_left_clusters_are_written_whole_left_to_right() { assert_eq!(visual_order(text, &glyphs, &ranges, &parts), [5, 4, 2, 3, 0, 1]); } +/// A PNG signature, an IHDR declaring `w`×`h` 8-bit RGBA and a short IDAT (all CRCs valid, so +/// the header parses), with no real pixel data. +fn ihdr_only_png(w: u32, h: u32) -> Vec { + fn chunk(out: &mut Vec, kind: &[u8; 4], data: &[u8]) { + let mut body = kind.to_vec(); + body.extend_from_slice(data); + let mut crc = 0xFFFF_FFFFu32; + for b in &body { + crc ^= *b as u32; + for _ in 0..8 { + crc = if crc & 1 != 0 { (crc >> 1) ^ 0xEDB8_8320 } else { crc >> 1 }; + } + } + out.extend_from_slice(&(data.len() as u32).to_be_bytes()); + out.extend_from_slice(&body); + out.extend_from_slice(&(!crc).to_be_bytes()); + } + let mut out = vec![0x89, b'P', b'N', b'G', 0x0D, 0x0A, 0x1A, 0x0A]; + let mut ihdr = w.to_be_bytes().to_vec(); + ihdr.extend_from_slice(&h.to_be_bytes()); + ihdr.extend_from_slice(&[8, 6, 0, 0, 0]); + chunk(&mut out, b"IHDR", &ihdr); + chunk(&mut out, b"IDAT", &[0x78, 0x9c, 0, 0]); + out +} + #[test] fn extreme_page_index_returns_an_error() { let d = Document::from_text("Hostile page selection"); @@ -1623,3 +1649,16 @@ fn zero_width_connector_dashes() { assert!(raw.contains("[3 2.25]0 d"), "stroke-relative dash array for {kind:?}"); } } + +#[test] +fn image_budget_is_checked_from_the_header() { + let data = Arc::new(png()); + // The decoded image and its RGBA conversion are charged: two buffers at peak. + assert!(load_image_within(&data, 1024).is_some()); + assert!(load_image_within(&data, 1023).is_none()); + // A header (valid CRC) claiming 30000x30000 with no pixel data reads fine, then the budget refuses it. + let hdr = ihdr_only_png(30_000, 30_000); + let dims = image::ImageReader::new(std::io::Cursor::new(hdr.as_slice())).with_guessed_format().unwrap().into_dimensions().unwrap(); + assert_eq!(dims, (30_000, 30_000)); + assert!(load_image(&Arc::new(hdr)).is_none()); +} diff --git a/crates/render/src/lib.rs b/crates/render/src/lib.rs index 1126e4ee1d..df7bfa2f15 100644 --- a/crates/render/src/lib.rs +++ b/crates/render/src/lib.rs @@ -58,7 +58,11 @@ impl Rendered { /// PNG bytes (empty on failure, logged). pub fn to_png(&self) -> Vec { let mut px = self.to_straight(); - px.resize(self.width as usize * self.height as usize * 4, 0); + let Some(len) = wordcraft_geom::rgba_bytes(self.width, self.height).and_then(|b| usize::try_from(b).ok()) else { + log::error!("PNG encode: size {}×{} overflows", self.width, self.height); + return Vec::new(); + }; + px.resize(len, 0); let Some(img) = image::RgbaImage::from_raw(self.width, self.height, px) else { log::error!("PNG encode: bad size {}×{}", self.width, self.height); return Vec::new(); @@ -175,12 +179,22 @@ const MAX_DECODE_SIDE: u32 = u16::MAX as u32; /// Decode an encoded image to a premultiplied pixmap. The dimension limit above and the default /// allocation cap (512 MB, what `load_from_memory` applied) bound the decode during paint. pub fn decode_pixmap(bytes: &[u8]) -> Option { + decode_pixmap_within(bytes, wordcraft_geom::MAX_DECODED_BYTES) +} + +/// [`decode_pixmap`] with an explicit RGBA byte budget (a small one in tests). +fn decode_pixmap_within(bytes: &[u8], budget: u64) -> Option { + // The header gives the size; refuse before the decoder (and the RGBA copy) allocate anything. + let (hw, hh) = image_size(bytes)?; + if !wordcraft_geom::decode_fits(hw, hh, budget) { + return None; + } let mut reader = image::ImageReader::new(std::io::Cursor::new(bytes)).with_guessed_format().ok()?; let mut limits = image::Limits::default(); limits.max_image_width = Some(MAX_DECODE_SIDE); limits.max_image_height = Some(MAX_DECODE_SIDE); reader.limits(limits); - let img = reader.decode().ok()?.to_rgba8(); + let img = reader.decode().ok()?.into_rgba8(); let (w, h) = img.dimensions(); premul_pixmap(w, h, img.as_raw()) } @@ -495,7 +509,16 @@ pub fn render_page(doc: &Document, page: &Page, scale: f32, opts: &RenderOptions /// Render a page into a `w`×`h` raster with `view` (page points → pixels). pub fn render_region(doc: &Document, page: &Page, w: u32, h: u32, view: Affine, opts: &RenderOptions) -> Rendered { - let (w16, h16) = (w.clamp(1, MAX_SIDE) as u16, h.clamp(1, MAX_SIDE) as u16); + render_region_capped(doc, page, w, h, view, opts, wordcraft_geom::MAX_RASTER_PIXELS) +} + +/// [`render_region`] with an explicit pixel cap (a small one in tests). +fn render_region_capped(doc: &Document, page: &Page, w: u32, h: u32, view: Affine, opts: &RenderOptions, max_pixels: u64) -> Rendered { + let (w, h) = (w.clamp(1, MAX_SIDE), h.clamp(1, MAX_SIDE)); + let (cw, ch) = wordcraft_geom::fit_pixels(w, h, max_pixels); + // Over the cap the whole picture is drawn smaller, not cropped. + let view = if (cw, ch) == (w, h) { view } else { Affine::scale_non_uniform(cw as f64 / w as f64, ch as f64 / h as f64) * view }; + let (w16, h16) = (cw as u16, ch as u16); let mut ctx = RenderContext::new_with(w16, h16, vello_cpu::RenderSettings { num_threads: default_threads(), ..Default::default() }); ctx.set_transform(Affine::IDENTITY); ctx.set_paint(color(opts.ink(doc.settings.page_color.unwrap_or(opts.paper)), 1.0)); @@ -578,7 +601,9 @@ pub fn render_text_line(face: &wordcraft_fonts::FontFace, text: &str, size: f32, let fin = |v: f32, d: f32| if v.is_finite() && v >= 0.0 { v } else { d }; let (size, w, h, margin) = (fin(size, 12.0).min(1000.0) as f64, fin(w, 1.0), fin(h, 1.0), fin(margin, 0.0).min(1000.0) as f64); let scale = if scale.is_finite() && scale > 0.0 { scale.min(16.0) } else { 1.0 }; - let (w16, h16) = (((w * scale).ceil() as u32).clamp(1, MAX_SIDE) as u16, ((h * scale).ceil() as u32).clamp(1, MAX_SIDE) as u16); + let (pw, ph) = (((w * scale).ceil() as u32).clamp(1, MAX_SIDE), ((h * scale).ceil() as u32).clamp(1, MAX_SIDE)); + let (cw, ch) = wordcraft_geom::fit_pixels(pw, ph, wordcraft_geom::MAX_RASTER_PIXELS); + let (w16, h16) = (cw as u16, ch as u16); let mut ctx = RenderContext::new_with(w16, h16, vello_cpu::RenderSettings { num_threads: 0, ..Default::default() }); ctx.set_transform(Affine::IDENTITY); ctx.set_paint(color(Rgb(255, 255, 255), 1.0)); @@ -586,7 +611,7 @@ pub fn render_text_line(face: &wordcraft_fonts::FontFace, text: &str, size: f32, ctx.set_paint(color(Rgb(0, 0, 0), 1.0)); let upem = if face.upem.is_finite() && face.upem > 0.0 { face.upem } else { 1000.0 }; let k = size / upem; - let view = Affine::scale(scale as f64); + let view = Affine::scale_non_uniform(cw as f64 / pw as f64, ch as f64 / ph as f64) * Affine::scale(scale as f64); let cap = if face.cap_height.is_finite() { face.cap_height.clamp(upem * 0.3, upem) } else { upem * 0.7 }; let baseline = (h as f64 + cap * k) / 2.0; let mut x = margin; @@ -1117,9 +1142,12 @@ pub fn render_area(doc: &Document, page: &Page, x: f32, y: f32, w: f32, h: f32, /// at `scale` px/pt: no paper, no watermark. A dragged object's preview. pub fn render_items(doc: &Document, items: &[Draw], x: f32, y: f32, w: f32, h: f32, scale: f32, opts: &RenderOptions) -> Rendered { let scale = if scale.is_finite() && scale > 0.0 { scale } else { 1.0 }; - let w16 = ((w * scale).ceil() as u32).clamp(1, MAX_SIDE) as u16; - let h16 = ((h * scale).ceil() as u32).clamp(1, MAX_SIDE) as u16; - let view = Affine::scale(scale as f64) * Affine::translate((-x as f64, -y as f64)); + let (pw, ph) = (((w * scale).ceil() as u32).clamp(1, MAX_SIDE), ((h * scale).ceil() as u32).clamp(1, MAX_SIDE)); + let (cw, ch) = wordcraft_geom::fit_pixels(pw, ph, wordcraft_geom::MAX_RASTER_PIXELS); + let (w16, h16) = (cw as u16, ch as u16); + let view = Affine::scale_non_uniform(cw as f64 / pw as f64, ch as f64 / ph as f64) + * Affine::scale(scale as f64) + * Affine::translate((-x as f64, -y as f64)); let mut ctx = RenderContext::new_with(w16, h16, vello_cpu::RenderSettings { num_threads: 0, ..Default::default() }); let visible = view.inverse().transform_rect_bbox(kurbo::Rect::new(0.0, 0.0, w16 as f64, h16 as f64)).inflate(40.0, 40.0); for it in items { @@ -1392,6 +1420,59 @@ mod tests { assert_eq!(&img.pixels[0..3], &[255, 255, 255]); } + /// A PNG signature, an IHDR declaring `w`×`h` 8-bit RGBA and a short IDAT (all CRCs valid, so + /// the header parses), with no real pixel data. + fn ihdr_only_png(w: u32, h: u32) -> Vec { + fn chunk(out: &mut Vec, kind: &[u8; 4], data: &[u8]) { + let mut body = kind.to_vec(); + body.extend_from_slice(data); + let mut crc = 0xFFFF_FFFFu32; + for b in &body { + crc ^= *b as u32; + for _ in 0..8 { + crc = if crc & 1 != 0 { (crc >> 1) ^ 0xEDB8_8320 } else { crc >> 1 }; + } + } + out.extend_from_slice(&(data.len() as u32).to_be_bytes()); + out.extend_from_slice(&body); + out.extend_from_slice(&(!crc).to_be_bytes()); + } + let mut out = vec![0x89, b'P', b'N', b'G', 0x0D, 0x0A, 0x1A, 0x0A]; + let mut ihdr = w.to_be_bytes().to_vec(); + ihdr.extend_from_slice(&h.to_be_bytes()); + ihdr.extend_from_slice(&[8, 6, 0, 0, 0]); + chunk(&mut out, b"IHDR", &ihdr); + chunk(&mut out, b"IDAT", &[0x78, 0x9c, 0, 0]); + out + } + + #[test] + fn decode_budget_is_checked_from_the_header() { + let mut buf = Vec::new(); + let img = image::RgbaImage::from_fn(16, 8, |x, y| image::Rgba([x as u8, y as u8, 9, 255])); + img.write_to(&mut std::io::Cursor::new(&mut buf), image::ImageFormat::Png).unwrap(); + // Decode, the RGBA conversion and the premultiplied copy are charged: two buffers at peak. + assert!(decode_pixmap_within(&buf, 1024).is_some()); + assert!(decode_pixmap_within(&buf, 1023).is_none()); + // A header (valid CRC) claiming 60000x60000 with no pixel data reads fine, then the budget refuses it. + let hdr = ihdr_only_png(60_000, 60_000); + assert_eq!(image_size(&hdr), Some((60_000, 60_000))); + assert!(decode_pixmap(&hdr).is_none()); + } + + #[test] + fn page_raster_is_capped_by_total_pixels() { + let d = Document::new(); + let l = layout(&d, &mut LayoutCache::new(), &LayoutOptions::default()); + let opts = RenderOptions::default(); + let big = render_region_capped(&d, &l.pages[0], 3000, 2000, Affine::scale(4.0), &opts, 100_000); + assert!(big.width as u64 * big.height as u64 <= 100_000, "{}x{}", big.width, big.height); + assert!(big.width > 300 && big.height > 200); + assert_eq!(big.pixels.len() as u64, big.width as u64 * big.height as u64 * 4); + let ok = render_region_capped(&d, &l.pages[0], 300, 200, Affine::IDENTITY, &opts, 100_000); + assert_eq!((ok.width, ok.height), (300, 200)); + } + #[test] fn hostile_scale_is_clamped() { let d = Document::new(); From da8b7b7feb6a1f742202540fe0ad38b48073f89c Mon Sep 17 00:00:00 2001 From: Dmitrii Khlapov Date: Sun, 18 Oct 2026 10:42:35 +0200 Subject: [PATCH 28/38] Tables: Backspace removes a selected table, rows or columns; Delete clears them (part of #1844) (#2005) * WIP for #1844 * Tables: Backspace removes a selected table, rows or columns; Delete clears them (#1844) Co-Authored-By: Claude Opus 5.5 (1M context) * Tables: delete_range edge-case tests, single-cell Backspace clears, deep tables kept (#1844) Co-Authored-By: Claude Opus 5.5 (1M context) --------- 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 | 4 +- crates/doc/src/edit.rs | 255 ++++++++++++++++++++++++++---- crates/doc/src/lib.rs | 5 +- crates/engine/src/cmd/column.rs | 7 + crates/engine/src/cmd/table.rs | 29 ++++ crates/engine/src/tests_typing.rs | 187 ++++++++++++++++++++++ docs/typing-parity.md | 6 +- 7 files changed, 460 insertions(+), 33 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index fccd2ed30b..f742fc3d51 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -2,7 +2,7 @@ **Stage: alpha** · next: beta, ~15 points (60% → 75% ready for real work) and ~150–230 h away -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-18 · **Change:** trivial (progress log: mixed Hebrew/Latin text keeps its order in Word, #1543; previously trivial: progress log: web typing in non-Latin text no longer repeats, #1480; previously trivial: progress log: font family caches bounded and math font lookups follow font changes, #1354; previously trivial: `-->` AutoCorrect replacement, #2223; previously trivial: numbered headings, #1793; previously trivial: Navigation Find exposes Results, #2096; previously trivial: progress log: Columns on selected text, #2024; previously trivial: progress log: legacy outline, shadow, emboss and imprint text effects drawn, #2534; previously trivial: progress log: Indonesian proofing, #498; previously trivial: progress log: DOCX glossary documents (building blocks); previously trivial: progress log: Read Mode keeps the keyboard and shows whole, unobscured columns, #1772); previously trivial: progress log: font list entries of tall-ascent fonts no longer cut off, #2570); previously trivial: progress log: Split Cells and Convert Text to Table dialogs follow the Word check, #2592; previously trivial: a new source's Tag is made like Word's, three letters of the author and two digits of the year with a number added when taken, and an edit keeps the Tag, #2094; previously trivial: Web Layout fills the window width, #1876; previously trivial: progress log: Different First Page headers reachable from page 1, #2826; previously trivial: Language dialog keeps inherited proofing language and no-proof choices (#2717); previously trivial: progress log: web preferences kept between visits, #2132; previously trivial: progress log: undrawable private-use list bullets show the level's default bullet, #1683); previously trivial: table style editor formats every part, #714; previously trivial: VML freeforms read and kept on save; previously trivial: progress log: consistent preferred cell widths; previously trivial: multi-paragraph field instructions no longer add blank lines; previously trivial: page-break-only list paragraphs hide their labels; previously trivial: progress log: grouped `flowChartOr` shapes draw their circle and crossing spokes; previously trivial: progress log: copies keep their formatting in other programs, #1523; previously trivial: progress log: privacy options on save, #1062; previously trivial: progress log: equation pictures in theme colours on Windows, #1002; previously trivial: symbol fonts, #776; previously trivial: the save prompt answers from the keyboard, #861; previously trivial: table column and row markers on the rulers, #700; previously trivial: table borders and the resize handle drag on the page, #217; previously trivial: formatting revisions landed, #41; previously trivial: Draw tab ink, #307, and Draw Table and Eraser, #303, landed: catalog 402/434; previously minor: readiness table with hours per audience; full number back to the additive weighted sum, 60%; mainstream and essentials numbers added; alpha gate checked; previously major: full re-measure against Word for Mac 16.113.4; restructured to the craftrules progress-docs standard) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-18 · **Change:** trivial (Backspace removes a selected table, rows or columns, #1844; previously trivial: progress log: mixed Hebrew/Latin text keeps its order in Word, #1543; previously trivial: progress log: web typing in non-Latin text no longer repeats, #1480; previously trivial: progress log: font family caches bounded and math font lookups follow font changes, #1354; previously trivial: `-->` AutoCorrect replacement, #2223; previously trivial: numbered headings, #1793; previously trivial: Navigation Find exposes Results, #2096; previously trivial: progress log: Columns on selected text, #2024; previously trivial: progress log: legacy outline, shadow, emboss and imprint text effects drawn, #2534; previously trivial: progress log: Indonesian proofing, #498; previously trivial: progress log: DOCX glossary documents (building blocks); previously trivial: progress log: Read Mode keeps the keyboard and shows whole, unobscured columns, #1772); previously trivial: progress log: font list entries of tall-ascent fonts no longer cut off, #2570); previously trivial: progress log: Split Cells and Convert Text to Table dialogs follow the Word check, #2592; previously trivial: a new source's Tag is made like Word's, three letters of the author and two digits of the year with a number added when taken, and an edit keeps the Tag, #2094; previously trivial: Web Layout fills the window width, #1876; previously trivial: progress log: Different First Page headers reachable from page 1, #2826; previously trivial: Language dialog keeps inherited proofing language and no-proof choices (#2717); previously trivial: progress log: web preferences kept between visits, #2132; previously trivial: progress log: undrawable private-use list bullets show the level's default bullet, #1683); previously trivial: table style editor formats every part, #714; previously trivial: VML freeforms read and kept on save; previously trivial: progress log: consistent preferred cell widths; previously trivial: multi-paragraph field instructions no longer add blank lines; previously trivial: page-break-only list paragraphs hide their labels; previously trivial: progress log: grouped `flowChartOr` shapes draw their circle and crossing spokes; previously trivial: progress log: copies keep their formatting in other programs, #1523; previously trivial: progress log: privacy options on save, #1062; previously trivial: progress log: equation pictures in theme colours on Windows, #1002; previously trivial: symbol fonts, #776; previously trivial: the save prompt answers from the keyboard, #861; previously trivial: table column and row markers on the rulers, #700; previously trivial: table borders and the resize handle drag on the page, #217; previously trivial: formatting revisions landed, #41; previously trivial: Draw tab ink, #307, and Draw Table and Eraser, #303, landed: catalog 402/434; previously minor: readiness table with hours per audience; full number back to the additive weighted sum, 60%; mainstream and essentials numbers added; alpha gate checked; previously major: full re-measure against Word for Mac 16.113.4; restructured to the craftrules progress-docs standard) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4 > **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-17 · **Change:** trivial (progress log: pasted blockquotes are indented paragraphs, #1270; previously trivial: progress log: print-area strings translated in every catalog; previously trivial: progress log: comments readable in the dark theme, #2045; previously trivial: progress log: ODT sections, #1379; previously trivial: progress log: table deletes, Change Case and Unlink Fields respect Track Changes and locked content controls, #1329; previously trivial: progress log: rounded rectangle corners, #1476; previously trivial: progress log: Save As type list, #1399; previously trivial: progress log: privacy options on save, #1062; previously trivial: progress log: equation pictures in theme colours on Windows, #1002; previously trivial: symbol fonts, #776; previously trivial: the save prompt answers from the keyboard, #861; previously trivial: table column and row markers on the rulers, #700; previously trivial: table borders and the resize handle drag on the page, #217; previously trivial: formatting revisions landed, #41; previously trivial: Draw tab ink, #307, and Draw Table and Eraser, #303, landed: catalog 402/434; previously minor: readiness table with hours per audience; full number back to the additive weighted sum, 60%; mainstream and essentials numbers added; alpha gate checked; previously major: full re-measure against Word for Mac 16.113.4; restructured to the craftrules progress-docs standard) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4 > **Printing update:** 2026-10-12 · direct desktop printing through craft-print; details below. @@ -137,6 +137,7 @@ Ranked, to beta (detail and remaining milestones in [docs/roadmap.md](docs/roadm - 2026-10-15: Find opens Navigation on Results; editing the query restores Results after manual Headings/Pages navigation. Typed and selected-text searches retain document contents (#2096). | Date | What landed | +| 2026-10-15 | Backspace with a whole table selected removes it, as in Word, and selected whole rows or columns go the same way; Delete clears them and keeps the table. A selection that covers a table and the paragraph before or after it removes the table, also when it starts inside the table or the table is nested (#1844) | | 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 | @@ -465,6 +466,7 @@ Ranked, to beta (detail and remaining milestones in [docs/roadmap.md](docs/roadm | 2026-10-14 | trivial | Progress log: Update Fields reaches headers, footers and text boxes (#1706) | | 2026-10-15 | trivial | Progress log: font family caches bounded; math font resolution follows font changes (#1354) | | 2026-10-15 | trivial | Progress log: web typing in non-Latin text no longer repeats (#1480) | +| 2026-10-15 | trivial | Progress log: Backspace removes a selected table, rows or columns (#1844) | | 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/edit.rs b/crates/doc/src/edit.rs index d52733af61..ecb1ba0137 100644 --- a/crates/doc/src/edit.rs +++ b/crates/doc/src/edit.rs @@ -74,6 +74,19 @@ impl Fragment { } } +/// Whether block `b`, in a block list the paragraph walk reaches at `depth`, has cells the walk +/// skips ([`crate::PARA_WALK_DEPTH`]). Bounded by that depth. +fn deeper_than_walk(b: &Block, depth: usize) -> bool { + match b { + Block::Para(_) => false, + Block::Table(t) => t + .rows + .iter() + .flat_map(|r| &r.cells) + .any(|c| depth + 1 > crate::PARA_WALK_DEPTH || c.blocks.iter().any(|x| deeper_than_walk(x, depth + 1))), + } +} + fn order(a: &Pos, b: &Pos) -> (Pos, Pos) { if a <= b { (a.clone(), b.clone()) } else { (b.clone(), a.clone()) } } @@ -425,7 +438,9 @@ impl Document { return Ok(a); } // Different containers (range enters or leaves a table): clear text in every paragraph - // inside; remove whole tables that are entirely covered when at the same level as `a`. + // inside, then remove the tables it covers whole (below). + // Whether `b` is at its paragraph's end, before that paragraph is trimmed. + let b_at_end = self.para(b.story, &b.path).is_some_and(|p| p.len() == b.off); let paths = self.paths_between(&a, &b); for p in paths.iter().rev() { let (from, to) = { @@ -436,41 +451,68 @@ impl Document { }; self.para_mut(a.story, p)?.delete_except(from, to, &kept_in(p))?; } - // Drop top-level tables strictly between a and b at a's level. - if a.path.depth() == 0 && b.path.depth() > 0 { - let first_b = b.path.0.first().copied().unwrap_or(0) as usize; - let ia = a.path.last(); - let bl = self.container_mut(a.story, &a.path)?; - let mut i = first_b; - while i > ia + 1 { - i -= 1; + // Tables the range covers whole, in the innermost container holding both ends, go (#1198, + // #1844): those strictly between the ends, the one `a` is in when it starts at that + // table's very start, and the one `b` is in when it ends at that table's very end. A range + // that is one table and nothing else only empties it (Word's Delete keeps the table). + let common = a.path.0.iter().zip(&b.path.0).take_while(|(x, y)| x == y).count(); + let level = common - common % 3; + let (Some(&ia), Some(&ib)) = (a.path.0.get(level), b.path.0.get(level)) else { return Ok(a) }; + let (ia, ib) = (ia as usize, ib as usize); + if ia >= ib { + return Ok(a); + } + let prefix = a.path.0.get(..level).unwrap_or_default().to_vec(); + let block_path = |i: usize| { + let mut v = prefix.clone(); + v.push(i as u32); + Path(v) + }; + // Paragraphs inside the block at index `i` of the container, in document order. + let paths = self.para_paths(a.story); + let inside = |i: usize| -> Vec<&Path> { + let pre = block_path(i); + paths.iter().filter(|q| q.0.len() > pre.0.len() && q.0.starts_with(&pre.0)).collect() + }; + // A table with cells deeper than the paragraph walk goes has paragraphs `inside` never + // lists, so where it starts or ends isn't known: it is never taken as covered. + let walked = |i: usize| self.container(a.story, &block_path(i)).and_then(|bl| bl.get(i)).is_some_and(|b| !deeper_than_walk(b, level / 3)); + let a_covers = a.path.0.len() > level + 1 && a.off == 0 && walked(ia) && inside(ia).first() == Some(&&a.path); + let b_covers = b.path.0.len() > level + 1 && walked(ib) && inside(ib).last() == Some(&&b.path) && b_at_end; + let first = if a_covers { ia } else { ia + 1 }; + let last = if b_covers { ib } else { ib - 1 }; + let mut removed = 0; + { + let bl = self.container_mut(a.story, &block_path(ia))?; + for i in (first..=last.min(bl.len().saturating_sub(1))).rev() { if matches!(bl.get(i).map(|b| &**b), Some(Block::Table(_))) { bl.remove(i); + removed += 1; } } - } - // From the very start of a top-level table to a top-level paragraph (Select All in a - // document that starts with a table, #1198): the tables are covered whole and go too, - // and the paragraphs left between join. - if a.path.depth() > 0 && b.path.depth() == 0 && a.off == 0 { - let ta = a.path.0.first().copied().unwrap_or(0) as usize; - let first = self.para_paths(a.story).into_iter().find(|q| q.0.first().is_some_and(|t| *t as usize == ta)); - if first.as_ref() == Some(&a.path) { - let ib = b.path.last(); - let bl = self.container_mut(a.story, &b.path)?; - let mut removed = 0; - for i in (ta..ib).rev() { - if matches!(bl.get(i).map(|b| &**b), Some(Block::Table(_))) { - bl.remove(i); - removed += 1; - } - } - let at = Pos { story: a.story, path: Path::top(ta), off: 0 }; - let end = Pos { story: a.story, path: Path::top(ib.saturating_sub(removed)), off: 0 }; - return self.delete_range(&at, &end); + if removed == 0 { + return Ok(a); + } + // A story or cell always ends with a paragraph. + if bl.is_empty() || matches!(bl.last().map(|b| &**b), Some(Block::Table(_))) { + bl.push(para_block(Paragraph::new())); } } - Ok(a) + // The paragraphs left between the ends join, as in a range within one container. + let at = if a_covers { Pos { story: a.story, path: block_path(ia), off: 0 } } else { a.clone() }; + let end = if b_covers { + Pos { story: a.story, path: block_path((ib + 1).saturating_sub(removed)), off: 0 } + } else { + Pos { story: a.story, path: block_path(ib.saturating_sub(removed)), off: 0 } + }; + let paras = |p: &Pos| p.path.0.len() == level + 1 && self.para(p.story, &p.path).is_some(); + if paras(&at) && paras(&end) && at.path <= end.path { + return self.delete_range(&at, &end); + } + if self.para(at.story, &at.path).is_some() { + return Ok(at); + } + Ok(self.clamp(&at)) } /// Apply `f` to the character formatting of `a..b`. A collapsed range changes nothing (the @@ -989,4 +1031,157 @@ mod tests { assert_eq!(c, Pos::body(0, 2)); assert!(d.delete_range(&Pos { story: StoryRef::Part(3), path: Path::top(0), off: 0 }, &Pos::body(0, 0)).is_err()); } + + // delete_range over whole tables (#1844), in the body, a header and a footnote. + + /// A document whose story (the body, or a header or footnote part) holds `lines`. + fn story_doc(kind: Option, lines: &[&str]) -> (Document, StoryRef) { + match kind { + None => (doc(lines), StoryRef::Body), + Some(k) => { + let mut d = doc(&["body"]); + let blocks = lines.iter().map(|l| para_block(Paragraph::with_text(l, CharProps::default()))).collect(); + let id = d.add_part(k, blocks); + (d, StoryRef::Part(id)) + } + } + } + + const STORIES: [Option; 3] = [None, Some(PartKind::Header), Some(PartKind::Footnote)]; + + fn at(story: StoryRef, path: &[u32], off: usize) -> Pos { + Pos { story, path: Path(path.to_vec()), off } + } + + /// Insert an `rows`×`cols` table at `path` whose cells hold `texts`, row by row. + fn put_table(d: &mut Document, story: StoryRef, path: &[u32], rows: usize, cols: usize, texts: &[&str]) { + d.insert_block(story, &Path(path.to_vec()), Block::Table(Table::new(rows, cols, 200.0))).unwrap(); + for (k, t) in texts.iter().enumerate() { + let mut p = path.to_vec(); + p.extend([(k / cols) as u32, (k % cols) as u32, 0]); + d.insert_text(&at(story, &p, 0), t, &CharProps::default()).unwrap(); + } + } + + /// The story's top-level blocks: a paragraph's text, or `[table]`. + fn shape(d: &Document, story: StoryRef) -> Vec { + d.story(story).unwrap().iter().map(|b| b.as_para().map_or("[table]".to_string(), |p| p.text.clone())).collect() + } + + fn cell_text(d: &Document, story: StoryRef, table: &[u32], r: u32, c: u32) -> String { + let mut p = table.to_vec(); + p.extend([r, c, 0]); + d.para(story, &Path(p)).map(|p| p.text.clone()).unwrap_or_else(|| "".into()) + } + + #[test] + fn a_range_ending_at_the_end_of_a_cell_that_isnt_the_last_keeps_the_table() { + for kind in STORIES { + for (r, c) in [(0, 1), (1, 0)] { + let (mut d, st) = story_doc(kind, &["before", "after"]); + put_table(&mut d, st, &[1], 2, 2, &["a", "b", "c", "d"]); + d.delete_range(&at(st, &[0], 0), &at(st, &[1, r, c, 0], 1)).unwrap(); + assert_eq!(shape(&d, st), ["", "[table]", "after"], "{kind:?} ({r},{c})"); + assert_eq!(cell_text(&d, st, &[1], 1, 1), "d", "{kind:?} ({r},{c})"); + } + } + } + + #[test] + fn a_range_from_the_start_of_one_table_into_the_middle_of_the_next_removes_only_the_first() { + for kind in STORIES { + let (mut d, st) = story_doc(kind, &["x", "mid", "y"]); + put_table(&mut d, st, &[2], 2, 2, &["e", "f", "g", "h"]); + put_table(&mut d, st, &[1], 2, 2, &["a", "b", "c", "d"]); + assert_eq!(shape(&d, st), ["x", "[table]", "mid", "[table]", "y"]); + let to = d.delete_range(&at(st, &[1, 0, 0, 0], 0), &at(st, &[3, 0, 1, 0], 1)).unwrap(); + assert_eq!(shape(&d, st), ["x", "", "[table]", "y"], "{kind:?}"); + assert_eq!([cell_text(&d, st, &[2], 0, 0), cell_text(&d, st, &[2], 0, 1), cell_text(&d, st, &[2], 1, 0)], ["", "", "g"], "{kind:?}"); + assert!(d.para(st, &to.path).is_some(), "{kind:?}: {to:?}"); + } + } + + #[test] + fn a_range_from_a_cell_paragraph_to_the_end_of_a_table_nested_in_that_cell_removes_only_the_nested_table() { + for kind in STORIES { + let (mut d, st) = story_doc(kind, &["before", "after"]); + put_table(&mut d, st, &[1], 1, 1, &["p"]); + put_table(&mut d, st, &[1, 0, 0, 1], 1, 1, &["n"]); + d.insert_block(st, &Path(vec![1, 0, 0, 2]), Block::Para(Paragraph::with_text("q", CharProps::default()))).unwrap(); + let to = d.delete_range(&at(st, &[1, 0, 0, 0], 1), &at(st, &[1, 0, 0, 1, 0, 0, 0], 1)).unwrap(); + assert_eq!(shape(&d, st), ["before", "[table]", "after"], "{kind:?}"); + let cell = d.container(st, &Path(vec![1, 0, 0, 0])).unwrap(); + let cell: Vec = cell.iter().map(|b| b.as_para().map_or("[table]".to_string(), |p| p.text.clone())).collect(); + assert_eq!(cell, ["pq"], "{kind:?}"); + assert_eq!(to, at(st, &[1, 0, 0, 0], 1), "{kind:?}"); + } + } + + #[test] + fn two_whole_tables_at_the_end_of_a_story_leave_one_empty_paragraph() { + for kind in STORIES { + for trailing in [true, false] { + let (mut d, st) = story_doc(kind, &[""]); + put_table(&mut d, st, &[0], 1, 2, &["a", "b"]); + put_table(&mut d, st, &[1], 2, 1, &["c", "d"]); + if !trailing { + // A story that ends in a table (a hostile file). + d.story_mut(st).unwrap().pop(); + } + let to = d.delete_range(&at(st, &[0, 0, 0, 0], 0), &at(st, &[1, 1, 0, 0], 1)).unwrap(); + assert_eq!(shape(&d, st), [""], "{kind:?} trailing {trailing}"); + assert_eq!(to, at(st, &[0], 0), "{kind:?} trailing {trailing}"); + } + } + } + + /// Content controls pair their markers within one block list (a story, or a cell), so a + /// marker in a table never has its partner outside it: a control inside a table removed whole + /// goes with it, and one the range only enters keeps both markers (the control is emptied). + #[test] + fn removing_a_whole_table_keeps_a_control_the_range_only_partly_covers() { + use crate::control::ContentControl; + let start = || InlineObject::ControlStart { control: Box::new(ContentControl::default()) }; + for kind in STORIES { + let (mut d, st) = story_doc(kind, &["before", "after"]); + put_table(&mut d, st, &[1], 1, 1, &["c"]); + // One control around "efo" in "before", one around the cell's text. + d.insert_object(&at(st, &[0], 4), InlineObject::ControlEnd, &CharProps::default()).unwrap(); + let in_before = d.insert_object(&at(st, &[0], 1), start(), &CharProps::default()).unwrap(); + d.insert_object(&at(st, &[1, 0, 0, 0], 1), InlineObject::ControlEnd, &CharProps::default()).unwrap(); + d.insert_object(&at(st, &[1, 0, 0, 0], 0), start(), &CharProps::default()).unwrap(); + assert_eq!(d.control_ranges(st).len(), 2, "setup"); + let end = d.para(st, &Path(vec![1, 0, 0, 0])).unwrap().len(); + // From inside the first control to the end of the table. + d.delete_range(&Pos { off: in_before.off + 1, ..in_before }, &at(st, &[1, 0, 0, 0], end)).unwrap(); + assert_eq!(shape(&d, st), ["b\u{FFFC}e\u{FFFC}after"], "{kind:?}: the table goes, the paragraphs join"); + let ranges = d.control_ranges(st); + assert_eq!(ranges.len(), 1, "{kind:?}: {ranges:?}"); + assert!(ranges.iter().all(|r| r.start.path == Path::top(0) && r.end.path == Path::top(0)), "{kind:?}"); + let objects = d.para(st, &Path::top(0)).map_or(0, |p| p.objects.len()); + assert_eq!(objects, 2, "{kind:?}: no orphan markers left"); + } + } + + #[test] + fn a_table_nested_deeper_than_the_paragraph_walk_is_never_removed_whole() { + // 20 tables, each in the first cell of the one around it, ahead of that cell's paragraph: + // the innermost paragraphs come first in the document but the paragraph walk stops short. + let mut t = Table::new(1, 1, 200.0); + if let Some(cell) = t.rows.get_mut(0).and_then(|r| r.cells.get_mut(0)) { + cell.blocks = vec![para_block(Paragraph::with_text("deep", CharProps::default()))]; + } + for _ in 0..20 { + let mut outer = Table::new(1, 1, 200.0); + if let Some(cell) = outer.rows.get_mut(0).and_then(|r| r.cells.get_mut(0)) { + cell.blocks.insert(0, Arc::new(Block::Table(t))); + } + t = outer; + } + let mut d = doc(&["before", "after"]); + d.insert_block(StoryRef::Body, &Path::top(1), Block::Table(t)).unwrap(); + let first = d.para_paths(StoryRef::Body).into_iter().find(|p| p.0.first() == Some(&1)).unwrap(); + d.delete_range(&Pos { story: StoryRef::Body, path: first, off: 0 }, &Pos::body(2, 0)).unwrap(); + assert!(d.body.get(1).is_some_and(|b| b.as_table().is_some()), "a table with paragraphs the walk never saw was removed"); + } } diff --git a/crates/doc/src/lib.rs b/crates/doc/src/lib.rs index c016400393..6870a16b38 100644 --- a/crates/doc/src/lib.rs +++ b/crates/doc/src/lib.rs @@ -1448,8 +1448,11 @@ pub fn count_words(s: &str) -> usize { s.split(|c: char| c.is_whitespace() || c == para::OBJ).filter(|w| w.chars().any(char::is_alphanumeric)).count() } +/// How deep in nested tables [`Document::para_paths`] goes: cells below this are skipped. +pub(crate) const PARA_WALK_DEPTH: usize = 16; + fn walk(bl: &Blocks, prefix: &mut Vec, out: &mut Vec, depth: usize) { - if depth > 16 { + if depth > PARA_WALK_DEPTH { return; } for (i, b) in bl.iter().enumerate() { diff --git a/crates/engine/src/cmd/column.rs b/crates/engine/src/cmd/column.rs index 2fc5595025..1a9fe17d92 100644 --- a/crates/engine/src/cmd/column.rs +++ b/crates/engine/src/cmd/column.rs @@ -159,6 +159,13 @@ pub(crate) fn dispatch(s: &mut Session, id: &str, mutates: bool, run: fn(&mut Se } return super::sel_result(s); } + // Backspace over whole rows, whole columns or the whole table deletes them, as in Word + // (Delete only clears them, as for any other cells). + if id == "text.backspace" + && let Some(r) = super::table::backspace_cells(s) + { + return r; + } let Some(segs) = s.column_segments().map(<[(Pos, Pos)]>::to_vec) else { return run(s, v) }; let cells = s.cell_range().is_some(); match id { diff --git a/crates/engine/src/cmd/table.rs b/crates/engine/src/cmd/table.rs index 16835df92f..b8b9471db0 100644 --- a/crates/engine/src/cmd/table.rs +++ b/crates/engine/src/cmd/table.rs @@ -586,6 +586,35 @@ fn del_col(s: &mut Session, _: &Value) -> CmdResult { fix_caret(s, &tp, r, c.saturating_sub(1)) } +/// Backspace with whole cells selected (#1844): whole rows go like Delete Rows (every row: the +/// table, like Delete Table), whole columns like Delete Columns, with their undo and Track +/// Changes. `None` for any other block of cells, and for a single cell of a table with more +/// (Backspace then clears them). +pub(crate) fn backspace_cells(s: &mut Session) -> Option { + let cr = s.cell_range()?.clone(); + let (tp, _, _) = s.sel.focus.path.cell()?; + if cr.story != s.sel.focus.story || cr.table != tp { + return None; + } + let t = s.doc.table(cr.story, &tp)?; + // One cell of several is just that cell, also in a one-row or one-column table (Word's cell + // selection doesn't take the end-of-row mark): Backspace clears it. + let cells: usize = t.rows.iter().map(|row| row.cells.len()).sum(); + if cells > 1 && cr.cells(t).len() <= 1 { + return None; + } + let (last_row, last_col) = (t.rows.len().saturating_sub(1), t.cols().saturating_sub(1)); + let r = if cr.cols == (0, last_col) { + del_row(s, &Value::Null) + } else if cr.rows == (0, last_row) { + del_col(s, &Value::Null) + } else { + return None; + }; + s.column = None; + Some(r) +} + fn del_table(s: &mut Session, _: &Value) -> CmdResult { let (tp, _, _) = cell(s)?; let story = s.sel.focus.story; diff --git a/crates/engine/src/tests_typing.rs b/crates/engine/src/tests_typing.rs index 910df273cd..2c1d5cd711 100644 --- a/crates/engine/src/tests_typing.rs +++ b/crates/engine/src/tests_typing.rs @@ -1608,3 +1608,190 @@ fn typing_1_dot_in_a_heading_keeps_the_heading_style() { assert_eq!(got[0].1, style); } } + +// Backspace and Delete on selected tables, rows and columns (Word for Mac, #1844). + +/// "before", a 3×3 table with cells `a1`…`c3` (column letter, row number), "after"; the caret +/// in the cell at row `r`, column `c`. +fn table_between(r: u32, c: u32) -> Session { + let mut s = typed("before\n"); + run(&mut s, "insert.table", json!({"rows": 3, "cols": 3})); + typ(&mut s, "a1\tb1\tc1\ta2\tb2\tc2\ta3\tb3\tc3"); + run(&mut s, "caret.docEnd", json!({})); + typ(&mut s, "after"); + let shape: Vec<&str> = s.doc.body.iter().map(|b| if b.as_table().is_some() { "table" } else { "para" }).collect(); + assert_eq!(shape, ["para", "table", "para"], "setup"); + run(&mut s, "caret.set", json!({"pos": {"story": "body", "path": [1, r, c, 0], "off": 0}})); + s +} + +fn tables(s: &Session) -> usize { + s.doc.body.iter().filter(|b| b.as_table().is_some()).count() +} + +/// The first top-level table's cells, row by row (tracked deletions included). +fn table_cells(s: &Session) -> Vec> { + s.doc + .body + .iter() + .find_map(|b| b.as_table()) + .map(|t| { + t.rows + .iter() + .map(|r| r.cells.iter().map(|c| c.blocks.iter().filter_map(|b| b.as_para()).map(|p| p.plain_text()).collect()).collect()) + .collect() + }) + .unwrap_or_default() +} + +#[test] +fn backspace_with_the_whole_table_selected_removes_the_table() { + let mut s = table_between(1, 1); + run(&mut s, "table.selectTable", json!({})); + run(&mut s, "text.backspace", json!({})); + assert_eq!(tables(&s), 0, "the table stays: {:?}", table_cells(&s)); + assert_eq!(texts(&s), ["before", "after"]); + // The caret is in the paragraph that followed the table. + assert_eq!(s.sel.focus, Pos { story: StoryRef::Body, path: wordcraft_doc::Path::top(1), off: 0 }); + assert!(s.sel.is_collapsed()); + // One undo brings it back. + run(&mut s, "edit.undo", json!({})); + assert_eq!(tables(&s), 1); + assert_eq!(table_cells(&s), [["a1", "b1", "c1"], ["a2", "b2", "c2"], ["a3", "b3", "c3"]]); +} + +#[test] +fn delete_with_the_whole_table_selected_clears_the_cells_and_keeps_the_table() { + let mut s = table_between(0, 0); + run(&mut s, "table.selectTable", json!({})); + run(&mut s, "text.delete", json!({})); + assert_eq!(tables(&s), 1); + assert_eq!(table_cells(&s), [["", "", ""], ["", "", ""], ["", "", ""]]); + assert_eq!(texts(&s), ["before", "after"]); +} + +#[test] +fn delete_or_backspace_with_the_table_and_the_paragraph_after_selected_removes_the_table() { + for key in ["text.delete", "text.backspace"] { + // From the first cell to the end of the document: Word's rule widens it to whole rows. + let mut s = table_between(0, 0); + run(&mut s, "caret.docEnd", json!({"extend": true})); + run(&mut s, key, json!({})); + assert_eq!(tables(&s), 0, "{key}: {:?}", table_cells(&s)); + assert_eq!(texts(&s), ["before", ""], "{key}"); + } +} + +#[test] +fn delete_or_backspace_with_the_paragraph_before_and_the_table_selected_removes_the_table() { + for key in ["text.delete", "text.backspace"] { + // From the start of "before" down into the last row: the rows are taken whole. + let mut s = table_between(0, 0); + run(&mut s, "caret.docStart", json!({})); + for _ in 0..3 { + run(&mut s, "caret.down", json!({"extend": true})); + } + assert_eq!(s.sel.focus.path.cell().map(|(_, r, _)| r), Some(2), "{key}: the selection reaches the last row"); + run(&mut s, key, json!({})); + assert_eq!(tables(&s), 0, "{key}: {:?}", table_cells(&s)); + assert_eq!(texts(&s), ["after"], "{key}"); + assert_eq!(s.sel.focus, Pos { story: StoryRef::Body, path: wordcraft_doc::Path::top(0), off: 0 }, "{key}"); + } +} + +#[test] +fn backspace_deletes_selected_rows_and_delete_clears_them() { + let mut s = table_between(1, 0); + run(&mut s, "table.selectRow", json!({})); + run(&mut s, "text.backspace", json!({})); + assert_eq!(table_cells(&s), [["a1", "b1", "c1"], ["a3", "b3", "c3"]]); + + let mut s = table_between(1, 0); + run(&mut s, "table.selectRow", json!({})); + run(&mut s, "text.delete", json!({})); + assert_eq!(table_cells(&s), [["a1", "b1", "c1"], ["", "", ""], ["a3", "b3", "c3"]]); +} + +#[test] +fn backspace_deletes_selected_columns_and_delete_clears_them() { + let mut s = table_between(0, 1); + run(&mut s, "table.selectColumn", json!({})); + run(&mut s, "text.backspace", json!({})); + assert_eq!(table_cells(&s), [["a1", "c1"], ["a2", "c2"], ["a3", "c3"]]); + + let mut s = table_between(0, 1); + run(&mut s, "table.selectColumn", json!({})); + run(&mut s, "text.delete", json!({})); + assert_eq!(table_cells(&s), [["a1", "", "c1"], ["a2", "", "c2"], ["a3", "", "c3"]]); +} + +#[test] +fn backspace_on_a_selected_nested_table_removes_only_it() { + let mut s = table_between(1, 1); + run(&mut s, "caret.set", json!({"pos": {"story": "body", "path": [1, 1, 1, 0], "off": 2}})); + run(&mut s, "text.newParagraph", json!({})); + run(&mut s, "insert.table", json!({"rows": 2, "cols": 2})); + typ(&mut s, "x"); + let cell_blocks = |s: &Session| { + s.doc + .body + .iter() + .find_map(|b| b.as_table()) + .and_then(|t| t.rows.get(1)?.cells.get(1)) + .map(|c| c.blocks.iter().filter(|b| b.as_table().is_some()).count()) + }; + assert_eq!(cell_blocks(&s), Some(1), "setup: a table nested in b2"); + run(&mut s, "table.selectTable", json!({})); + run(&mut s, "text.backspace", json!({})); + assert_eq!(cell_blocks(&s), Some(0), "the nested table stays"); + assert_eq!(tables(&s), 1, "the outer table goes too"); + // The caret is in the cell's paragraph after where the nested table was. + assert_eq!(s.sel.focus.path.cell().map(|(t, r, c)| (t.0, r, c)), Some((vec![1], 1, 1))); + assert!(s.doc.para_at(&s.sel.focus).is_some()); +} + +#[test] +fn backspace_on_a_selected_table_that_is_the_only_block_leaves_an_empty_paragraph() { + let mut s = s(); + run(&mut s, "insert.table", json!({"rows": 2, "cols": 2})); + typ(&mut s, "x"); + s.doc.body.retain(|b| b.as_table().is_some()); + assert_eq!(s.doc.body.len(), 1, "setup: the table is the only block"); + run(&mut s, "table.selectTable", json!({})); + run(&mut s, "text.backspace", json!({})); + assert_eq!(tables(&s), 0); + assert_eq!(texts(&s), [""]); + assert!(s.doc.para_at(&s.sel.focus).is_some(), "the caret is in a paragraph: {:?}", s.sel.focus); +} + +#[test] +fn tracked_backspace_on_a_selected_table_marks_its_text_deleted_like_delete_table() { + let mut s = table_between(0, 0); + run(&mut s, "review.trackChanges", json!({"value": true})); + run(&mut s, "table.selectTable", json!({})); + run(&mut s, "text.backspace", json!({})); + // Like Table Layout › Delete Table with tracking on: the structure stays, the text is marked deleted. + assert_eq!(tables(&s), 1); + assert_eq!(table_cells(&s), [["a1", "b1", "c1"], ["a2", "b2", "c2"], ["a3", "b3", "c3"]]); + assert_eq!(s.doc.final_text(StoryRef::Body).replace(['\t', '\n'], ""), "Beforeafter"); + run(&mut s, "review.rejectAll", json!({})); + assert_eq!(s.doc.final_text(StoryRef::Body).replace(['\t', '\n'], ""), "Beforea1b1c1a2b2c2a3b3c3after"); + assert!(s.doc.revisions.is_empty()); +} + +/// One selected cell isn't a whole row or column, even in a one-row or one-column table: +/// Backspace clears it (Word's cell selection has no end-of-row mark). +#[test] +fn backspace_on_one_selected_cell_of_a_one_row_or_one_column_table_clears_it() { + for (rows, cols, at) in [(1, 3, [0, 1]), (3, 1, [1, 0])] { + let mut s = typed("before\n"); + run(&mut s, "insert.table", json!({"rows": rows, "cols": cols})); + typ(&mut s, "x\ty\tz"); + run(&mut s, "caret.set", json!({"pos": {"story": "body", "path": [1, at[0], at[1], 0], "off": 0}})); + run(&mut s, "table.selectCell", json!({})); + run(&mut s, "text.backspace", json!({})); + let want: Vec> = + if rows == 1 { vec![vec!["x".into(), "".into(), "z".into()]] } else { vec![vec!["x".into()], vec!["".into()], vec!["z".into()]] }; + assert_eq!(table_cells(&s), want, "{rows}×{cols}"); + } +} diff --git a/docs/typing-parity.md b/docs/typing-parity.md index 4581f84c66..013131fa25 100644 --- a/docs/typing-parity.md +++ b/docs/typing-parity.md @@ -1,6 +1,6 @@ # Typing parity with Word -> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-18 · **Change:** trivial (typed text takes its direction from its own letters, #1543; previously trivial: an input method's request to erase text around the caret is honoured, so web typing in Cyrillic and other non-Latin text no longer repeats, #1480; previously trivial: `-->` AutoCorrect replacement, #2223; previously trivial: a heading-list preset keeps Heading styles and makes other paragraphs Heading 1, as in Word, #1793; previously trivial: numbered headings: a heading-linked Multilevel List numbers new headings by their style; numbering keeps a heading a heading, #1793; previously trivial: reversing an automatic capital adds an exception, #1857; previously trivial: smart quotes open after guillemets and low quotes and nest; previously trivial: Enter on an empty list item numbered by its paragraph style moves it up a level, #2143; previously trivial: Backspace after a section break and Delete before it remove the break, also on the last paragraph, #2127; previously trivial: a table inserted in right-to-left text is right to left, so Tab moves left, #2634; previously trivial: Shift+Delete cuts the selection on every platform, #1669; previously trivial: Select All then Delete removes a table the document starts with, #1198; previously trivial: Alt+numpad character codes, #961; previously trivial: Down and Up from a line start beside a table go into it, #1007; previously trivial: sample-text formulas on Enter, #1067; previously trivial: Track Changes: Accept All after a tracked delete from a paragraph end keeps the next paragraph; Reject All after typing into an empty paragraph keeps it (#864); previously minor: Correct TWo INitial CApitals, cAPS LOCK and table-cell capitals, #544; previously trivial: complex-script caret and Backspace, #604; Move Paragraph Up/Down, Promote/Demote, Go Back and Alt+X keys, #343) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4 +> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-18 · **Change:** trivial (Backspace on a selected table, rows or columns deletes them, Delete clears them, #1844; previously trivial: typed text takes its direction from its own letters, #1543; previously trivial: an input method's request to erase text around the caret is honoured, so web typing in Cyrillic and other non-Latin text no longer repeats, #1480; previously trivial: `-->` AutoCorrect replacement, #2223; previously trivial: a heading-list preset keeps Heading styles and makes other paragraphs Heading 1, as in Word, #1793; previously trivial: numbered headings: a heading-linked Multilevel List numbers new headings by their style; numbering keeps a heading a heading, #1793; previously trivial: reversing an automatic capital adds an exception, #1857; previously trivial: smart quotes open after guillemets and low quotes and nest; previously trivial: Enter on an empty list item numbered by its paragraph style moves it up a level, #2143; previously trivial: Backspace after a section break and Delete before it remove the break, also on the last paragraph, #2127; previously trivial: a table inserted in right-to-left text is right to left, so Tab moves left, #2634; previously trivial: Shift+Delete cuts the selection on every platform, #1669; previously trivial: Select All then Delete removes a table the document starts with, #1198; previously trivial: Alt+numpad character codes, #961; previously trivial: Down and Up from a line start beside a table go into it, #1007; previously trivial: sample-text formulas on Enter, #1067; previously trivial: Track Changes: Accept All after a tracked delete from a paragraph end keeps the next paragraph; Reject All after typing into an empty paragraph keeps it (#864); previously minor: Correct TWo INitial CApitals, cAPS LOCK and table-cell capitals, #544; previously trivial: complex-script caret and Backspace, #604; Move Paragraph Up/Down, Promote/Demote, Go Back and Alt+X keys, #343) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4 What Word does when you type, observed black-box (same keystrokes typed into Word and WordCraft, results read back paragraph by paragraph) and pinned by tests in @@ -65,6 +65,9 @@ formatting use the existing editing primitives. Tests: `sample_text_formulas_exp | Delete a range from the end of one paragraph into a later one (tracking on) | the paragraph marks and text in the range are tracked deletions. Accept All removes exactly those, so the paragraph after the range stays a paragraph of its own; Reject All restores everything. Typing into an empty paragraph and rejecting leaves that paragraph | `tests::accept_all_after_a_tracked_delete_from_a_paragraph_end_keeps_the_next_paragraph`, `tests::reject_all_after_typing_into_an_empty_paragraph_keeps_the_paragraph` | | Backspace just after a section break / Delete just before it | the break goes and the two paragraphs join; the text before it takes the following section's page setup (Word's documented behaviour: a break holds the setup of the section before it); headers and footers the following section showed by linking to the previous one stay. Delete at the end of the last paragraph removes a break on its mark too, with no paragraph to join (with Track Changes on it still does nothing there). Undo brings the break back. Breaks read from a .docx and breaks inserted here alike (#2127) | `backspace_and_delete_remove_a_section_break`, `delete_removes_a_section_break_on_the_last_paragraph` | | Select All, then Delete or Backspace | the whole document goes, tables included, also when it starts with a table; one empty paragraph is left | `select_all_then_delete_removes_tables` | +| Backspace / Delete with a whole table selected (Select Table, or every cell dragged over) | Backspace removes the table, like Delete Table (one undo step; with tracking on its text is marked deleted and the structure stays); the caret goes to the paragraph after it. Delete clears the cells and keeps the table. A nested table goes on its own and leaves the cell's paragraph; a table that was the only block leaves one empty paragraph | `backspace_with_the_whole_table_selected_removes_the_table`, `delete_with_the_whole_table_selected_clears_the_cells_and_keeps_the_table`, `backspace_on_a_selected_nested_table_removes_only_it`, `backspace_on_a_selected_table_that_is_the_only_block_leaves_an_empty_paragraph`, `tracked_backspace_on_a_selected_table_marks_its_text_deleted_like_delete_table` | +| Backspace / Delete with a table and the paragraph before or after it selected | both remove the table, whatever end the selection starts in; the text around joins. Any table the selection covers whole goes, at any nesting level | `delete_or_backspace_with_the_table_and_the_paragraph_after_selected_removes_the_table`, `delete_or_backspace_with_the_paragraph_before_and_the_table_selected_removes_the_table` | +| Backspace / Delete with whole rows or columns selected | Backspace deletes the rows or columns (Delete Rows / Delete Columns); Delete clears their cells. One selected cell is just that cell, also in a one-row or one-column table: Backspace clears it. **Differs:** in a 1×1 table, Select Cell then Backspace removes the table (WordCraft has no end-of-row mark to tell the cell from the whole table; the end-of-row caret position is still missing, #1844) | `backspace_deletes_selected_rows_and_delete_clears_them`, `backspace_deletes_selected_columns_and_delete_clears_them`, `backspace_on_one_selected_cell_of_a_one_row_or_one_column_table_clears_it`; `delete_range` edge cases: `edit::tests::a_range_ending_at_the_end_of_a_cell_that_isnt_the_last_keeps_the_table` and five more (other tables, nested tables, story ends, headers and footnotes, content controls, nesting past the paragraph walk) | | Table Layout › Delete Table / Rows / Columns / Cells (tracking on) | like Word deleting a selected table: the cells' text is marked deleted and the table structure stays (WordCraft has no tracked row or column deletion, `w:trPr/w:del`, so rows and columns keep their cells too, where Word's Accept removes the rows); text you inserted yourself is just removed; Reject All restores the text. A table, row or column with no text at all (empty, or its deletion already accepted) is removed as with tracking off; one whose text is all already marked deleted is left alone with a message (accept or reject first). Delete Columns leaves a merged cell that also spans a kept column alone (it narrows, keeping its text). A content control locked against deletion, or content locked against editing, refuses the delete with tracking on or off | `tests_revision_checks::tracked_table_delete_marks_the_text_deleted_and_reject_all_restores_it`, `tracked_row_column_and_cell_deletes_mark_their_text_deleted`, `tracked_table_delete_over_tracked_insertions_rejects_back_to_the_original`, `table_deletes_refuse_a_control_locked_against_deletion`, `tracked_delete_table_removes_a_table_with_no_live_text`, `delete_columns_leaves_a_merged_cell_that_keeps_a_column_alone`, `tracked_row_and_column_deletes_over_tracked_changes_reject_back_to_the_original` | | Change Case (Shift+F3, Home › Aa) (tracking on) | Word shows no insertion or deletion markup (checked with Formatting markup set to none, so Word may record it as a hidden formatting revision). **Differs:** WordCraft records the changed letters as a tracked deletion of the old and insertion of the new, since case isn't a run property a formatting revision could hold; Reject All restores the old case, Accept All keeps the new. Your own tracked insertion is rewritten in place; already-deleted text is left alone and doesn't count (with tracking off too), so changing case again (or Shift+F3) works from the visible text. Refused in content locked against editing | `tests_revision_checks::tracked_change_case_rejects_back_to_the_original_case`, `tracked_change_case_of_own_insertion_stays_an_insertion`, `tracked_change_case_twice_reads_only_the_live_text`, `tracked_change_case_handles_sharp_s_and_mixed_formatting`, `change_case_refuses_content_locked_against_editing` | | Unlink Fields (Ctrl+Shift+F9) (tracking on) | not tracked, like Word: the field's result stays as ordinary text. Refused in content locked against editing | `tests_revision_checks::tracked_unlink_keeps_the_result_as_plain_text`, `unlink_refuses_a_field_in_content_locked_against_editing` | @@ -277,6 +280,7 @@ Word. | 2026-10-15 | trivial | Add the missing `-->` → `→` AutoCorrect entry (#2223), with typing, undo and preference regression coverage; existing arrow bullets unchanged | | 2026-10-15 | trivial | Input methods (#1480 review): the rewritten text keeps its formatting, doesn't overtype, keeps the typing undo group; AutoCorrect/AutoFormat reset the web text field; desktop doesn't receive `DeleteSurrounding` | | 2026-10-15 | trivial | Input methods: `DeleteSurrounding` erases around the caret, so web typing in non-Latin text no longer repeats; Chrome's caret jump in the hidden field is listed as a known gap (#1480) | +| 2026-10-15 | minor | Backspace with a whole table, whole rows or whole columns selected deletes them; Delete clears them; a selection covering a table and the text beside it removes the table (#1844) | | 2026-10-14 | trivial | Shift+Delete cuts the selection, and does nothing without one, on macOS and Linux too, as in Word for Windows (#1669); Shift+Backspace already erased (#707) | | 2026-10-13 | trivial | Select All then Delete or Backspace removes a table the document starts with (#1198) | | 2026-10-12 | trivial | Editing keys: Down and Up from the start of a line beside a table go into it instead of skipping it (#1007) | From 0254f3afc74f2c5a1a8365eac0600e1031efdc68 Mon Sep 17 00:00:00 2001 From: Dmitrii Khlapov Date: Sun, 18 Oct 2026 10:42:54 +0200 Subject: [PATCH 29/38] Hebrew: field results like STYLEREF draw right to left; smart quotes match Word (#1670) (#2007) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Field results (STYLEREF, REF, PAGE text, note marks) shown as one atomic cluster now place their inner clusters in UAX #9 visual order, in the direction of the text around the field, so "פרק א" in a header no longer draws reversed. Smart quotes already matched Word's logical-order result in Hebrew (” in acronyms, not gershayim); pinned with a typing test. Co-Authored-By: Claude Opus 5.5 * #1670: fix round 1: field results shaped per bidi run, so brackets mirror correctly 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 Co-authored-by: Brandon Thomas --- ROADMAP.md | 4 +- crates/engine/src/tests_typing.rs | 17 +++++ crates/layout/src/para.rs | 68 +++++++++++++---- crates/layout/src/tests_styleref.rs | 112 ++++++++++++++++++++++++++++ docs/layout-parity.md | 5 +- docs/typing-parity.md | 8 +- 6 files changed, 196 insertions(+), 18 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index f742fc3d51..016e9818a5 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -2,7 +2,7 @@ **Stage: alpha** · next: beta, ~15 points (60% → 75% ready for real work) and ~150–230 h away -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-18 · **Change:** trivial (Backspace removes a selected table, rows or columns, #1844; previously trivial: progress log: mixed Hebrew/Latin text keeps its order in Word, #1543; previously trivial: progress log: web typing in non-Latin text no longer repeats, #1480; previously trivial: progress log: font family caches bounded and math font lookups follow font changes, #1354; previously trivial: `-->` AutoCorrect replacement, #2223; previously trivial: numbered headings, #1793; previously trivial: Navigation Find exposes Results, #2096; previously trivial: progress log: Columns on selected text, #2024; previously trivial: progress log: legacy outline, shadow, emboss and imprint text effects drawn, #2534; previously trivial: progress log: Indonesian proofing, #498; previously trivial: progress log: DOCX glossary documents (building blocks); previously trivial: progress log: Read Mode keeps the keyboard and shows whole, unobscured columns, #1772); previously trivial: progress log: font list entries of tall-ascent fonts no longer cut off, #2570); previously trivial: progress log: Split Cells and Convert Text to Table dialogs follow the Word check, #2592; previously trivial: a new source's Tag is made like Word's, three letters of the author and two digits of the year with a number added when taken, and an edit keeps the Tag, #2094; previously trivial: Web Layout fills the window width, #1876; previously trivial: progress log: Different First Page headers reachable from page 1, #2826; previously trivial: Language dialog keeps inherited proofing language and no-proof choices (#2717); previously trivial: progress log: web preferences kept between visits, #2132; previously trivial: progress log: undrawable private-use list bullets show the level's default bullet, #1683); previously trivial: table style editor formats every part, #714; previously trivial: VML freeforms read and kept on save; previously trivial: progress log: consistent preferred cell widths; previously trivial: multi-paragraph field instructions no longer add blank lines; previously trivial: page-break-only list paragraphs hide their labels; previously trivial: progress log: grouped `flowChartOr` shapes draw their circle and crossing spokes; previously trivial: progress log: copies keep their formatting in other programs, #1523; previously trivial: progress log: privacy options on save, #1062; previously trivial: progress log: equation pictures in theme colours on Windows, #1002; previously trivial: symbol fonts, #776; previously trivial: the save prompt answers from the keyboard, #861; previously trivial: table column and row markers on the rulers, #700; previously trivial: table borders and the resize handle drag on the page, #217; previously trivial: formatting revisions landed, #41; previously trivial: Draw tab ink, #307, and Draw Table and Eraser, #303, landed: catalog 402/434; previously minor: readiness table with hours per audience; full number back to the additive weighted sum, 60%; mainstream and essentials numbers added; alpha gate checked; previously major: full re-measure against Word for Mac 16.113.4; restructured to the craftrules progress-docs standard) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-18 · **Change:** trivial (progress log: Hebrew field results and smart quotes, #1670; previously trivial: Backspace removes a selected table, rows or columns, #1844; previously trivial: progress log: mixed Hebrew/Latin text keeps its order in Word, #1543; previously trivial: progress log: web typing in non-Latin text no longer repeats, #1480; previously trivial: progress log: font family caches bounded and math font lookups follow font changes, #1354; previously trivial: `-->` AutoCorrect replacement, #2223; previously trivial: numbered headings, #1793; previously trivial: Navigation Find exposes Results, #2096; previously trivial: progress log: Columns on selected text, #2024; previously trivial: progress log: legacy outline, shadow, emboss and imprint text effects drawn, #2534; previously trivial: progress log: Indonesian proofing, #498; previously trivial: progress log: DOCX glossary documents (building blocks); previously trivial: progress log: Read Mode keeps the keyboard and shows whole, unobscured columns, #1772); previously trivial: progress log: font list entries of tall-ascent fonts no longer cut off, #2570); previously trivial: progress log: Split Cells and Convert Text to Table dialogs follow the Word check, #2592; previously trivial: a new source's Tag is made like Word's, three letters of the author and two digits of the year with a number added when taken, and an edit keeps the Tag, #2094; previously trivial: Web Layout fills the window width, #1876; previously trivial: progress log: Different First Page headers reachable from page 1, #2826; previously trivial: Language dialog keeps inherited proofing language and no-proof choices (#2717); previously trivial: progress log: web preferences kept between visits, #2132; previously trivial: progress log: undrawable private-use list bullets show the level's default bullet, #1683); previously trivial: table style editor formats every part, #714; previously trivial: VML freeforms read and kept on save; previously trivial: progress log: consistent preferred cell widths; previously trivial: multi-paragraph field instructions no longer add blank lines; previously trivial: page-break-only list paragraphs hide their labels; previously trivial: progress log: grouped `flowChartOr` shapes draw their circle and crossing spokes; previously trivial: progress log: copies keep their formatting in other programs, #1523; previously trivial: progress log: privacy options on save, #1062; previously trivial: progress log: equation pictures in theme colours on Windows, #1002; previously trivial: symbol fonts, #776; previously trivial: the save prompt answers from the keyboard, #861; previously trivial: table column and row markers on the rulers, #700; previously trivial: table borders and the resize handle drag on the page, #217; previously trivial: formatting revisions landed, #41; previously trivial: Draw tab ink, #307, and Draw Table and Eraser, #303, landed: catalog 402/434; previously minor: readiness table with hours per audience; full number back to the additive weighted sum, 60%; mainstream and essentials numbers added; alpha gate checked; previously major: full re-measure against Word for Mac 16.113.4; restructured to the craftrules progress-docs standard) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4 > **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-17 · **Change:** trivial (progress log: pasted blockquotes are indented paragraphs, #1270; previously trivial: progress log: print-area strings translated in every catalog; previously trivial: progress log: comments readable in the dark theme, #2045; previously trivial: progress log: ODT sections, #1379; previously trivial: progress log: table deletes, Change Case and Unlink Fields respect Track Changes and locked content controls, #1329; previously trivial: progress log: rounded rectangle corners, #1476; previously trivial: progress log: Save As type list, #1399; previously trivial: progress log: privacy options on save, #1062; previously trivial: progress log: equation pictures in theme colours on Windows, #1002; previously trivial: symbol fonts, #776; previously trivial: the save prompt answers from the keyboard, #861; previously trivial: table column and row markers on the rulers, #700; previously trivial: table borders and the resize handle drag on the page, #217; previously trivial: formatting revisions landed, #41; previously trivial: Draw tab ink, #307, and Draw Table and Eraser, #303, landed: catalog 402/434; previously minor: readiness table with hours per audience; full number back to the additive weighted sum, 60%; mainstream and essentials numbers added; alpha gate checked; previously major: full re-measure against Word for Mac 16.113.4; restructured to the craftrules progress-docs standard) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4 > **Printing update:** 2026-10-12 · direct desktop printing through craft-print; details below. @@ -137,6 +137,7 @@ Ranked, to beta (detail and remaining milestones in [docs/roadmap.md](docs/roadm - 2026-10-15: Find opens Navigation on Results; editing the query restores Results after manual Headings/Pages navigation. Typed and selected-text searches retain document contents (#2096). | Date | What landed | +| 2026-10-16 | Hebrew in fields and quotes (#1670): a STYLEREF (or any field) showing right-to-left text such as "פרק א" reads right to left in headers and the body instead of reversed; smart quotes in Hebrew pinned to Word's logical-order result (“ before, ” after, ” in acronyms like צה"ל) | | 2026-10-15 | Backspace with a whole table selected removes it, as in Word, and selected whole rows or columns go the same way; Delete clears them and keeps the table. A selection that covers a table and the paragraph before or after it removes the table, also when it starts inside the table or the table is nested (#1844) | | 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). | @@ -467,6 +468,7 @@ Ranked, to beta (detail and remaining milestones in [docs/roadmap.md](docs/roadm | 2026-10-15 | trivial | Progress log: font family caches bounded; math font resolution follows font changes (#1354) | | 2026-10-15 | trivial | Progress log: web typing in non-Latin text no longer repeats (#1480) | | 2026-10-15 | trivial | Progress log: Backspace removes a selected table, rows or columns (#1844) | +| 2026-10-16 | trivial | Progress log: Hebrew field results and smart quotes (#1670) | | 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/engine/src/tests_typing.rs b/crates/engine/src/tests_typing.rs index 2c1d5cd711..7b862da612 100644 --- a/crates/engine/src/tests_typing.rs +++ b/crates/engine/src/tests_typing.rs @@ -1795,3 +1795,20 @@ fn backspace_on_one_selected_cell_of_a_one_row_or_one_column_table_clears_it() { assert_eq!(table_cells(&s), want, "{rows}×{cols}"); } } + +/// #1670: smart quotes in Hebrew follow the characters around the quote in logical order, as +/// Word does (Word for Mac 16.113.4): an opening “ after a space or at the start, a closing ” +/// after a letter, and ” inside an acronym (צה"ל), not gershayim; ' after a letter is ’. +#[test] +fn smart_quotes_in_hebrew_use_logical_order() { + let bidi = |s: &Session| s.doc.body.iter().filter_map(|b| b.as_para()).next().and_then(|p| p.props.bidi); + let s = typed("הוא אמר \"שלום\" לי"); + assert_eq!(bidi(&s), Some(true), "a right-to-left paragraph"); + assert_eq!(s.doc.plain_text(StoryRef::Body), "הוא אמר “שלום” לי"); + let s = typed("\"שלום\""); + assert_eq!(s.doc.plain_text(StoryRef::Body), "“שלום”"); + let s = typed("צה\"ל "); + assert_eq!(s.doc.plain_text(StoryRef::Body), "צה”ל "); + let s = typed("ג'ירפה 'כן'"); + assert_eq!(s.doc.plain_text(StoryRef::Body), "ג’ירפה ‘כן’"); +} diff --git a/crates/layout/src/para.rs b/crates/layout/src/para.rs index 82e851033e..171f912f9f 100644 --- a/crates/layout/src/para.rs +++ b/crates/layout/src/para.rs @@ -634,6 +634,27 @@ pub(crate) fn remap_range(map_starts: &[usize], orig_of: &[usize], orig_len: usi (s.min(e), s.max(e)) } +/// The bidi levels a piece of text is shaped by. +#[derive(Clone, Copy)] +enum Dir<'l> { + /// Paragraph text: the paragraph's levels at the piece's offset. + Para, + /// Text laid out on its own (a field result): these levels, by byte of the text. + Own(&'l [u8]), + /// Each strong run in its own direction (list labels, ruby, left-to-right results). + Guess, +} + +/// The direction of `c` if it is a strong letter (UAX #9 P2): `Some(true)` right to left. +fn strong_rtl(c: char) -> Option { + use unicode_bidi::BidiClass::{AL, L, R}; + match unicode_bidi::bidi_class(c) { + R | AL => Some(true), + L => Some(false), + _ => None, + } +} + /// Bidi embedding levels per byte of `text` (UAX #9 rules up to I2), or empty when the /// paragraph is left to right and has no right-to-left characters. pub fn bidi_levels(text: &str, rtl_para: bool) -> Vec { @@ -792,12 +813,11 @@ impl<'a> Builder<'a> { /// Shape `text` (a piece of the paragraph starting at byte `base`) in one style and append clusters. fn shape(&mut self, text: &str, base: usize, rc: &Arc, kind_override: Option) { - self.shape_with(text, base, rc, kind_override, true); + self.shape_with(text, base, rc, kind_override, Dir::Para); } - /// `para_text`: `text` is paragraph text at `base`, so the paragraph's bidi levels apply - /// (false for field results and list labels, which are shaped on their own). - fn shape_with(&mut self, text: &str, base: usize, rc: &Arc, kind_override: Option, para_text: bool) { + /// `dir`: the bidi levels `text` is shaped by (see [`Dir`]). + fn shape_with(&mut self, text: &str, base: usize, rc: &Arc, kind_override: Option, dir: Dir<'_>) { if text.is_empty() { return; } @@ -819,8 +839,14 @@ impl<'a> Builder<'a> { .map(|f| wordcraft_fonts::word::resolve(f, rc.bold, rc.italic)) .filter(|r| !r.substituted) .map(|r| r.face); - let use_levels = para_text && !self.levels.is_empty(); - let context = if self.env.numeral == NumeralMode::Context && use_levels { context_digit_dirs(text, &self.levels, base) } else { Vec::new() }; + // The levels by byte of `text`, from `lbase` on. + let (levels, lbase): (&[u8], usize) = match dir { + Dir::Para => (&self.levels, base), + Dir::Own(l) => (l, 0), + Dir::Guess => (&[], 0), + }; + let use_levels = !levels.is_empty(); + let context = if self.env.numeral == NumeralMode::Context && use_levels { context_digit_dirs(text, levels, lbase) } else { Vec::new() }; // A missing Thai font: one face for the run's Thai and Latin text (#1549). A run with its // own complex-script font keeps that font for the Thai letters. let run_face: Option = if primary_cs.is_none_or(|f| f.id() == primary.id()) { @@ -849,7 +875,7 @@ impl<'a> Builder<'a> { let mut cur: Option = None; let mut segs: Vec<(usize, usize, SegKey)> = Vec::new(); for (i, c) in text.char_indices() { - let rtl = use_levels && self.levels.get(base + i).is_some_and(|l| l % 2 == 1); + let rtl = use_levels && levels.get(lbase + i).is_some_and(|l| l % 2 == 1); // A symbol-font character becomes its Unicode symbol (#776). let c = show(c); // Displayed form (Word › Numeral): one char in, one char out. Coverage and the @@ -1003,8 +1029,14 @@ impl<'a> Builder<'a> { /// One cluster for a whole object with its display text (fields, note references). fn shape_atomic(&mut self, text: &str, start: usize, end: usize, rc: &Arc) { + // Right-to-left text in the result reads like ordinary text (UAX #9, #1670): the result is + // an isolate whose direction is its first strong letter's (else the text's around the + // field), and each level run is shaped (mirrored, joined) and placed in that direction. + let around = self.levels.get(start).is_some_and(|l| l % 2 == 1); + let rtl_base = text.chars().find_map(strong_rtl).unwrap_or(around); + let lv = bidi_levels(text, rtl_base); let before = self.clusters.len(); - self.shape_with(text, start, rc, None, false); + self.shape_with(text, start, rc, None, if lv.is_empty() { Dir::Guess } else { Dir::Own(&lv) }); let added: Vec = self.clusters.drain(before..).collect(); let Some(first) = added.first() else { let si = self.style(rc, None, false); @@ -1024,15 +1056,23 @@ impl<'a> Builder<'a> { }); return; }; - // Merge into one cluster: rebase glyph dx onto the first cluster, and keep each glyph's - // text (a ligature's glyph takes the letters of the clusters after it that have none). + let order = if lv.is_empty() { + (0..added.len()).collect() + } else { + let cl_lv: Vec = added.iter().map(|c| lv.get(c.start.saturating_sub(start)).copied().unwrap_or(u8::from(rtl_base))).collect(); + visual_order(&cl_lv) + }; + // Merge into one cluster: rebase glyph dx onto the first cluster drawn, and keep each + // glyph's text (a ligature's glyph takes the letters of the clusters after it that have none). let mut x = 0.0; - let mut ranges: Vec> = Vec::new(); - for c in &added { + for c in order.iter().filter_map(|&i| added.get(i)) { for g in self.glyphs.get_mut(c.g0 as usize..c.g1 as usize).into_iter().flatten() { g.dx += x; } x += c.adv; + } + let mut ranges: Vec> = Vec::new(); + for c in &added { let (a, b) = (c.start.saturating_sub(start), c.end.saturating_sub(start)); if c.g1 <= c.g0 { let from = ranges.last().map(|r| r.start); @@ -1207,7 +1247,7 @@ impl Builder<'_> { self.shape_atomic(base, start, end, rc); let ci = self.clusters.len().checked_sub(1)?; let (bc, bg) = (self.clusters.len(), self.glyphs.len()); - self.shape_with(ruby, 0, ruby_rc, None, false); + self.shape_with(ruby, 0, ruby_rc, None, Dir::Guess); let cl: Vec = self.clusters.drain(bc..).collect(); let ruby_w: f32 = cl.iter().map(|c| c.adv).sum(); let base_w = self.clusters.get(ci).map_or(0.0, |c| c.adv); @@ -1589,7 +1629,7 @@ pub fn layout_para(p: &Paragraph, env: &ParaEnv) -> ParaLayout { let shown = |t: &str| -> String { wordcraft_doc::symbol_fonts::map_text(&rc.font, t) }; let before_c = b.clusters.len(); let before_g = b.glyphs.len(); - b.shape_with(text, 0, &rc, None, false); + b.shape_with(text, 0, &rc, None, Dir::Guess); let mut cl: Vec = b.clusters.drain(before_c..).collect(); // A right-to-left paragraph's label reads right to left too ("1." shows as ".1"). if rp.bidi { diff --git a/crates/layout/src/tests_styleref.rs b/crates/layout/src/tests_styleref.rs index cba5d9f8c5..0db962ad05 100644 --- a/crates/layout/src/tests_styleref.rs +++ b/crates/layout/src/tests_styleref.rs @@ -175,3 +175,115 @@ fn body_edits_keep_styleref_headers_cached() { assert_eq!(shown(&edited.pages[2].header), ["The Middles", "above"]); assert_eq!(shown(&edited.pages[0].header), ["Beginnings", "below"]); } + +/// The x of each shown character's first glyph in a header field result, in text order. +fn field_char_xs(d: &Document) -> Vec<(String, Vec)> { + let mut c = LayoutCache::new(); + let l = layout(d, &mut c, &LayoutOptions::default()); + let mut out = Vec::new(); + for it in &l.pages[0].header { + let Placed::Lines { para, .. } = it else { continue }; + for (k, text, ranges) in ¶.shown { + let cl = ¶.clusters[*k]; + let glyphs = ¶.glyphs[cl.g0 as usize..cl.g1 as usize]; + let xs = text + .char_indices() + .map(|(i, _)| glyphs.iter().zip(ranges).find(|(_, r)| r.start == i).map(|(g, _)| g.dx).unwrap_or(f32::NAN)) + .collect(); + out.push((text.clone(), xs)); + } + } + out +} + +/// A STYLEREF showing a Hebrew heading reads right to left in the header like ordinary text +/// (#1670): "פרק א" draws פ rightmost, not reversed as "א קרפ". Latin results keep their order, +/// also in a right-to-left header paragraph. +#[test] +fn hebrew_styleref_result_reads_right_to_left() { + let d = doc(&[("Heading1", "פרק א", false), ("Normal", "Text.", false)], &[r#"STYLEREF "Heading 1""#]); + let got = field_char_xs(&d); + let (text, xs) = &got[0]; + assert_eq!(text, "פרק א"); + assert!(xs.iter().all(|x| x.is_finite()), "every letter has a glyph: {xs:?}"); + assert!(xs.windows(2).all(|w| w[0] > w[1]), "letters in text order run right to left: {xs:?}"); + // A Latin heading in a right-to-left header paragraph still reads left to right. + let mut d = doc(&[("Heading1", "Part one", false)], &[r#"STYLEREF "Heading 1""#]); + let hdr = d.last_section.headers.default.unwrap(); + d.para_mut(StoryRef::Part(hdr), &Path::top(0)).unwrap().props.bidi = Some(true); + let got = field_char_xs(&d); + let (text, xs) = &got[0]; + assert_eq!(text, "Part one"); + assert!(xs.windows(2).all(|w| w[0] < w[1]), "Latin letters run left to right: {xs:?}"); +} + +/// What each header field result draws, read left to right: each glyph's character, a bracket +/// read as the bracket its glyph shows (a mirrored `(` reads `)`). +fn field_drawn(d: &Document, opts: &LayoutOptions) -> Vec { + let mirror = |c: char| match c { + '(' => ')', + ')' => '(', + '{' => '}', + '}' => '{', + '[' => ']', + ']' => '[', + c => c, + }; + let mut c = LayoutCache::new(); + let l = layout(d, &mut c, opts); + let mut out = Vec::new(); + for it in &l.pages[0].header { + let Placed::Lines { para, .. } = it else { continue }; + for (k, text, ranges) in ¶.shown { + let cl = ¶.clusters[*k]; + // A fallback face's glyphs share the cluster, so a glyph is matched in every face. + let gids = |ch: char| -> Vec> { + para.styles.iter().map(|st| wordcraft_fonts::shape_run(&st.face, &ch.to_string(), &[], |c| c, false).first().map(|g| g.gid)).collect() + }; + let glyphs = ¶.glyphs[cl.g0 as usize..cl.g1 as usize]; + let mut drawn: Vec<(f32, char)> = glyphs + .iter() + .zip(ranges) + .map(|(g, r)| { + let ch = text[r.start..].chars().next().unwrap(); + let m = mirror(ch); + let is = |ch: char| gids(ch).contains(&Some(g.gid)); + let shown = if m != ch && is(m) && !is(ch) { m } else { ch }; + (g.dx, shown) + }) + .collect(); + drawn.sort_by(|a, b| a.0.total_cmp(&b.0)); + out.push(drawn.into_iter().map(|d| d.1).collect()); + } + } + out +} + +fn rtl_header(d: &mut Document) { + let hdr = d.last_section.headers.default.unwrap(); + d.para_mut(StoryRef::Part(hdr), &Path::top(0)).unwrap().props.bidi = Some(true); +} + +/// Brackets in a field result are mirrored and placed by the same bidi run (#1670 review): a +/// field code or a Latin result in a right-to-left header keeps its brackets as written, and a +/// Hebrew word in brackets in a left-to-right result draws its brackets the right way round. +#[test] +fn field_results_mirror_brackets_with_their_bidi_runs() { + let mut got = Vec::new(); + // The field-code view of a PAGE field in a right-to-left header. + let mut d = doc(&[("Normal", "Text.", false)], &["PAGE"]); + rtl_header(&mut d); + got.extend(field_drawn(&d, &LayoutOptions { field_codes: true, ..LayoutOptions::default() })); + // A Latin result with brackets, and digits, in a right-to-left header. + for heading in ["(Chapter 1)", "12"] { + let mut d = doc(&[("Heading1", heading, false)], &[r#"STYLEREF "Heading 1""#]); + rtl_header(&mut d); + got.extend(field_drawn(&d, &LayoutOptions::default())); + } + // A Hebrew word in brackets at the end of a left-to-right result, and the Hebrew heading. + for heading in ["Part 1 (פרק)", "פרק א"] { + let d = doc(&[("Heading1", heading, false)], &[r#"STYLEREF "Heading 1""#]); + got.extend(field_drawn(&d, &LayoutOptions::default())); + } + assert_eq!(got, ["{ PAGE }", "(Chapter 1)", "12", "Part 1 (קרפ)", "א קרפ"]); +} diff --git a/docs/layout-parity.md b/docs/layout-parity.md index 3eb83afad4..64a26bd186 100644 --- a/docs/layout-parity.md +++ b/docs/layout-parity.md @@ -1,6 +1,6 @@ # Layout and pagination parity with Microsoft Word -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-17 · **Change:** trivial (a list bullet no installed font can draw shows the level's default bullet, #1683; previously trivial: a missing Thai font is drawn in one Thai face for the whole run, #1549; previously trivial: the caret after spaces hanging past the margin stays at the margin, #2146; previously trivial: covered black-circle fallback before UI sans; previously trivial: resolved split-cell continuation top insets; previously trivial: Symbol PCL6 alias uses Symbol encoding; previously trivial: exact full font names select their installed face; previously trivial: Arial Unicode MS uses proportional fallbacks; previously trivial: DrawingML outline dashes, including zero-width paths; previously trivial: consistent cell widths override a stale table grid; previously trivial: multi-paragraph field instructions no longer add blank lines; previously trivial: page-break-only list paragraphs hide their labels; previously trivial: floating-table overlap ignores empty line-break lines; previously trivial: paragraph spacing adds up when the document turns HTML auto spacing off; previously trivial: an inherited footer sizes its percentage tables by its own section; previously trivial: an empty section mark stays on a full page; previously trivial: a fitting row with less room than its minimum height moves on whole; oversized rows still split; previously trivial: text past an exact-height row is cut off; previously trivial: a list number stays with text after a leading page break; previously trivial: a bookmark after a page break takes no line; previously trivial: a page break followed by a page break before or a next-page section starts one page; previously trivial: All Caps and Small Caps draw ß as SS, #1581; previously trivial: justified vertical page alignment, #1320; previously trivial: page numbers restart at 0; previously trivial: a list label moves with its first line when the paragraph is centred or end-aligned (#1159); previously trivial: PAGE fields in a paragraph split across pages show the page they are on, #1352; previously trivial: a dragged floating object anchors in the column it lands in, #884; previously trivial: a paragraph ending on a page with footnotes keeps its space after above the separator, #1116; previously trivial: symbol-font text drawn as Unicode symbols, #776; previously trivial: large-table scrolling measured and fixed, #811; previously trivial: text boxes use their own margins; Resize shape to fit text never cuts text, #743; previously trivial: complex-script shaping and Myanmar line breaking, #604; previously trivial: body page totals, table-cell footnotes and bookmark link pages fixed, #450 #451 #455; previously major: first version; line breaking, pagination and object placement checked against Word's behaviour from the layout source on origin/main) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-17 · **Change:** trivial (field results with right-to-left text read right to left, #1670; previously trivial: a list bullet no installed font can draw shows the level's default bullet, #1683; previously trivial: a missing Thai font is drawn in one Thai face for the whole run, #1549; previously trivial: the caret after spaces hanging past the margin stays at the margin, #2146; previously trivial: covered black-circle fallback before UI sans; previously trivial: resolved split-cell continuation top insets; previously trivial: Symbol PCL6 alias uses Symbol encoding; previously trivial: exact full font names select their installed face; previously trivial: Arial Unicode MS uses proportional fallbacks; previously trivial: DrawingML outline dashes, including zero-width paths; previously trivial: consistent cell widths override a stale table grid; previously trivial: multi-paragraph field instructions no longer add blank lines; previously trivial: page-break-only list paragraphs hide their labels; previously trivial: floating-table overlap ignores empty line-break lines; previously trivial: paragraph spacing adds up when the document turns HTML auto spacing off; previously trivial: an inherited footer sizes its percentage tables by its own section; previously trivial: an empty section mark stays on a full page; previously trivial: a fitting row with less room than its minimum height moves on whole; oversized rows still split; previously trivial: text past an exact-height row is cut off; previously trivial: a list number stays with text after a leading page break; previously trivial: a bookmark after a page break takes no line; previously trivial: a page break followed by a page break before or a next-page section starts one page; previously trivial: All Caps and Small Caps draw ß as SS, #1581; previously trivial: justified vertical page alignment, #1320; previously trivial: page numbers restart at 0; previously trivial: a list label moves with its first line when the paragraph is centred or end-aligned (#1159); previously trivial: PAGE fields in a paragraph split across pages show the page they are on, #1352; previously trivial: a dragged floating object anchors in the column it lands in, #884; previously trivial: a paragraph ending on a page with footnotes keeps its space after above the separator, #1116; previously trivial: symbol-font text drawn as Unicode symbols, #776; previously trivial: large-table scrolling measured and fixed, #811; previously trivial: text boxes use their own margins; Resize shape to fit text never cuts text, #743; previously trivial: complex-script shaping and Myanmar line breaking, #604; previously trivial: body page totals, table-cell footnotes and bookmark link pages fixed, #450 #451 #455; previously major: first version; line breaking, pagination and object placement checked against Word's behaviour from the layout source on origin/main) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4 A document only "opens correctly" if its pages break where Word breaks them: the same lines, the same page count, footnotes and pictures on the same pages. This checklist covers line breaking, @@ -69,7 +69,7 @@ font can't match Word's metrics. | Hidden text excluded from breaking and hyphenation | ✅ | #173 | — | | Fields: PAGE, NUMPAGES, SECTIONPAGES, TOC page numbers | ✅ | #52; totals in the body need a second pass once the page count is known (#451) | — | | Compatibility modes (Word 2003/2007/2010 layout for old files: `w:compat` options) | 🟡 | `allowSpaceOfSameStyleInTable` supported in modes 11/12/14; mode 15 retains contextual suppression. Most other legacy compat options remain ignored | 10–20 | -| Fields: STYLEREF in headers and footers (running chapter titles) | ✅ | Evaluated per page at layout time: the page's first (or with `\l` last) paragraph of the style, else the nearest before it; style name, id or heading level (a level finds the style named `heading N` whatever its id, so localized documents match, #1286); character styles; `\n \r \w \s \t \p` (`\r` treated as `\w`); body STYLEREF updated with the other fields (#775). Header paragraphs are cached on their per-page results, so typing in body text no STYLEREF shows doesn't lay the headers out again (#1286) | — | +| Fields: STYLEREF in headers and footers (running chapter titles) | ✅ | Evaluated per page at layout time: the page's first (or with `\l` last) paragraph of the style, else the nearest before it; style name, id or heading level (a level finds the style named `heading N` whatever its id, so localized documents match, #1286); character styles; `\n \r \w \s \t \p` (`\r` treated as `\w`); body STYLEREF updated with the other fields (#775). Field results with right-to-left text (a Hebrew heading, "פרק א") are laid out with bidi reordering like ordinary text: the result is an isolate taking its first strong letter's direction (else the text's around the field), and each level run is shaped and mirrored in its own direction, so `{ PAGE }` in field-code view and `(Chapter 1)` keep their brackets in right-to-left paragraphs; this covers every field and note mark shown as text (#1670). Header paragraphs are cached on their per-page results, so typing in body text no STYLEREF shows doesn't lay the headers out again (#1286) | — | | Fields: PAGE, NUMPAGES, SECTIONPAGES, TOC page numbers, PAGEREF in headers and footers | ✅ | #52; totals in the body need a second pass once the page count is known (#451); a PAGE field in a paragraph that runs onto a later page (a page break inside it, or a natural split) shows that page's number, the rest of the paragraph broken again for the wider result (#1352); PAGEREF in headers and footers shows its bookmark's page, filled in after pagination like their totals (#1477); in the body it keeps its result until fields are updated | — | | Page-by-page comparison harness against Word | ❌ | Run Word locally on our own synthetic documents, export PDF, compare line breaks; Word output stays under `plan/` (never committed) | 8–12 | @@ -190,6 +190,7 @@ Theme-inherited dash styles, line caps, ink and open freeform strokes are not co | 2026-10-15 | trivial | The caret after spaces hanging past the right margin stays at the margin instead of running off the page (#2146) | | 2026-10-15 | trivial | A missing Thai document font is substituted by one Thai face for the whole run, Latin letters and digits included (#1549) | | 2026-10-15 | trivial | A list bullet no installed font can draw (a private-use code) shows the level's default bullet instead of an icon from an unrelated font (#1683) | +| 2026-10-16 | trivial | Field results (STYLEREF, REF, PAGE text…) with right-to-left text are reordered like ordinary text, so a Hebrew heading in a header reads correctly (#1670) | | 2026-10-14 | trivial | Row table border exceptions draw without leaking into other rows | | 2026-10-14 | trivial | Consistent cell widths override a stale table grid | | 2026-10-14 | trivial | Paragraphs inside complex-field instructions take no flow height; empty result paragraphs keep their line. | diff --git a/docs/typing-parity.md b/docs/typing-parity.md index 013131fa25..afd629c8cf 100644 --- a/docs/typing-parity.md +++ b/docs/typing-parity.md @@ -1,6 +1,6 @@ # Typing parity with Word -> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-18 · **Change:** trivial (Backspace on a selected table, rows or columns deletes them, Delete clears them, #1844; previously trivial: typed text takes its direction from its own letters, #1543; previously trivial: an input method's request to erase text around the caret is honoured, so web typing in Cyrillic and other non-Latin text no longer repeats, #1480; previously trivial: `-->` AutoCorrect replacement, #2223; previously trivial: a heading-list preset keeps Heading styles and makes other paragraphs Heading 1, as in Word, #1793; previously trivial: numbered headings: a heading-linked Multilevel List numbers new headings by their style; numbering keeps a heading a heading, #1793; previously trivial: reversing an automatic capital adds an exception, #1857; previously trivial: smart quotes open after guillemets and low quotes and nest; previously trivial: Enter on an empty list item numbered by its paragraph style moves it up a level, #2143; previously trivial: Backspace after a section break and Delete before it remove the break, also on the last paragraph, #2127; previously trivial: a table inserted in right-to-left text is right to left, so Tab moves left, #2634; previously trivial: Shift+Delete cuts the selection on every platform, #1669; previously trivial: Select All then Delete removes a table the document starts with, #1198; previously trivial: Alt+numpad character codes, #961; previously trivial: Down and Up from a line start beside a table go into it, #1007; previously trivial: sample-text formulas on Enter, #1067; previously trivial: Track Changes: Accept All after a tracked delete from a paragraph end keeps the next paragraph; Reject All after typing into an empty paragraph keeps it (#864); previously minor: Correct TWo INitial CApitals, cAPS LOCK and table-cell capitals, #544; previously trivial: complex-script caret and Backspace, #604; Move Paragraph Up/Down, Promote/Demote, Go Back and Alt+X keys, #343) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4 +> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-18 · **Change:** trivial (smart quotes in Hebrew pinned: logical order, ” in acronyms as in Word, #1670; previously trivial: Backspace on a selected table, rows or columns deletes them, Delete clears them, #1844; previously trivial: typed text takes its direction from its own letters, #1543; previously trivial: an input method's request to erase text around the caret is honoured, so web typing in Cyrillic and other non-Latin text no longer repeats, #1480; previously trivial: `-->` AutoCorrect replacement, #2223; previously trivial: a heading-list preset keeps Heading styles and makes other paragraphs Heading 1, as in Word, #1793; previously trivial: numbered headings: a heading-linked Multilevel List numbers new headings by their style; numbering keeps a heading a heading, #1793; previously trivial: reversing an automatic capital adds an exception, #1857; previously trivial: smart quotes open after guillemets and low quotes and nest; previously trivial: Enter on an empty list item numbered by its paragraph style moves it up a level, #2143; previously trivial: Backspace after a section break and Delete before it remove the break, also on the last paragraph, #2127; previously trivial: a table inserted in right-to-left text is right to left, so Tab moves left, #2634; previously trivial: Shift+Delete cuts the selection on every platform, #1669; previously trivial: Select All then Delete removes a table the document starts with, #1198; previously trivial: Alt+numpad character codes, #961; previously trivial: Down and Up from a line start beside a table go into it, #1007; previously trivial: sample-text formulas on Enter, #1067; previously trivial: Track Changes: Accept All after a tracked delete from a paragraph end keeps the next paragraph; Reject All after typing into an empty paragraph keeps it (#864); previously minor: Correct TWo INitial CApitals, cAPS LOCK and table-cell capitals, #544; previously trivial: complex-script caret and Backspace, #604; Move Paragraph Up/Down, Promote/Demote, Go Back and Alt+X keys, #343) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4 What Word does when you type, observed black-box (same keystrokes typed into Word and WordCraft, results read back paragraph by paragraph) and pinned by tests in @@ -105,6 +105,11 @@ Applied when a word is finished by a space, punctuation **or Enter** (`enter_fin `wait...`→`wait…`, smart quotes (`autocorrect_as_you_type`); a quote opens after a space, an opening bracket, a dash, `«` `‹` `„` `‚`, or the other kind of opening quote, so nested quotes curl correctly (`smart_quotes_nested_and_opening_delimiters`). + Smart quotes in Hebrew (and other right-to-left text) are chosen from the characters around + the quote in logical order, whatever the display direction: `"` after a space or at the start + is “ (U+201C) even though it shows on the right of the word, after a letter ” (U+201D); in an + acronym (`צה"ל`) it becomes ” as in Word for Mac 16.113.4, not gershayim (״); `'` after a letter + (`ג'ירפה`) is ’ as after any letter, with no geresh conversion (not yet checked in Word; `smart_quotes_in_hebrew_use_logical_order`, #1670). - Correct TWo INitial CApitals: a finished word of three or more letters with two capitals and the rest lowercase loses its second capital (`WOrd`→`Word`, `TEh`→`The`); not the exceptions (`IDs`, `CDs`, `PCs`, `TVs`, … plus the user's, `tools.autocorrect {"addInitialCapsException"}`) @@ -281,6 +286,7 @@ Word. | 2026-10-15 | trivial | Input methods (#1480 review): the rewritten text keeps its formatting, doesn't overtype, keeps the typing undo group; AutoCorrect/AutoFormat reset the web text field; desktop doesn't receive `DeleteSurrounding` | | 2026-10-15 | trivial | Input methods: `DeleteSurrounding` erases around the caret, so web typing in non-Latin text no longer repeats; Chrome's caret jump in the hidden field is listed as a known gap (#1480) | | 2026-10-15 | minor | Backspace with a whole table, whole rows or whole columns selected deletes them; Delete clears them; a selection covering a table and the text beside it removes the table (#1844) | +| 2026-10-16 | trivial | Smart quotes in Hebrew follow logical order; ” (not gershayim) in acronyms, as Word does (#1670) | | 2026-10-14 | trivial | Shift+Delete cuts the selection, and does nothing without one, on macOS and Linux too, as in Word for Windows (#1669); Shift+Backspace already erased (#707) | | 2026-10-13 | trivial | Select All then Delete or Backspace removes a table the document starts with (#1198) | | 2026-10-12 | trivial | Editing keys: Down and Up from the start of a line beside a table go into it instead of skipping it (#1007) | From 559f16601d8f52d62c986b0cd84beaf5be84cb00 Mon Sep 17 00:00:00 2001 From: Dmitrii Khlapov Date: Sun, 18 Oct 2026 10:43:13 +0200 Subject: [PATCH 30/38] A mouse drag on a table no longer scrolls the view to the caret (#1738) (#2008) * Co-Authored-By: Claude Sonnet 5.5 * Co-Authored-By: Claude Sonnet 5.5 --------- Co-authored-by: Dmitrii Khlapov <19991457+dkhlapov@users.noreply.github.com> Co-authored-by: Brandon Thomas --- ROADMAP.md | 3 +- crates/ui-egui/src/canvas.rs | 4 +++ crates/ui-egui/src/lib.rs | 11 ++++++- crates/ui-egui/src/objects.rs | 49 ++++++++++++++++++++++++++++++ crates/ui-egui/src/table_mouse.rs | 30 +++++++++++++++--- crates/ui-egui/src/table_resize.rs | 28 +++++++++++++++-- docs/ui-parity.md | 3 +- 7 files changed, 119 insertions(+), 9 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 016e9818a5..ab75834391 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -2,7 +2,7 @@ **Stage: alpha** · next: beta, ~15 points (60% → 75% ready for real work) and ~150–230 h away -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-18 · **Change:** trivial (progress log: Hebrew field results and smart quotes, #1670; previously trivial: Backspace removes a selected table, rows or columns, #1844; previously trivial: progress log: mixed Hebrew/Latin text keeps its order in Word, #1543; previously trivial: progress log: web typing in non-Latin text no longer repeats, #1480; previously trivial: progress log: font family caches bounded and math font lookups follow font changes, #1354; previously trivial: `-->` AutoCorrect replacement, #2223; previously trivial: numbered headings, #1793; previously trivial: Navigation Find exposes Results, #2096; previously trivial: progress log: Columns on selected text, #2024; previously trivial: progress log: legacy outline, shadow, emboss and imprint text effects drawn, #2534; previously trivial: progress log: Indonesian proofing, #498; previously trivial: progress log: DOCX glossary documents (building blocks); previously trivial: progress log: Read Mode keeps the keyboard and shows whole, unobscured columns, #1772); previously trivial: progress log: font list entries of tall-ascent fonts no longer cut off, #2570); previously trivial: progress log: Split Cells and Convert Text to Table dialogs follow the Word check, #2592; previously trivial: a new source's Tag is made like Word's, three letters of the author and two digits of the year with a number added when taken, and an edit keeps the Tag, #2094; previously trivial: Web Layout fills the window width, #1876; previously trivial: progress log: Different First Page headers reachable from page 1, #2826; previously trivial: Language dialog keeps inherited proofing language and no-proof choices (#2717); previously trivial: progress log: web preferences kept between visits, #2132; previously trivial: progress log: undrawable private-use list bullets show the level's default bullet, #1683); previously trivial: table style editor formats every part, #714; previously trivial: VML freeforms read and kept on save; previously trivial: progress log: consistent preferred cell widths; previously trivial: multi-paragraph field instructions no longer add blank lines; previously trivial: page-break-only list paragraphs hide their labels; previously trivial: progress log: grouped `flowChartOr` shapes draw their circle and crossing spokes; previously trivial: progress log: copies keep their formatting in other programs, #1523; previously trivial: progress log: privacy options on save, #1062; previously trivial: progress log: equation pictures in theme colours on Windows, #1002; previously trivial: symbol fonts, #776; previously trivial: the save prompt answers from the keyboard, #861; previously trivial: table column and row markers on the rulers, #700; previously trivial: table borders and the resize handle drag on the page, #217; previously trivial: formatting revisions landed, #41; previously trivial: Draw tab ink, #307, and Draw Table and Eraser, #303, landed: catalog 402/434; previously minor: readiness table with hours per audience; full number back to the additive weighted sum, 60%; mainstream and essentials numbers added; alpha gate checked; previously major: full re-measure against Word for Mac 16.113.4; restructured to the craftrules progress-docs standard) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-18 · **Change:** trivial (a mouse drag on a table leaves the view where it is, #1738; previously trivial: progress log: Hebrew field results and smart quotes, #1670; previously trivial: Backspace removes a selected table, rows or columns, #1844; previously trivial: progress log: mixed Hebrew/Latin text keeps its order in Word, #1543; previously trivial: progress log: web typing in non-Latin text no longer repeats, #1480; previously trivial: progress log: font family caches bounded and math font lookups follow font changes, #1354; previously trivial: `-->` AutoCorrect replacement, #2223; previously trivial: numbered headings, #1793; previously trivial: Navigation Find exposes Results, #2096; previously trivial: progress log: Columns on selected text, #2024; previously trivial: progress log: legacy outline, shadow, emboss and imprint text effects drawn, #2534; previously trivial: progress log: Indonesian proofing, #498; previously trivial: progress log: DOCX glossary documents (building blocks); previously trivial: progress log: Read Mode keeps the keyboard and shows whole, unobscured columns, #1772); previously trivial: progress log: font list entries of tall-ascent fonts no longer cut off, #2570); previously trivial: progress log: Split Cells and Convert Text to Table dialogs follow the Word check, #2592; previously trivial: a new source's Tag is made like Word's, three letters of the author and two digits of the year with a number added when taken, and an edit keeps the Tag, #2094; previously trivial: Web Layout fills the window width, #1876; previously trivial: progress log: Different First Page headers reachable from page 1, #2826; previously trivial: Language dialog keeps inherited proofing language and no-proof choices (#2717); previously trivial: progress log: web preferences kept between visits, #2132; previously trivial: progress log: undrawable private-use list bullets show the level's default bullet, #1683); previously trivial: table style editor formats every part, #714; previously trivial: VML freeforms read and kept on save; previously trivial: progress log: consistent preferred cell widths; previously trivial: multi-paragraph field instructions no longer add blank lines; previously trivial: page-break-only list paragraphs hide their labels; previously trivial: progress log: grouped `flowChartOr` shapes draw their circle and crossing spokes; previously trivial: progress log: copies keep their formatting in other programs, #1523; previously trivial: progress log: privacy options on save, #1062; previously trivial: progress log: equation pictures in theme colours on Windows, #1002; previously trivial: symbol fonts, #776; previously trivial: the save prompt answers from the keyboard, #861; previously trivial: table column and row markers on the rulers, #700; previously trivial: table borders and the resize handle drag on the page, #217; previously trivial: formatting revisions landed, #41; previously trivial: Draw tab ink, #307, and Draw Table and Eraser, #303, landed: catalog 402/434; previously minor: readiness table with hours per audience; full number back to the additive weighted sum, 60%; mainstream and essentials numbers added; alpha gate checked; previously major: full re-measure against Word for Mac 16.113.4; restructured to the craftrules progress-docs standard) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4 > **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-17 · **Change:** trivial (progress log: pasted blockquotes are indented paragraphs, #1270; previously trivial: progress log: print-area strings translated in every catalog; previously trivial: progress log: comments readable in the dark theme, #2045; previously trivial: progress log: ODT sections, #1379; previously trivial: progress log: table deletes, Change Case and Unlink Fields respect Track Changes and locked content controls, #1329; previously trivial: progress log: rounded rectangle corners, #1476; previously trivial: progress log: Save As type list, #1399; previously trivial: progress log: privacy options on save, #1062; previously trivial: progress log: equation pictures in theme colours on Windows, #1002; previously trivial: symbol fonts, #776; previously trivial: the save prompt answers from the keyboard, #861; previously trivial: table column and row markers on the rulers, #700; previously trivial: table borders and the resize handle drag on the page, #217; previously trivial: formatting revisions landed, #41; previously trivial: Draw tab ink, #307, and Draw Table and Eraser, #303, landed: catalog 402/434; previously minor: readiness table with hours per audience; full number back to the additive weighted sum, 60%; mainstream and essentials numbers added; alpha gate checked; previously major: full re-measure against Word for Mac 16.113.4; restructured to the craftrules progress-docs standard) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4 > **Printing update:** 2026-10-12 · direct desktop printing through craft-print; details below. @@ -137,6 +137,7 @@ Ranked, to beta (detail and remaining milestones in [docs/roadmap.md](docs/roadm - 2026-10-15: Find opens Navigation on Results; editing the query restores Results after manual Headings/Pages navigation. Typed and selected-text searches retain document contents (#2096). | Date | What landed | +| 2026-10-16 | Releasing a table drag with the mouse (column or row border, move handle, selection areas) leaves the view where it is instead of scrolling back to the caret (#1738) | | 2026-10-16 | Hebrew in fields and quotes (#1670): a STYLEREF (or any field) showing right-to-left text such as "פרק א" reads right to left in headers and the body instead of reversed; smart quotes in Hebrew pinned to Word's logical-order result (“ before, ” after, ” in acronyms like צה"ל) | | 2026-10-15 | Backspace with a whole table selected removes it, as in Word, and selected whole rows or columns go the same way; Delete clears them and keeps the table. A selection that covers a table and the paragraph before or after it removes the table, also when it starts inside the table or the table is nested (#1844) | | 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. | diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 4873093495..c7cb690ff5 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -45,6 +45,9 @@ pub struct CanvasState { /// revision: finding out walks every paragraph, too slow for every frame (#811). markup_facts: std::cell::Cell>, pub scroll_to_caret: bool, + /// Set while a command runs for a mouse drag on a table: the pointer is already + /// on the target, so the view stays put instead of following the caret (#1738). + pub keep_view: bool, /// A heading navigation click aligns its page against the navigation pane, not just visible. pub(crate) nav_to_caret: bool, /// Touchpad travel not yet converted to an entire reading-column turn. @@ -198,6 +201,7 @@ impl Default for CanvasState { page_keys: PageKeys::default(), markup_facts: std::cell::Cell::new(None), scroll_to_caret: true, + keep_view: false, nav_to_caret: false, read_wheel_accum: 0.0, read_viewport: Settle::default(), diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 052b567feb..968fc6db98 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -823,6 +823,14 @@ impl WordApp { } } + /// `run` for a command a mouse drag on a table sends: the view stays where it is. + pub(crate) fn run_keep_view(&mut self, id: &str, params: Value) -> Result { + self.canvas.keep_view = true; + let r = self.run(id, params); + self.canvas.keep_view = false; + r + } + /// Run a command the user asked for (ribbon, shortcut, Backstage, file drop). New, Open, /// Close, Envelopes, Labels and Finish & Merge on a document with unsaved changes first ask /// Save / Don't Save / Cancel, and the command runs once that is answered (`ui.saveChanges`). @@ -1264,7 +1272,8 @@ impl WordApp { self.canvas.read_first_visible = None; } // Drawing leaves the caret where it was: following it would pull the view off the ink. - if self.session.selects_whole_story() || id == "select.all" { + // A mouse drag on a table (`run_keep_view`) does the same (#1738). + if self.session.selects_whole_story() || id == "select.all" || self.canvas.keep_view { self.canvas.scroll_to_caret = false; } else if !["ui.", "view.", "document.", "format.state", "draw."].iter().any(|p| id.starts_with(p)) { self.canvas.scroll_to_caret = true; diff --git a/crates/ui-egui/src/objects.rs b/crates/ui-egui/src/objects.rs index 37af3f7b87..6a0c79ea64 100644 --- a/crates/ui-egui/src/objects.rs +++ b/crates/ui-egui/src/objects.rs @@ -751,6 +751,55 @@ mod tests { let r = shape(&mut h); assert!((r.x + r.w / 2.0 - centre - 1.5).abs() < 0.01, "{r:?}"); } + /// #1738: a floating shape dragged from page 1 onto page 2 leaves the view where it is, even + /// though the command that drops it (`arrange.bounds`) is not a view command. + #[test] + fn dropping_a_shape_on_another_page_keeps_the_view_where_it_is() { + use egui::{Event, PointerButton}; + let mut a = WordApp::new(wordcraft_engine::Session::new(Document::new()), crate::Services::default()); + a.run("view.commentsPane", json!({"value": false})).unwrap(); + a.run("insert.shape", json!({"kind": "rectangle", "width": 100, "height": 50})).unwrap(); + a.run("arrange.bounds", json!({"page": 0, "x": 100, "y": 250})).unwrap(); + let end = Pos::body(0, wordcraft_doc::para::OBJ.len_utf8()); + a.run("caret.set", json!({"pos": end})).unwrap(); + a.run("text.insert", json!({"text": "one"})).unwrap(); + a.run("text.newParagraph", json!({})).unwrap(); + a.run("para.pageBreakBefore", json!({})).unwrap(); + a.run("text.insert", json!({"text": "two"})).unwrap(); + a.run("view.zoom", json!({"value": 30})).unwrap(); + a.run("select.range", json!({"anchor": Pos::body(0, 0), "focus": end})).unwrap(); + let mut h = egui_kittest::Harness::builder().with_size(egui::vec2(1440.0, 900.0)).with_step_dt(1.0 / 60.0).build_ui_state( + |ui, app: &mut WordApp| { + let ctx = ui.ctx().clone(); + app.logic(&ctx); + app.ui(ui); + }, + a, + ); + for _ in 0..6 { + h.step(); + } + let r = h.state_mut().session.layout().object(&Pos::body(0, 0), 0).unwrap().rect; + let from = crate::canvas::page_to_screen(h.state_mut(), 0, r.x + r.w / 2.0, r.y + r.h / 2.0).unwrap(); + let to = crate::canvas::page_to_screen(h.state_mut(), 1, 200.0, 300.0).unwrap(); + let button = + |at: Pos2, pressed: bool| Event::PointerButton { pos: at, button: PointerButton::Primary, pressed, modifiers: Default::default() }; + h.event(Event::PointerMoved(from)); + h.event(button(from, true)); + h.step(); + for k in 1..=4 { + h.event(Event::PointerMoved(from + (to - from) * (k as f32 / 4.0))); + h.step(); + } + h.state_mut().canvas.scroll_to_caret = false; + h.event(button(to, false)); + h.step(); + h.step(); + let landed = h.state_mut().session.layout().object_at(1, 200.0, 300.0, 0.0).is_some(); + assert!(landed, "the shape landed on page 2"); + assert!(!h.state().canvas.scroll_to_caret, "the drop scrolled to the caret"); + } + /// A floating rectangle shape, `w` × `h`. fn floating_rect(w: f32, h: f32) -> InlineObject { InlineObject::Shape { diff --git a/crates/ui-egui/src/table_mouse.rs b/crates/ui-egui/src/table_mouse.rs index 8cce90d9eb..4c5f8daf9a 100644 --- a/crates/ui-egui/src/table_mouse.rs +++ b/crates/ui-egui/src/table_mouse.rs @@ -66,7 +66,7 @@ pub fn pointer(app: &mut WordApp, ui: &Ui, resp: &egui::Response, rects: &[Rect] // Released: a drag moves the table (one undo step), a click selects it. let moved = (d.to.0 - d.from.0).abs() >= 0.5 || (d.to.1 - d.from.1).abs() >= 0.5; let id = if moved && table_mouse::movable(&d.target.geom) { "table.moveDrag" } else { "table.selectAt" }; - if let Err(e) = app.run(id, at) { + if let Err(e) = app.run_keep_view(id, at) { app.status(e); } return true; @@ -76,7 +76,7 @@ pub fn pointer(app: &mut WordApp, ui: &Ui, resp: &egui::Response, rects: &[Rect] if down { if d.to != d.sent { d.sent = d.to; - let _ = app.run("table.selectAt", at); + let _ = app.run_keep_view("table.selectAt", at); } app.canvas.table_drag = Some(d); } else if matches!(d.target.spot, Spot::Cell(..)) @@ -95,14 +95,14 @@ pub fn pointer(app: &mut WordApp, ui: &Ui, resp: &egui::Response, rects: &[Rect] let at = json!({"page": page, "x": x, "y": y, "reach": reach}); match target.spot { Spot::InsertRow(_) | Spot::InsertColumn(_) => { - if let Err(e) = app.run("table.insertAt", at) { + if let Err(e) = app.run_keep_view("table.insertAt", at) { app.status(e); } } spot => { // A selection area selects at once; the move handle waits for the release. if spot != Spot::Handle - && let Err(e) = app.run("table.selectAt", at) + && let Err(e) = app.run_keep_view("table.selectAt", at) { app.status(e); } @@ -399,4 +399,26 @@ mod tests { assert!((moved.x - (a.x + 40.0)).abs() < 1.0 && (moved.y - (a.y + 30.0)).abs() < 1.0, "{a:?} → {moved:?}"); assert_eq!(h.state().session.undo_depth(), depth + 1); } + + /// #1738: dropping the move handle leaves the view where it is. + #[test] + fn dropping_the_move_handle_keeps_the_view_where_it_is() { + let mut h = table_window(); + let a = cell(&mut h, 0, 0); + let (hx, hy) = (a.x - 6.0, a.y - 6.0); + let start = screen(&mut h, hx, hy); + h.event(egui::Event::PointerMoved(start)); + steps(&mut h, 2); + button(&h, start, true); + h.step(); + let end = screen(&mut h, hx + 40.0, hy + 30.0); + h.event(egui::Event::PointerMoved(end)); + steps(&mut h, 2); + h.state_mut().canvas.scroll_to_caret = false; + let depth = h.state().session.undo_depth(); + button(&h, end, false); + h.step(); + assert_eq!(h.state().session.undo_depth(), depth + 1, "the table moved"); + assert!(!h.state().canvas.scroll_to_caret, "the move scrolled to the caret"); + } } diff --git a/crates/ui-egui/src/table_resize.rs b/crates/ui-egui/src/table_resize.rs index 67be6b86a1..69d8d7085e 100644 --- a/crates/ui-egui/src/table_resize.rs +++ b/crates/ui-egui/src/table_resize.rs @@ -133,7 +133,7 @@ pub fn pointer(app: &mut WordApp, ui: &Ui, resp: &egui::Response, rects: &[Rect] if resp.double_clicked() && matches!(d.target.edge, Edge::Column(_)) { let sc = page_scale(rects, layout, scale, d.page).unwrap_or(1.0); let params = json!({"page": d.page, "x": d.from.0, "y": d.from.1, "reach": REACH_PX / sc}); - if let Err(e) = app.run("table.fitColumn", params) { + if let Err(e) = app.run_keep_view("table.fitColumn", params) { app.status(e); } } @@ -145,7 +145,7 @@ pub fn pointer(app: &mut WordApp, ui: &Ui, resp: &egui::Response, rects: &[Rect] "page": d.page, "x": d.from.0, "y": d.from.1, "toX": d.to.0, "toY": d.to.1, "shift": share == Share::LeftOnly, "ctrl": share == Share::Proportional, "reach": REACH_PX / sc, }); - if let Err(e) = app.run("table.resizeDrag", params) { + if let Err(e) = app.run_keep_view("table.resizeDrag", params) { app.status(e); } return true; @@ -380,6 +380,30 @@ pub(crate) mod tests { assert_eq!(cell(&mut h, 0, 0), a); } + /// #1738: releasing a column-border drag leaves the view where it is, not scrolled back to + /// the caret; typing afterwards still follows the caret. + #[test] + fn dragging_a_column_border_keeps_the_view_where_it_is() { + let mut h = table_window(); + let a = cell(&mut h, 0, 0); + let start = screen(&mut h, a.right(), a.y + a.h / 2.0); + let end = screen(&mut h, a.right() + 30.0, a.y + a.h / 2.0); + h.event(egui::Event::PointerMoved(start)); + steps(&mut h, 2); + button(&h, start, true); + h.step(); + h.event(egui::Event::PointerMoved(end)); + steps(&mut h, 2); + h.state_mut().canvas.scroll_to_caret = false; + let depth = h.state().session.undo_depth(); + button(&h, end, false); + h.step(); + assert_eq!(h.state().session.undo_depth(), depth + 1, "the drag was applied"); + assert!(!h.state().canvas.scroll_to_caret, "the resize scrolled to the caret"); + h.state_mut().run("text.insert", json!({"text": "x"})).unwrap(); + assert!(h.state().canvas.scroll_to_caret, "typing still brings the caret into view"); + } + /// The zone of the ruler marker named `label` nearest to screen point `near`. fn ruler_marker(h: &H, label: &str, near: Pos2) -> Rect { use egui_kittest::kittest::Queryable; diff --git a/docs/ui-parity.md b/docs/ui-parity.md index c6229559cc..d8db932ad1 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -1,6 +1,6 @@ # UI parity with Microsoft Word -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-18 · **Change:** trivial (numbered headings, #1793; previously trivial: Navigation Find exposes Results, #2096; previously trivial: the tab strip never draws tabs over Comments / Editing / Share, #2026; previously trivial: Columns on selected text, #2024; previously trivial: Different First Page headers reachable from page 1, #2826; previously trivial: a copied equation also goes in the clipboard's MathML formats, #1297; previously trivial: Read Mode keeps the keyboard, shows whole columns and keeps text clear of the page-turn bar, #1772); previously trivial: font list entries of tall-ascent fonts sit in their row, not cut off, #2570); previously trivial: Split Cells and Convert Text to Table dialogs follow Word's merged-cell limits and Initial column width, #2592; previously trivial: a new source's Tag is made like Word's, three letters of the author and two digits of the year with a number added when taken, and an edit keeps the Tag, #2094; previously trivial: Web Layout text fills the window width, #1876; previously trivial: Language dialog keeps inherited proofing language and no-proof choices (#2717)); previously trivial: comment balloons readable in the dark theme, #2045; previously trivial: Recently Used Fonts section at the top of the font list, #2068; previously trivial: web preferences kept between visits, #2132; previously trivial: dialog close control: a Windows-style box at the title bar's right end, the whole box clickable, red on hover, #1885); previously trivial (Modify Table Style formats every table style part, #714); previously trivial: a click on the ribbon chrome hands the keyboard to the page in the same frame, #1093 follow-up; previously trivial: Page Setup vertical alignment, #1320; previously trivial: the app opens on the Home tab every launch, as in Word; previously trivial: no document behind the Start screen, #1129; previously trivial: equation pictures share a few textures, #1002; previously trivial: selection bar in the left margin, #856; previously trivial: table column and row markers on the rulers, #700; previously trivial: table borders and the resize handle drag on the page, #217; previously major: first version; ribbon, dialogs, shortcuts and on-canvas interaction measured from `crates/ui-egui` and the command registry) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-18 · **Change:** trivial (a mouse drag on a table leaves the view where it is, #1738; previously trivial: numbered headings, #1793; previously trivial: Navigation Find exposes Results, #2096; previously trivial: the tab strip never draws tabs over Comments / Editing / Share, #2026; previously trivial: Columns on selected text, #2024; previously trivial: Different First Page headers reachable from page 1, #2826; previously trivial: a copied equation also goes in the clipboard's MathML formats, #1297; previously trivial: Read Mode keeps the keyboard, shows whole columns and keeps text clear of the page-turn bar, #1772); previously trivial: font list entries of tall-ascent fonts sit in their row, not cut off, #2570); previously trivial: Split Cells and Convert Text to Table dialogs follow Word's merged-cell limits and Initial column width, #2592; previously trivial: a new source's Tag is made like Word's, three letters of the author and two digits of the year with a number added when taken, and an edit keeps the Tag, #2094; previously trivial: Web Layout text fills the window width, #1876; previously trivial: Language dialog keeps inherited proofing language and no-proof choices (#2717)); previously trivial: comment balloons readable in the dark theme, #2045; previously trivial: Recently Used Fonts section at the top of the font list, #2068; previously trivial: web preferences kept between visits, #2132; previously trivial: dialog close control: a Windows-style box at the title bar's right end, the whole box clickable, red on hover, #1885); previously trivial (Modify Table Style formats every table style part, #714); previously trivial: a click on the ribbon chrome hands the keyboard to the page in the same frame, #1093 follow-up; previously trivial: Page Setup vertical alignment, #1320; previously trivial: the app opens on the Home tab every launch, as in Word; previously trivial: no document behind the Start screen, #1129; previously trivial: equation pictures share a few textures, #1002; previously trivial: selection bar in the left margin, #856; previously trivial: table column and row markers on the rulers, #700; previously trivial: table borders and the resize handle drag on the page, #217; previously major: first version; ribbon, dialogs, shortcuts and on-canvas interaction measured from `crates/ui-egui` and the command registry) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4 How WordCraft looks and feels next to Word: ribbon, dialogs, panes, keyboard, mouse and touch. Typing behaviour has its own checklist ([`typing-parity.md`](typing-parity.md)). All assets are our @@ -83,6 +83,7 @@ Find selects the Results tab, including when it prefills selected text. Editing | 2026-10-16 | trivial | Multilevel List heading presets: every selected paragraph takes the first level (Heading 1), as Word for Mac does; headings outside the selection keep their styles and levels (#1793) | | 2026-10-16 | trivial | Multilevel List heading presets: selected paragraphs already in a Heading style keep their style and level, every other selected paragraph becomes Heading 1, as Word for Mac does (#1793) | | 2026-10-15 | trivial | Multilevel List gallery: four lists linked to Heading 1–9 (1 / 1.1 / 1.1.1; Article I. / Section 1.01 / (a); I. / A. / 1.; Chapter 1), `list.multilevelPreset`; headings keep their styles and new headings are numbered (#1793). Style gallery previews don't show the numbers yet | +| 2026-10-16 | trivial | A mouse drag that edits a table (column or row border, move handle) leaves the view where it is; typing still follows the caret (#1738) | | 2026-10-13 | trivial | New/Modify Table Style: No Border on a part that inherits its lines hides them; emptying the font box makes the part's font inherited again (#1288) | | 2026-10-14 | trivial | Tab stops edited on the ruler: corner kind selector, click to set, drag to move or off to clear, per-kind markers mirrored for RTL; each gesture one `para.tabs` call and one undo step; Rulers 85% → 90% | | 2026-10-12 | trivial | Tables on the page: one selected cell (Select Cell, or a click along its left edge) is a cell selection when its border is dragged: the border moves in its row only (#962) | From 8fcaa66e19320e3b3ccf337cc48d370752734002 Mon Sep 17 00:00:00 2001 From: Dmitrii Khlapov Date: Sun, 18 Oct 2026 10:43:32 +0200 Subject: [PATCH 31/38] =?UTF-8?q?Turkish=20case=20mapping:=20i/=C4=B0=20an?= =?UTF-8?q?d=20=C4=B1/I=20capitalise=20correctly=20(#1923)=20(#2009)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * WIP for #1923 * AutoCorrect: Turkish and Azerbaijani case mapping (#1923) Co-Authored-By: Claude Opus 5.5 (1M context) * AutoCorrect: Turkic language detection shares proofing's tag normalisation, I to dotless i covered (#1923) Co-Authored-By: Claude Opus 5.5 (1M context) --------- 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 | 3 +- crates/engine/src/cmd/tools.rs | 59 +++++++++++++++++++++++-------- crates/engine/src/tests_typing.rs | 37 +++++++++++++++++++ docs/typing-parity.md | 11 +++++- 4 files changed, 94 insertions(+), 16 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index ab75834391..c979dfa618 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -2,7 +2,7 @@ **Stage: alpha** · next: beta, ~15 points (60% → 75% ready for real work) and ~150–230 h away -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-18 · **Change:** trivial (a mouse drag on a table leaves the view where it is, #1738; previously trivial: progress log: Hebrew field results and smart quotes, #1670; previously trivial: Backspace removes a selected table, rows or columns, #1844; previously trivial: progress log: mixed Hebrew/Latin text keeps its order in Word, #1543; previously trivial: progress log: web typing in non-Latin text no longer repeats, #1480; previously trivial: progress log: font family caches bounded and math font lookups follow font changes, #1354; previously trivial: `-->` AutoCorrect replacement, #2223; previously trivial: numbered headings, #1793; previously trivial: Navigation Find exposes Results, #2096; previously trivial: progress log: Columns on selected text, #2024; previously trivial: progress log: legacy outline, shadow, emboss and imprint text effects drawn, #2534; previously trivial: progress log: Indonesian proofing, #498; previously trivial: progress log: DOCX glossary documents (building blocks); previously trivial: progress log: Read Mode keeps the keyboard and shows whole, unobscured columns, #1772); previously trivial: progress log: font list entries of tall-ascent fonts no longer cut off, #2570); previously trivial: progress log: Split Cells and Convert Text to Table dialogs follow the Word check, #2592; previously trivial: a new source's Tag is made like Word's, three letters of the author and two digits of the year with a number added when taken, and an edit keeps the Tag, #2094; previously trivial: Web Layout fills the window width, #1876; previously trivial: progress log: Different First Page headers reachable from page 1, #2826; previously trivial: Language dialog keeps inherited proofing language and no-proof choices (#2717); previously trivial: progress log: web preferences kept between visits, #2132; previously trivial: progress log: undrawable private-use list bullets show the level's default bullet, #1683); previously trivial: table style editor formats every part, #714; previously trivial: VML freeforms read and kept on save; previously trivial: progress log: consistent preferred cell widths; previously trivial: multi-paragraph field instructions no longer add blank lines; previously trivial: page-break-only list paragraphs hide their labels; previously trivial: progress log: grouped `flowChartOr` shapes draw their circle and crossing spokes; previously trivial: progress log: copies keep their formatting in other programs, #1523; previously trivial: progress log: privacy options on save, #1062; previously trivial: progress log: equation pictures in theme colours on Windows, #1002; previously trivial: symbol fonts, #776; previously trivial: the save prompt answers from the keyboard, #861; previously trivial: table column and row markers on the rulers, #700; previously trivial: table borders and the resize handle drag on the page, #217; previously trivial: formatting revisions landed, #41; previously trivial: Draw tab ink, #307, and Draw Table and Eraser, #303, landed: catalog 402/434; previously minor: readiness table with hours per audience; full number back to the additive weighted sum, 60%; mainstream and essentials numbers added; alpha gate checked; previously major: full re-measure against Word for Mac 16.113.4; restructured to the craftrules progress-docs standard) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-18 · **Change:** trivial (progress log: Turkish case mapping in AutoCorrect, #1923; previously trivial: a mouse drag on a table leaves the view where it is, #1738; previously trivial: progress log: Hebrew field results and smart quotes, #1670; previously trivial: Backspace removes a selected table, rows or columns, #1844; previously trivial: progress log: mixed Hebrew/Latin text keeps its order in Word, #1543; previously trivial: progress log: web typing in non-Latin text no longer repeats, #1480; previously trivial: progress log: font family caches bounded and math font lookups follow font changes, #1354; previously trivial: `-->` AutoCorrect replacement, #2223; previously trivial: numbered headings, #1793; previously trivial: Navigation Find exposes Results, #2096; previously trivial: progress log: Columns on selected text, #2024; previously trivial: progress log: legacy outline, shadow, emboss and imprint text effects drawn, #2534; previously trivial: progress log: Indonesian proofing, #498; previously trivial: progress log: DOCX glossary documents (building blocks); previously trivial: progress log: Read Mode keeps the keyboard and shows whole, unobscured columns, #1772); previously trivial: progress log: font list entries of tall-ascent fonts no longer cut off, #2570); previously trivial: progress log: Split Cells and Convert Text to Table dialogs follow the Word check, #2592; previously trivial: a new source's Tag is made like Word's, three letters of the author and two digits of the year with a number added when taken, and an edit keeps the Tag, #2094; previously trivial: Web Layout fills the window width, #1876; previously trivial: progress log: Different First Page headers reachable from page 1, #2826; previously trivial: Language dialog keeps inherited proofing language and no-proof choices (#2717); previously trivial: progress log: web preferences kept between visits, #2132; previously trivial: progress log: undrawable private-use list bullets show the level's default bullet, #1683); previously trivial: table style editor formats every part, #714; previously trivial: VML freeforms read and kept on save; previously trivial: progress log: consistent preferred cell widths; previously trivial: multi-paragraph field instructions no longer add blank lines; previously trivial: page-break-only list paragraphs hide their labels; previously trivial: progress log: grouped `flowChartOr` shapes draw their circle and crossing spokes; previously trivial: progress log: copies keep their formatting in other programs, #1523; previously trivial: progress log: privacy options on save, #1062; previously trivial: progress log: equation pictures in theme colours on Windows, #1002; previously trivial: symbol fonts, #776; previously trivial: the save prompt answers from the keyboard, #861; previously trivial: table column and row markers on the rulers, #700; previously trivial: table borders and the resize handle drag on the page, #217; previously trivial: formatting revisions landed, #41; previously trivial: Draw tab ink, #307, and Draw Table and Eraser, #303, landed: catalog 402/434; previously minor: readiness table with hours per audience; full number back to the additive weighted sum, 60%; mainstream and essentials numbers added; alpha gate checked; previously major: full re-measure against Word for Mac 16.113.4; restructured to the craftrules progress-docs standard) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4 > **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-17 · **Change:** trivial (progress log: pasted blockquotes are indented paragraphs, #1270; previously trivial: progress log: print-area strings translated in every catalog; previously trivial: progress log: comments readable in the dark theme, #2045; previously trivial: progress log: ODT sections, #1379; previously trivial: progress log: table deletes, Change Case and Unlink Fields respect Track Changes and locked content controls, #1329; previously trivial: progress log: rounded rectangle corners, #1476; previously trivial: progress log: Save As type list, #1399; previously trivial: progress log: privacy options on save, #1062; previously trivial: progress log: equation pictures in theme colours on Windows, #1002; previously trivial: symbol fonts, #776; previously trivial: the save prompt answers from the keyboard, #861; previously trivial: table column and row markers on the rulers, #700; previously trivial: table borders and the resize handle drag on the page, #217; previously trivial: formatting revisions landed, #41; previously trivial: Draw tab ink, #307, and Draw Table and Eraser, #303, landed: catalog 402/434; previously minor: readiness table with hours per audience; full number back to the additive weighted sum, 60%; mainstream and essentials numbers added; alpha gate checked; previously major: full re-measure against Word for Mac 16.113.4; restructured to the craftrules progress-docs standard) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4 > **Printing update:** 2026-10-12 · direct desktop printing through craft-print; details below. @@ -137,6 +137,7 @@ Ranked, to beta (detail and remaining milestones in [docs/roadmap.md](docs/roadm - 2026-10-15: Find opens Navigation on Results; editing the query restores Results after manual Headings/Pages navigation. Typed and selected-text searches retain document contents (#2096). | Date | What landed | +| 2026-10-15 | AutoCorrect follows Turkish and Azerbaijani case mapping: `istanbul` → `İstanbul`, `ılık` → `Ilık`, `İNgilizce` → `İngilizce` with no combining dot (#1923) | | 2026-10-16 | Releasing a table drag with the mouse (column or row border, move handle, selection areas) leaves the view where it is instead of scrolling back to the caret (#1738) | | 2026-10-16 | Hebrew in fields and quotes (#1670): a STYLEREF (or any field) showing right-to-left text such as "פרק א" reads right to left in headers and the body instead of reversed; smart quotes in Hebrew pinned to Word's logical-order result (“ before, ” after, ” in acronyms like צה"ל) | | 2026-10-15 | Backspace with a whole table selected removes it, as in Word, and selected whole rows or columns go the same way; Delete clears them and keeps the table. A selection that covers a table and the paragraph before or after it removes the table, also when it starts inside the table or the table is nested (#1844) | diff --git a/crates/engine/src/cmd/tools.rs b/crates/engine/src/cmd/tools.rs index 7020738f73..ab7a28cd55 100644 --- a/crates/engine/src/cmd/tools.rs +++ b/crates/engine/src/cmd/tools.rs @@ -851,8 +851,17 @@ pub fn autocorrect_word(s: &mut Session, on_enter: bool) -> Result<(), CmdError> if word.is_empty() { return Ok(()); } + // The word's language (run, paragraph style, document default) picks the replacement list + // (#835) and the case mapping (#1923). Text with no language at all is in the editing + // language (a Polish interface writes Polish), else English. + let lang: Option = s.doc.para_at(&f).and_then(|p| { + let rc = s.doc.styles.resolve_char(p.props.style.as_deref(), p.props_of_char(start)); + rc.lang.clone() + }); + let lang = lang.or_else(|| s.editing_lang.clone()); + let turkic = is_turkic(lang.as_deref()); // TWo INitial CApitals and cAPS LOCK come first, so "TEh" is still a replacement's typo. - if let Some((at, reps)) = case_fix(&ac, &word) { + if let Some((at, reps)) = case_fix(&ac, &word, turkic) { let para = s.doc.para_mut(f.story, &f.path)?; let letters = word.get(at..).unwrap_or("").chars().take(reps.len()).collect::(); let mut end = start + at + letters.len(); @@ -882,15 +891,9 @@ pub fn autocorrect_word(s: &mut Session, on_enter: bool) -> Result<(), CmdError> s.sel = Selection::caret(f.clone()); word = format!("{}{fixed}{}", word.get(..at).unwrap_or(""), word.get(at + letters.len()..).unwrap_or("")); } - // A whole-word replacement, else "wait..." → "wait…". - // The word's language (run, paragraph style, document default) picks the list (#835). - // Text with no language at all is in the editing language (a Polish interface writes Polish), - // else English. Proofing decides what is English the same way. - let english = s.doc.para_at(&f).is_none_or(|p| { - let rc = s.doc.styles.resolve_char(p.props.style.as_deref(), p.props_of_char(start)); - let lang = rc.lang.as_deref().or(s.editing_lang.as_deref()); - wordcraft_proof::ProofLang::from_tag(lang) == wordcraft_proof::ProofLang::En - }); + // A whole-word replacement, else "wait..." → "wait…". Proofing decides what is English the + // same way (#835). + let english = s.doc.para_at(&f).is_none_or(|_| wordcraft_proof::ProofLang::from_tag(lang.as_deref()) == wordcraft_proof::ProofLang::En); let found = if ac.replace_text { ac.replacement_in(&word, english) } else { None }; let (from, rep) = match found { Some(r) => (start, Some(r)), @@ -967,7 +970,7 @@ pub fn autocorrect_word(s: &mut Session, on_enter: bool) -> Result<(), CmdError> if sentence || day { let para = s.doc.para_mut(f.story, &f.path)?; if let Some(c) = word.chars().next() { - let up: String = c.to_uppercase().collect(); + let up = upper_char(c, turkic); let props = para.props_of_char(start).clone(); para.delete(start, start + c.len_utf8())?; para.insert_text(start, &up, &props)?; @@ -997,6 +1000,34 @@ fn prev_token(before: &str) -> String { t.rsplit(char::is_whitespace).next().unwrap_or("").trim_start_matches(|c: char| "\"'“‘([".contains(c)).to_lowercase() } +/// Turkish, Azerbaijani (and Crimean Tatar) map `i`/`İ` and `ı`/`I` as pairs (#1923). +fn is_turkic(lang: Option<&str>) -> bool { + if wordcraft_proof::ProofLang::from_tag(lang) == wordcraft_proof::ProofLang::Tr { + return true; // tr, tur, any region, same normalisation as proofing + } + let Some(tag) = lang.map(str::trim) else { return false }; + let primary = tag.split(['-', '_']).next().unwrap_or(""); + ["az", "aze", "crh"].iter().any(|c| primary.eq_ignore_ascii_case(c)) +} + +/// A letter in capitals: `i` → `İ` in Turkic languages, otherwise the Unicode default. +fn upper_char(c: char, turkic: bool) -> String { + if turkic && c == 'i' { + return "\u{130}".to_string(); + } + c.to_uppercase().collect() +} + +/// A letter in lowercase: `I` → `ı` in Turkic languages. `İ` is always the single letter `i`, +/// never `i` plus a combining dot. +fn lower_char(c: char, turkic: bool) -> String { + match c { + 'I' if turkic => "\u{131}".to_string(), + '\u{130}' => "i".to_string(), + _ => c.to_lowercase().collect(), + } +} + /// Built-in words that keep their two initial capitals (plurals of two-letter abbreviations). pub const INITIAL_CAPS_EXCEPTIONS: &[&str] = &["CDs", "IDs", "PCs", "TVs", "DJs", "GBs", "KBs", "MBs", "TBs", "VMs"]; @@ -1006,7 +1037,7 @@ const CAPS_LOCK_EXCEPTIONS: &[&str] = &["iOS", "mRNA", "tRNA", "rRNA", "cDNA", " /// The capitalisation fix for a finished word: TWo INitial CApitals ("WOrd" → "Word") or cAPS /// LOCK ("wORD" → "Word"). Returns the byte offset of the word's letters and each letter's /// replacement. Only words of three or more letters, optionally in quotes or brackets. -fn case_fix(caps: &AutoCorrectPrefs, word: &str) -> Option<(usize, Vec)> { +fn case_fix(caps: &AutoCorrectPrefs, word: &str, turkic: bool) -> Option<(usize, Vec)> { if !caps.two_initial_caps && !caps.caps_lock { return None; } @@ -1023,7 +1054,7 @@ fn case_fix(caps: &AutoCorrectPrefs, word: &str) -> Option<(usize, Vec)> _ => return None, }; let one = |c: char| c.to_string(); - let lower = |c: char| c.to_lowercase().collect::(); + let lower = |c: char| lower_char(c, turkic); if caps.two_initial_caps && a.is_uppercase() && b.is_uppercase() @@ -1034,7 +1065,7 @@ fn case_fix(caps: &AutoCorrectPrefs, word: &str) -> Option<(usize, Vec)> return Some((at, std::iter::once(one(a)).chain(std::iter::once(lower(b))).chain(more.iter().map(|&c| one(c))).collect())); } if caps.caps_lock && a.is_lowercase() && b.is_uppercase() && more.iter().all(|c| c.is_uppercase()) && !CAPS_LOCK_EXCEPTIONS.contains(&letters) { - let up = a.to_uppercase().collect::(); + let up = upper_char(a, turkic); return Some((at, std::iter::once(up).chain(std::iter::once(lower(b))).chain(more.iter().map(|&c| lower(c))).collect())); } None diff --git a/crates/engine/src/tests_typing.rs b/crates/engine/src/tests_typing.rs index 7b862da612..e6008eaedf 100644 --- a/crates/engine/src/tests_typing.rs +++ b/crates/engine/src/tests_typing.rs @@ -1812,3 +1812,40 @@ fn smart_quotes_in_hebrew_use_logical_order() { let s = typed("ג'ירפה 'כן'"); assert_eq!(s.doc.plain_text(StoryRef::Body), "ג’ירפה ‘כן’"); } + +/// #1923: Turkish and Azerbaijani case mapping (i↔İ, ı↔I) in AutoCorrect, checked against Word +/// with Turkish proofing; other languages keep the Unicode default. +#[test] +fn autocorrect_capitalisation_follows_turkic_case_mapping() { + for tag in ["tr-TR", "az-AZ"] { + let mut s = s(); + run(&mut s, "review.language", json!({"lang": tag, "default": true})); + typ(&mut s, "istanbul güzel. "); + assert_eq!(s.doc.plain_text(StoryRef::Body), "İstanbul güzel. ", "{tag}: i capitalises to İ"); + let mut s = self::s(); + run(&mut s, "review.language", json!({"lang": tag, "default": true})); + typ(&mut s, "İNgilizce "); + assert_eq!(s.doc.plain_text(StoryRef::Body), "İngilizce ", "{tag}: İ kept, N lowercased, no combining dot"); + let mut s = self::s(); + run(&mut s, "review.language", json!({"lang": tag, "default": true})); + typ(&mut s, "ılık ırmak. "); + assert_eq!(s.doc.plain_text(StoryRef::Body), "Ilık ırmak. ", "{tag}: ı capitalises to I"); + let mut s = self::s(); + run(&mut s, "review.language", json!({"lang": tag, "default": true})); + typ(&mut s, "iSTANBUL "); + assert_eq!(s.doc.plain_text(StoryRef::Body), "İstanbul ", "{tag}: cAPS LOCK fix"); + let mut s = self::s(); + run(&mut s, "review.language", json!({"lang": tag, "default": true})); + typ(&mut s, "kızım. TIklamak "); + assert_eq!(s.doc.plain_text(StoryRef::Body), "Kızım. Tıklamak ", "{tag}: TWo INitial CApitals lowercases I to ı"); + } + // Other languages are unchanged; a stray İ still lowercases to a single "i". + let mut s = s(); + run(&mut s, "review.language", json!({"lang": "en-US", "default": true})); + typ(&mut s, "istanbul güzel. "); + assert_eq!(s.doc.plain_text(StoryRef::Body), "Istanbul güzel. "); + let mut s = self::s(); + run(&mut s, "review.language", json!({"lang": "en-US", "default": true})); + typ(&mut s, "İNgilizce "); + assert_eq!(s.doc.plain_text(StoryRef::Body), "İngilizce ", "no combining dot in any language"); +} diff --git a/docs/typing-parity.md b/docs/typing-parity.md index afd629c8cf..5dea595220 100644 --- a/docs/typing-parity.md +++ b/docs/typing-parity.md @@ -1,6 +1,6 @@ # Typing parity with Word -> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-18 · **Change:** trivial (smart quotes in Hebrew pinned: logical order, ” in acronyms as in Word, #1670; previously trivial: Backspace on a selected table, rows or columns deletes them, Delete clears them, #1844; previously trivial: typed text takes its direction from its own letters, #1543; previously trivial: an input method's request to erase text around the caret is honoured, so web typing in Cyrillic and other non-Latin text no longer repeats, #1480; previously trivial: `-->` AutoCorrect replacement, #2223; previously trivial: a heading-list preset keeps Heading styles and makes other paragraphs Heading 1, as in Word, #1793; previously trivial: numbered headings: a heading-linked Multilevel List numbers new headings by their style; numbering keeps a heading a heading, #1793; previously trivial: reversing an automatic capital adds an exception, #1857; previously trivial: smart quotes open after guillemets and low quotes and nest; previously trivial: Enter on an empty list item numbered by its paragraph style moves it up a level, #2143; previously trivial: Backspace after a section break and Delete before it remove the break, also on the last paragraph, #2127; previously trivial: a table inserted in right-to-left text is right to left, so Tab moves left, #2634; previously trivial: Shift+Delete cuts the selection on every platform, #1669; previously trivial: Select All then Delete removes a table the document starts with, #1198; previously trivial: Alt+numpad character codes, #961; previously trivial: Down and Up from a line start beside a table go into it, #1007; previously trivial: sample-text formulas on Enter, #1067; previously trivial: Track Changes: Accept All after a tracked delete from a paragraph end keeps the next paragraph; Reject All after typing into an empty paragraph keeps it (#864); previously minor: Correct TWo INitial CApitals, cAPS LOCK and table-cell capitals, #544; previously trivial: complex-script caret and Backspace, #604; Move Paragraph Up/Down, Promote/Demote, Go Back and Alt+X keys, #343) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4 +> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-18 · **Change:** trivial (Turkish and Azerbaijani case mapping in AutoCorrect, #1923; previously trivial: smart quotes in Hebrew pinned: logical order, ” in acronyms as in Word, #1670; previously trivial: Backspace on a selected table, rows or columns deletes them, Delete clears them, #1844; previously trivial: typed text takes its direction from its own letters, #1543; previously trivial: an input method's request to erase text around the caret is honoured, so web typing in Cyrillic and other non-Latin text no longer repeats, #1480; previously trivial: `-->` AutoCorrect replacement, #2223; previously trivial: a heading-list preset keeps Heading styles and makes other paragraphs Heading 1, as in Word, #1793; previously trivial: numbered headings: a heading-linked Multilevel List numbers new headings by their style; numbering keeps a heading a heading, #1793; previously trivial: reversing an automatic capital adds an exception, #1857; previously trivial: smart quotes open after guillemets and low quotes and nest; previously trivial: Enter on an empty list item numbered by its paragraph style moves it up a level, #2143; previously trivial: Backspace after a section break and Delete before it remove the break, also on the last paragraph, #2127; previously trivial: a table inserted in right-to-left text is right to left, so Tab moves left, #2634; previously trivial: Shift+Delete cuts the selection on every platform, #1669; previously trivial: Select All then Delete removes a table the document starts with, #1198; previously trivial: Alt+numpad character codes, #961; previously trivial: Down and Up from a line start beside a table go into it, #1007; previously trivial: sample-text formulas on Enter, #1067; previously trivial: Track Changes: Accept All after a tracked delete from a paragraph end keeps the next paragraph; Reject All after typing into an empty paragraph keeps it (#864); previously minor: Correct TWo INitial CApitals, cAPS LOCK and table-cell capitals, #544; previously trivial: complex-script caret and Backspace, #604; Move Paragraph Up/Down, Promote/Demote, Go Back and Alt+X keys, #343) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4 What Word does when you type, observed black-box (same keystrokes typed into Word and WordCraft, results read back paragraph by paragraph) and pinned by tests in @@ -165,6 +165,14 @@ Applied when a word is finished by a space, punctuation **or Enter** (`enter_fin - Smileys `:)` `:-)` `:(` `:-(` `:|` → ☺ ☹ 😐, also straight after a word (`ok:)` → `ok☺`) ¹ (`smileys_are_replaced_inside_words_too`). +- Turkish, Azerbaijani and Crimean Tatar text (the word's language, else the editing language) + maps `i` → `İ` and `ı` → `I` when a sentence or day is capitalised, and `I` → `ı` when + TWo INitial CApitals / cAPS LOCK lowercase a letter (`istanbul` → `İstanbul`, `İNgilizce` → + `İngilizce`, `ılık` → `Ilık`; observed in Word for Mac with Turkish proofing). Lowercasing `İ` + gives the single letter `i` in every language, never `i` + combining dot. Other languages keep + the Unicode default (`istanbul` → `Istanbul` in English) + (`autocorrect_capitalisation_follows_turkic_case_mapping`). Format > Change Case is not yet + Turkish-aware. - Names of days are capitalised (`monday` → `Monday`). AutoCorrect Options (#412, `tools.autocorrect`, saved per user) switch each of these off: replacements, sentence and table-cell capitals, days, smart quotes, fractions, ordinals, dashes, links, bulleted and @@ -293,6 +301,7 @@ Word. | 2026-10-12 | trivial | Sample-text formulas on Enter (#1067), exact matching, bounded parameters, original English and one-step undo | | 2026-10-13 | minor | Alt+numpad decimal character codes: the ANSI code page with a leading 0, the OEM page without (the system locale's on Windows: 1250–1254, 437/737/850/852/857/866), Unicode above 255 (#961) | | 2026-10-13 | trivial | Fields: text typed or pasted just after a field (a Zotero citation) goes outside it (#898) | +| 2026-10-15 | trivial | AutoCorrect: Turkish/Azerbaijani case mapping (i↔İ, ı↔I), no combining dot from İ (#1923) | | 2026-10-14 | trivial | Track Changes: a table delete over text already marked deleted changes nothing, so Reject All still restores it (#1329 review 2) | | 2026-10-14 | trivial | Track Changes: empty tables are removed under tracking; Delete Columns leaves merged cells that keep a column; repeated Change Case reads only live text (#1329 review) | | 2026-10-14 | trivial | Track Changes: table deletes mark the cells' text deleted and keep the table; Change Case is a tracked delete+insert (differs from Word's hidden formatting revision); Unlink Fields stays untracked; all three refuse locked content controls (#1329) | From a3aab66bfa720e321bcb9ab9e8a036d85f5d4a56 Mon Sep 17 00:00:00 2001 From: divyanshu2074 <43274510+divyanshu2074@users.noreply.github.com> Date: Sun, 18 Oct 2026 14:13:44 +0530 Subject: [PATCH 32/38] fix(formats): support none and dual scripts in mmultiscripts, mpadded phantoms, menclose borders, and mtable column align (#2300) * fix(formats): support none and dual scripts in mmultiscripts, mpadded phantoms, menclose borders, and mtable column align * cargo fmt Co-Authored-By: Claude Opus 5.5 * Collapse the nested if (clippy collapsible_if) Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: Brandon Thomas Co-authored-by: Claude Opus 5.5 --- crates/formats/src/mathml.rs | 120 +++++++++++++++++++++++++++++++---- 1 file changed, 106 insertions(+), 14 deletions(-) diff --git a/crates/formats/src/mathml.rs b/crates/formats/src/mathml.rs index cf5d8fa3e4..d9974b1fc5 100644 --- a/crates/formats/src/mathml.rs +++ b/crates/formats/src/mathml.rs @@ -175,8 +175,16 @@ fn node(n: &MNode, o: &mut String, depth: usize, eq_arr: bool) { o.push_str(""); group(base, o, depth); o.push_str(""); - group(sub, o, depth); - group(sup, o, depth); + if sub.is_empty() { + o.push_str(""); + } else { + group(sub, o, depth); + } + if sup.is_empty() { + o.push_str(""); + } else { + group(sup, o, depth); + } o.push_str(""); } }, @@ -443,7 +451,8 @@ pub fn local(name: &str) -> &str { /// The linear format of a `` element, and whether it's a display equation. `None` when it /// holds nothing. pub fn read(math: &El) -> Option<(String, bool)> { - let display = math.attr("display").is_some_and(|d| d.eq_ignore_ascii_case("block")) || math.attr("mode").is_some_and(|d| d == "display"); + let display = + math.attr("display").is_some_and(|d| d.eq_ignore_ascii_case("block")) || math.attr("mode").is_some_and(|d| d.eq_ignore_ascii_case("display")); let linear = linear_of(math); let linear: String = linear.chars().filter(|c| !c.is_control()).collect(); let linear = linear.trim().to_string(); @@ -636,7 +645,20 @@ fn elem(e: &El, depth: usize, budget: &mut usize) -> Arg { Some(p) => { let sub = script(p + 1, budget); let sup = script(p + 2, budget); - vec![MNode::Script { kind: ScriptKind::Pre, base, sub, sup }] + let pre_node = MNode::Script { kind: ScriptKind::Pre, base, sub, sup }; + if p > 1 { + let post_sub = script(1, budget); + let post_sup = script(2, budget); + let kind = match (post_sub.is_empty(), post_sup.is_empty()) { + (true, true) => return vec![pre_node], + (false, true) => ScriptKind::Sub, + (true, false) => ScriptKind::Sup, + (false, false) => ScriptKind::SubSup, + }; + vec![MNode::Script { kind, base: vec![pre_node], sub: post_sub, sup: post_sup }] + } else { + vec![pre_node] + } } None => { let sub = script(1, budget); @@ -660,12 +682,9 @@ fn elem(e: &El, depth: usize, budget: &mut usize) -> Arg { vec![MNode::Delim { beg, end, sep, grow: true, shp_match: false, elems }] } "mtable" => { - let rows: Vec> = e - .els() - .filter(|r| matches!(local(&r.name), "mtr" | "mlabeledtr")) - .take(256) - .map(|r| r.els().filter(|c| local(&c.name) == "mtd").take(256).map(|c| children(c, d, budget)).collect()) - .collect(); + let mtr_list: Vec<&El> = e.els().filter(|r| matches!(local(&r.name), "mtr" | "mlabeledtr")).take(256).collect(); + let rows: Vec> = + mtr_list.iter().map(|r| r.els().filter(|c| local(&c.name) == "mtd").take(256).map(|c| children(c, d, budget)).collect()).collect(); if rows.is_empty() { return Vec::new(); } @@ -676,14 +695,68 @@ fn elem(e: &El, depth: usize, budget: &mut usize) -> Arg { if marked && rows.iter().all(|r| r.len() <= 1) { return vec![MNode::EqArr { rows: rows.into_iter().map(|mut r| r.pop().unwrap_or_default()).collect() }]; } - vec![MNode::Matrix { rows, col_jc: Vec::new() }] + let mut col_jc: Vec = e + .attr("columnalign") + .map(|ca| { + ca.split_whitespace() + .map(|a| match a { + "left" => ColJc::Left, + "right" => ColJc::Right, + _ => ColJc::Center, + }) + .collect() + }) + .unwrap_or_default(); + if col_jc.is_empty() + && let Some(first_row) = mtr_list.first() + { + let cell_aligns: Vec = first_row + .els() + .filter(|c| local(&c.name) == "mtd") + .map(|c| match c.attr("columnalign") { + Some("left") => ColJc::Left, + Some("right") => ColJc::Right, + _ => ColJc::Center, + }) + .collect(); + if cell_aligns.iter().any(|a| *a != ColJc::Center) { + col_jc = cell_aligns; + } + } + vec![MNode::Matrix { rows, col_jc }] + } + "mpadded" => { + let zero_wid = e.attr("width").is_some_and(|w| w.trim() == "0"); + let zero_asc = e.attr("height").is_some_and(|h| h.trim() == "0"); + let zero_desc = e.attr("depth").is_some_and(|d| d.trim() == "0"); + let ch = children(e, d, budget); + if zero_wid || zero_asc || zero_desc { + if let [MNode::Phant { show, e, .. }] = ch.as_slice() { + vec![MNode::Phant { show: *show, zero_wid, zero_asc, zero_desc, e: e.clone() }] + } else { + vec![MNode::Phant { show: true, zero_wid, zero_asc, zero_desc, e: ch }] + } + } else { + ch + } } "mphantom" => vec![MNode::Phant { show: false, zero_wid: false, zero_asc: false, zero_desc: false, e: children(e, d, budget) }], "menclose" => { let n = e.attr("notation").unwrap_or("longdiv"); let has = |k: &str| n.split_whitespace().any(|w| w == k); - if has("box") || has("roundedbox") { - return vec![MNode::BorderBox { hide: [false; 4], strike: [false; 4], e: children(e, d, budget) }]; + let is_box = has("box") || has("roundedbox") || has("circle"); + let has_border = is_box || has("top") || has("bottom") || has("left") || has("right"); + let has_strike = has("horizontalstrike") || has("verticalstrike") || has("updiagonalstrike") || has("downdiagonalstrike"); + if has_border || has_strike { + let hide = if is_box { + [false; 4] + } else if has_border { + [!has("top"), !has("bottom"), !has("left"), !has("right")] + } else { + [true; 4] + }; + let strike = [has("horizontalstrike"), has("verticalstrike"), has("updiagonalstrike"), has("downdiagonalstrike")]; + return vec![MNode::BorderBox { hide, strike, e: children(e, d, budget) }]; } children(e, d, budget) } @@ -751,7 +824,7 @@ mod tests { let gc = to_mathml(&[MNode::GroupChr { chr: '⏟', top: false, e: parse_linear("a") }], false, ""); assert!(gc.contains("a⏟"), "{gc}"); let pre = to_mathml(&[MNode::Script { kind: ScriptKind::Pre, base: parse_linear("C"), sub: parse_linear("n"), sup: vec![] }], false, ""); - assert!(pre.contains(""), "{pre}"); + assert!(pre.contains("") && pre.contains(""), "{pre}"); let eq = to_mathml(&[MNode::EqArr { rows: vec![parse_linear("x&=1"), parse_linear("y&=2")] }], false, ""); assert!(eq.contains("x=1") && !eq.contains("&"), "{eq}"); let ph = to_mathml(&[MNode::Phant { show: false, zero_wid: true, zero_asc: false, zero_desc: false, e: parse_linear("a") }], false, ""); @@ -884,6 +957,25 @@ mod tests { assert!(matches!(t.as_slice(), [MNode::EqArr { .. }]), "{ml}: {t:?}"); } + #[test] + fn mmultiscripts_and_borderbox_round_trip() { + let html = "X12"; + let p = pasted(html); + assert!(!p.is_empty(), "should parse mmultiscripts with pre and post scripts"); + + let html_box = "x"; + let p_box = pasted(html_box); + assert!(!p_box.is_empty(), "should parse menclose"); + + let html_tbl = "ab"; + let p_tbl = pasted(html_tbl); + assert!(!p_tbl.is_empty(), "should parse mtable with columnalign"); + + let html_pad = "z"; + let p_pad = pasted(html_pad); + assert!(!p_pad.is_empty(), "should parse mpadded phantom"); + } + #[test] fn deep_nesting_is_bounded() { let mut n = parse_linear("x"); From 5e2b63b710b98dcdf9a8fd8d3758edc8b6f8388b Mon Sep 17 00:00:00 2001 From: Marco Arnulfo <103679419+marcoarnulfo@users.noreply.github.com> Date: Sun, 18 Oct 2026 10:44:03 +0200 Subject: [PATCH 33/38] Contributors: add Marco Arnulfo (#2302) Co-authored-by: Brandon Thomas --- contributors/people.toml | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/contributors/people.toml b/contributors/people.toml index a61c954952..33aa27264d 100644 --- a/contributors/people.toml +++ b/contributors/people.toml @@ -140,3 +140,8 @@ sync_github_name = false real_name = "Aleksander Kutycki" display_name = "Aleksander Kutycki" sync_github_name = true + +[people.marcoarnulfo] +real_name = "Marco Arnulfo" +display_name = "Marco Arnulfo" +sync_github_name = false From 6b3de1c4dcae699b44c5b5e27270d4d1262b5e9e Mon Sep 17 00:00:00 2001 From: divyanshu2074 <43274510+divyanshu2074@users.noreply.github.com> Date: Sun, 18 Oct 2026 14:14:13 +0530 Subject: [PATCH 34/38] fix(docbin): handle default codepage, VT_I4, multiple property sets, and FILETIME dates (#2304) * fix(docbin): handle default codepage, VT_I4, multiple property sets, and FILETIME dates * cargo fmt Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: Brandon Thomas Co-authored-by: Claude Opus 5.5 --- crates/docbin/src/metadata.rs | 51 +++++++++++++++++++--- crates/docbin/src/tests/metadata.rs | 66 +++++++++++++++++++++++++++++ 2 files changed, 111 insertions(+), 6 deletions(-) diff --git a/crates/docbin/src/metadata.rs b/crates/docbin/src/metadata.rs index a07de61056..207591fcc2 100644 --- a/crates/docbin/src/metadata.rs +++ b/crates/docbin/src/metadata.rs @@ -27,7 +27,7 @@ fn u32_at(bytes: &[u8], offset: usize) -> Option { } fn parse(bytes: &[u8]) -> Option { - if bytes.get(..2)? != [0xfe, 0xff] || bytes.get(28..44)? != SUMMARY_FMTID || u32_at(bytes, 24)? != 1 { + if bytes.get(..2)? != [0xfe, 0xff] || bytes.get(28..44)? != SUMMARY_FMTID || u32_at(bytes, 24)? < 1 { return None; } if u16::from_le_bytes(bytes.get(2..4)?.try_into().ok()?) > 1 { @@ -65,12 +65,14 @@ fn parse(bytes: &[u8]) -> Option { .min(set.len()); set.get(start..end) }; - let cp = property(1)?; - if u32_at(cp, 0)? != 2 { - return None; - } // VT_I2 - let codepage = u16::from_le_bytes(cp.get(4..6)?.try_into().ok()?); + let codepage = property(1) + .and_then(|cp| { + let vt = u32_at(cp, 0)?; + if vt == 2 || vt == 3 { Some(u16::from_le_bytes(cp.get(4..6)?.try_into().ok()?)) } else { None } + }) + .unwrap_or(1252); let get = |id| property(id).and_then(|value| text(value, codepage)).unwrap_or_default(); + let get_filetime = |id| property(id).and_then(filetime_iso).unwrap_or_default(); Some(CoreProps { title: get(2), subject: get(3), @@ -79,10 +81,47 @@ fn parse(bytes: &[u8]) -> Option { description: get(6), last_modified_by: get(8), revision: get(9).parse().unwrap_or_default(), + created: get_filetime(12), + modified: get_filetime(13), ..Default::default() }) } +fn civil(days: i64) -> (i64, u32, u32) { + let z = days + 719_468; + let era = (if z >= 0 { z } else { z - 146_096 }) / 146_097; + let doe = (z - era * 146_097) as u32; + let yoe = (doe - doe / 1460 + doe / 36524 - doe / 146096) / 365; + let y = yoe as i64 + era * 400; + let doy = doe - (365 * yoe + yoe / 4 - yoe / 100); + let mp = (5 * doy + 2) / 153; + let d = doy - (153 * mp + 2) / 5 + 1; + let m = if mp < 10 { mp + 3 } else { mp - 9 }; + let y = y + i64::from(m <= 2); + (y, m, d) +} + +fn filetime_iso(value: &[u8]) -> Option { + let kind = u32_at(value, 0)?; + if kind != 64 { + return None; + } + let ft_low = u32_at(value, 4)? as u64; + let ft_high = u32_at(value, 8)? as u64; + let ft = (ft_high << 32) | ft_low; + if ft == 0 { + return None; + } + const EPOCH_DIFFERENCE: u64 = 116_444_736_000_000_000; + let unix_100ns = ft.checked_sub(EPOCH_DIFFERENCE)?; + let unix_secs = unix_100ns / 10_000_000; + let days = (unix_secs / 86_400) as i64; + let secs_of_day = (unix_secs % 86_400) as u32; + let (h, mi, s) = (secs_of_day / 3600, (secs_of_day / 60) % 60, secs_of_day % 60); + let (y, m, d) = civil(days); + Some(format!("{y:04}-{m:02}-{d:02}T{h:02}:{mi:02}:{s:02}Z")) +} + fn text(value: &[u8], codepage: u16) -> Option { let kind = u32_at(value, 0)?; let count = u32_at(value, 4)? as usize; diff --git a/crates/docbin/src/tests/metadata.rs b/crates/docbin/src/tests/metadata.rs index f36b93df9c..b36a963c47 100644 --- a/crates/docbin/src/tests/metadata.rs +++ b/crates/docbin/src/tests/metadata.rs @@ -96,3 +96,69 @@ fn malformed_or_oversized_summary_does_not_break_doc_open() { assert!(open_with_metadata(&huge, false).core.title.is_empty()); assert!(open_with_metadata(&vec![0; (2 << 20) + 1], false).core.title.is_empty()); } + +fn custom_summary(props: &[(u32, Vec)], num_property_sets: u32) -> Vec { + let mut section = vec![0; 8 + 8 * props.len()]; + section[4..8].copy_from_slice(&(props.len() as u32).to_le_bytes()); + for (i, (id, value)) in props.iter().enumerate() { + section[8 + i * 8..12 + i * 8].copy_from_slice(&id.to_le_bytes()); + let offset = section.len() as u32; + section[12 + i * 8..16 + i * 8].copy_from_slice(&offset.to_le_bytes()); + section.extend_from_slice(value); + } + let size = section.len() as u32; + section[..4].copy_from_slice(&size.to_le_bytes()); + let mut header = vec![0; 28 + 20 * num_property_sets as usize]; + header[..2].copy_from_slice(&[0xfe, 0xff]); + header[24..28].copy_from_slice(&num_property_sets.to_le_bytes()); + // First property set FMTID and offset + header[28..44].copy_from_slice(&[0xe0, 0x85, 0x9f, 0xf2, 0xf9, 0x4f, 0x68, 0x10, 0xab, 0x91, 0x08, 0, 0x2b, 0x27, 0xb3, 0xd9]); + let first_offset = header.len() as u32; + header[44..48].copy_from_slice(&first_offset.to_le_bytes()); + if num_property_sets > 1 { + // Dummy second property set header + header[48..64].copy_from_slice(&[1; 16]); + let second_offset = (first_offset + size) as u32; + header[64..68].copy_from_slice(&second_offset.to_le_bytes()); + } + header.extend(section); + header +} + +#[test] +fn summary_defaults_codepage_and_accepts_vti4_and_num_property_sets() { + let title_prop = [30u32.to_le_bytes().as_slice(), &7u32.to_le_bytes(), b"Default\0"].concat(); + // 1. Missing PID_CODEPAGE (property 1) should default to 1252 per MS-OLEPS. + let no_cp = custom_summary(&[(2, title_prop.clone())], 1); + assert_eq!(open_with_metadata(&no_cp, false).core.title, "Default"); + + // 2. PID_CODEPAGE as VT_I4 (type 3). + let cp_vti4 = [3u32.to_le_bytes().as_slice(), &1252u32.to_le_bytes()].concat(); + let with_vti4 = custom_summary(&[(1, cp_vti4), (2, title_prop.clone())], 1); + assert_eq!(open_with_metadata(&with_vti4, false).core.title, "Default"); + + // 3. Stream with NumPropertySets = 2. + let multi_sets = custom_summary(&[(2, title_prop)], 2); + assert_eq!(open_with_metadata(&multi_sets, false).core.title, "Default"); +} + +#[test] +fn summary_parses_filetime_created_and_modified_dates() { + // 2024-03-15T12:30:45Z = 133549794450000000 in 100ns since 1601-01-01 + let ft_val: u64 = 133_549_794_450_000_000; + let ft_low = (ft_val & 0xFFFFFFFF) as u32; + let ft_high = (ft_val >> 32) as u32; + let filetime_bytes = [64u32.to_le_bytes().as_slice(), &ft_low.to_le_bytes(), &ft_high.to_le_bytes()].concat(); + + let stream = custom_summary( + &[ + (12, filetime_bytes.clone()), // PIDSI_CREATE_DTM + (13, filetime_bytes), // PIDSI_LASTSAVE_DTM + ], + 1, + ); + + let core = open_with_metadata(&stream, false).core; + assert_eq!(core.created, "2024-03-15T12:30:45Z"); + assert_eq!(core.modified, "2024-03-15T12:30:45Z"); +} From d32119c377c795cbf67e02c9790b9c6c8c053c4e Mon Sep 17 00:00:00 2001 From: Marco Arnulfo <103679419+marcoarnulfo@users.noreply.github.com> Date: Sun, 18 Oct 2026 10:44:23 +0200 Subject: [PATCH 35/38] Copying a picture or shape replaces the clipboard's old text, so Paste duplicates it (#2306) --- crates/ui-egui/src/lib.rs | 33 ++++++++++++++++++++++++++ crates/ui-egui/src/system_clipboard.rs | 5 ++++ 2 files changed, 38 insertions(+) diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 968fc6db98..b7142da10a 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -4158,6 +4158,39 @@ mod tests { assert!(copied(&ctx).contains(&text), "copy put {text:?} on the clipboard: {:?}", copied(&ctx)); } + /// #2276: copying a selected picture, which has no text, still replaces what the system + /// clipboard held, so Paste inserts the picture instead of the text copied before it. + #[test] + fn copying_a_picture_replaces_the_clipboards_old_text() { + let last_copied = |ctx: &egui::Context| -> Option { + ctx.output(|o| { + o.commands.iter().rev().find_map(|c| match c { + egui::OutputCommand::CopyText(t) => Some(t.clone()), + _ => None, + }) + }) + }; + let ctx = egui::Context::default(); + let mut a = typed(); + a.ctx = Some(ctx.clone()); + a.run("select.all", json!({})).unwrap(); + a.run("edit.copy", json!({})).unwrap(); + assert_eq!(last_copied(&ctx).as_deref(), Some(UNSAVED)); + + a.run("select.collapse", json!({"end": true})).unwrap(); + let data = wordcraft_engine::cmd::insert::base64_encode(&png_bytes([200, 30, 30, 255])); + a.run("insert.picture", json!({"data": data})).unwrap(); + assert!(crate::objects::selected(&a).is_some()); + a.run("edit.copy", json!({})).unwrap(); + assert_eq!(last_copied(&ctx).as_deref(), Some(""), "the old text is gone from the system clipboard"); + + // The system clipboard now holds no text, so Paste finds no text to send: our copy. + a.run("select.collapse", json!({"end": true})).unwrap(); + a.run("edit.paste", json!({})).unwrap(); + assert_eq!(object_count(&mut a), 2, "Paste duplicated the picture"); + assert_eq!(body_text(&a).matches(UNSAVED).count(), 1, "and didn't paste the old text"); + } + /// #1265: every user entry point publishes Cut to the one clipboard sink — the ribbon's button /// and a real Cmd/Ctrl+X key event, not only the generic command dispatch the sink sits behind. #[test] diff --git a/crates/ui-egui/src/system_clipboard.rs b/crates/ui-egui/src/system_clipboard.rs index f057a55bf2..893e1e017e 100644 --- a/crates/ui-egui/src/system_clipboard.rs +++ b/crates/ui-egui/src/system_clipboard.rs @@ -73,6 +73,11 @@ pub(crate) fn fill(app: &mut WordApp, id: &str, params: &mut Value) -> Option Date: Sun, 18 Oct 2026 14:14:34 +0530 Subject: [PATCH 36/38] fix(docbin): correct sprmTCellWidth operand parsing and handle percentage widths (#2312) --- crates/docbin/src/table.rs | 41 ++++++++++++++++++++++++++-------- crates/docbin/src/tests.rs | 45 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 77 insertions(+), 9 deletions(-) diff --git a/crates/docbin/src/table.rs b/crates/docbin/src/table.rs index b8ac34220c..f9048c5f3e 100644 --- a/crates/docbin/src/table.rs +++ b/crates/docbin/src/table.rs @@ -77,6 +77,7 @@ pub(crate) struct RowInfo { /// automatic `Shd` (`None`) clears it. shading: Vec<(u8, Option)>, widths: Vec<(u8, f32)>, + width_pcts: Vec<(u8, f32)>, valign: Vec<(u8, VAlign)>, } @@ -93,6 +94,18 @@ impl RowInfo { pub(crate) fn shading_of(&self, i: u8) -> Option { self.shading.iter().rfind(|(k, _)| *k == i).and_then(|(_, s)| *s) } + + /// The width cell `i` ends up with, for tests. + #[cfg(test)] + pub(crate) fn width_of(&self, i: u8) -> Option { + self.widths.iter().rfind(|(k, _)| *k == i).map(|(_, w)| *w) + } + + /// The width percentage cell `i` ends up with, for tests. + #[cfg(test)] + pub(crate) fn width_pct_of(&self, i: u8) -> Option { + self.width_pcts.iter().rfind(|(k, _)| *k == i).map(|(_, w)| *w) + } } pub(crate) fn decode(papx: &[u8]) -> RowInfo { @@ -187,13 +200,20 @@ pub(crate) fn decode(papx: &[u8]) -> RowInfo { } } T_CELL_WIDTH => { - // cb, itcFirst, itcLim, ftsWidth (3 = twips), wWidth. - if let [a, b, fts, w0, w1, ..] = prl.operand - && *fts == 3 - { - let w = (i16::from_le_bytes([*w0, *w1]) as f32 / 20.0).clamp(0.0, 1584.0); - for c in *a..(*b).min(MAX_COLS as u8) { - r.widths.push((c, w)); + // Operand: cb (1 byte), itcFirst (1 byte), itcLim (1 byte), ftsWidth (1 byte), wWidth (2 bytes). + // [MS-DOC] §2.9.290 sprmTCellWidth (0xD635). + // ftsWidth: 3 = twips (dxa), 2 = percentage (50ths of a percent). + if let [_, a, b, fts, w0, w1, ..] = prl.operand { + if *fts == 3 { + let w = (i16::from_le_bytes([*w0, *w1]) as f32 / 20.0).clamp(0.0, 1584.0); + for c in *a..(*b).min(MAX_COLS as u8) { + r.widths.push((c, w)); + } + } else if *fts == 2 { + let pct = (u16::from_le_bytes([*w0, *w1]) as f32 / 50.0).clamp(0.0, 100.0); + for c in *a..(*b).min(MAX_COLS as u8) { + r.width_pcts.push((c, pct)); + } } } } @@ -256,13 +276,16 @@ pub(crate) fn assemble(paras: Vec) -> Blocks { if let Some((_, m)) = info.vmerge.iter().find(|(k, _)| *k == i) { c.props.vmerge = *m; } - if let Some((_, w)) = info.widths.iter().find(|(k, _)| *k == i) { + if let Some((_, w)) = info.widths.iter().rfind(|(k, _)| *k == i) { c.props.width = Some(*w); } + if let Some((_, pct)) = info.width_pcts.iter().rfind(|(k, _)| *k == i) { + c.props.width_pct = Some(*pct); + } if let Some((_, s)) = info.shading.iter().rfind(|(k, _)| *k == i) { c.props.shading = *s; } - if let Some((_, v)) = info.valign.iter().find(|(k, _)| *k == i) { + if let Some((_, v)) = info.valign.iter().rfind(|(k, _)| *k == i) { c.props.valign = Some(*v); } } diff --git a/crates/docbin/src/tests.rs b/crates/docbin/src/tests.rs index 9e2559ffcc..8c67ff676a 100644 --- a/crates/docbin/src/tests.rs +++ b/crates/docbin/src/tests.rs @@ -2285,3 +2285,48 @@ fn second_table_gets_its_own_grid() { assert_eq!(tables[1].props.align, None, "alignment reset"); assert_eq!(tables[1].props.indent, None, "indent reset"); } + +#[test] +fn sprm_t_cell_width_twips_and_percentage() { + // [MS-DOC] §2.9.290 sprmTCellWidth (0xD635): + // cb (1 byte), itcFirst (1 byte), itcLim (1 byte), ftsWidth (1 byte), wWidth (2 bytes). + // ftsWidth = 3 (twips): 1440 twips = 72.0 pt. + let w_twips = [0u8, 2, 3, 0xA0, 0x05]; // cell 0..2, fts=3, 1440 dxa + // ftsWidth = 2 (percentage in 50ths of a percent): 2500 = 50.0%. + let w_pct = [2u8, 3, 2, 0xC4, 0x09]; // cell 2..3, fts=2, 2500 (50%) + let papx = [sprm_cb(0xD635, &w_twips), sprm_cb(0xD635, &w_pct)].concat(); + + let row = crate::table::decode(&papx); + assert_eq!(row.width_of(0), Some(72.0)); + assert_eq!(row.width_of(1), Some(72.0)); + assert_eq!(row.width_pct_of(2), Some(50.0)); + + // End-to-end document test: row cells receive the preferred widths. + let text = "a\u{7}b\u{7}c\u{7}\u{7}\r"; + let in_tbl = grpprl(&[(0x2416, &[0x01]), (0x6649, &[1, 0, 0, 0])]); + let ttp = [ + grpprl(&[(0x2416, &[0x01]), (0x2417, &[0x01]), (0x6649, &[1, 0, 0, 0])]), + t_def_edges(&[0, 1440, 2880, 4320]), + sprm_cb(0xD635, &w_twips), + sprm_cb(0xD635, &w_pct), + ] + .concat(); + let spec = StructSpec { + text, + header_stories: vec![], + sections: vec![(text.chars().count() as u32, Vec::new())], + chpx_runs: vec![(0, Vec::new())], + papx_entries: vec![(0, 0, in_tbl), (6, 0, ttp), (7, 0, Vec::new())], + plf_lst: Vec::new(), + plf_lfo: Vec::new(), + note_stories: vec![], + endnote_stories: vec![], + extra_pairs: Vec::new(), + data_stream: Vec::new(), + }; + let doc = read(&struct_doc(&spec)).expect("opens"); + let table = doc.body[0].as_table().expect("table block"); + assert_eq!(table.rows[0].cells[0].props.width, Some(72.0)); + assert_eq!(table.rows[0].cells[1].props.width, Some(72.0)); + assert_eq!(table.rows[0].cells[2].props.width_pct, Some(50.0)); +} From 088156d4caf5c706d0c46c0b41f26259f8d35061 Mon Sep 17 00:00:00 2001 From: NetterSilver <133535268+Silvertree2010@users.noreply.github.com> Date: Sun, 18 Oct 2026 10:44:44 +0200 Subject: [PATCH 37/38] Keep spaces that follow a tab in the ODT round trip (#2317) Found while testing crates/formats/src/odt.rs:347 (writer, `plain_inlines`) and crates/formats/src/odt.rs:1550 (reader, `text`) Co-authored-by: Claude Haiku 5.5 --- crates/formats/src/odt.rs | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/crates/formats/src/odt.rs b/crates/formats/src/odt.rs index da438604b7..7296d826da 100644 --- a/crates/formats/src/odt.rs +++ b/crates/formats/src/odt.rs @@ -344,7 +344,12 @@ impl Writer { flush(&mut body, &mut spaces, prev_space); prev_space = false; match c { - '\t' => body.push_str(""), + '\t' => { + // The reader collapses a literal space after a tab, so spaces + // that follow are written as ``. + body.push_str(""); + prev_space = true; + } '\n' => { body.push_str(""); prev_space = true; @@ -2920,4 +2925,13 @@ mod tests { assert_eq!(marks(&back), marks(&doc)); assert_eq!(marks(&back).len(), 1); } + + #[test] + fn spaces_after_a_tab_survive_a_round_trip() { + for src in ["a\t b", "a\t b", "a\t éa", "é\t 😀a", "a\t\t b"] { + let doc = Document::from_text(src); + let back = import(&export(&doc).unwrap()).unwrap(); + assert_eq!(back.plain_text(wordcraft_doc::StoryRef::Body).trim_end(), src, "{src:?}"); + } + } } From 09f14d5a5e4dc839fc69018a893db36d170bea8c Mon Sep 17 00:00:00 2001 From: divyanshu2074 <43274510+divyanshu2074@users.noreply.github.com> Date: Sun, 18 Oct 2026 14:14:55 +0530 Subject: [PATCH 38/38] fix(math): parse array column specs, support starred operatorname and tag, preserve delim sep in latex (#2353) --- crates/doc/src/math_latex.rs | 96 +++++++++++++++++++++++++++++++++--- 1 file changed, 89 insertions(+), 7 deletions(-) diff --git a/crates/doc/src/math_latex.rs b/crates/doc/src/math_latex.rs index 9498ca4c7b..48c60b7815 100644 --- a/crates/doc/src/math_latex.rs +++ b/crates/doc/src/math_latex.rs @@ -6,7 +6,7 @@ //! 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, WRAP_DEPTH, any_too_deep, is_nary_char, math_class, merge_runs, too_deep, + Arg, ColJc, FracKind, MAX_DEPTH, MNode, MRun, MScr, MSty, ScriptKind, WRAP_DEPTH, any_too_deep, is_nary_char, math_class, merge_runs, too_deep, }; use crate::math_linear::{FUNCTION_NAMES, map_args}; use crate::math_symbols::{name_of, symbol}; @@ -380,9 +380,30 @@ impl Lp { one(MNode::Run(MRun { text: t, nor: true, sty: Some(MSty::Italic), ..Default::default() })) } "operatorname" => { + let starred = self.peek() == Some(&Tok::Ch('*')); + if starred { + self.i += 1; + } let t = self.raw_arg(); let name = vec![MNode::Run(MRun::plain(t))]; - self.apply(name) + if starred && self.peek() == Some(&Tok::Sub) { + self.i += 1; + let lim = self.arg(); + vec![MNode::Lim { upper: false, e: name, lim }] + } else { + self.apply(name) + } + } + "tag" => { + let starred = self.peek() == Some(&Tok::Ch('*')); + if starred { + self.i += 1; + } + let t = self.raw_arg(); + let num_str = if starred { format!("#{t}") } else { format!("# ({t})") }; + let mut r = MRun::new(num_str); + r.lit = false; + vec![MNode::Run(r)] } "mathrm" | "rm" => { let a = self.arg(); @@ -542,6 +563,22 @@ impl Lp { } fn environment(&mut self, env: &str) -> Arg { + let base = env.trim_end_matches('*'); + let mut col_jc = Vec::new(); + if base == "array" || base == "tabular" { + let _ = self.opt(); + let spec = self.raw_arg(); + for ch in spec.chars() { + match ch { + 'l' | 'L' => col_jc.push(ColJc::Left), + 'r' | 'R' => col_jc.push(ColJc::Right), + 'c' | 'C' => col_jc.push(ColJc::Center), + _ => {} + } + } + } else if base == "aligned" { + let _ = self.opt(); + } // Rows of `&`-separated cells up to `\end{…}`. let mut rows: Vec> = vec![vec![]]; loop { @@ -569,7 +606,6 @@ impl Lp { 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(']'))), @@ -587,8 +623,8 @@ impl Lp { elems: vec![vec![inner]], }; match base { - "matrix" | "pmatrix" | "bmatrix" | "Bmatrix" | "vmatrix" | "Vmatrix" | "smallmatrix" | "array" => { - let m = MNode::Matrix { rows, col_jc: Vec::new() }; + "matrix" | "pmatrix" | "bmatrix" | "Bmatrix" | "vmatrix" | "Vmatrix" | "smallmatrix" | "array" | "tabular" => { + let m = MNode::Matrix { rows, col_jc }; match delims { Some((b, e)) => vec![wrap(m, b, e)], None => vec![m], @@ -890,12 +926,16 @@ fn node(n: &MNode, s: &mut String, depth: usize) { s.push(' '); braced(e, s, depth); } - MNode::Delim { beg, end, elems, .. } => { + MNode::Delim { beg, end, sep, elems, .. } => { s.push_str("\\left"); s.push_str(&delim_tex(*beg)); + let mid = match sep { + Some(c) => format!("\\middle{}", delim_tex(Some(*c))), + None => "\\middle|".to_string(), + }; for (i, e) in elems.iter().enumerate() { if i > 0 { - s.push_str("\\middle|"); + s.push_str(&mid); } arg(e, s, depth); } @@ -1070,4 +1110,46 @@ mod tests { let deep = "\\hat".repeat(5000) + "x"; let _ = parse_latex(&deep); } + + #[test] + fn array_column_specs_and_aligned_optional_args() { + let n = parse_latex(r"\begin{array}{lcr} a & b & c \\ d & e & f \end{array}"); + let MNode::Matrix { rows, col_jc } = &n[0] else { panic!("{n:#?}") }; + assert_eq!(col_jc, &[ColJc::Left, ColJc::Center, ColJc::Right]); + assert_eq!(rows.len(), 2); + assert_eq!(crate::math::to_linear(&rows[0][0]), "a"); + assert_eq!(crate::math::to_linear(&rows[0][1]), "b"); + assert_eq!(crate::math::to_linear(&rows[0][2]), "c"); + + let n = parse_latex(r"\begin{aligned}[t] x &= 1 \\ y &= 2 \end{aligned}"); + assert!(matches!(&n[0], MNode::EqArr { rows } if rows.len() == 2)); + } + + #[test] + fn operatorname_starred_and_tag() { + let n = parse_latex(r"\operatorname*{argmin}_{x} f(x)"); + assert!(matches!(&n[0], MNode::Lim { upper: false, .. })); + + let n = parse_latex(r"E=mc^2 \tag{1}"); + let MNode::EqArr { rows } = &n[0] else { panic!("{n:#?}") }; + assert!(crate::math::has_number_mark(&rows[0])); + + let n = parse_latex(r"E=mc^2 \tag*{(A)}"); + let MNode::EqArr { rows } = &n[0] else { panic!("{n:#?}") }; + assert!(crate::math::has_number_mark(&rows[0])); + } + + #[test] + fn delim_preserves_custom_separator() { + let delim = MNode::Delim { + beg: Some('{'), + end: Some('}'), + sep: Some('‖'), + grow: true, + shp_match: false, + elems: vec![vec![MNode::Run(MRun::new("x"))], vec![MNode::Run(MRun::new("y"))]], + }; + let tex = to_latex(&[delim]); + assert!(tex.contains(r"\middle\|"), "Expected \\middle\\|, got: {tex}"); + } }