diff --git a/ROADMAP.md b/ROADMAP.md index 1b1dd3d..fd4d8c4 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-11 · **Change:** trivial (adding fonts at run time, #582; previously trivial: Insert › SmartArt landed, #497: catalog 429/451 → 437/458 (95.4%); previously trivial: Turkish interface landed; 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-11 · **Change:** trivial (Show revisions in balloons draws deletion balloons; previously trivial: Turkish interface landed; 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 WordCraft aims for complete parity with Microsoft Word, then goes further on speed, openness and agent control. This page is the summary; the assessment is @@ -133,6 +133,7 @@ Ranked, to beta (detail and remaining milestones in [docs/roadmap.md](docs/roadm | 2026-10-11 | Hide White Space (#645): `view.hideWhiteSpace` (View › Show checkbox, or double-click the gap between pages) collapses margins, headers and footers between pages to a thin line in Print Layout; screen only, page count unchanged | | 2026-10-11 | Fonts: Palatino Linotype and Book Antiqua fall back to Palatino (macOS) or TeX Gyre Pagella / P052 / URW Palladio (Linux) before generic serif (#670) | |---|---| +| 2026-10-11 | Review › Track Changes Options › Show revisions in balloons: tracked deletions in the body leave the text for "Deleted: …" balloons in the margin, joined to where they were (the option was saved but drew nothing new before) | | 2026-10-11 | Security review fixes: `ui.render`/`ui.screenshot` without a `path` (and the MCP bridge's `render_page`/`screenshot`) return the PNG in the reply instead of writing a fixed name into the shared temporary folder; exported HTML/Markdown write `href` only for web, mail, bookmark and relative links, never `javascript:` and other schemes (one allow-list in `wordcraft_doc::link`) | | 2026-10-11 | Design tab: the style set gallery gets a box and gaps of its own, so it no longer runs into Colors and Fonts (#638) | | 2026-10-11 | File › Print: page preview, page range (all, current, selected, custom 1-3,5), odd/even, copies and collate go into the printed PDF (`file.printPdf`, `ui.print` params); printer, duplex and pages per sheet are still the system dialog's (#641, stage 1) | diff --git a/crates/engine/src/cmd/review.rs b/crates/engine/src/cmd/review.rs index 553a4ee..b89f0fa 100644 --- a/crates/engine/src/cmd/review.rs +++ b/crates/engine/src/cmd/review.rs @@ -576,6 +576,90 @@ pub fn has_format_changes(doc: &wordcraft_doc::Document) -> bool { }) } +/// A tracked deletion in the body shown in a balloon (Show revisions in balloons). +#[derive(Clone, Debug, PartialEq)] +pub struct DeletionBalloon { + /// Where the deleted text was: the balloon's anchor. + pub at: Pos, + pub author: String, + /// The deleted text, a deleted paragraph mark as ¶, shortened to [`DELETION_TEXT_CHARS`]. + pub text: String, +} + +/// At most this many characters of deleted text show in a balloon (a deleted page shows its start). +pub const DELETION_TEXT_CHARS: usize = 200; +/// At most this many deletion balloons (a document of many small deletions can't flood the area). +const MAX_DELETION_BALLOONS: usize = 500; + +/// The tracked deletions in the body, for balloons, in document order. Deleted text running on +/// across runs, revisions and paragraph marks by the same author is one balloon. The source of a +/// move is left out: it has its "Moved from" balloon ([`move_changes`]). +pub fn deletion_balloons(s: &Session) -> Vec { + let doc = &s.doc; + let author = |rev: u32| doc.revisions.get(rev as usize).map(|r| r.author.clone()).unwrap_or_default(); + let mk = |path: &wordcraft_doc::Path, off| Pos { story: StoryRef::Body, path: path.clone(), off }; + let mut out = Vec::new(); + for path in doc.para_paths(StoryRef::Body) { + let Some(p) = doc.para(StoryRef::Body, &path) else { continue }; + for (r, c) in p.run_ranges() { + // Inserted and then deleted text is a deletion (it's drawn and left out as one). + let Some(rev) = plain_deletion(doc, c.del) else { continue }; + push_deletion(&mut out, mk(&path, r.start), mk(&path, r.end), author(rev), p.text.get(r).unwrap_or("")); + } + if let Some(rev) = plain_deletion(doc, p.mark.del) { + let at = mk(&path, p.len()); + let next = path.with_last(path.last().saturating_add(1)); + let end = if super::has_next_para(s, StoryRef::Body, &path) { mk(&next, 0) } else { at.clone() }; + push_deletion(&mut out, at, end, author(rev), "¶"); + } + } + out.into_iter().map(|(d, _, _)| d).collect() +} + +/// Add `author`'s deleted `text` (from `at` to `end`) to `out`: to the last balloon when that +/// deletion ends at `at` and is theirs, else as a new one. Each balloon carries where its deletion +/// ends and how many characters its text has. +fn push_deletion(out: &mut Vec<(DeletionBalloon, Pos, usize)>, at: Pos, end: Pos, author: String, text: &str) { + let joins = out.last().is_some_and(|(d, e, _)| *e == at && d.author == author); + if !joins { + if out.len() >= MAX_DELETION_BALLOONS { + return; + } + out.push((DeletionBalloon { at, author, text: String::new() }, end.clone(), 0)); + } + let Some((d, e, chars)) = out.last_mut() else { return }; + *e = end; + for c in text.chars() { + if *chars > DELETION_TEXT_CHARS { + break; + } + let c = match c { + // Pictures, fields and other objects show no text of their own. + wordcraft_doc::para::OBJ => continue, + '\t' | wordcraft_doc::para::LINE_BREAK | wordcraft_doc::para::PAGE_BREAK | wordcraft_doc::para::COLUMN_BREAK => ' ', + c if c.is_control() => continue, + c => c, + }; + *chars += 1; + d.text.push(if *chars > DELETION_TEXT_CHARS { '…' } else { c }); + } +} + +/// A deletion revision that is not the source of a move (`w:moveFrom` is a deletion revision +/// with a move name). +fn plain_deletion(doc: &wordcraft_doc::Document, del: Option) -> Option { + del.filter(|_| doc.move_name(del).is_none()) +} + +/// Whether the body has any tracked deletion other than a move's source (cheap: stops at the +/// first). +pub fn has_deletions(doc: &wordcraft_doc::Document) -> bool { + doc.para_paths(StoryRef::Body).iter().any(|path| { + doc.para(StoryRef::Body, path) + .is_some_and(|p| plain_deletion(doc, p.mark.del).is_some() || p.runs.iter().any(|r| plain_deletion(doc, r.props.del).is_some())) + }) +} + fn nav_change(s: &mut Session, dir: i32) -> CmdResult { let list = changes(s); let caret = s.sel.ordered(); @@ -899,6 +983,183 @@ mod tests { assert_eq!(s.run("review.trackingOptions", &json!({})).unwrap()["insertColor"], "00AA00"); } + fn glyph_text(d: &[Draw]) -> String { + d.iter().filter_map(|x| if let Draw::Glyphs { text, .. } = x { Some(text.as_str()) } else { None }).collect() + } + + /// Show revisions in balloons: a tracked deletion leaves the text for a balloon at its place + /// and keeps its change bar; it comes back inline in the other modes and wherever no balloons + /// are shown (here the Comments pane, which changes the view without a relayout call). + #[test] + fn revisions_in_balloons_take_deletions_out_of_the_text() { + use super::{DeletionBalloon, deletion_balloons}; + let mut s = Session::new(Document::new()); + s.view.proofing = false; + s.author = "Ana".into(); + s.run("document.setText", &json!({"text": "keep gone stay"})).unwrap(); + s.run("review.trackChanges", &json!({"value": true})).unwrap(); + s.run("select.range", &json!({"anchor": {"block": 0, "off": 5}, "focus": {"block": 0, "off": 10}})).unwrap(); + s.run("text.delete", &json!({})).unwrap(); + + // The default (only comments and formatting in balloons): struck through inline. + assert!(!s.revisions_in_balloons()); + let (d, _) = draws(&mut s); + assert!(glyph_text(&d).contains("gone"), "{:?}", glyph_text(&d)); + + s.run("review.trackingOptions", &json!({"balloons": "revisions"})).unwrap(); + assert!(s.revisions_in_balloons()); + let (d, body_x) = draws(&mut s); + assert!(!glyph_text(&d).contains("gone"), "the deletion left the text: {:?}", glyph_text(&d)); + let horizontal = lines(&d).into_iter().filter(|(_, y0, _, y1)| y0 == y1).count(); + assert_eq!(horizontal, 0, "no strikethrough left behind: {:?}", lines(&d)); + assert_eq!(lines(&d).into_iter().filter(|(x0, _, x1, _)| x0 == x1 && *x0 < body_x).count(), 1, "the changed line keeps its bar"); + let at = wordcraft_doc::Pos::body(0, 5); + assert_eq!(deletion_balloons(&s), vec![DeletionBalloon { at: at.clone(), author: "Ana".into(), text: "gone ".into() }]); + // The balloon's anchor is where the deletion was: between "keep " and "stay". + let l = s.layout(); + let (anchor, stay) = (l.caret(&at).unwrap().x, l.caret(&wordcraft_doc::Pos::body(0, 10)).unwrap().x); + assert!((anchor - stay).abs() < 0.01, "the deletion takes no room: {anchor} vs {stay}"); + + // No balloons beside the page while the Comments pane lists comments: inline again. + s.run("view.commentsPane", &json!({"value": true})).unwrap(); + assert!(!s.revisions_in_balloons()); + assert!(glyph_text(&draws(&mut s).0).contains("gone")); + s.run("view.commentsPane", &json!({"value": false})).unwrap(); + assert!(!glyph_text(&draws(&mut s).0).contains("gone")); + s.run("review.trackingOptions", &json!({"balloons": "inline"})).unwrap(); + assert!(!s.revisions_in_balloons()); + assert!(glyph_text(&draws(&mut s).0).contains("gone")); + } + + /// Deleted text running on across runs, separate deletions and a paragraph mark is one balloon + /// per author; a long deletion shows its start. + #[test] + fn deletion_balloons_join_runs_and_paragraph_marks_and_stay_short() { + use super::{DELETION_TEXT_CHARS, deletion_balloons}; + let mut s = Session::new(Document::new()); + s.author = "Ana".into(); + s.run("document.setText", &json!({"text": "one two\nthree four"})).unwrap(); + s.run("review.trackChanges", &json!({"value": true})).unwrap(); + // "two", then the paragraph mark and "three" in a second deletion: one balloon. + s.run("select.range", &json!({"anchor": {"block": 0, "off": 4}, "focus": {"block": 0, "off": 7}})).unwrap(); + s.run("text.delete", &json!({})).unwrap(); + s.run("select.range", &json!({"anchor": {"block": 0, "off": 7}, "focus": {"block": 1, "off": 5}})).unwrap(); + s.run("text.delete", &json!({})).unwrap(); + // Bo's deletion right after Ana's is a balloon of its own. + s.author = "Bo".into(); + s.run("select.range", &json!({"anchor": {"block": 1, "off": 5}, "focus": {"block": 1, "off": 10}})).unwrap(); + s.run("text.delete", &json!({})).unwrap(); + let b = deletion_balloons(&s); + let got: Vec<(&str, &str)> = b.iter().map(|d| (d.author.as_str(), d.text.as_str())).collect(); + assert_eq!(got, vec![("Ana", "two¶three"), ("Bo", " four")]); + assert_eq!(b[0].at, wordcraft_doc::Pos::body(0, 4)); + + // A deletion far longer than a balloon shows: its first characters and an ellipsis. + let mut s = Session::new(Document::new()); + let long = "word ".repeat(400); + s.run("document.setText", &json!({"text": long})).unwrap(); + s.run("review.trackChanges", &json!({"value": true})).unwrap(); + s.run("select.all", &json!({})).unwrap(); + s.run("text.delete", &json!({})).unwrap(); + let b = deletion_balloons(&s); + assert_eq!(b.len(), 1); + assert_eq!(b[0].text.chars().count(), DELETION_TEXT_CHARS + 1); + assert!(b[0].text.starts_with("word word") && b[0].text.ends_with('…')); + } + + /// #404: the source of a move is a deletion revision with a move name. With revisions in + /// balloons it leaves the text like a deletion but has only its "Moved from" balloon, not a + /// "Deleted: …" one as well; a plain deletion right after it is a balloon of its own. + #[test] + fn a_moves_source_gets_no_deletion_balloon() { + use super::{DeletionBalloon, deletion_balloons, has_deletions, move_changes}; + use wordcraft_doc::Pos; + let mut s = Session::new(Document::new()); + s.run("document.setText", &json!({"text": "First one.\nSecond one.\nThird one."})).unwrap(); + s.run("file.setAuthor", &json!({"name": "Ana"})).unwrap(); + s.run("review.trackChanges", &json!({"value": true})).unwrap(); + // The second paragraph, with its mark, cut and pasted before the first: a move. + 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", &json!({"balloons": "revisions"})).unwrap(); + assert!(s.revisions_in_balloons()); + + let ends: Vec<(Pos, bool)> = move_changes(&s).into_iter().map(|(at, _, from)| (at, from)).collect(); + assert_eq!(ends, vec![(Pos::body(0, 0), false), (Pos::body(2, 0), true)]); + assert!(deletion_balloons(&s).is_empty(), "the move's source has its \"Moved from\" balloon only: {:?}", deletion_balloons(&s)); + assert!(!has_deletions(&s.doc), "a move alone needs no deletion balloons"); + assert_eq!(glyph_text(&draws(&mut s).0), "Second one.First one.Third one.", "the moved-away text left the text"); + + // "Third " deleted right where the moved-away paragraph ends: not joined to it. + s.run("select.range", &json!({"anchor": {"block": 3, "off": 0}, "focus": {"block": 3, "off": 6}})).unwrap(); + s.run("text.delete", &json!({})).unwrap(); + assert!(has_deletions(&s.doc)); + assert_eq!(deletion_balloons(&s), vec![DeletionBalloon { at: Pos::body(3, 0), author: "Ana".into(), text: "Third ".into() }]); + assert_eq!(move_changes(&s).len(), 2, "the move keeps both its balloons"); + } + + /// Revisions in balloons take deletions out of the body only, where their balloons are: + /// headers, footers, footnotes, endnotes and text boxes get no balloons, so their deletions + /// stay in the text, struck through, as in the other modes. + #[test] + fn deletions_outside_the_body_stay_inline_when_revisions_go_in_balloons() { + use super::{DeletionBalloon, deletion_balloons}; + use wordcraft_doc::{Path, Pos, StoryRef}; + let mut s = Session::new(Document::new()); + s.view.proofing = false; + s.author = "Ana".into(); + s.run("document.setText", &json!({"text": "Body zeta keep"})).unwrap(); + let part = |v: serde_json::Value, key: &str| StoryRef::Part(v[key].as_u64().unwrap() as u32); + let header = part(s.run("insert.header", &json!({"text": "Head alpha"})).unwrap(), "story"); + s.run("insert.closeHeader", &json!({})).unwrap(); + let footer = part(s.run("insert.footer", &json!({"text": "Foot beta"})).unwrap(), "story"); + s.run("insert.closeHeader", &json!({})).unwrap(); + let in_body = json!({"pos": {"block": 0, "off": 4}}); + s.run("caret.set", &in_body).unwrap(); + let footnote = part(s.run("references.footnote", &json!({"text": "Note gamma"})).unwrap(), "id"); + s.run("caret.set", &in_body).unwrap(); + let endnote = part(s.run("references.endnote", &json!({"text": "End delta"})).unwrap(), "id"); + s.run("caret.set", &in_body).unwrap(); + let text_box = part(s.run("insert.textBox", &json!({"text": "Box epsilon"})).unwrap(), "story"); + + // With Track Changes on, a word deleted in each story. + s.run("review.trackChanges", &json!({"value": true})).unwrap(); + let words = [(StoryRef::Body, "zeta"), (header, "alpha"), (footer, "beta"), (footnote, "gamma"), (endnote, "delta"), (text_box, "epsilon")]; + for (story, word) in words { + let at = s.doc.para(story, &Path::top(0)).and_then(|p| p.text.find(word)).unwrap(); + let pos = |off: usize| json!({"story": story, "path": [0], "off": off}); + s.run("select.range", &json!({"anchor": pos(at), "focus": pos(at + word.len())})).unwrap(); + s.run("text.delete", &json!({})).unwrap(); + let p = s.doc.para(story, &Path::top(0)).unwrap(); + assert!(p.run_ranges().any(|(r, c)| c.del.is_some() && p.text.get(r) == Some(word)), "{word} is a tracked deletion"); + } + let struck = |d: &[Draw]| lines(d).into_iter().filter(|(_, y0, _, y1)| y0 == y1).count(); + + // Comments and formatting in balloons: every deletion struck through where it was. + let (d, _) = draws(&mut s); + let text = glyph_text(&d); + for (_, word) in words { + assert!(text.contains(word), "{word} in {text:?}"); + } + let all_struck = struck(&d); + + // Revisions in balloons: only the body's deletion leaves the text, for its balloon. + s.run("review.trackingOptions", &json!({"balloons": "revisions"})).unwrap(); + assert!(s.revisions_in_balloons()); + let (d, _) = draws(&mut s); + let text = glyph_text(&d); + assert!(!text.contains("zeta"), "the body's deletion left the text: {text:?}"); + for (_, word) in &words[1..] { + assert!(text.contains(word), "{word} stays inline: {text:?}"); + } + assert_eq!(struck(&d), all_struck - 1, "the other deletions stay struck through"); + // Its balloon is the only one: the notes' and text box's objects sit before "zeta". + let at = s.doc.para(StoryRef::Body, &Path::top(0)).and_then(|p| p.text.find("zeta")).unwrap(); + assert_eq!(deletion_balloons(&s), vec![DeletionBalloon { at: Pos::body(0, at), author: "Ana".into(), text: "zeta".into() }]); + } + fn body_para(s: &Session) -> &wordcraft_doc::Paragraph { s.doc.para(wordcraft_doc::StoryRef::Body, &wordcraft_doc::Path::top(0)).unwrap() } diff --git a/crates/engine/src/session.rs b/crates/engine/src/session.rs index 0a103f8..f372e8b 100644 --- a/crates/engine/src/session.rs +++ b/crates/engine/src/session.rs @@ -268,7 +268,9 @@ pub struct Session { /// mail merge, recover), never on an edit. document_id: u64, cache: LayoutCache, - layout: Option<(u64, f32, ViewMode, Arc, bool, Option)>, + /// (revision, web width, view, layout, proofing, outline, (deletions left out, body + /// deletions in balloons)). + layout: Option<(u64, f32, ViewMode, Arc, bool, Option, (bool, bool))>, /// Picture edits: edited media key → original media key (Reset Picture). pub originals: std::collections::HashMap, /// Last mutating command (Repeat). @@ -523,6 +525,27 @@ impl Session { self.find.highlights.as_ref().map(|(_, v)| v.as_slice()).unwrap_or(&[]) } + /// Whether the markup area beside the page can show balloons: markup shown in Print Layout + /// (not Read Mode or pages side by side), comments not listed in the Comments pane instead, + /// and Track Changes Options not set to show every revision inline. + pub fn balloons_shown(&self) -> bool { + let v = &self.view; + v.show_markup + && !v.comments_pane + && !v.read_mode + && !v.multi_page + && v.mode == ViewMode::Print + && self.prefs.markup.balloons != wordcraft_layout::display::BalloonMode::Inline + } + + /// Show revisions in balloons, with the balloons on screen: tracked deletions in the body leave + /// the text for balloons in the markup area. Where no balloons can be shown they stay inline, + /// as in Word; so do deletions in headers, footers, notes and text boxes, which get no balloons. + pub fn revisions_in_balloons(&self) -> bool { + let m = &self.prefs.markup; + self.balloons_shown() && m.balloons == wordcraft_layout::display::BalloonMode::Revisions && m.insertions_deletions + } + /// Initials for new comments: the ones set in File › Options, else the user name's. pub fn user_initials(&self) -> String { let set = self.initials.trim(); @@ -532,12 +555,17 @@ impl Session { /// The current layout (recomputed when the document or view changed). pub fn layout(&mut self) -> Arc { let ww = self.view.web_width; + // 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 outline = (self.view.mode == ViewMode::Outline).then(|| self.view.outline.view()); - if let Some((r, w, m, l, pf, o)) = &self.layout + if let Some((r, w, m, l, pf, o, hd)) = &self.layout && *r == self.rev && (*w == ww || self.view.mode == ViewMode::Print) && *m == self.view.mode && *pf == self.view.proofing + && *hd == deleted && *o == outline { return l.clone(); @@ -546,7 +574,8 @@ impl Session { view: self.view.mode, web_width: ww, show_hidden: self.view.marks || self.prefs.marks.hidden, - hide_deleted: !self.view.show_markup || self.prefs.markup.hides_deletions(), + hide_deleted, + deletions_in_balloons: deleted.1, proofing: self.view.proofing, comment_balloons: false, field_codes: self.view.field_codes, @@ -554,7 +583,7 @@ impl Session { proof: wordcraft_layout::ProofOptions { grammar: self.prefs.mark_grammar, spelling: self.prefs.spelling() }, }; let l = Arc::new(wordcraft_layout::layout(&self.doc, &mut self.cache, &opts)); - self.layout = Some((self.rev, ww, self.view.mode, l.clone(), self.view.proofing, outline)); + self.layout = Some((self.rev, ww, self.view.mode, l.clone(), self.view.proofing, outline, deleted)); l } /// A layout for output (PDF, images, print): no proofing marks, print view. diff --git a/crates/layout/src/display.rs b/crates/layout/src/display.rs index c38c2d7..48d1a50 100644 --- a/crates/layout/src/display.rs +++ b/crates/layout/src/display.rs @@ -276,8 +276,11 @@ named! { named! { /// What the markup area beside the page shows. BalloonMode { - /// Deletions, formatting and comments in balloons (drawn like `CommentsAndFormatting` - /// for now: revisions stay inline). + /// Deletions, formatting and comments in balloons; insertions stay inline. Where the + /// editor shows balloons, deleted body text leaves the layout (`deletions_in_balloons`) + /// and the editor draws a "Deleted: …" balloon at its place; elsewhere (no markup area, + /// PDF, and headers, footers, notes and text boxes, which get no balloons) it is drawn + /// inline as in `CommentsAndFormatting`. Revisions = "revisions", /// Everything inline, no markup area. Inline = "inline", diff --git a/crates/layout/src/lib.rs b/crates/layout/src/lib.rs index dca16e9..6aa89d4 100644 --- a/crates/layout/src/lib.rs +++ b/crates/layout/src/lib.rs @@ -60,6 +60,10 @@ pub struct LayoutOptions { pub show_hidden: bool, /// Final text ("No Markup"): tracked deletions take no space and draw nothing. pub hide_deleted: bool, + /// 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. + pub deletions_in_balloons: bool, /// Check spelling as you type (squiggles). pub proofing: bool, /// Print Markup (PDF, print, page images): widen each page by [`balloons::MARKUP_AREA`] @@ -89,6 +93,13 @@ 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) + } +} + /// Something placed on a page (page coordinates, points, y down). #[derive(Clone, Debug)] pub enum Placed { @@ -350,6 +361,7 @@ struct Key { excl: u64, eq: u32, grid: (u32, u32, u32, bool), + hide_deleted: bool, } /// Memoised paragraph layouts. @@ -395,6 +407,7 @@ fn env_hash(doc: &Document, opts: &LayoutOptions) -> u64 { s, opts.show_hidden, opts.hide_deleted, + opts.deletions_in_balloons, opts.proofing, opts.field_codes, opts.proof, @@ -485,6 +498,9 @@ struct Ctx<'a> { chains: textbox::Chains, /// The current section's document grid (body text only: none for headers and footers). 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, /// 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, @@ -584,6 +600,7 @@ impl Ctx<'_> { excl: if exclusions.is_empty() { 0 } else { hash_of(&format!("{exclusions:?}")) }, eq: if eq_here > 0 { eq_number } else { 0 }, grid: self.grid.key(), + hide_deleted: self.hide_deleted, }; self.cache.used.insert(key.clone()); if let Some(pl) = self.cache.paras.get(&key) { @@ -598,7 +615,7 @@ impl Ctx<'_> { fields: &self.fields, show_hidden: self.opts.show_hidden, field_codes: self.opts.field_codes, - hide_deleted: self.opts.hide_deleted, + hide_deleted: self.hide_deleted, table, proofing: self.opts.proofing.then_some(self.opts.proof), exclusions, @@ -741,6 +758,9 @@ fn layout_box( depth: usize, frame: Option, ) -> BoxLayout { + // Deletions in balloons leave the body's text only: a header, note or text box keeps its own + // inline (restored for the story around this box when it's done). + let outer_hide = std::mem::replace(&mut ctx.hide_deleted, ctx.opts.hides_deleted(story)); let mut items = Vec::new(); // Pictures behind the text go first; `behind` counts them. let mut behind = 0; @@ -769,7 +789,7 @@ fn layout_box( // Floating objects anchored here: place them, then wrap the text around them. // One left out of the layout (hidden, or deleted in the final text) takes no room. let mut floats = HashMap::new(); - let left_out = left_out_objects(ctx.doc, p, table.map(|t| &t.chr), ctx.opts.show_hidden, ctx.opts.hide_deleted); + let left_out = left_out_objects(ctx.doc, p, table.map(|t| &t.chr), ctx.opts.show_hidden, ctx.hide_deleted); for (oi, o) in p.objects.iter().enumerate() { let Some((w, h, float)) = floating(o) else { continue }; if left_out(oi) { @@ -835,6 +855,7 @@ fn layout_box( } } let floats_bottom = float_tables.iter().map(|r| r.bottom()).fold(0.0, f32::max); + ctx.hide_deleted = outer_hide; BoxLayout { items, height: y.max(0.0), floats_bottom } } @@ -1529,7 +1550,8 @@ fn layout_pass( cache.env = env; } cache.used.clear(); - let notes = note_info(doc, opts.hide_deleted, note_pages); + // Note references are counted as the body shows them. + let notes = note_info(doc, opts.hides_deleted(StoryRef::Body), note_pages); let notes_hash = hash_of(&{ let mut v: Vec<_> = notes.iter().map(|(a, b)| (*a, b.n, b.fmt)).collect(); v.sort_by_key(|(a, ..)| *a); @@ -1539,6 +1561,7 @@ fn layout_pass( doc, cache, opts, + hide_deleted: opts.hides_deleted(StoryRef::Body), counters: Counters::default(), fields: FieldCtx { page: 1, @@ -2240,7 +2263,7 @@ fn anchor_floats( let frame = PageFrame { sect: pb.sect, origin: (0.0, 0.0) }; // An object left out of the layout (hidden, or deleted in the final text) is not placed, so // it takes no room either. - let left_out = left_out_objects(ctx.doc, p, None, ctx.opts.show_hidden, ctx.opts.hide_deleted); + let left_out = left_out_objects(ctx.doc, p, None, ctx.opts.show_hidden, ctx.hide_deleted); for (oi, o) in p.objects.iter().enumerate() { let Some((w, h, float)) = floating(o) else { continue }; if left_out(oi) { @@ -2290,7 +2313,7 @@ fn place_objects( let (mut behind, mut front) = (Vec::new(), Vec::new()); let Some(fl) = pl.lines.get(l0) else { return (behind, front) }; // Objects left out of the layout (hidden, or deleted in the final text) aren't placed. - let left_out = left_out_objects(ctx.doc, p, at.table_chr, ctx.opts.show_hidden, ctx.opts.hide_deleted); + let left_out = left_out_objects(ctx.doc, p, at.table_chr, ctx.opts.show_hidden, ctx.hide_deleted); for li in l0..l1 { let Some(line) = pl.lines.get(li) else { continue }; for k in line.c0..line.c1 { @@ -2685,7 +2708,7 @@ fn next_first_line(ctx: &mut Ctx, block: usize, width: f32) -> f32 { fields: &ctx.fields, show_hidden: ctx.opts.show_hidden, field_codes: ctx.opts.field_codes, - hide_deleted: ctx.opts.hide_deleted, + hide_deleted: ctx.opts.hides_deleted(StoryRef::Body), table: None, proofing: None, exclusions: &[], diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 67900ee..b7c73b6 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -239,19 +239,17 @@ fn on_page_gap(rects: &[Rect], bands: &[(f32, f32)], p: Pos2) -> bool { }) } -/// Width of the markup area beside each page for comment balloons (points), or 0. +/// Width of the markup area beside each page for comment and revision balloons (points), or 0. pub fn markup_width(app: &WordApp) -> f32 { - let v = &app.session.view; - // Word shows comments either in balloons (contextual) or in the Comments pane (list). - let on = v.show_markup && !v.comments_pane && !v.read_mode && !v.multi_page && v.mode == wordcraft_layout::ViewMode::Print; - // Track Changes Options: comments and formatting hidden, or every revision inline, leave - // no markup area. - let m = &app.session.prefs.markup; - let on = on && m.balloons != wordcraft_layout::display::BalloonMode::Inline; - let comments = m.comments && !app.session.doc.comments.is_empty(); - let formats = m.formatting && wordcraft_engine::cmd::review::has_format_changes(&app.session.doc); - let moves = m.insertions_deletions && wordcraft_engine::cmd::moves::has_moves(&app.session.doc); - if on && (comments || formats || moves) { 216.0 } else { 0.0 } + // Word shows comments either in balloons (contextual) or in the Comments pane (list); Track + // Changes Options can put every revision inline (`Session::balloons_shown`). + let s = &app.session; + let m = &s.prefs.markup; + let comments = m.comments && !s.doc.comments.is_empty(); + let formats = m.formatting && wordcraft_engine::cmd::review::has_format_changes(&s.doc); + let moves = m.insertions_deletions && wordcraft_engine::cmd::moves::has_moves(&s.doc); + let deletions = s.revisions_in_balloons() && wordcraft_engine::cmd::review::has_deletions(&s.doc); + if s.balloons_shown() && (comments || formats || moves || deletions) { 216.0 } else { 0.0 } } /// A page dimension (points) safe to lay out: finite, at least 1 pt, at most `cap`. @@ -928,8 +926,9 @@ fn balloons(app: &mut WordApp, ui: &mut Ui, painter: &egui::Painter, rects: &[Re replies.entry(p).or_default().push(*id); } } - // (page, anchor x, anchor y, item) for each anchored, unresolved-or-not comment, and each - // tracked formatting change (Word's default shows those in balloons too). + // (page, anchor x, anchor y, item) for each anchored, unresolved-or-not comment, each + // tracked formatting change (Word's default shows those in balloons too) and, when revisions + // go in balloons, each tracked deletion. let mut by_page: HashMap> = HashMap::new(); let m = &app.session.prefs.markup; let comments = if m.comments { wordcraft_engine::cmd::review::comment_list(&app.session) } else { Vec::new() }; @@ -955,6 +954,13 @@ fn balloons(app: &mut WordApp, ui: &mut Ui, painter: &egui::Painter, rects: &[Re let text = if from { tl!("Moved from") } else { tl!("Moved to") }.to_string(); by_page.entry(c.page).or_default().push((c.x, c.top + c.height, Balloon::Format(author, text), pos)); } + // Show revisions in balloons: each tracked deletion, taken out of the text, at its place. + let deletions = if app.session.revisions_in_balloons() { wordcraft_engine::cmd::review::deletion_balloons(&app.session) } else { Vec::new() }; + for d in deletions { + let Some(c) = layout.caret_on(&d.at, app.session.page_hint) else { continue }; + let text = format!("{}: {}", tl!("Deleted"), d.text); + by_page.entry(c.page).or_default().push((c.x, c.top + c.height, Balloon::Format(d.author, text), d.at)); + } let palette = [t.blue, Color32::from_rgb(0xB0, 0x3A, 0x2E), Color32::from_rgb(0x2E, 0x7D, 0x32), Color32::from_rgb(0x8E, 0x44, 0xAD), t.orange]; let mut authors: Vec = Vec::new(); let mut clicked: Option<(u32, Pos)> = None; @@ -1089,7 +1095,8 @@ fn balloons(app: &mut WordApp, ui: &mut Ui, painter: &egui::Painter, rects: &[Re /// What a balloon in the markup area shows. enum Balloon { Comment(u32), - /// A tracked formatting change or move: its author and "Formatted: …" / "Moved from". + /// A tracked formatting change, move or deletion: its author and "Formatted: …" / + /// "Moved from" / "Deleted: …". Format(String, String), } @@ -2001,6 +2008,26 @@ mod tests { assert_eq!(wordcraft_engine::cmd::page::sect(&app.session).margin_left, sect.margin_left); } + /// Show revisions in balloons: a document whose only revision is a deletion gets the markup + /// area for its balloon; the other modes have nothing to show there, and neither does the view + /// while the Comments pane is open. + #[test] + fn a_deletion_alone_opens_the_markup_area_when_revisions_go_in_balloons() { + let mut a = WordApp::new(wordcraft_engine::Session::new(wordcraft_doc::Document::new()), Default::default()); + a.run("document.setText", json!({"text": "keep gone"})).unwrap(); + a.run("review.trackChanges", json!({"value": true})).unwrap(); + a.run("select.range", json!({"anchor": {"block": 0, "off": 5}, "focus": {"block": 0, "off": 9}})).unwrap(); + a.run("text.delete", json!({})).unwrap(); + assert_eq!(markup_width(&a), 0.0, "comments and formatting only: nothing to show"); + a.run("review.trackingOptions", json!({"balloons": "revisions"})).unwrap(); + assert!(markup_width(&a) > 0.0, "the deletion's balloon needs the markup area"); + a.run("view.commentsPane", json!({"value": true})).unwrap(); + assert_eq!(markup_width(&a), 0.0); + a.run("view.commentsPane", json!({"value": false})).unwrap(); + a.run("review.trackingOptions", json!({"insertionsDeletions": false})).unwrap(); + assert_eq!(markup_width(&a), 0.0, "deletions not shown at all"); + } + /// #549: presses count as double/triple as they go down; slow or far presses start again. #[test] fn presses_count_up_to_three() { diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index 12094d3..002f85b 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -1413,6 +1413,7 @@ Selected Fields Campos seleccionados Edit Field… Editar campo… Formatted Con formato + Deleted Eliminado Interface size: Tamaño de la interfaz: Makes the ribbon, menus and dialogs larger or smaller, on top of your system's display scaling. Document zoom is separate. Agranda o reduce la cinta, los menús y los cuadros de diálogo, además del escalado de pantalla del sistema. El zoom del documento es aparte. Print Markup Imprimir marcas diff --git a/crates/ui-egui/src/i18n/et.tsv b/crates/ui-egui/src/i18n/et.tsv index d6fdc45..2f138d5 100644 --- a/crates/ui-egui/src/i18n/et.tsv +++ b/crates/ui-egui/src/i18n/et.tsv @@ -1399,6 +1399,7 @@ Choose the sheet that holds the recipients. Valige leht, millel on adressaadid. First row of data contains column headers Andmete esimene rida sisaldab veerupäiseid Formatted Vormindatud + Deleted Kustutatud Interface size: Kasutajaliidese suurus: Makes the ribbon, menus and dialogs larger or smaller, on top of your system's display scaling. Document zoom is separate. Muudab menüülindi, menüüd ja dialoogid suuremaks või väiksemaks, lisaks süsteemi ekraani skaleerimisele. Dokumendi suum on eraldi. Print Markup Prindi märgistus diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index eeb9289..fd07913 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -1397,6 +1397,7 @@ Selected Fields 選択したフィールド Edit Field… フィールドの編集… Formatted 書式変更 + Deleted 削除 Interface size: 表示サイズ: Makes the ribbon, menus and dialogs larger or smaller, on top of your system's display scaling. Document zoom is separate. リボン、メニュー、ダイアログを、システムの表示倍率に加えて拡大または縮小します。文書のズームは別に設定します。 Print Markup 変更履歴とコメントを印刷 diff --git a/crates/ui-egui/src/i18n/pt-br.tsv b/crates/ui-egui/src/i18n/pt-br.tsv index 508e078..72079c2 100644 --- a/crates/ui-egui/src/i18n/pt-br.tsv +++ b/crates/ui-egui/src/i18n/pt-br.tsv @@ -1409,6 +1409,7 @@ Selected Fields Campos selecionados Edit Field… Editar campo… Formatted Formatado + Deleted Excluído Interface size: Tamanho da interface: Makes the ribbon, menus and dialogs larger or smaller, on top of your system's display scaling. Document zoom is separate. Aumenta ou diminui a faixa de opções, os menus e as caixas de diálogo, além da escala de exibição do sistema. O zoom do documento é separado. Print Markup Imprimir marcação diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv index 4db24be..b4f0bcd 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -1706,6 +1706,7 @@ preview Title Пример Centered as Group По центру как группа ✓ None ✓ Нет Formatted Отформатировано + Deleted Удалено Interface size: Размер интерфейса: Makes the ribbon, menus and dialogs larger or smaller, on top of your system's display scaling. Document zoom is separate. Увеличивает или уменьшает ленту, меню и диалоговые окна поверх системного масштабирования экрана. Масштаб документа настраивается отдельно. Copyright Sign Знак авторского права diff --git a/crates/ui-egui/src/i18n/sr-latn.tsv b/crates/ui-egui/src/i18n/sr-latn.tsv index 810226c..a0fa93b 100644 --- a/crates/ui-egui/src/i18n/sr-latn.tsv +++ b/crates/ui-egui/src/i18n/sr-latn.tsv @@ -1407,6 +1407,7 @@ Selected Fields Izabrana polja Edit Field… Uredi polje… Formatted Formatirano + Deleted Izbrisano Interface size: Veličina interfejsa: Makes the ribbon, menus and dialogs larger or smaller, on top of your system's display scaling. Document zoom is separate. Povećava ili smanjuje traku, menije i dijaloge, povrh skaliranja ekrana u sistemu. Zumiranje dokumenta je zasebno. Print Markup Štampaj oznake diff --git a/crates/ui-egui/src/i18n/sr.tsv b/crates/ui-egui/src/i18n/sr.tsv index 48b3c3e..7deeef3 100644 --- a/crates/ui-egui/src/i18n/sr.tsv +++ b/crates/ui-egui/src/i18n/sr.tsv @@ -1411,6 +1411,7 @@ Selected Fields Изабрана поља Edit Field… Уреди поље… Formatted Форматирано + Deleted Избрисано Interface size: Величина интерфејса: Makes the ribbon, menus and dialogs larger or smaller, on top of your system's display scaling. Document zoom is separate. Повећава или смањује траку, меније и дијалоге, поврх скалирања екрана у систему. Зумирање документа је засебно. Print Markup Штампај ознаке diff --git a/crates/ui-egui/src/i18n/tr.tsv b/crates/ui-egui/src/i18n/tr.tsv index bbf6b37..32bd75d 100644 --- a/crates/ui-egui/src/i18n/tr.tsv +++ b/crates/ui-egui/src/i18n/tr.tsv @@ -1322,6 +1322,7 @@ Flip Vertical Dikey Çevir Flip Horizontal Yatay Çevir Formatted Biçimlendirildi + Deleted Silindi Copyright Sign Telif Hakkı İşareti Demote Düzey Düşür Edit Field Code Alan Kodunu Düzenle diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index f0cb281..8363fdd 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -1409,6 +1409,7 @@ Selected Fields Вибрані поля Edit Field… Змінити поле… Formatted Відформатовано + Deleted Видалено Interface size: Розмір інтерфейсу: Makes the ribbon, menus and dialogs larger or smaller, on top of your system's display scaling. Document zoom is separate. Збільшує або зменшує стрічку, меню й діалогові вікна понад системне масштабування екрана. Масштаб документа налаштовується окремо. Print Markup Друкувати примітки та виправлення diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv index baf7301..a7fb019 100644 --- a/crates/ui-egui/src/i18n/zh-hans.tsv +++ b/crates/ui-egui/src/i18n/zh-hans.tsv @@ -1409,6 +1409,7 @@ Selected Fields 所选域 Edit Field… 编辑域… Formatted 已设置格式 + Deleted 已删除 Interface size: 界面大小: Makes the ribbon, menus and dialogs larger or smaller, on top of your system's display scaling. Document zoom is separate. 在系统显示缩放的基础上放大或缩小功能区、菜单和对话框。文档缩放单独设置。 Print Markup 打印标记 diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv index dbb70d4..88b77ed 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -1398,6 +1398,7 @@ Selected Fields 選取的功能變數 Edit Field… 編輯功能變數… Formatted 已設定格式 + Deleted 已刪除 Interface size: 介面大小: Makes the ribbon, menus and dialogs larger or smaller, on top of your system's display scaling. Document zoom is separate. 在系統顯示縮放的基礎上放大或縮小功能區、功能表與對話方塊。文件縮放另行設定。 Print Markup 列印標記 diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 7abc01e..182fc97 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1,6 +1,6 @@ # WordCraft parity with Microsoft Word -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** trivial (Insert › SmartArt landed, #497; previously trivial: English spelling knows common modern words, #520; previously trivial: all 12 bibliography styles, #383; previously trivial: formatting revisions landed, #41; previously trivial: Draw Table and Eraser landed, #303; 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; replaces the parity tables that lived in ROADMAP.md) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** trivial (Show revisions in balloons draws deletion balloons; previously trivial: English spelling knows common modern words, #520; previously trivial: all 12 bibliography styles, #383; previously trivial: formatting revisions landed, #41; previously trivial: Draw Table and Eraser landed, #303; 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; replaces the parity tables that lived in ROADMAP.md) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4 The authoritative assessment of how close WordCraft is to Microsoft Word. [`ROADMAP.md`](../ROADMAP.md) summarizes it; [`gaps.md`](gaps.md) itemizes every shortfall; the deep checklists are @@ -65,7 +65,7 @@ full parity for the area. | Headers, footers, page numbers, fields | 5 | 70% | 70% | 8–12 | First/even/odd, link to previous, TOC as an updatable field (#52), cross-references to real bookmarks (#227). Field coverage is a subset of Word's ~90 field codes; Field dialog missing. | | Footnotes and endnotes | 3 | 60% | 60% | 3–5 | Placed and editable, numbers from Word files (#103); long notes continue onto the next page under the continuation separator (#352). Footnote and Endnote dialog with options per section: below text, end of section, number format, start at, restart each section or page, custom mark, convert (#385). | | References (TOC, citations, bibliography, captions, index, TOA) | 5 | 65% | 60% | 12–20 | Working first versions, Zotero integration (#189), Zotero/Mendeley `ADDIN` fields round-trip. Caption, Index, Mark Entry, Custom TOC and Create/Edit Source dialogs; updates honour the TOC (`\o \u \t \f \n \p \h`), INDEX (`\c \e \h \r`), XE (`\b \i \t \r`) and SEQ/STYLEREF chapter switches (#402). All 12 of Word's bibliography styles (APA, MLA, Chicago, IEEE, Harvard, ISO 690 author-date and numerical, Turabian, GB/T 7714, GOST name and title sort, SIST02; #383); bibliography sources not yet in DOCX (#169 open). | -| Review (proofing UI, comments, track changes, compare, protect) | 8 | 72% | 75% | 15–25 | Comment balloons, tracked insert/delete incl. paragraph marks (#244, #125), Compare / Combine with comparison settings (case, white space, formatting, tables, word or character level, #412), Restrict Editing pane with formatting and editing restrictions, exceptions for everyone and a password (#412). Formatting revisions (#41): kept in DOCX, recorded while tracking, accepted/rejected, "Formatted: …" balloons and change bars. Moves (#400): kept in DOCX, recorded on a tracked cut and paste, accepted/rejected at either end, green markup and "Moved from/to" balloons. Missing: drag-and-drop moves, insert/delete balloons, Translate, Block Authors, modern comment threads/mentions. Lowered: these gaps are confirmed by source (no `rPrChange` anywhere). | +| Review (proofing UI, comments, track changes, compare, protect) | 8 | 72% | 75% | 15–25 | Comment balloons, tracked insert/delete incl. paragraph marks (#244, #125), Compare / Combine with comparison settings (case, white space, formatting, tables, word or character level, #412), Restrict Editing pane with formatting and editing restrictions, exceptions for everyone and a password (#412). Formatting revisions (#41): kept in DOCX, recorded while tracking, accepted/rejected, "Formatted: …" balloons and change bars. Moves (#400): kept in DOCX, recorded on a tracked cut and paste, accepted/rejected at either end, green markup and "Moved from/to" balloons. Show revisions in balloons takes tracked deletions in the body out of the text into "Deleted: …" balloons (insertions stay inline, as in Word; deletions in headers, footers, notes and text boxes, and in print and PDF, stay inline). Missing: drag-and-drop moves, insert/delete balloons, Translate, Block Authors, modern comment threads/mentions. Lowered: these gaps are confirmed by source (no `rPrChange` anywhere). | | Proofing (spelling, grammar, thesaurus, languages) | 5 | 35% | (in Review) | 25–45 | English only: one dictionary (Moby list plus our supplement of modern words such as "desktop", #520), a rule-based grammar checker. Word ships 120 proofing tools for ~50 languages plus Editor (style refinements, similarity). Issues #25, #40, #100. | | Mailings | 2 | 70% | 70% | 6–10 | Mail merge from CSV, fields, rules (IF, SKIPIF, NEXT, NEXTIF, evaluated alike in preview and merge), fields in headers/footers (one section per letter), preview, envelopes, labels. Select Recipients takes a typed list or CSV (#247); no Excel/Outlook/Contacts data sources, no email merge. | | Pictures, shapes, text boxes, WordArt | 6 | 60% | 60% | 20–30 | Insert, crop, recolour, styles, floating placement like Word (#136), wrap (square/top-bottom/behind/front), text boxes edit and overflow (#46), VML text boxes (#242), free rotation and flips with a rotation handle (#332), WordArt: text fill (solid, gradient, none), outline, shadow, glow and reflection with our own styles, and Transform warps along arch, circle, wave, slant, inflate/deflate, chevron and triangle (other presets kept and drawn flat), connectors (straight, elbow, curved, arrowheads) glued to shapes' connection sites and re-routed when those move (#416). Missing: tight/through contour wrap, rotated text-box text, group (#267 open), shape effects, WordArt 3-D/bevel and editing text in its bent form, connector routing around shapes, Drawing Canvas, Icons, online pictures, screenshot. | @@ -251,6 +251,7 @@ can be used without committing Word output. | Date | Change | Summary | |---|---|---| +| 2026-10-11 | trivial | Review: Show revisions in balloons takes tracked deletions in the body out of the text into "Deleted: …" balloons (it drew like the comments-and-formatting mode before); Review row updated, no percentage change | | 2026-10-11 | trivial | Insert › SmartArt (#497): charts/SmartArt row updated; catalog 429/451 → 437/458 (95.4%); no percentage change | | 2026-10-11 | trivial | Outline view depth (#493): View row updated; catalog 428/450 → 441/463 (95.2%) with the Outlining tab rows | | 2026-10-11 | trivial | Screen readers can read the document canvas (#488): Accessibility row updated; no percentage change | diff --git a/docs/ui-parity.md b/docs/ui-parity.md index eda6b7c..3f1a1d0 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-11 · **Change:** trivial (dark-theme accent controls readable, #655; previously trivial: input borders reach 3:1, #657; previously trivial: Outlining tab and outline keys, #493; previously trivial: Customize Ribbon and the Quick Access Toolbar, #379; 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-11 · **Change:** trivial (Show revisions in balloons draws deletion balloons; previously trivial: Customize Ribbon and the Quick Access Toolbar, #379; 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 @@ -39,6 +39,7 @@ own (`AGENTS.md`: no Microsoft iconography, ever). | Date | Change | Summary | |---|---|---| +| 2026-10-11 | trivial | Track Changes Options › Show revisions in balloons works: tracked deletions in the body leave the text for "Deleted: …" balloons in the markup area, joined to where they were; inline again where no balloons show (Comments pane, Read Mode, side by side, Web Layout), and always inline in headers, footers, notes and text boxes | | 2026-10-11 | trivial | Mini toolbar in two rows like Word's (font, size, grow, shrink; B I U S, highlight, font colour, four alignments) with every button inside the bar; font and size lists drop from the box's left edge; dropdown arrows a readable size; font list entries take the theme's text colour in dark mode (#535) | | 2026-10-11 | trivial | Find and Replace: Special ▾ menu (^p, ^t, ^?, ^#, ^$, ^w, ^&, ^c … ; a shorter wildcard list), Use wildcards (`?` `*` `[a-z]` `[!x]` `{n,m}` `@` `<` `>` `( )`, `\1` in Replace with), Match prefix/suffix, Ignore punctuation and white space; `^p` matches and splits paragraphs, Replace All is one undo step (#543) | | 2026-10-11 | trivial | Update Style to Match Selection takes how the selection looks (its paragraph and character styles as well as direct formatting) and applies the style to it; Home › Styles gallery tiles get a right-click menu with Update to Match Selection and Modify… (#555) |