diff --git a/ROADMAP.md b/ROADMAP.md index 8eea5c6..5991bad 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-10 · **Change:** 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 (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 @@ -43,7 +43,7 @@ discounts and the user evidence (120 issues from outside users, 4 praise, 0 "swi merge, open and save .docx) work end to end and ship as signed builds on every desktop platform and the web. **Why not beta:** DOCX has never been tested on real-world files; charts, SmartArt and OLE objects are dropped silently; pagination differs from Word (no Aptos-metric font, no column -balancing or footnote continuation); formatting revisions are lost; there is open field evidence of +balancing or footnote continuation); move tracking and content controls are lost; there is open field evidence of a startup crash and several hangs; no native printing. Gaps #1–#7 in [`docs/gaps.md`](docs/gaps.md) are the beta list. All six core workflows pass the [alpha gate](docs/roadmap.md#alpha-gate-core-workflows) end to end on macOS, with save and reopen; @@ -131,6 +131,7 @@ Ranked, to beta (detail and remaining milestones in [docs/roadmap.md](docs/roadm | Date | What landed | |---|---| +| 2026-10-11 | Formatting revisions (#41): `w:rPrChange`/`w:pPrChange`, table/row/cell, section and numbering changes kept in DOCX; formatting recorded as revisions while tracking; accept/reject, Reviewing Pane descriptions, "Formatted: …" balloons and change bars | | 2026-10-10 | Estonian (`et`) interface (#294): 1,161 translated entries, locale and saved-preference tests, bundled-font checks; no Estonian proofing resources | | 2026-10-10 | Draw tab: pen, pencil and highlighter ink, stroke eraser, Select, Review › Hide Ink; ink saved to DOCX as DrawingML freeforms (#307); catalog 399/431 (92.6%) | | 2026-10-10 | Draw Table and Eraser (#303): a pen draws one-cell tables and splits cells along drawn lines; the eraser merges the cells beside a border. Catalog 393/431 (91.2%) | diff --git a/crates/doc/src/props.rs b/crates/doc/src/props.rs index 2dea2d8..763092f 100644 --- a/crates/doc/src/props.rs +++ b/crates/doc/src/props.rs @@ -315,6 +315,37 @@ pub struct CharProps { /// Tracked deletion: index into `Document::revisions`. #[serde(skip_serializing_if = "Option::is_none")] pub del: Option, + /// Tracked formatting change (`w:rPrChange`): the formatting before it. + #[serde(skip_serializing_if = "Option::is_none")] + pub fmt_change: Option>>, +} + +/// A tracked formatting change (ECMA-376 §17.13.5: `w:rPrChange`, `w:pPrChange`, +/// `w:tblPrChange`, `w:trPrChange`, `w:tcPrChange`, `w:sectPrChange`): the properties as they +/// were before the change, and the revision (author, date) that made it. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct PropChange { + /// Index into `Document::revisions`. + pub rev: u32, + /// The properties before the change (formatting only: no revision or link marks). + pub old: T, +} + +impl PropChange { + pub fn boxed(rev: u32, old: T) -> Option>> { + Some(Box::new(PropChange { rev, old })) + } +} + +/// A tracked change of a paragraph's list numbering (`w:numberingChange`, kept for round trip): +/// `original` is the number as it was shown before the change. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, Default)] +#[serde(default, rename_all = "camelCase")] +pub struct NumChange { + /// Index into `Document::revisions`. + pub rev: u32, + pub original: String, } macro_rules! overlay_fields { @@ -334,7 +365,7 @@ impl CharProps { pub fn overlay(&mut self, patch: &CharProps) { overlay_fields!(self, patch; style, font, size, bold, italic, underline, underline_color, strike, double_strike, color, highlight, shading, border, vert_align, caps, small_caps, hidden, spacing, scale, position, kern, outline, shadow, emboss, engrave, lang, no_proof, rtl, - cs, font_cs, size_cs, bold_cs, italic_cs, lang_bidi, link, ins, del); + cs, font_cs, size_cs, bold_cs, italic_cs, lang_bidi, link, ins, del, fmt_change); if patch.size.is_some() && patch.size_cs.is_none() { self.size_cs = None; } @@ -355,7 +386,11 @@ impl CharProps { /// Clear direct formatting but keep what isn't "formatting" (links, revisions, character style /// is cleared too, as Word's Clear Formatting does). pub fn cleared(&self) -> CharProps { - CharProps { link: self.link.clone(), ins: self.ins, del: self.del, ..Default::default() } + CharProps { link: self.link.clone(), ins: self.ins, del: self.del, fmt_change: self.fmt_change.clone(), ..Default::default() } + } + /// Just the formatting: without the link, revision marks and tracked formatting change. + pub fn formatting(&self) -> CharProps { + CharProps { link: None, ins: None, del: None, fmt_change: None, ..self.clone() } } } @@ -643,6 +678,12 @@ pub struct ParaProps { /// `w:autoSpaceDN`: automatic space between Asian text and numbers. Unset = on. #[serde(rename = "autoSpaceDN", skip_serializing_if = "Option::is_none")] pub auto_space_dn: Option, + /// Tracked change of the list numbering (`w:numberingChange`). + #[serde(skip_serializing_if = "Option::is_none")] + pub num_change: Option>, + /// Tracked formatting change (`w:pPrChange`): the paragraph properties before it. + #[serde(skip_serializing_if = "Option::is_none")] + pub fmt_change: Option>>, } impl ParaProps { @@ -658,6 +699,10 @@ impl ParaProps { pub fn is_empty(&self) -> bool { *self == ParaProps::default() } + /// Just the formatting: without the tracked changes. + pub fn formatting(&self) -> ParaProps { + ParaProps { num_change: None, fmt_change: None, ..self.clone() } + } } #[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize, Default)] @@ -739,6 +784,9 @@ pub struct TableProps { /// Floating placement (`w:tblpPr`); `None` for a table in the text flow. #[serde(skip_serializing_if = "Option::is_none")] pub float: Option, + /// Tracked formatting change (`w:tblPrChange`). + #[serde(skip_serializing_if = "Option::is_none")] + pub fmt_change: Option>>, } /// Where a floating table sits; the text after it wraps around it. @@ -804,6 +852,9 @@ pub struct RowProps { pub header: bool, /// Don't let the row break across pages. pub cant_split: bool, + /// Tracked formatting change (`w:trPrChange`). + #[serde(skip_serializing_if = "Option::is_none")] + pub fmt_change: Option>>, } #[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize, Default)] @@ -836,6 +887,9 @@ pub struct CellProps { /// Which way the cell's text runs. pub text_direction: TextDirection, pub no_wrap: bool, + /// Tracked formatting change (`w:tcPrChange`). + #[serde(skip_serializing_if = "Option::is_none")] + pub fmt_change: Option>>, } #[cfg(test)] diff --git a/crates/doc/src/resolve.rs b/crates/doc/src/resolve.rs index c12ad65..430c200 100644 --- a/crates/doc/src/resolve.rs +++ b/crates/doc/src/resolve.rs @@ -39,6 +39,8 @@ pub struct ResolvedChar { pub link: Option, pub ins: Option, pub del: Option, + /// The revision of a tracked formatting change (`w:rPrChange`) of the run. + pub fmt: Option, pub no_proof: bool, pub lang: Option, /// Right-to-left run (`w:rtl`). @@ -81,6 +83,7 @@ impl ResolvedChar { link: c.link.clone(), ins: c.ins, del: c.del, + fmt: c.fmt_change.as_ref().map(|f| f.rev), no_proof: c.no_proof.unwrap_or(false), lang: c.lang.clone(), rtl: c.rtl.unwrap_or(false), diff --git a/crates/doc/src/section.rs b/crates/doc/src/section.rs index 9095ad0..9fc1ded 100644 --- a/crates/doc/src/section.rs +++ b/crates/doc/src/section.rs @@ -296,6 +296,9 @@ pub struct SectionProps { pub page_borders: Option, /// Text direction / bidi section. pub rtl: bool, + /// Tracked change of the section's properties (`w:sectPrChange`). + #[serde(skip_serializing_if = "Option::is_none")] + pub fmt_change: Option>>, } impl Default for SectionProps { @@ -323,6 +326,7 @@ impl Default for SectionProps { valign: VAlign::Top, page_borders: None, rtl: false, + fmt_change: None, } } } diff --git a/crates/docbin/src/table.rs b/crates/docbin/src/table.rs index 1452140..22c2462 100644 --- a/crates/docbin/src/table.rs +++ b/crates/docbin/src/table.rs @@ -244,7 +244,16 @@ pub(crate) fn assemble(paras: Vec) -> Blocks { } } } - Row { props: RowProps { height: info.height, height_rule: info.height_rule, header: info.header, cant_split: info.cant_split }, cells } + Row { + props: RowProps { + height: info.height, + height_rule: info.height_rule, + header: info.header, + cant_split: info.cant_split, + fmt_change: None, + }, + cells, + } } fn flush_table(out: &mut Blocks, rows: &mut Vec, cells: &mut Vec, cell: &mut Blocks, grid: &[f32], props: &TableProps, nested: bool) { if !cells.is_empty() || !cell.is_empty() { diff --git a/crates/docx/src/read/story.rs b/crates/docx/src/read/story.rs index a6feb78..48e92f4 100644 --- a/crates/docx/src/read/story.rs +++ b/crates/docx/src/read/story.rs @@ -5,7 +5,7 @@ use std::sync::Arc; use wordcraft_doc::effects::{Glow, Shadow, ShapeEffects}; use wordcraft_doc::graphic::{Embedded, Graphic, GraphicItem, GraphicKind}; use wordcraft_doc::para::{Anchor, Float, FloatAlign, NoteKind, ShapeKind, Wrap}; -use wordcraft_doc::props::{CharProps, Rgb}; +use wordcraft_doc::props::{CharProps, NumChange, PropChange, Rgb}; use wordcraft_doc::table::{Cell, MAX_COLS, MAX_ROWS, Row, Table}; use wordcraft_doc::{Block, Blocks, InlineObject, Paragraph, PartKind, RevisionKind, Run, para_block}; @@ -250,6 +250,19 @@ impl Reader<'_> { _ => {} } } + para.mark.fmt_change = self.rpr_change(rpr); + } + // Tracked formatting changes (§17.13.5.29, §17.13.5.19): the paragraph properties + // before the change, and a change of the list numbering. + if let Some(ch) = ppr.child("w:pPrChange") { + let old = ch.child("w:pPr").map(|o| self.pc.ppr(o).0.formatting()).unwrap_or_default(); + para.props.fmt_change = PropChange::boxed(self.revision(RevisionKind::Format, ch), old); + } + if para.props.numbering.is_some() + && let Some(ch) = ppr.child("w:numPr").and_then(|n| n.child("w:numberingChange")) + { + let original = ch.attr("w:original").unwrap_or("").chars().take(256).collect(); + para.props.num_change = Some(Box::new(NumChange { rev: self.revision(RevisionKind::Format, ch), original })); } if let Some(s) = ppr.child("w:sectPr") { para.section = Some(Box::new(self.read_section(s, rels))); @@ -265,8 +278,21 @@ impl Reader<'_> { para } + /// A tracked run formatting change in `rpr` (`w:rPrChange`, §17.13.5.31): the formatting + /// before it. A change nested inside it is ignored. + fn rpr_change(&mut self, rpr: &El) -> Option>> { + let ch = rpr.child("w:rPrChange")?; + let old = ch.child("w:rPr").map(|o| self.pc.rpr(o).formatting()).unwrap_or_default(); + PropChange::boxed(self.revision(RevisionKind::Format, ch), old) + } + pub fn read_section(&mut self, s: &El, rels: &Rels) -> wordcraft_doc::SectionProps { let (mut sp, refs) = sectpr(s); + // §17.13.5.32: the section's properties before a tracked change (no header references). + if let Some(ch) = s.child("w:sectPrChange") { + let old = ch.child("w:sectPr").map(|o| sectpr(o).0).unwrap_or_default(); + sp.fmt_change = PropChange::boxed(self.revision(RevisionKind::Format, ch), old); + } for r in refs { let Some(rel) = rels.by_id(&r.rid) else { continue }; if rel.external { @@ -368,11 +394,12 @@ impl Reader<'_> { } } - fn run_props(&self, rpr: &El, ctx: &RunCtx) -> CharProps { + fn run_props(&mut self, rpr: &El, ctx: &RunCtx) -> CharProps { let mut p = self.pc.rpr(rpr); p.link = ctx.link.clone(); p.ins = ctx.ins; p.del = ctx.del; + p.fmt_change = self.rpr_change(rpr); p } fn run_props_none(&self, ctx: &RunCtx) -> CharProps { @@ -935,6 +962,10 @@ impl Reader<'_> { let mut table = Table::default(); if let Some(p) = t.child("w:tblPr") { table.props = self.pc.tblpr(p); + if let Some(ch) = p.child("w:tblPrChange") { + let old = ch.child("w:tblPr").map(|o| self.pc.tblpr(o)).unwrap_or_default(); + table.props.fmt_change = PropChange::boxed(self.revision(RevisionKind::Format, ch), old); + } } if let Some(g) = t.child("w:tblGrid") { table.grid = g @@ -947,11 +978,19 @@ impl Reader<'_> { collect(t, "w:tr", &mut rows, 0); for tr in rows.into_iter().take(MAX_ROWS) { let mut row = Row { props: tr.child("w:trPr").map(trpr).unwrap_or_default(), cells: Vec::new() }; + if let Some(ch) = tr.child("w:trPr").and_then(|p| p.child("w:trPrChange")) { + let old = ch.child("w:trPr").map(trpr).unwrap_or_default(); + row.props.fmt_change = PropChange::boxed(self.revision(RevisionKind::Format, ch), old); + } let mut tcs = Vec::new(); collect(tr, "w:tc", &mut tcs, 0); for tc in tcs.into_iter().take(MAX_COLS) { - let (props, hcont) = + let (mut props, hcont) = tc.child("w:tcPr").map(tcpr).unwrap_or_else(|| (wordcraft_doc::props::CellProps { span: 1, ..Default::default() }, false)); + if let Some(ch) = tc.child("w:tcPr").and_then(|p| p.child("w:tcPrChange")) { + let old = ch.child("w:tcPr").map(|o| tcpr(o).0).unwrap_or_default(); + props.fmt_change = PropChange::boxed(self.revision(RevisionKind::Format, ch), old); + } if hcont && let Some(prev) = row.cells.last_mut() { prev.props.span = (prev.props.span + props.span.max(1)).min(63); continue; diff --git a/crates/docx/src/write/mod.rs b/crates/docx/src/write/mod.rs index 439d964..a48ddee 100644 --- a/crates/docx/src/write/mod.rs +++ b/crates/docx/src/write/mod.rs @@ -576,7 +576,7 @@ fn styles_xml(doc: &Document) -> Vec { w.close("w:rPrDefault"); w.open("w:pPrDefault", &[]); w.open("w:pPr", &[]); - props::ppr_inner(&mut w, &s.default_para, false); + props::ppr_inner(&mut w, &s.default_para, false, None); w.close("w:pPr"); w.close("w:pPrDefault"); w.close("w:docDefaults"); @@ -631,7 +631,7 @@ fn style_xml(w: &mut W, st: &Style) { } if !st.para.is_empty() && st.kind != StyleKind::Character { w.open("w:pPr", &[]); - props::ppr_inner(w, &st.para, false); + props::ppr_inner(w, &st.para, false, None); w.close("w:pPr"); } if props::has_rpr(&st.chr) && st.kind != StyleKind::Numbering { diff --git a/crates/docx/src/write/props.rs b/crates/docx/src/write/props.rs index 9d7948b..18ef59c 100644 --- a/crates/docx/src/write/props.rs +++ b/crates/docx/src/write/props.rs @@ -108,13 +108,27 @@ pub fn rpr_inner(w: &mut W, c: &CharProps) { } } -/// Does `c` produce any `w:rPr` content? +/// Does `c` produce any `w:rPr` content (besides a tracked change)? pub fn has_rpr(c: &CharProps) -> bool { - let mut x = c.clone(); - x.link = None; - x.ins = None; - x.del = None; - !x.is_empty() + !c.formatting().is_empty() +} + +/// Attributes of a tracked change element: `w:id`, `w:author`, `w:date`. +pub type ChangeAttrs = Vec<(&'static str, String)>; + +/// Open a tracked change element (`w:rPrChange`, `w:pPrChange`, `w:tblPrChange`…). +pub fn open_change(w: &mut W, tag: &str, a: &ChangeAttrs) { + let refs: Vec<(&str, &str)> = a.iter().map(|(k, v)| (*k, v.as_str())).collect(); + w.open(tag, &refs); +} + +/// A tracked change element holding the properties before the change: `…`. +fn change_el(w: &mut W, tag: &str, inner: &str, a: &ChangeAttrs, body: impl FnOnce(&mut W)) { + open_change(w, tag, a); + w.open(inner, &[]); + body(w); + w.close(inner); + w.close(tag); } pub fn rpr(w: &mut W, c: &CharProps) { @@ -173,7 +187,9 @@ pub fn borders(w: &mut W, tag: &str, b: &Borders, inner: Option<&str>, extra: &[ } /// `w:pPr` content except the paragraph mark `w:rPr` and `w:sectPr`, which the caller appends. -pub fn ppr_inner(w: &mut W, p: &ParaProps, framed: bool) { +/// `w:pPr` content (without the wrapper); `num_change` are the attributes of a tracked list +/// numbering change (`w:numberingChange`, written in `w:numPr`). +pub fn ppr_inner(w: &mut W, p: &ParaProps, framed: bool, num_change: Option<&ChangeAttrs>) { if let Some(s) = &p.style { w.val("w:pStyle", s); } @@ -191,6 +207,10 @@ pub fn ppr_inner(w: &mut W, p: &ParaProps, framed: bool) { w.open("w:numPr", &[]); w.val("w:ilvl", &n(nr.level.min(8) as i64)); w.val("w:numId", &n(nr.num as i64)); + if let Some(a) = num_change { + let refs: Vec<(&str, &str)> = a.iter().map(|(k, v)| (*k, v.as_str())).collect(); + w.empty("w:numberingChange", &refs); + } w.close("w:numPr"); } toggle(w, "w:suppressLineNumbers", p.suppress_line_numbers); @@ -323,8 +343,17 @@ pub fn look_attrs(l: &TableLook) -> Vec<(&'static str, String)> { ] } -pub fn tblpr(w: &mut W, t: &TableProps) { +/// `w:tblPr`, with the properties before a tracked change last when `change` gives its attributes. +pub fn tblpr(w: &mut W, t: &TableProps, change: Option<&ChangeAttrs>) { w.open("w:tblPr", &[]); + tblpr_inner(w, t); + if let (Some(a), Some(ch)) = (change, &t.fmt_change) { + change_el(w, "w:tblPrChange", "w:tblPr", a, |w| tblpr_inner(w, &ch.old)); + } + w.close("w:tblPr"); +} + +fn tblpr_inner(w: &mut W, t: &TableProps) { if let Some(s) = &t.style { w.val("w:tblStyle", s); } @@ -362,14 +391,23 @@ pub fn tblpr(w: &mut W, t: &TableProps) { if let Some(c) = &t.caption { w.val("w:tblCaption", c); } - w.close("w:tblPr"); } -pub fn trpr(w: &mut W, r: &RowProps) { - if r.height.is_none() && !r.header && !r.cant_split { +/// `w:trPr` (when the row has any), with a tracked change last as for [`tblpr`]. +pub fn trpr(w: &mut W, r: &RowProps, change: Option<&ChangeAttrs>) { + let change = change.zip(r.fmt_change.as_ref()); + if r.height.is_none() && !r.header && !r.cant_split && change.is_none() { return; } w.open("w:trPr", &[]); + trpr_inner(w, r); + if let Some((a, ch)) = change { + change_el(w, "w:trPrChange", "w:trPr", a, |w| trpr_inner(w, &ch.old)); + } + w.close("w:trPr"); +} + +fn trpr_inner(w: &mut W, r: &RowProps) { if r.cant_split { w.empty("w:cantSplit", &[]); } @@ -384,11 +422,19 @@ pub fn trpr(w: &mut W, r: &RowProps) { if r.header { w.empty("w:tblHeader", &[]); } - w.close("w:trPr"); } -pub fn tcpr(w: &mut W, c: &CellProps) { +/// `w:tcPr`, with a tracked change last as for [`tblpr`]. +pub fn tcpr(w: &mut W, c: &CellProps, change: Option<&ChangeAttrs>) { w.open("w:tcPr", &[]); + tcpr_inner(w, c); + if let (Some(a), Some(ch)) = (change, &c.fmt_change) { + change_el(w, "w:tcPrChange", "w:tcPr", a, |w| tcpr_inner(w, &ch.old)); + } + w.close("w:tcPr"); +} + +fn tcpr_inner(w: &mut W, c: &CellProps) { match (c.width, c.width_pct) { (Some(v), _) => w.empty("w:tcW", &[("w:w", &twips(v.max(0.0))), ("w:type", "dxa")]), (None, Some(p)) => w.empty("w:tcW", &[("w:w", &n((p.clamp(0.0, 100.0) * 50.0).round() as i64)), ("w:type", "pct")]), @@ -422,7 +468,6 @@ pub fn tcpr(w: &mut W, c: &CellProps) { VAlign::Center => w.val("w:vAlign", "center"), VAlign::Bottom => w.val("w:vAlign", "bottom"), } - w.close("w:tcPr"); } /// `w:tblpPr` and `w:tblOverlap` for a floating table. diff --git a/crates/docx/src/write/story.rs b/crates/docx/src/write/story.rs index 93f329a..0144881 100644 --- a/crates/docx/src/write/story.rs +++ b/crates/docx/src/write/story.rs @@ -2,12 +2,12 @@ use wordcraft_doc::effects::ShapeEffects; use wordcraft_doc::para::{Anchor, Float, NoteKind, ShapeKind, Wrap}; -use wordcraft_doc::props::{CharProps, Rgb}; +use wordcraft_doc::props::{CharProps, PropChange, Rgb}; use wordcraft_doc::section::{LineNumberRestart, SectionProps, SectionStart}; use wordcraft_doc::table::Table; use wordcraft_doc::{Block, Blocks, InlineObject, Paragraph, RevisionKind}; -use super::props::{borders, ppr_inner, rpr, rpr_inner, tblpr, tcpr, trpr}; +use super::props::{ChangeAttrs, borders, open_change, ppr_inner, rpr, rpr_inner, tblpr, tcpr, trpr}; use super::{PartRels, Writer}; use crate::package::rt; use crate::units::{emu, n, twips}; @@ -100,10 +100,15 @@ impl Writer<'_> { .into_iter() .filter_map(|(idx, tag)| idx.filter(|i| self.rev_kind(Some(*i)).is_some_and(|k| k != RevisionKind::Format)).map(|i| (i, tag))) .collect(); - let has_mark = super::props::has_rpr(&p.mark) || !mark_revs.is_empty(); + let has_mark = super::props::has_rpr(&p.mark) || !mark_revs.is_empty() || p.mark.fmt_change.is_some(); if !pp.is_empty() || has_mark || section.is_some() { w.open("w:pPr", &[]); - ppr_inner(w, &pp, framed); + let num_change = pp.num_change.as_ref().map(|c| { + let mut a = self.change_attrs(c.rev); + a.push(("w:original", c.original.clone())); + a + }); + ppr_inner(w, &pp, framed, num_change.as_ref()); if has_mark { w.open("w:rPr", &[]); for (idx, tag) in mark_revs { @@ -115,11 +120,23 @@ impl Writer<'_> { } } rpr_inner(w, &p.mark); + if let Some(ch) = p.mark.fmt_change.as_deref() { + self.rpr_change(w, ch); + } w.close("w:rPr"); } if let Some(s) = section { self.sectpr(w, s, rels); } + // The paragraph properties before a tracked change come last (§17.13.5.29). + if let Some(ch) = &pp.fmt_change { + let a = self.change_attrs(ch.rev); + open_change(w, "w:pPrChange", &a); + w.open("w:pPr", &[]); + ppr_inner(w, &ch.old, false, None); + w.close("w:pPr"); + w.close("w:pPrChange"); + } w.close("w:pPr"); } // A note's first paragraph starts with its reference mark. When the paragraph holds the @@ -226,7 +243,7 @@ impl Writer<'_> { let del = self.is_del(props); self.rev_open(w, props); w.open("w:r", &[]); - rpr(w, props); + self.rpr(w, props); for (pc, _) in pieces.get(i..j).unwrap_or(&[]) { match pc { Piece::Text(t) if !t.is_empty() => w.leaf(if del { "w:delText" } else { "w:t" }, &[("xml:space", "preserve")], t), @@ -265,6 +282,34 @@ impl Writer<'_> { (self.next_rev_id.to_string(), author, date) } + /// `w:id`, `w:author` and `w:date` of a tracked change by revision `idx`. + fn change_attrs(&mut self, idx: u32) -> ChangeAttrs { + let (id, author, date) = self.rev_attrs(idx); + let mut a = vec![("w:id", id), ("w:author", author)]; + if !date.is_empty() { + a.push(("w:date", date)); + } + a + } + + /// A run's `w:rPr`, with its tracked formatting change last (§17.13.5.31). + fn rpr(&mut self, w: &mut W, c: &CharProps) { + let Some(ch) = c.fmt_change.as_deref() else { return rpr(w, c) }; + w.open("w:rPr", &[]); + rpr_inner(w, c); + self.rpr_change(w, ch); + w.close("w:rPr"); + } + + fn rpr_change(&mut self, w: &mut W, ch: &PropChange) { + let a = self.change_attrs(ch.rev); + open_change(w, "w:rPrChange", &a); + w.open("w:rPr", &[]); + rpr_inner(w, &ch.old); + w.close("w:rPr"); + w.close("w:rPrChange"); + } + fn rev_open(&mut self, w: &mut W, props: &CharProps) { for (idx, tag) in [(props.ins, "w:ins"), (props.del, "w:del")] { let Some(idx) = idx else { continue }; @@ -345,7 +390,7 @@ impl Writer<'_> { } self.rev_open(w, props); w.open("w:r", &[]); - rpr(w, &p); + self.rpr(w, &p); w.empty(mark, &[]); w.close("w:r"); self.rev_close(w, props); @@ -359,7 +404,7 @@ impl Writer<'_> { } self.rev_open(w, props); w.open("w:r", &[]); - rpr(w, &p); + self.rpr(w, &p); let tag = if foot { "w:footnoteReference" } else { "w:endnoteReference" }; if custom.is_empty() { w.empty(tag, &[("w:id", &nid)]); @@ -377,7 +422,7 @@ impl Writer<'_> { let Some(inner) = self.embedded_xml(src, rels, None) else { return }; self.rev_open(w, props); w.open("w:r", &[]); - rpr(w, props); + self.rpr(w, props); w.open("w:drawing", &[]); let docpr = self.next_docpr(); let name = match graphic.kind { @@ -399,7 +444,7 @@ impl Writer<'_> { { self.rev_open(w, props); w.open("w:r", &[]); - rpr(w, props); + self.rpr(w, props); w.raw(&obj); w.close("w:r"); self.rev_close(w, props); @@ -408,7 +453,7 @@ impl Writer<'_> { let Some(file) = self.media_files.get(media).cloned() else { return }; self.rev_open(w, props); w.open("w:r", &[]); - rpr(w, props); + self.rpr(w, props); w.open("w:drawing", &[]); let docpr = self.next_docpr(); let name = format!("Picture {docpr}"); @@ -427,7 +472,7 @@ impl Writer<'_> { InlineObject::Shape { kind, w: sw, h: sh, float, effects, freeform, .. } => { self.rev_open(w, props); w.open("w:r", &[]); - rpr(w, props); + self.rpr(w, props); w.open("w:drawing", &[]); let docpr = self.next_docpr(); // Ink is told apart by its name (and its pen), which reading looks for. @@ -453,7 +498,7 @@ impl Writer<'_> { InlineObject::Group { w: gw, h: gh, float, ch_w, ch_h, children } => { self.rev_open(w, props); w.open("w:r", &[]); - rpr(w, props); + self.rpr(w, props); w.open("w:drawing", &[]); let docpr = self.next_docpr(); let name = format!("Group {docpr}"); @@ -502,7 +547,7 @@ impl Writer<'_> { } else if !text.is_empty() { self.rev_open(w, props); w.open("w:r", &[]); - rpr(w, props); + self.rpr(w, props); w.leaf("w:t", &[("xml:space", "preserve")], text); w.close("w:r"); self.rev_close(w, props); @@ -782,7 +827,8 @@ impl Writer<'_> { return; } w.open("w:tbl", &[]); - tblpr(w, &t.props); + let chg = t.props.fmt_change.as_ref().map(|c| self.change_attrs(c.rev)); + tblpr(w, &t.props, chg.as_ref()); w.open("w:tblGrid", &[]); let cols = t.cols().clamp(1, 63); for g in 0..cols { @@ -792,10 +838,12 @@ impl Writer<'_> { w.close("w:tblGrid"); for r in rows { w.open("w:tr", &[]); - trpr(w, &r.props); + let chg = r.props.fmt_change.as_ref().map(|c| self.change_attrs(c.rev)); + trpr(w, &r.props, chg.as_ref()); for c in &r.cells { w.open("w:tc", &[]); - tcpr(w, &c.props); + let chg = c.props.fmt_change.as_ref().map(|c| self.change_attrs(c.rev)); + tcpr(w, &c.props, chg.as_ref()); self.blocks(w, &c.blocks, rels, false, depth + 1); w.close("w:tc"); } @@ -814,6 +862,23 @@ impl Writer<'_> { } } } + sectpr_body(w, s); + // The properties before a tracked change come last (§17.13.5.32). + if let Some(ch) = &s.fmt_change { + let a = self.change_attrs(ch.rev); + open_change(w, "w:sectPrChange", &a); + w.open("w:sectPr", &[]); + sectpr_body(w, &ch.old); + w.close("w:sectPr"); + w.close("w:sectPrChange"); + } + w.close("w:sectPr"); + } +} + +/// `w:sectPr` content after the header and footer references. +fn sectpr_body(w: &mut W, s: &SectionProps) { + { let start = match s.start { SectionStart::NextPage => "nextPage", SectionStart::Continuous => "continuous", @@ -898,7 +963,6 @@ impl Writer<'_> { if s.rtl { w.empty("w:bidi", &[]); } - w.close("w:sectPr"); } } diff --git a/crates/docx/tests/fixtures.rs b/crates/docx/tests/fixtures.rs index e0a76de..6c7adc0 100644 --- a/crates/docx/tests/fixtures.rs +++ b/crates/docx/tests/fixtures.rs @@ -1016,3 +1016,62 @@ fn rotated_ole_objects_round_trip() { assert_eq!(spin(o), wordcraft_geom::Spin::new(90.0, true, false)); assert_eq!(o.frame().unwrap().2.effect, [0.0; 4], "the rotated overhang is not kept as effects room"); } + +/// Issue #41: tracked formatting changes (ECMA-376 §17.13.5) survive open and save — run, +/// paragraph mark, paragraph, numbering, table, row, cell and section — each written last in +/// its parent, holding the properties before the change. +#[test] +fn formatting_revisions_round_trip() { + let body = r#" +Plain, bold by Alice end. +Centred + +cell + +"#; + let d = read_body(body); + let rev = |i: u32| d.revisions.get(i as usize).map(|r| (r.kind, r.author.as_str(), r.date.as_str())); + let p = paras(&d); + let runs: Vec<_> = p[0].run_ranges().map(|(r, c)| (r, c.clone())).collect(); + assert_eq!(runs.len(), 3); + let alice = runs[1].1.fmt_change.as_deref().expect("Alice's rPrChange"); + assert_eq!(rev(alice.rev), Some((wordcraft_doc::RevisionKind::Format, "Alice", "2026-10-01T00:00:00Z"))); + assert_eq!((runs[1].1.bold, alice.old.bold), (Some(true), None)); + let bob = runs[2].1.fmt_change.as_deref().expect("Bob's rPrChange"); + assert_eq!((bob.old.italic, bob.old.size, bob.old.fmt_change.is_none()), (Some(true), Some(12.0), true), "a nested change is dropped"); + assert!(runs[0].1.fmt_change.is_none()); + let pc = p[1].props.fmt_change.as_deref().expect("pPrChange"); + assert_eq!((p[1].props.align, pc.old.align, pc.old.indent_left), (Some(Align::Center), None, Some(36.0))); + assert_eq!(p[1].props.num_change.as_deref().map(|n| n.original.as_str()), Some("1.")); + assert!(p[1].mark.fmt_change.is_some()); + let Some(Block::Table(t)) = d.body.get(2).map(|b| &**b) else { panic!("table") }; + assert_eq!(t.props.fmt_change.as_deref().map(|c| c.old.align), Some(None)); + assert_eq!(t.rows[0].props.fmt_change.as_deref().map(|c| c.old.cant_split), Some(false)); + assert_eq!(t.rows[0].cells[0].props.fmt_change.as_deref().map(|c| c.old.shading), Some(None)); + let sc = d.last_section.fmt_change.as_deref().expect("sectPrChange"); + assert_eq!((d.last_section.landscape, sc.old.landscape), (true, false)); + assert_eq!(rev(sc.rev).map(|r| r.1), Some("Carol")); + + // Written back: each change is the last child of its parent, and reads back the same. + let bytes = wordcraft_docx::write(&d).unwrap(); + let mut zip = zip::ZipArchive::new(std::io::Cursor::new(&bytes)).unwrap(); + let mut xml = String::new(); + std::io::Read::read_to_string(&mut zip.by_name("word/document.xml").unwrap(), &mut xml).unwrap(); + for (tag, parent) in [ + ("w:rPrChange", ""), + ("w:pPrChange", ""), + ("w:tblPrChange", ""), + ("w:trPrChange", ""), + ("w:tcPrChange", ""), + ("w:sectPrChange", ""), + ] { + assert!(xml.contains(&format!("<{tag} w:id=")), "{tag}: {xml}"); + assert!(xml.contains(parent), "{tag} last: {xml}"); + } + assert!(xml.contains(r#""#), "{xml}"); + assert!(xml.contains(r#" bool { + !(id.starts_with("review.accept") + || id.starts_with("review.reject") + || matches!(id, "review.compare" | "review.combine" | "edit.undo" | "edit.redo") + || id.starts_with("file.")) +} + +/// Tables nested deeper than this aren't compared (hostile documents). +const MAX_DEPTH: usize = 16; + +struct Recorder<'a> { + author: String, + date: String, + before: &'a [Revision], + revisions: &'a mut Vec, + rid: Option, +} + +impl Recorder<'_> { + /// The author's formatting revision for this command (made on first use). + fn rid(&mut self) -> u32 { + if let Some(r) = self.rid { + return r; + } + let found = self.revisions.iter().rposition(|r| r.kind == RevisionKind::Format && r.author == self.author && r.date == self.date); + let r = match found { + Some(i) => i as u32, + None => { + self.revisions.push(Revision { kind: RevisionKind::Format, author: self.author.clone(), date: self.date.clone() }); + self.revisions.len().saturating_sub(1) as u32 + } + }; + self.rid = Some(r); + r + } + + fn own(&self, rev: u32) -> bool { + self.before.get(rev as usize).is_some_and(|r| r.author == self.author) + } + + /// The tracked change `after` should carry, given the props `before` the command (`f` strips + /// everything that isn't formatting); `None` when it already carries the right one. + #[allow(clippy::type_complexity, clippy::option_option)] + fn change( + &mut self, + before: &T, + after: &T, + f: fn(&T) -> T, + get: fn(&T) -> &Option>>, + ) -> Option>>> { + let (fb, fa) = (f(before), f(after)); + let (had, has) = (get(before), get(after)); + let want = if fb == fa { + // Not formatted by this command: keep the change it carries (or carried). + if has.is_some() { + return None; + } + had.clone() + } else { + // The author's own earlier change keeps the formatting before it; another author's + // is superseded by this one. + let own = had.as_ref().filter(|c| self.own(c.rev)); + let old = own.map(|c| c.old.clone()).unwrap_or(fb); + if old == fa { + None + } else { + let rev = own.map(|c| c.rev).unwrap_or_else(|| self.rid()); + PropChange::boxed(rev, old) + } + }; + (want != *has).then_some(want) + } + + /// Record the formatting changes between `b` and `a` (the same text) into `a`. + fn para(&mut self, b: &Paragraph, a: &mut Paragraph) { + let mut runs = Vec::new(); + let (rb, ra): (Vec<_>, Vec<_>) = (b.run_ranges().collect(), a.run_ranges().collect()); + let (mut i, mut j) = (0, 0); + while let (Some((x, pb)), Some((y, pa))) = (rb.get(i), ra.get(j)) { + let (from, to) = (x.start.max(y.start), x.end.min(y.end)); + // Formatting text the author is inserting themselves changes the insertion, as in Word. + let own_insert = pa.ins.and_then(|i| self.revisions.get(i as usize)).is_some_and(|r| r.author == self.author); + if from < to + && !own_insert + && let Some(want) = self.change(*pb, *pa, CharProps::formatting, |c| &c.fmt_change) + { + runs.push((from, to, want)); + } + if x.end <= y.end { + i += 1; + } + if y.end <= x.end { + j += 1; + } + } + let mark = self.change(&b.mark, &a.mark, CharProps::formatting, |c| &c.fmt_change); + let props = self.change(&b.props, &a.props, ParaProps::formatting, |p| &p.fmt_change); + if runs.is_empty() && mark.is_none() && props.is_none() { + return; + } + for (from, to, want) in runs { + let _ = a.format(from, to, &|c| c.fmt_change = want.clone()); + } + if let Some(m) = mark { + a.mark.fmt_change = m; + } + if let Some(p) = props { + a.props.fmt_change = p; + } + a.normalize(); + a.touch(); + } + + fn blocks(&mut self, before: &Blocks, after: &mut Blocks, depth: usize) { + if before.len() != after.len() || depth > MAX_DEPTH { + return; + } + for (b, a) in before.iter().zip(after.iter_mut()) { + if Arc::ptr_eq(b, a) { + continue; + } + match (&**b, &**a) { + (Block::Para(pb), Block::Para(pa)) if pb.text == pa.text => { + if let Block::Para(pa) = Arc::make_mut(a) { + self.para(pb, pa); + } + } + (Block::Table(tb), Block::Table(ta)) if tb.rows.len() == ta.rows.len() => { + let Block::Table(ta) = Arc::make_mut(a) else { continue }; + for (rb, ra) in tb.rows.iter().zip(ta.rows.iter_mut()) { + if rb.cells.len() != ra.cells.len() { + continue; + } + for (cb, ca) in rb.cells.iter().zip(ra.cells.iter_mut()) { + self.blocks(&cb.blocks, &mut ca.blocks, depth + 1); + } + } + } + _ => {} + } + } + } +} + +/// Keep the formatting changes the command `id` made to `s.doc` (compared with `before`) as +/// tracked formatting changes, when Track Changes and Track Formatting are on. +pub(crate) fn record(s: &mut Session, id: &str, before: &Document) { + if !s.doc.settings.track_changes || !s.prefs.markup.track_formatting || !records(id) { + return; + } + let author = s.author.clone(); + let doc = &mut s.doc; + let mut r = Recorder { author, date: super::now_iso(), before: &before.revisions, revisions: &mut doc.revisions, rid: None }; + r.blocks(&before.body, &mut doc.body, 0); + for (k, pb) in &before.parts { + if let Some(pa) = doc.parts.get_mut(k) { + r.blocks(&pb.blocks, &mut pa.blocks, 0); + } + } +} + +/// The properties a change sets, as the Style Inspector lists them: `[{"prop", "value"}]` in +/// field order; a switch turned off is `false`, a value removed is `null`. +pub fn diff(old: &T, new: &T) -> Vec { + let (Ok(Value::Object(o)), Ok(Value::Object(n))) = (serde_json::to_value(old), serde_json::to_value(new)) else { return Vec::new() }; + let mut out = Vec::new(); + for (k, v) in &n { + if o.get(k) != Some(v) { + out.push(json!({"prop": k, "value": v})); + } + } + for (k, v) in &o { + if !n.contains_key(k) { + out.push(json!({"prop": k, "value": if v.is_boolean() { json!(false) } else { Value::Null }})); + } + } + out +} + +/// The properties a run's (or paragraph mark's) change set. +pub fn char_diff(c: &CharProps) -> Vec { + c.fmt_change.as_ref().map(|ch| diff(&ch.old.formatting(), &c.formatting())).unwrap_or_default() +} + +/// The properties a paragraph's change set. +pub fn para_diff(p: &ParaProps) -> Vec { + p.fmt_change.as_ref().map(|ch| diff(&ch.old.formatting(), &p.formatting())).unwrap_or_default() +} + +/// A section's change: the properties it set. +pub fn section_diff(s: &SectionProps) -> Vec { + let strip = |s: &SectionProps| SectionProps { fmt_change: None, headers: Default::default(), footers: Default::default(), ..s.clone() }; + s.fmt_change.as_ref().map(|ch| diff(&strip(&ch.old), &strip(s))).unwrap_or_default() +} + +/// "Formatted: Bold, Not italic, Size: 14" — the change in plain English, for agents. +pub fn describe(props: &[Value]) -> String { + let items: Vec = props + .iter() + .map(|d| { + let label = words(d["prop"].as_str().unwrap_or("")); + match &d["value"] { + Value::Bool(true) => label, + Value::Bool(false) => format!("Not {}", label.to_lowercase()), + Value::Null => format!("{label}: (default)"), + Value::String(s) => format!("{label}: {s}"), + Value::Number(n) => { + let x = n.as_f64().filter(|x| x.is_finite()).unwrap_or(0.0); + let t = format!("{x:.2}"); + format!("{label}: {}", t.trim_end_matches('0').trim_end_matches('.')) + } + v => format!("{label}: {v}"), + } + }) + .collect(); + if items.is_empty() { "Formatted".into() } else { format!("Formatted: {}", items.join(", ")) } +} + +/// `indentLeft` → `Indent left`. +fn words(s: &str) -> String { + let mut out = String::with_capacity(s.len() + 4); + for (i, c) in s.chars().take(64).enumerate() { + if i == 0 { + out.extend(c.to_uppercase()); + } else if c.is_uppercase() { + out.push(' '); + out.extend(c.to_lowercase()); + } else { + out.push(c); + } + } + out +} + +/// Accept (drop the record) or reject (restore the old formatting) a run's change. +pub fn resolve_char(c: &mut CharProps, accept: bool) { + let Some(ch) = c.fmt_change.take() else { return }; + if !accept { + *c = CharProps { link: c.link.take(), ins: c.ins, del: c.del, ..ch.old.formatting() }; + } +} + +/// Accept or reject a paragraph's change (its list numbering change goes with it). +pub fn resolve_para(p: &mut ParaProps, accept: bool) { + p.num_change = None; + let Some(ch) = p.fmt_change.take() else { return }; + if !accept { + *p = ch.old.formatting(); + } +} + +/// Accept or reject every table, row, cell and section change in `blocks` (paragraph changes +/// are resolved with their text). +pub fn resolve_containers(blocks: &mut Blocks, accept: bool, depth: usize) { + if depth > MAX_DEPTH { + return; + } + for b in blocks.iter_mut() { + let needs = match &**b { + Block::Para(p) => p.section.as_ref().is_some_and(|s| s.fmt_change.is_some()), + Block::Table(_) => true, + }; + if !needs { + continue; + } + match Arc::make_mut(b) { + Block::Para(p) => { + if let Some(s) = p.section.as_deref_mut() { + resolve_section(s, accept); + p.touch(); + } + } + Block::Table(t) => { + resolve_table(t, accept); + for r in &mut t.rows { + resolve_row(r, accept); + for c in &mut r.cells { + resolve_cell(c, accept); + resolve_containers(&mut c.blocks, accept, depth + 1); + } + } + } + } + } +} + +/// Accept or reject a table's own change (not its rows' or cells'). +pub fn resolve_table(t: &mut Table, accept: bool) { + if let Some(ch) = t.props.fmt_change.take().filter(|_| !accept) { + t.props = TableProps { fmt_change: None, ..ch.old }; + } +} + +/// Accept or reject a row's change. +pub fn resolve_row(r: &mut Row, accept: bool) { + if let Some(ch) = r.props.fmt_change.take().filter(|_| !accept) { + r.props = RowProps { fmt_change: None, ..ch.old }; + } +} + +/// Accept or reject a cell's change. Merges are the table's structure, not formatting: they stay. +pub fn resolve_cell(c: &mut Cell, accept: bool) { + if let Some(ch) = c.props.fmt_change.take().filter(|_| !accept) { + c.props = CellProps { fmt_change: None, span: c.props.span, vmerge: c.props.vmerge, ..ch.old }; + } +} + +/// Accept or reject a section's change (its headers and footers stay). +pub fn resolve_section(s: &mut SectionProps, accept: bool) { + let Some(ch) = s.fmt_change.take() else { return }; + if !accept { + let (headers, footers) = (std::mem::take(&mut s.headers), std::mem::take(&mut s.footers)); + *s = SectionProps { headers, footers, fmt_change: None, ..ch.old }; + } +} + +/// A table's, row's or cell's change: the properties it set. +pub fn table_diff(t: &TableProps) -> Vec { + t.fmt_change + .as_ref() + .map(|ch| diff(&TableProps { fmt_change: None, ..ch.old.clone() }, &TableProps { fmt_change: None, ..t.clone() })) + .unwrap_or_default() +} +pub fn row_diff(r: &RowProps) -> Vec { + r.fmt_change + .as_ref() + .map(|ch| diff(&RowProps { fmt_change: None, ..ch.old.clone() }, &RowProps { fmt_change: None, ..r.clone() })) + .unwrap_or_default() +} +pub fn cell_diff(c: &CellProps) -> Vec { + c.fmt_change + .as_ref() + .map(|ch| diff(&CellProps { fmt_change: None, ..ch.old.clone() }, &CellProps { fmt_change: None, ..c.clone() })) + .unwrap_or_default() +} diff --git a/crates/engine/src/cmd/inspector.rs b/crates/engine/src/cmd/inspector.rs index 7bfd18e..9aae42f 100644 --- a/crates/engine/src/cmd/inspector.rs +++ b/crates/engine/src/cmd/inspector.rs @@ -13,9 +13,9 @@ use crate::{CmdError, CmdResult, CommandSpec, Session, p}; pub const LEVELS: [&str; 4] = ["paragraphStyle", "paragraphFormatting", "characterStyle", "characterFormatting"]; /// Direct character properties that aren't formatting (links, revisions, proofing, direction). -const CHAR_SKIP: [&str; 7] = ["style", "link", "ins", "del", "lang", "noProof", "rtl"]; +const CHAR_SKIP: [&str; 8] = ["style", "link", "ins", "del", "fmtChange", "lang", "noProof", "rtl"]; /// Direct paragraph properties the inspector doesn't list (lists have their own UI). -const PARA_SKIP: [&str; 2] = ["style", "numbering"]; +const PARA_SKIP: [&str; 4] = ["style", "numbering", "numChange", "fmtChange"]; pub fn specs() -> Vec { vec![ diff --git a/crates/engine/src/cmd/mod.rs b/crates/engine/src/cmd/mod.rs index 7a7965b..38a10e6 100644 --- a/crates/engine/src/cmd/mod.rs +++ b/crates/engine/src/cmd/mod.rs @@ -8,6 +8,7 @@ pub mod draw; pub mod edit; pub mod equation; pub mod file; +pub mod fmt_revisions; pub mod format; pub mod insert; pub mod inspector; @@ -181,6 +182,8 @@ pub(crate) fn track_delete(s: &mut Session, a: &Pos, b: &Pos) -> Result Result<(), CmdError> { let mut props = s.typing_props(); delete_selection(s)?; + // Typed text is new: never deleted, and not a formatting change of the text around it. + props.fmt_change = None; if s.doc.settings.track_changes { props.ins = Some(new_revision(s, RevisionKind::Insert)); props.del = None; diff --git a/crates/engine/src/cmd/para.rs b/crates/engine/src/cmd/para.rs index 1093f24..51f5995 100644 --- a/crates/engine/src/cmd/para.rs +++ b/crates/engine/src/cmd/para.rs @@ -655,6 +655,8 @@ fn create_style(s: &mut Session, v: &Value) -> CmdResult { st.chr.link = None; st.chr.ins = None; st.chr.del = None; + st.chr.fmt_change = None; + st.para = st.para.formatting(); } s.doc.styles.upsert(st); apply_style(s, &json!({"style": id}))?; @@ -699,7 +701,8 @@ fn update_to_match(s: &mut Session, v: &Value) -> CmdResult { chr.link = None; chr.ins = None; chr.del = None; - let mut pp = para.props.clone(); + chr.fmt_change = None; + let mut pp = para.props.formatting(); pp.style = None; pp.numbering = None; if let Some(st) = s.doc.styles.get_mut(&id) { diff --git a/crates/engine/src/cmd/paste.rs b/crates/engine/src/cmd/paste.rs index ad1ba09..2d0540c 100644 --- a/crates/engine/src/cmd/paste.rs +++ b/crates/engine/src/cmd/paste.rs @@ -136,6 +136,7 @@ pub fn insert(s: &mut Session, mut frag: Fragment) -> Result<(), CmdError> { for r in &mut p.runs { r.props.ins = Some(rid); r.props.del = None; + r.props.fmt_change = None; } }); } diff --git a/crates/engine/src/cmd/review.rs b/crates/engine/src/cmd/review.rs index 369bf89..882fb23 100644 --- a/crates/engine/src/cmd/review.rs +++ b/crates/engine/src/cmd/review.rs @@ -4,7 +4,7 @@ use serde_json::{Value, json}; use wordcraft_doc::para::InlineObject; use wordcraft_doc::{Comment, Paragraph, PartKind, Pos, StoryRef, para_block}; -use super::{now_iso, pos_json, sel_result}; +use super::{fmt_revisions, now_iso, pos_json, sel_result}; use crate::{CmdError, CmdResult, CommandSpec, Selection, Session, p}; pub fn specs() -> Vec { @@ -261,7 +261,9 @@ fn nav_comment(s: &mut Session, dir: i32) -> CmdResult { sel_result(s) } -/// Accept or reject a revision range in one paragraph; `mark` includes its paragraph mark. +/// Accept or reject a revision range in one paragraph; `mark` includes its paragraph mark (and +/// the section break on it), `props` the paragraph's own formatting change. +#[allow(clippy::too_many_arguments)] fn resolve_para( s: &mut Session, story: StoryRef, @@ -269,26 +271,42 @@ fn resolve_para( from: usize, to: usize, mark: bool, + props: bool, accept: bool, ) -> Result<(), CmdError> { let para = s.doc.para_mut(story, path)?; - let ranges: Vec<(usize, usize, bool, bool)> = para + if props && (para.props.fmt_change.is_some() || para.props.num_change.is_some()) { + fmt_revisions::resolve_para(&mut para.props, accept); + para.touch(); + } + let ranges: Vec<(usize, usize, bool, bool, bool)> = para .run_ranges() .filter(|(r, _)| r.end > from && r.start < to) - .map(|(r, c)| (r.start.max(from), r.end.min(to), c.ins.is_some(), c.del.is_some())) + .map(|(r, c)| (r.start.max(from), r.end.min(to), c.ins.is_some(), c.del.is_some(), c.fmt_change.is_some())) .collect(); - for (a, b, ins, del) in ranges.into_iter().rev() { + for (a, b, ins, del, fmt) in ranges.into_iter().rev() { if (del && accept) || (ins && !accept) { para.delete(a, b)?; - } else if ins || del { + } else if ins || del || fmt { para.format(a, b, &|c| { c.ins = None; c.del = None; + fmt_revisions::resolve_char(c, accept); })?; } } + if !mark { + return Ok(()); + } + if para.mark.fmt_change.is_some() || para.section.as_ref().is_some_and(|x| x.fmt_change.is_some()) { + fmt_revisions::resolve_char(&mut para.mark, accept); + if let Some(sec) = para.section.as_deref_mut() { + fmt_revisions::resolve_section(sec, accept); + } + para.touch(); + } let (ins, del) = (para.mark.ins, para.mark.del); - if !mark || (ins.is_none() && del.is_none()) { + if ins.is_none() && del.is_none() { return Ok(()); } // An inserted paragraph mark that is rejected, or a deleted one that is accepted, goes away: @@ -304,9 +322,49 @@ fn resolve_para( Ok(()) } +/// Accept or reject the changes of the table cell `path` is in: the table's, its row's and the +/// cell's own. Returns whether there were any. +fn resolve_cell(s: &mut Session, story: StoryRef, path: &wordcraft_doc::Path, accept: bool) -> Result { + let Some((tp, r, c)) = path.cell() else { return Ok(false) }; + let has = s.doc.table(story, &tp).is_some_and(|t| { + let row = t.rows.get(r); + t.props.fmt_change.is_some() + || row.is_some_and(|x| x.props.fmt_change.is_some()) + || row.and_then(|x| x.cells.get(c)).is_some_and(|x| x.props.fmt_change.is_some()) + }); + if !has { + return Ok(false); + } + let t = s.doc.table_mut(story, &tp)?; + fmt_revisions::resolve_table(t, accept); + if let Some(row) = t.rows.get_mut(r) { + fmt_revisions::resolve_row(row, accept); + if let Some(cell) = row.cells.get_mut(c) { + fmt_revisions::resolve_cell(cell, accept); + } + } + Ok(true) +} + +/// A tracked change, for navigation and the Reviewing Pane: its range, kind (`insert`, +/// `delete`, `format`), revision, and for a formatting change the properties it set. +struct Change { + a: Pos, + b: Pos, + kind: &'static str, + rev: Option, + props: Vec, +} + +impl Change { + fn format(a: Pos, b: Pos, rev: u32, props: Vec) -> Change { + Change { a, b, kind: "format", rev: Some(rev), props } + } +} + /// The tracked paragraph mark at the end of the paragraph at `path`, as a change range: from /// the paragraph's end to the start of the next paragraph (or the end itself when none follows). -fn mark_change(s: &Session, story: StoryRef, path: &wordcraft_doc::Path) -> Option<(Pos, Pos, &'static str, Option)> { +fn mark_change(s: &Session, story: StoryRef, path: &wordcraft_doc::Path) -> Option { let p = s.doc.para(story, path)?; let kind = if p.mark.ins.is_some() { "insert" @@ -317,7 +375,7 @@ fn mark_change(s: &Session, story: StoryRef, path: &wordcraft_doc::Path) -> Opti }; let a = Pos { story, path: path.clone(), off: p.len() }; let b = if super::has_next_para(s, story, path) { Pos { story, path: path.with_last(path.last().saturating_add(1)), off: 0 } } else { a.clone() }; - Some((a, b, kind, p.mark.ins.or(p.mark.del))) + Some(Change { a, b, kind, rev: p.mark.ins.or(p.mark.del), props: Vec::new() }) } fn resolve_all(s: &mut Session, accept: bool) -> CmdResult { @@ -325,9 +383,15 @@ fn resolve_all(s: &mut Session, accept: bool) -> CmdResult { for st in stories { for path in s.doc.para_paths(st).into_iter().rev() { let len = s.doc.para(st, &path).map(|p| p.len()).unwrap_or(0); - resolve_para(s, st, &path, 0, len, true, accept)?; + resolve_para(s, st, &path, 0, len, true, true, accept)?; } } + // Tables, rows, cells and sections. + fmt_revisions::resolve_containers(&mut s.doc.body, accept, 0); + for part in s.doc.parts.values_mut() { + fmt_revisions::resolve_containers(&mut part.blocks, accept, 0); + } + fmt_revisions::resolve_section(&mut s.doc.last_section, accept); s.doc.revisions.clear(); s.clamp_selection(); sel_result(s) @@ -337,19 +401,35 @@ fn resolve_sel(s: &mut Session, accept: bool) -> CmdResult { let (a, b) = s.sel.ordered(); // A caret right before a tracked paragraph mark (and no tracked text around it): that mark. let mut caret_mark = None; + // A caret in a paragraph whose own formatting changed (and no tracked text around it). + let mut caret_props = None; + let ranged = a != b; let (a, b) = if a == b { - // The change at the caret: the run around it, else the paragraph mark right after it. - let found = s - .doc - .para_at(&a) - .and_then(|p| p.run_ranges().find(|(r, c)| r.start <= a.off && a.off <= r.end && (c.ins.is_some() || c.del.is_some())).map(|(r, _)| r)); + // The change at the caret: the run around it, else the paragraph mark right after it, + // else the paragraph's formatting, else the table cell's. + let found = s.doc.para_at(&a).and_then(|p| { + p.run_ranges() + .find(|(r, c)| r.start <= a.off && a.off <= r.end && (c.ins.is_some() || c.del.is_some() || c.fmt_change.is_some())) + .map(|(r, _)| r) + }); + let para_fmt = s.doc.para_at(&a).is_some_and(|p| p.props.fmt_change.is_some() || p.props.num_change.is_some()); + let mark_fmt = s.doc.para_at(&a).is_some_and(|p| a.off == p.len() && p.mark.fmt_change.is_some()); match found { Some(r) => (Pos { off: r.start, ..a.clone() }, Pos { off: r.end, ..a }), - None if mark_change(s, a.story, &a.path).is_some_and(|m| m.0 == a) => { + None if mark_fmt || mark_change(s, a.story, &a.path).is_some_and(|m| m.a == a) => { caret_mark = Some(a.path.clone()); (a.clone(), a) } - None => return nav_change(s, 1), + None if para_fmt => { + caret_props = Some(a.path.clone()); + (a.clone(), a) + } + None => { + if resolve_cell(s, a.story, &a.path, accept)? { + return sel_result(s); + } + return nav_change(s, 1); + } } } else { (a, b) @@ -360,39 +440,121 @@ fn resolve_sel(s: &mut Session, accept: bool) -> CmdResult { let to = if path == b.path { b.off } else { len }; // A paragraph's mark is in the selection when the selection goes on past it. let mark = path != b.path || caret_mark.as_ref() == Some(&path); - resolve_para(s, a.story, &path, from, to, mark, accept)?; + let props = (ranged && (path != b.path || to > from)) || caret_props.as_ref() == Some(&path); + resolve_para(s, a.story, &path, from, to, mark, props, accept)?; + if ranged { + resolve_cell(s, a.story, &path, accept)?; + } } s.sel = Selection::caret(a); s.clamp_selection(); sel_result(s) } -fn changes(s: &Session) -> Vec<(Pos, Pos, &'static str, Option)> { - let mut out = Vec::new(); +fn changes(s: &Session) -> Vec { + let mut out: Vec = Vec::new(); + let mk = |path: &wordcraft_doc::Path, off| Pos { story: StoryRef::Body, path: path.clone(), off }; for path in s.doc.para_paths(StoryRef::Body) { let Some(p) = s.doc.para(StoryRef::Body, &path) else { continue }; + // A table's, row's and cell's changes sit at the start of the cell's first paragraph. + if path.last() == 0 + && let Some((tp, r, c)) = path.cell() + && let Some(t) = s.doc.table(StoryRef::Body, &tp) + { + let row = t.rows.get(r); + let cell = row.and_then(|x| x.cells.get(c)); + let found = [ + (r == 0 && c == 0).then(|| t.props.fmt_change.as_ref().map(|ch| (ch.rev, fmt_revisions::table_diff(&t.props)))).flatten(), + (c == 0).then(|| row.and_then(|x| x.props.fmt_change.as_ref().map(|ch| (ch.rev, fmt_revisions::row_diff(&x.props))))).flatten(), + cell.and_then(|x| x.props.fmt_change.as_ref().map(|ch| (ch.rev, fmt_revisions::cell_diff(&x.props)))), + ]; + for (rev, props) in found.into_iter().flatten() { + out.push(Change::format(mk(&path, 0), mk(&path, 0), rev, props)); + } + } + if let Some(ch) = &p.props.fmt_change { + out.push(Change::format(mk(&path, 0), mk(&path, p.len()), ch.rev, fmt_revisions::para_diff(&p.props))); + } for (r, c) in p.run_ranges() { let kind = if c.ins.is_some() { "insert" } else if c.del.is_some() { "delete" } else { - continue; + "" }; - let mk = |off| Pos { story: StoryRef::Body, path: path.clone(), off }; - out.push((mk(r.start), mk(r.end), kind, c.ins.or(c.del))); + if !kind.is_empty() { + out.push(Change { a: mk(&path, r.start), b: mk(&path, r.end), kind, rev: c.ins.or(c.del), props: Vec::new() }); + } + let Some(ch) = &c.fmt_change else { continue }; + let props = fmt_revisions::char_diff(c); + // Neighbouring runs changed the same way by the same revision are one change. + if let Some(last) = + out.last_mut().filter(|l| l.kind == "format" && l.rev == Some(ch.rev) && l.b == mk(&path, r.start) && l.props == props) + { + last.b = mk(&path, r.end); + } else { + out.push(Change::format(mk(&path, r.start), mk(&path, r.end), ch.rev, props)); + } + } + if let Some(ch) = &p.mark.fmt_change { + let end = mk(&path, p.len()); + out.push(Change::format(end.clone(), end, ch.rev, fmt_revisions::char_diff(&p.mark))); } out.extend(mark_change(s, StoryRef::Body, &path)); + if let Some(ch) = p.section.as_ref().and_then(|x| x.fmt_change.as_ref().map(|ch| (ch.rev, x))) { + let end = mk(&path, p.len()); + out.push(Change::format(end.clone(), end, ch.0, fmt_revisions::section_diff(ch.1))); + } + } + if let Some(ch) = &s.doc.last_section.fmt_change { + let end = s.doc.end_of(StoryRef::Body); + out.push(Change::format(end.clone(), end, ch.rev, fmt_revisions::section_diff(&s.doc.last_section))); } out } +/// The formatting changes in the body, for balloons: (position, author, properties set). +pub fn format_changes(s: &Session) -> Vec<(Pos, String, Vec)> { + changes(s) + .into_iter() + .filter(|c| c.kind == "format") + .map(|c| { + let author = c.rev.and_then(|r| s.doc.revisions.get(r as usize)).map(|r| r.author.clone()).unwrap_or_default(); + (c.a, author, c.props) + }) + .collect() +} + +/// Whether the document has any tracked formatting change (cheap: stops at the first). +pub fn has_format_changes(doc: &wordcraft_doc::Document) -> bool { + if doc.last_section.fmt_change.is_some() { + return true; + } + doc.para_paths(StoryRef::Body).iter().any(|path| { + doc.para(StoryRef::Body, path).is_some_and(|p| { + p.props.fmt_change.is_some() + || p.mark.fmt_change.is_some() + || p.runs.iter().any(|r| r.props.fmt_change.is_some()) + || p.section.as_ref().is_some_and(|x| x.fmt_change.is_some()) + || path.cell().and_then(|(tp, r, c)| doc.table(StoryRef::Body, &tp).map(|t| (t, r, c))).is_some_and(|(t, r, c)| { + t.props.fmt_change.is_some() + || t.rows.get(r).is_some_and(|x| x.props.fmt_change.is_some() || x.cells.get(c).is_some_and(|x| x.props.fmt_change.is_some())) + }) + }) + }) +} + fn nav_change(s: &mut Session, dir: i32) -> CmdResult { let list = changes(s); let caret = s.sel.ordered(); - let t = if dir > 0 { list.iter().find(|c| c.0 >= caret.1).or(list.first()) } else { list.iter().rev().find(|c| c.1 <= caret.0).or(list.last()) }; - if let Some((a, b, _, _)) = t { - s.sel = Selection { anchor: a.clone(), focus: b.clone() }; + let t = if dir > 0 { + list.iter().find(|c| c.a >= caret.1 && (c.a > caret.0 || c.b > caret.1)).or(list.first()) + } else { + list.iter().rev().find(|c| c.b <= caret.0 && (c.b < caret.1 || c.a < caret.0)).or(list.last()) + }; + if let Some(c) = t { + s.sel = Selection { anchor: c.a.clone(), focus: c.b.clone() }; } sel_result(s) } @@ -401,13 +563,18 @@ fn list_changes(s: &mut Session, _: &Value) -> CmdResult { let list = changes(s); Ok(Value::Array( list.iter() - .map(|(a, b, kind, rid)| { - let rev = rid.and_then(|r| s.doc.revisions.get(r as usize)); - json!({ - "kind": kind, "start": pos_json(a), "end": pos_json(b), - "text": s.doc.copy_range(a, b).plain_text(), + .map(|c| { + let rev = c.rev.and_then(|r| s.doc.revisions.get(r as usize)); + let mut v = json!({ + "kind": c.kind, "start": pos_json(&c.a), "end": pos_json(&c.b), + "text": s.doc.copy_range(&c.a, &c.b).plain_text(), "author": rev.map(|r| r.author.clone()), "date": rev.map(|r| r.date.clone()), - }) + }); + if c.kind == "format" { + v["description"] = json!(fmt_revisions::describe(&c.props)); + v["props"] = json!(c.props); + } + v }) .collect(), )) @@ -695,4 +862,80 @@ mod tests { assert!(s.run("review.trackingOptions", &json!({"deleteMark": "sparkles"})).is_err()); assert_eq!(s.run("review.trackingOptions", &json!({})).unwrap()["insertColor"], "00AA00"); } + + fn body_para(s: &Session) -> &wordcraft_doc::Paragraph { + s.doc.para(wordcraft_doc::StoryRef::Body, &wordcraft_doc::Path::top(0)).unwrap() + } + + /// "plain words here" with "words" made bold, then 14 pt, and the paragraph centred, under + /// Track Changes by Ana. + fn formatted_while_tracking() -> Session { + let mut s = Session::new(Document::new()); + s.view.proofing = false; + s.author = "Ana".into(); + s.run("document.setText", &json!({"text": "plain words here"})).unwrap(); + // Untracked formatting is no revision. + s.run("select.range", &json!({"anchor": {"block": 0, "off": 12}, "focus": {"block": 0, "off": 16}})).unwrap(); + s.run("format.italic", &json!({})).unwrap(); + assert!(body_para(&s).runs.iter().all(|r| r.props.fmt_change.is_none())); + s.run("review.trackChanges", &json!({"value": true})).unwrap(); + s.run("select.range", &json!({"anchor": {"block": 0, "off": 6}, "focus": {"block": 0, "off": 11}})).unwrap(); + s.run("format.bold", &json!({})).unwrap(); + s.run("format.size", &json!({"size": 14})).unwrap(); + s.run("para.alignCenter", &json!({})).unwrap(); + s + } + + /// #41: formatting with Track Changes on records the formatting before the author's first + /// change; it's listed in the Reviewing Pane and marked by a bar beside the line. + #[test] + fn formatting_is_tracked_while_tracking_changes() { + let mut s = formatted_while_tracking(); + let p = body_para(&s); + let ch = p.props_of_char(6).fmt_change.as_deref().expect("a formatting change on \"words\""); + assert_eq!((ch.old.bold, ch.old.size), (None, None), "the formatting before the first change is kept"); + let rev = &s.doc.revisions[ch.rev as usize]; + assert_eq!((rev.kind, rev.author.as_str()), (wordcraft_doc::RevisionKind::Format, "Ana")); + assert!(p.props_of_char(0).fmt_change.is_none() && p.props_of_char(13).fmt_change.is_none()); + assert_eq!(p.props.fmt_change.as_deref().map(|c| c.old.align), Some(None)); + + let list = s.run("review.changes", &json!({})).unwrap(); + let desc: Vec<&str> = list.as_array().unwrap().iter().filter(|c| c["kind"] == "format").filter_map(|c| c["description"].as_str()).collect(); + assert_eq!(desc, ["Formatted: Align: center", "Formatted: Bold, Size: 14"], "{list:#}"); + + let (d, body_x) = draws(&mut s); + let bars = lines(&d).into_iter().filter(|(x0, _, x1, _)| x0 == x1 && *x0 < body_x).count(); + assert_eq!(bars, 1, "one changed line, one bar"); + + // Formatting changed back is no change any more. + s.run("select.range", &json!({"anchor": {"block": 0, "off": 6}, "focus": {"block": 0, "off": 11}})).unwrap(); + s.run("format.set", &json!({"props": {"bold": false}})).unwrap(); + s.run("format.size", &json!({"size": 11})).unwrap(); + assert!(body_para(&s).props_of_char(6).fmt_change.is_some(), "explicit Not Bold and 11 pt differ from inherited"); + } + + /// #41: rejecting restores the formatting before the change; accepting keeps the new + /// formatting and drops the record. Both find formatting changes at the caret too. + #[test] + fn formatting_changes_are_accepted_and_rejected() { + let mut s = formatted_while_tracking(); + s.run("review.rejectAll", &json!({})).unwrap(); + let p = body_para(&s); + let c = p.props_of_char(6); + assert_eq!((c.bold, c.size, c.fmt_change.is_none()), (None, None, true)); + assert_eq!((p.props.align, p.props.fmt_change.is_none()), (None, true)); + assert_eq!(p.props_of_char(13).italic, Some(true), "untracked formatting stays"); + + s.run("edit.undo", &json!({})).unwrap(); + // Accept at the caret: just the run's change. + s.run("select.range", &json!({"anchor": {"block": 0, "off": 8}, "focus": {"block": 0, "off": 8}})).unwrap(); + s.run("review.accept", &json!({})).unwrap(); + let p = body_para(&s); + assert_eq!((p.props_of_char(6).bold, p.props_of_char(6).fmt_change.is_none()), (Some(true), true)); + assert!(p.props.fmt_change.is_some(), "the paragraph's change is still there"); + s.run("review.acceptAll", &json!({})).unwrap(); + let p = body_para(&s); + assert_eq!((p.props.align, p.props.fmt_change.is_none()), (Some(wordcraft_doc::props::Align::Center), true)); + assert!(s.run("review.changes", &json!({})).unwrap().as_array().unwrap().is_empty()); + } } diff --git a/crates/engine/src/session.rs b/crates/engine/src/session.rs index 1e24f96..c457fac 100644 --- a/crates/engine/src/session.rs +++ b/crates/engine/src/session.rs @@ -727,6 +727,10 @@ impl Session { match &result { Ok(_) => { if spec.mutates { + // Formatting changed while tracking becomes a formatting revision. + if let Some((before, ..)) = &before_doc { + crate::cmd::fmt_revisions::record(self, id, before); + } self.touch(); self.doc.ensure_nonempty(); self.doc.prune_text_boxes(); diff --git a/crates/layout/src/display.rs b/crates/layout/src/display.rs index 2bfe601..85a23df 100644 --- a/crates/layout/src/display.rs +++ b/crates/layout/src/display.rs @@ -320,7 +320,7 @@ pub fn page_display(doc: &Document, page: &Page, opts: &DisplayOptions) -> Vec Vec) { // Outside: the left margin, or the outer one of a right-hand page with mirrored margins. let right = match m.changed_lines { @@ -345,12 +346,17 @@ fn change_bars(doc: &Document, page: &Page, m: &MarkupOptions, alpha: f32, out: continue; } let Some(first) = pl.lines.get(*l0) else { continue }; - let mark_rev = doc.para(*story, path).is_some_and(|p| p.mark.ins.is_some() || p.mark.del.is_some()); + let (text, fmt) = (m.insertions_deletions, m.formatting); + let para = doc.para(*story, path); + let mark_rev = para.is_some_and(|p| (text && (p.mark.ins.is_some() || p.mark.del.is_some())) || (fmt && p.mark.fmt_change.is_some())); + // A paragraph whose own formatting changed is marked on every line. + let para_fmt = fmt && para.is_some_and(|p| p.props.fmt_change.is_some()); for li in *l0..*l1 { let Some(line) = pl.lines.get(li) else { continue }; - let changed = (line.c0..line.c1) - .filter_map(|k| pl.clusters.get(k).and_then(|c| pl.styles.get(c.style as usize))) - .any(|st| st.rc.ins.is_some() || st.rc.del.is_some()) + let changed = para_fmt + || (line.c0..line.c1) + .filter_map(|k| pl.clusters.get(k).and_then(|c| pl.styles.get(c.style as usize))) + .any(|st| (text && (st.rc.ins.is_some() || st.rc.del.is_some())) || (fmt && st.rc.fmt.is_some())) || (mark_rev && line.end == LineEnd::Para); if changed { let top = y + (line.top - first.top); diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 2c5cd36..93e035b 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -145,10 +145,12 @@ 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 hidden, or every revision inline, leave no markup area. + // 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.comments && m.balloons != wordcraft_layout::display::BalloonMode::Inline; - if on && !app.session.doc.comments.is_empty() { 216.0 } else { 0.0 } + let on = on && m.balloons != wordcraft_layout::display::BalloonMode::Inline; + let comments = m.comments && !app.session.doc.comments.is_empty(); + if on && (comments || (m.formatting && wordcraft_engine::cmd::review::has_format_changes(&app.session.doc))) { 216.0 } else { 0.0 } } /// A page dimension (points) safe to lay out: finite, at least 1 pt, at most `cap`. @@ -749,15 +751,25 @@ 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, id) for each anchored, unresolved-or-not comment. - let mut by_page: HashMap> = HashMap::new(); - for (id, pos) in wordcraft_engine::cmd::review::comment_list(&app.session) { + // (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). + 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() }; + for (id, pos) in comments { let Some(pos) = pos else { continue }; if app.session.doc.comments.get(&id).is_some_and(is_reply) { continue; } let Some(c) = layout.caret_on(&pos, app.session.page_hint) else { continue }; - by_page.entry(c.page).or_default().push((c.x, c.top + c.height, id, pos)); + by_page.entry(c.page).or_default().push((c.x, c.top + c.height, Balloon::Comment(id), pos)); + } + let formats = if m.formatting { wordcraft_engine::cmd::review::format_changes(&app.session) } else { Vec::new() }; + for (pos, author, props) in formats.into_iter().take(500) { + let Some(c) = layout.caret_on(&pos, app.session.page_hint) else { continue }; + let what = crate::panes::describe_props(&props).join(", "); + let text = if what.is_empty() { tl!("Formatted").to_string() } else { format!("{}: {what}", tl!("Formatted")) }; + by_page.entry(c.page).or_default().push((c.x, c.top + c.height, Balloon::Format(author, text), pos)); } 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(); @@ -784,7 +796,38 @@ fn balloons(app: &mut WordApp, ui: &mut Ui, painter: &egui::Painter, rects: &[Re let x0 = pr.max.x + 10.0; let w = (mw * page_scale - 20.0).max(60.0); let mut next_y = pr.min.y; - for (ax, ay, id, pos) in list.iter() { + for (ax, ay, item, pos) in list.iter() { + let id = match item { + Balloon::Comment(id) => id, + Balloon::Format(author, text) => { + // A read-only card: who formatted, and how. + let author = author_name(author); + let ai = authors.iter().position(|a| *a == author).unwrap_or_else(|| { + authors.push(author.clone()); + authors.len() - 1 + }); + let color = palette.get(ai % palette.len()).copied().unwrap_or(t.blue); + let fs = (11.0 * page_scale / PX_PER_PT).clamp(8.0, 16.0); + let anchor = pos2(pr.min.x + ax * page_scale, pr.min.y + ay * page_scale); + let top = (anchor.y - 12.0).max(next_y); + let head = painter.layout(author, semibold(fs), t.text, w - 16.0); + let body = painter.layout(text.clone(), regular(fs), t.text_dim, w - 16.0); + let card = Rect::from_min_size(pos2(x0, top), vec2(w, head.size().y + body.size().y + 16.0)); + next_y = card.max.y + 6.0; + if !card.intersects(clip) { + continue; + } + let lead = Stroke::new(1.0, color.linear_multiply(0.7)); + dashed(painter, anchor, pos2(pr.max.x, anchor.y), lead); + painter.line_segment([pos2(pr.max.x, anchor.y), pos2(x0, top + 10.0)], lead); + card_shapes(painter, &t, card, color, false, None); + let y = card.min.y + 6.0; + let hh = head.size().y; + painter.galley(pos2(card.min.x + 10.0, y), head, t.text); + painter.galley(pos2(card.min.x + 10.0, y + hh + 4.0), body, t.text_dim); + continue; + } + }; let Some(c) = app.session.doc.comments.get(id) else { continue }; let resolved = c.resolved; let author = author_name(&c.author); @@ -859,6 +902,13 @@ 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: its author and "Formatted: …". + Format(String, String), +} + fn author_name(a: &str) -> String { if a.is_empty() { tl!("Author").to_string() } else { a.to_string() } } diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index 4b64d73..80f0b38 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -1326,3 +1326,4 @@ Rotate Left 90° Girar 90° a la izquierda Flip Vertical Voltear verticalmente Flip Horizontal Voltear horizontalmente + Formatted Con formato diff --git a/crates/ui-egui/src/i18n/et.tsv b/crates/ui-egui/src/i18n/et.tsv index 5886a85..f35f90c 100644 --- a/crates/ui-egui/src/i18n/et.tsv +++ b/crates/ui-egui/src/i18n/et.tsv @@ -1323,3 +1323,4 @@ Type the field's name and its switches, for example PAGE \\* roman. Sisesta välja nimi ja lülitid, näiteks PAGE \\* roman. Width and spacing Laius ja vahe from Unicode (hex) Unicode (kuueteistkümnend) + Formatted Vormindatud diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index abc1ecf..d3b477f 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -1310,3 +1310,4 @@ Rotate Left 90° 左へ 90 度回転 Flip Vertical 上下反転 Flip Horizontal 左右反転 + Formatted 書式変更 diff --git a/crates/ui-egui/src/i18n/pt-br.tsv b/crates/ui-egui/src/i18n/pt-br.tsv index cc24521..3186333 100644 --- a/crates/ui-egui/src/i18n/pt-br.tsv +++ b/crates/ui-egui/src/i18n/pt-br.tsv @@ -1322,3 +1322,4 @@ Rotate Left 90° Girar 90° para a esquerda Flip Vertical Inverter verticalmente Flip Horizontal Inverter horizontalmente + Formatted Formatado diff --git a/crates/ui-egui/src/i18n/sr-latn.tsv b/crates/ui-egui/src/i18n/sr-latn.tsv index 1b8224e..f1eedfa 100644 --- a/crates/ui-egui/src/i18n/sr-latn.tsv +++ b/crates/ui-egui/src/i18n/sr-latn.tsv @@ -1320,3 +1320,4 @@ Rotate Left 90° Rotiraj ulevo za 90° Flip Vertical Okreni vertikalno Flip Horizontal Okreni horizontalno + Formatted Formatirano diff --git a/crates/ui-egui/src/i18n/sr.tsv b/crates/ui-egui/src/i18n/sr.tsv index 4ba1821..cf8ddfa 100644 --- a/crates/ui-egui/src/i18n/sr.tsv +++ b/crates/ui-egui/src/i18n/sr.tsv @@ -1324,3 +1324,4 @@ Rotate Left 90° Ротирај улево за 90° Flip Vertical Окрени вертикално Flip Horizontal Окрени хоризонтално + Formatted Форматирано diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index 4523f93..a912425 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -1322,3 +1322,4 @@ Rotate Left 90° Повернути ліворуч на 90° Flip Vertical Віддзеркалити по вертикалі Flip Horizontal Віддзеркалити по горизонталі + Formatted Відформатовано diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv index 9866e81..483ac47 100644 --- a/crates/ui-egui/src/i18n/zh-hans.tsv +++ b/crates/ui-egui/src/i18n/zh-hans.tsv @@ -1321,3 +1321,4 @@ Rotate Left 90° 向左旋转 90° Flip Vertical 垂直翻转 Flip Horizontal 水平翻转 + Formatted 已设置格式 diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv index 727eb58..bf1e0a2 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -1311,3 +1311,4 @@ Rotate Left 90° 向左旋轉 90° Flip Vertical 垂直翻轉 Flip Horizontal 水平翻轉 + Formatted 已設定格式 diff --git a/crates/ui-egui/src/panes.rs b/crates/ui-egui/src/panes.rs index c4f731d..df88068 100644 --- a/crates/ui-egui/src/panes.rs +++ b/crates/ui-egui/src/panes.rs @@ -518,6 +518,11 @@ fn direct_list(v: &Value) -> Vec { v.as_array().map(|a| a.iter().take(64).map(|d| describe(d["prop"].as_str().unwrap_or(""), &d["value"])).collect()).unwrap_or_default() } +/// Each `{"prop", "value"}` of a list (the Style Inspector's, or a formatting change's) as text. +pub(crate) fn describe_props(list: &[Value]) -> Vec { + list.iter().take(64).map(|d| describe(d["prop"].as_str().unwrap_or(""), &d["value"])).collect() +} + /// "Bold", "Not Italic", "Font: Arial", "Size: 14 pt"… fn describe(prop: &str, v: &Value) -> String { let label = match prop { @@ -562,6 +567,9 @@ fn describe(prop: &str, v: &Value) -> String { "suppressLineNumbers" => "Suppress line numbers", "bidi" => "Right-to-left", "dropCap" => "Drop cap", + "style" => "Style", + "numbering" => "Numbering", + "lang" => "Language", other => other, }; let label = tl!(label); diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index 0de1cd4..5efedaa 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-10 · **Last updated:** 2026-10-10 · **Change:** 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-10 · **Last updated:** 2026-10-11 · **Change:** trivial (formatting revisions landed, #41; 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; @@ -50,7 +50,7 @@ Measured by grepping `crates/docx/src/read` and `src/write` for the OOXML elemen | Headers/footers (first, even/odd), page borders, line numbers, gutter, mirror margins | ✅ | ✅ | | | Footnotes, endnotes, comments | ✅ | ✅ | `commentsExtended` written; `commentsIds`, modern threaded comments (`w16cex`) partly | | Tracked insertions and deletions (`w:ins`/`w:del`), paragraph-mark revisions | ✅ | ✅ | #125, #244 | -| Formatting revisions (`w:rPrChange`, `w:pPrChange`, `w:sectPrChange`, `w:tblPrChange`) | ❌ | ❌ | Lost on open. Issue #41 | +| 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`) | 🟡 | ❌ | Read as plain insert/delete | | Fields (`w:fldChar`, `w:fldSimple`), TOC, cross-references, `ADDIN` citations | ✅ | ✅ | Field codes Word supports but we don't evaluate keep their cached result | | Content controls (`w:sdt`) | 🟡 | ❌ | Content kept, the control (type, binding, placeholder, lock) dropped | @@ -83,6 +83,7 @@ Measured by grepping `crates/docx/src/read` and `src/write` for the OOXML elemen | Date | Change | Summary | |---|---|---| +| 2026-10-11 | trivial | Formatting revisions (`w:rPrChange`, `w:pPrChange`, table, row, cell, section and numbering changes) read and written (#41) | | 2026-10-10 | trivial | OLE objects turned or flipped in WordCraft keep the turn on save (VML `rotation`/`flip`, DrawingML `a:xfrm` with the rotated effect extent); test counts refreshed | | 2026-10-10 | trivial | Charts, SmartArt and OLE objects written back on save with their parts (#319); chart/SmartArt reading (#292) recorded | | 2026-10-10 | trivial | DrawingML rotation and flips read and written (#332) | diff --git a/docs/gaps.md b/docs/gaps.md index fc570cb..d01be47 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-10 · **Change:** 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-11 · **Change:** 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 The work list. One entry per shortfall, ranked by how much it stops a Word user from switching (**B** = blocks beta). Each says what's missing, the evidence, who it hurts, an Opus 5.5 agent @@ -15,7 +15,7 @@ first (`gh pr list`), and update this file and the parity doc when a gap closes. | 2 | **Ink and drawing canvases are dropped on open and lost on save, silently**; OLE objects show only their picture | B | No ink or drawing-canvas (`wpc`) handling in `crates/docx`; 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 ink and drawing canvases; 45–70 to render and edit charts and SmartArt (#13) | [file-format-parity.md](file-format-parity.md) | | 3 | **Crashes and freezes in the field**: startup crash on Intel UHD Graphics (#170) (lag on GPUs without a DirectX 12 driver fixed, #316), freezes (#77, #59 Thai fonts, #31 font selector), no writes without admin on Windows 11 (#218), broken shape tool (#142), images can't be moved (#82) | B | 62 open issues, ~15 of them crashes, hangs or data-path failures | 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; columns don't balance; custom footnote continuation separators and notices aren't read; legacy compatibility options ignored; no page-by-page comparison harness | B | [layout-parity.md](layout-parity.md); [typing-parity.md](typing-parity.md) Known gaps | Page counts and line breaks differ, which matters for forms, legal and academic work | 30–50 + owner (font) | [layout-parity.md](layout-parity.md) | -| 5 | **Formatting revisions, move tracking and content controls** are lost (`w:rPrChange`/`w:pPrChange` absent; `w:sdt` unwrapped) | B | Issue #41; source grep | Legal and editorial review workflows; templates with form controls | 12–18 | [file-format-parity.md](file-format-parity.md) | +| 5 | **Move tracking and content controls** are lost (`w:moveFrom`/`w:moveTo` read as insert/delete; `w:sdt` unwrapped). 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 | B | Issue #41; source grep | Legal and editorial review workflows; templates with form controls | 8–12 | [file-format-parity.md](file-format-parity.md) | | 6 | **No native printing** on desktop: File › Print opens the PDF in the system viewer, which prints (#286); no print dialog of our own (printer, copies, ranges); 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**: 22 modal dialogs vs Word's ~100 (Define Multilevel List, Track Changes Options, Options panes…) | B | `Dialog` enum in `crates/ui-egui/src/dialogs.rs` | Power users reach for dialogs the ribbon alone doesn't cover | 20–30 | [ui-parity.md](ui-parity.md) | | 7 | **Dialog depth**: 19 modal dialogs vs Word's ~100 (Tabs, Borders and Shading, Define Multilevel List, Track Changes Options, Options panes…) | B | `Dialog` enum in `crates/ui-egui/src/dialogs.rs` | Power users reach for dialogs the ribbon alone doesn't cover | 20–30 | [ui-parity.md](ui-parity.md) | @@ -66,6 +66,7 @@ The same gaps grouped the way the parity documents are, for agents working in on | Date | Change | Summary | |---|---|---| +| 2026-10-11 | trivial | Formatting revisions landed (#41); gap #5 narrowed to move tracking and content controls, 8–12 h | | 2026-10-10 | trivial | Draw Table and Eraser landed (#303); removed from the feature-gap list | | 2026-10-10 | trivial | Tabs and Borders and Shading dialogs landed (#320); removed from the dialog-depth list | | 2026-10-10 | trivial | Columns, Symbol and Field dialogs landed (#321); removed from the dialog-depth list | diff --git a/docs/roadmap.md b/docs/roadmap.md index 31f5c21..ea85968 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -1,6 +1,6 @@ # WordCraft roadmap: milestones and what's next -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-10 · **Change:** trivial (M5: Draw Table and Eraser landed, #303; previously minor: beta distance restated: ~15 points, ~150–230 h; previously: alpha gate checked: six core workflows pass; previously major: milestones moved here from ROADMAP.md; Current focus set from the re-measured gaps) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** trivial (M7: formatting revisions landed, #41; previously trivial: M5: Draw Table and Eraser landed, #303; previously minor: beta distance restated: ~15 points, ~150–230 h; previously: alpha gate checked: six core workflows pass; previously major: milestones moved here from ROADMAP.md; Current focus set from the re-measured gaps) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4 Forward-looking plan. The summary is [`ROADMAP.md`](../ROADMAP.md); the ranked work list is [`gaps.md`](gaps.md); the numbers are in [`target-app-parity.md`](target-app-parity.md). Hours are @@ -31,9 +31,9 @@ every day, checked end to end on the main platform (macOS desktop), including sa | Core workflow | Works end to end? | Evidence | Hours to pass | |---|---|---|---| | Write and format a document: type, fonts, paragraph formatting, styles, lists, undo, find/replace; save as .docx and reopen | yes | 422 commands, typing parity pinned by `tests_typing.rs`, DOCX round-trip tests (`crates/docx/tests/roundtrip.rs`) | 0 | -| Open a .docx someone sent, edit it, send back a .docx Word opens | partial (not blocking) | Word opens our files; typical text/table/picture documents work; never tested on a real-world corpus; charts, SmartArt and OLE dropped silently, formatting revisions lost (gaps #1, #2, #5) | 0 (beta work: 50–80) | +| Open a .docx someone sent, edit it, send back a .docx Word opens | partial (not blocking) | Word opens our files; typical text/table/picture documents work; never tested on a real-world corpus; charts, SmartArt and OLE dropped silently, move tracking lost (gaps #1, #2, #5) | 0 (beta work: 50–80) | | Build a structured document: tables, pictures, headers/footers, page numbers, sections, columns | yes | Tables with row splitting and floating tables (#137), floating pictures (#136), first/odd/even headers; columns don't balance (gap #4) | 0 | -| Review with others: comments, track changes, accept/reject, compare | yes | Margin balloons, tracked insert/delete incl. paragraph marks (#125, #244), compare; formatting revisions missing (#41) | 0 | +| Review with others: comments, track changes, accept/reject, compare | yes | Margin balloons, tracked insert/delete incl. paragraph marks (#125, #244), formatting revisions (#41), compare | 0 | | References for reports and papers: TOC, footnotes, citations and bibliography, captions | yes | Updatable TOC field (#52), notes, APA/MLA/Chicago/IEEE, Zotero (#189); long footnotes don't continue (gap #4) | 0 | | Share or print: export PDF, print | partial (not blocking) | PDF export solid (krilla); desktop printing goes through a PDF, no native print dialog (gap #6) | 0 (beta work: 6–10) | @@ -51,7 +51,7 @@ partial rows are fidelity gaps (beta work), not broken workflows. WordCraft stay | M4 | Layout and Design tabs | done except column balancing, group | 6–10 h | | M5 | Tables | done (row splitting, floating tables, style formatting); custom table styles (#256); Draw Table and Eraser (#303) | 6–10 h | | M6 | References | first version done (TOC fields, cross-references, Zotero); footnote continuation, 8 more bibliography styles missing | 15–25 h | -| M7 | Review | done (balloons, paragraph-mark revisions); formatting revisions, move tracking, multilingual proofing missing | 35–60 h | +| M7 | Review | done (balloons, paragraph-mark revisions, formatting revisions #41); move tracking, multilingual proofing missing | 35–60 h | | M8 | View | mostly done; window commands missing | 3–5 h | | M9 | Mailings | done (first version); Excel/Outlook data sources and email merge missing | 6–10 h | | M10 | File/Backstage | mostly done; native printing, Options depth missing | 12–20 h | @@ -71,6 +71,7 @@ languages, screen-reader access, ecosystem decisions (VBA, add-ins, cloud), an i | Date | Change | Summary | |---|---|---| +| 2026-10-11 | trivial | M7: formatting revisions landed (#41) | | 2026-10-10 | trivial | M5: Draw Table and Eraser landed (#303) | | 2026-10-10 | trivial | Merged main: PRs that landed removed from the Current focus list | | 2026-10-10 | minor | Current focus heading back to ~150–230 h to beta: full number restored to the additive weighted sum (~60%), method aligned with the standard, no new evidence | diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 4c47c0f..9ae22dc 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-10 · **Change:** 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 (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% | 5–8 | Placed and editable, numbers from Word files (#103). Long notes don't continue onto the next page; no continuation separator; no note options per section. | | References (TOC, citations, bibliography, captions, index, TOA) | 5 | 65% | 60% | 12–20 | Working first versions, Zotero integration (#189), Zotero/Mendeley `ADDIN` fields round-trip. 4 of Word's 12 bibliography styles (APA, MLA, Chicago, IEEE); source manager depth; 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, restrict editing. Missing: formatting revisions (`w:rPrChange`/`w:pPrChange`, issue #41), move tracking, track-changes 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, restrict editing. Formatting revisions (#41): kept in DOCX, recorded while tracking, accepted/rejected, "Formatted: …" balloons and change bars. Missing: move tracking, 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, 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, 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). Missing: tight/through contour wrap, rotated text-box text, group (#267 open), shape effects, WordArt, connectors, Drawing Canvas, Icons, online pictures, screenshot. | @@ -106,7 +106,7 @@ h), pictures and tables (12–18 h), spelling depth (8–12 h), polish (10–20 | Interaction fidelity | ×0.93 | — | Typing pinned to Word (`tests_typing.rs`), ribbon, keytips, mini-toolbar; but 16 modal dialogs against Word's ~100, 101 of ~250 shortcuts, no table column drag on the canvas (#217, #49), picture/shape handling complaints (#82, #142), lines wrap differently without an Aptos-metric font | | Discoverability and UI clarity | — | ×0.95 | Word-style ribbon a Word user already knows, command search, keytips; some newer pane strings and tooltips terse or untranslated | | Stability on real machines | ×0.90 | ×0.90 (launch and install) | Never-crash standard, hostile-param fuzzing, panic guard; but open startup crash on Intel UHD (#170), freezes (#77, #59, #31), Windows writes fail without admin (#218), installer conflicts with other Crafting Apps (#64, #73, #106, #51); 5 days of field history | -| Exchanging files with Word users | ×0.90 | ×0.92 (opening files people send) | Word opens our .docx; `.doc` import; but no real-world corpus test, charts/SmartArt/OLE dropped silently, formatting revisions lost (#41), pagination differs (Aptos, no column balancing or footnote continuation). Simple documents, which are most of what a casual user receives, come through well; double-click open on macOS is fixed on main but not yet released (#223, #279) | +| Exchanging files with Word users | ×0.90 | ×0.92 (opening files people send) | Word opens our .docx; `.doc` import; but no real-world corpus test, charts/SmartArt/OLE dropped silently, move tracking lost, pagination differs (Aptos, no column balancing or footnote continuation). Simple documents, which are most of what a casual user receives, come through well; double-click open on macOS is fixed on main but not yet released (#223, #279) | | Product | **×0.753** | **×0.787** | | ### Full target (ready for real work): ~60% @@ -188,7 +188,7 @@ Word. Their weighted sum is the **full ready for real work** number. | Dimension | Weight | Parity | Hours to full | Doc | Evidence | |---|---|---|---|---|---| | Features (depth, table above) | 30% | 68% | 210–335 | this file | Weighted table above. Hours: the area table sums to 290–460 h; the Page layout, Footnotes, RTL and East Asian rows (~50–85 h) are counted under Layout and the Proofing row (25–45 h) under Localization, so they aren't counted twice | -| File formats (DOCX fidelity first) | 20% | 60% | 185–300 | [file-format-parity.md](file-format-parity.md) | DOCX opens in Word and round-trips our own tests, but no real-world corpus test yet; charts, SmartArt, OLE, content controls (unwrapped), formatting revisions and encrypted files are lost or refused | +| File formats (DOCX fidelity first) | 20% | 60% | 185–300 | [file-format-parity.md](file-format-parity.md) | DOCX opens in Word and round-trips our own tests, but no real-world corpus test yet; charts, SmartArt, OLE, content controls (unwrapped), move tracking and encrypted files are lost or refused | | Layout and pagination fidelity | 15% | 55% | 90–145 | [layout-parity.md](layout-parity.md) | Aptos has no metric-matched substitute so lines break differently from Word; no column balancing, footnote continuation or document grid; compatibility modes beyond 15 partial | | UI/UX fidelity | 10% | 65% | 65–100 | [ui-parity.md](ui-parity.md) | Ribbon, keytips, mini-toolbar, Backstage, 101 shortcuts; 16 modal dialogs against Word's ~100; no ribbon/keyboard customization | | Stability | 10% | 50% | 25–45 | [gaps.md](gaps.md) | Never-crash standard and hostile-param fuzzing in place, but open reports of a startup crash on Intel UHD (#170), freezes (#77, #59, #31) and Windows write failure without admin (#218); no soak test; 5 days of field history | @@ -249,6 +249,7 @@ can be used without committing Word output. | Date | Change | Summary | |---|---|---| +| 2026-10-11 | trivial | Formatting revisions (#41): Review row updated; no percentage change | | 2026-10-10 | trivial | #307: Draw tab pens, eraser and Hide Ink; catalog 384/430 → 390/430 (90.7%) | | 2026-10-10 | trivial | Draw Table and Eraser (#303): catalog 384/430 → 387/430 (90.0%) | | 2026-10-10 | trivial | Pictures, shapes, text boxes: free rotation and flips, rotation handle, rotated wrap bounds, DOCX `a:xfrm` rot/flipH/flipV (#332); no percentage change |