Track Changes: moves (w:moveFrom/w:moveTo) (#400) (#404)

Moved text is tracked as a move instead of a deletion plus an insertion:
- Model: Revision::move_name links the source (a deletion revision) and
  the destination (an insertion revision) of a move; runs and paragraph
  marks keep using ins/del, so everything that hides deletions or exports
  the result treats the moved-away text like a deletion.
- DOCX: w:moveFrom / w:moveTo inside w:moveFromRangeStart/End and
  w:moveToRangeStart/End (ECMA-376 17.13.5.21-28) are read as linked moves,
  paragraph marks included; on save the ranges are written around each
  moved stretch with the move's name, author and date, moved-away text in
  w:t. A move missing an end, or whose range never ends, opens as a plain
  deletion and insertion.
- Recording: with Track Changes on, a cut of a paragraph or at least a
  sentence gets a revision of its own; pasting it right after makes both
  a move (pasted paragraph marks are tracked as inserted too).
- Accept/Reject at either end resolves both; the Reviewing Pane lists
  moveFrom/moveTo with the move name and "Moved from"/"Moved to".
- Markup: green, double strikethrough at the source and double underline
  at the destination; "Moved from"/"Moved to" balloons.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
This commit is contained in:
authored and GitHub committed 2026-10-11 15:12:59 -04:00
1 parent 4e710a920b
commit 30993ffb3f
34 files changed
+668 -55

No files matched your search

+2 -1
View File
@@ -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); move tracking and content controls are lost; there is open field evidence of
balancing or footnote continuation); 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 | Move tracking (#400): `w:moveFrom`/`w:moveTo` and their named ranges kept in DOCX, a tracked cut and paste of a paragraph or sentence records a move, accept/reject at either end resolves both, green double strikethrough/underline and "Moved from/to" balloons |
| 2026-10-11 | Footnote and Endnote dialog from the References › Footnotes launcher: options per section or document (below text, end of section, number format, start at, restart each section or page), custom marks, convert footnotes ↔ endnotes; kept in DOCX (#385) |
| 2026-10-11 | Shape Format › Text: text direction, Align Text and linked text boxes (Create Link / Break Link), read and written in DOCX (#369) |
| 2026-10-11 | Mail-merge recipients from Excel (.xlsx/.xlsm) and OpenDocument (.ods) spreadsheets, with Select Table to pick the sheet (#334) |
+18
View File
@@ -305,6 +305,24 @@ pub struct Revision {
pub kind: RevisionKind,
pub author: String,
pub date: String,
/// A move (ECMA-376 §17.13.5.21–28): the name linking the source (an `Delete` revision,
/// `w:moveFrom`) with the destination (an `Insert` revision, `w:moveTo`). `None` = a plain
/// insertion or deletion.
#[serde(skip_serializing_if = "Option::is_none")]
pub move_name: Option<String>,
}
impl Revision {
pub fn new(kind: RevisionKind, author: impl Into<String>, date: impl Into<String>) -> Revision {
Revision { kind, author: author.into(), date: date.into(), move_name: None }
}
}
impl Document {
/// The move name of revision `idx`, when it is one end of a move.
pub fn move_name(&self, idx: Option<u32>) -> Option<&str> {
idx.and_then(|i| self.revisions.get(i as usize)).and_then(|r| r.move_name.as_deref())
}
}
/// A bibliography source.
+86 -3
View File
@@ -38,7 +38,9 @@ pub(crate) struct Reader<'p> {
pkg: &'p Package,
pub doc: Document,
pub pc: PropCtx,
revs: HashMap<(u8, String, String), u32>,
revs: HashMap<(u8, String, String, Option<String>), u32>,
/// Move ranges (`w:moveFromRangeStart` …) seen so far.
pub moves: MoveCtx,
media_by_path: HashMap<String, String>,
hf_by_path: HashMap<String, u32>,
pub footnotes: HashMap<i64, u32>,
@@ -104,6 +106,7 @@ pub(crate) fn read_package(bytes: &[u8]) -> Result<Document, DocxError> {
doc,
pc: PropCtx::default(),
revs: HashMap::new(),
moves: MoveCtx::default(),
media_by_path: HashMap::new(),
hf_by_path: HashMap::new(),
footnotes: HashMap::new(),
@@ -191,6 +194,7 @@ pub(crate) fn read_package(bytes: &[u8]) -> Result<Document, DocxError> {
let manifest = r.embedded.to_bytes();
r.doc.passthrough.insert(EMBEDDED_PARTS.into(), Arc::new(manifest));
}
r.finish_moves();
let links = std::mem::take(&mut r.txbx_links);
link_text_boxes(&mut r.doc, links);
let mut doc = r.doc;
@@ -198,6 +202,81 @@ pub(crate) fn read_package(bytes: &[u8]) -> Result<Document, DocxError> {
Ok(doc)
}
/// Most move ranges open at once, and most move names tracked, per file.
const MAX_OPEN_MOVES: usize = 64;
const MAX_MOVES: usize = 100_000;
/// The move ranges of a file (ECMA-376 §17.13.5.22–27): a `w:moveFrom` / `w:moveTo` is one end
/// of a move when it sits inside a range of that kind; the range's name links both ends.
#[derive(Default)]
pub(crate) struct MoveCtx {
/// Open source and destination ranges: (`w:id`, `w:name`).
open: [Vec<(String, String)>; 2],
/// Per name: a source range started / ended, a destination range started / ended (bits 0–3).
seen: HashMap<String, u8>,
/// The range of each kind that ended last in the paragraph being read (for its mark).
pub closed_here: [Option<String>; 2],
}
impl Reader<'_> {
/// A `w:moveFromRangeStart/End` or `w:moveToRangeStart/End`.
pub fn move_range(&mut self, e: &El) {
let (side, start) = match e.name.as_str() {
"w:moveFromRangeStart" => (0, true),
"w:moveFromRangeEnd" => (0, false),
"w:moveToRangeStart" => (1, true),
"w:moveToRangeEnd" => (1, false),
_ => return,
};
let id = e.attr("w:id").unwrap_or("").chars().take(64).collect::<String>();
let m = &mut self.moves;
let Some(open) = m.open.get_mut(side) else { return };
if start {
let name = e.attr("w:name").unwrap_or("").chars().take(256).collect::<String>();
if name.is_empty() || open.len() >= MAX_OPEN_MOVES || (m.seen.len() >= MAX_MOVES && !m.seen.contains_key(&name)) {
return;
}
*m.seen.entry(name.clone()).or_default() |= 1 << (side * 2);
open.push((id, name));
} else if let Some(i) = open.iter().rposition(|(x, _)| *x == id) {
let (_, name) = open.remove(i);
*m.seen.entry(name.clone()).or_default() |= 2 << (side * 2);
if let Some(c) = m.closed_here.get_mut(side) {
*c = Some(name);
}
}
}
/// The revision of a `w:moveFrom` (`to` false) or `w:moveTo`: one end of the innermost open
/// range of its kind, else (no range) a plain deletion or insertion.
pub fn move_revision(&mut self, to: bool, e: &El, mark: bool) -> u32 {
let side = usize::from(to);
let open = self.moves.open.get(side).and_then(|o| o.last()).map(|(_, n)| n.clone());
// A paragraph mark comes after its paragraph's text: a range that ended in it counts.
let name = if mark { open.or_else(|| self.moves.closed_here.get(side).cloned().flatten()) } else { open };
let kind = if to { RevisionKind::Insert } else { RevisionKind::Delete };
self.revision_named(kind, e, name)
}
/// Moves missing an end (a source or destination range, or its text) are plain deletions
/// and insertions.
fn finish_moves(&mut self) {
let mut ends: HashMap<&str, u8> = HashMap::new();
for r in &self.doc.revisions {
if let Some(n) = r.move_name.as_deref() {
*ends.entry(n).or_default() |= if r.kind == RevisionKind::Delete { 1 } else { 2 };
}
}
let ok: HashSet<String> =
ends.into_iter().filter(|(n, e)| *e == 3 && self.moves.seen.get(*n) == Some(&0b1111)).map(|(n, _)| n.to_string()).collect();
for r in &mut self.doc.revisions {
if r.move_name.as_ref().is_some_and(|n| !ok.contains(n)) {
r.move_name = None;
}
}
}
}
/// Link the text boxes read as (id, seq, part) into chains (see [`Reader::txbx_links`]): each
/// id's boxes in `seq` order after the box holding the text. A repeated seq, a chain without its
/// first box or more than [`wordcraft_doc::MAX_LINKED_BOXES`] boxes: the extra boxes stay apart.
@@ -337,6 +416,10 @@ impl Reader<'_> {
}
pub fn revision(&mut self, kind: RevisionKind, e: &El) -> u32 {
self.revision_named(kind, e, None)
}
fn revision_named(&mut self, kind: RevisionKind, e: &El, move_name: Option<String>) -> u32 {
let author = e.attr("w:author").unwrap_or("").chars().take(256).collect::<String>();
let date = e.attr("w:date").unwrap_or("").chars().take(64).collect::<String>();
let k = match kind {
@@ -344,12 +427,12 @@ impl Reader<'_> {
RevisionKind::Delete => 1,
RevisionKind::Format => 2,
};
let key = (k, author.clone(), date.clone());
let key = (k, author.clone(), date.clone(), move_name.clone());
if let Some(i) = self.revs.get(&key) {
return *i;
}
let i = self.doc.revisions.len() as u32;
self.doc.revisions.push(Revision { kind, author, date });
self.doc.revisions.push(Revision { kind, author, date, move_name });
self.revs.insert(key, i);
i
}
+18 -4
View File
@@ -203,6 +203,7 @@ impl Reader<'_> {
sc.pending.push(o);
}
}
"w:moveFromRangeStart" | "w:moveFromRangeEnd" | "w:moveToRangeStart" | "w:moveToRangeEnd" => self.move_range(e),
_ => {}
}
}
@@ -254,6 +255,8 @@ impl Reader<'_> {
fn read_para(&mut self, sc: &mut StoryCtx, e: &El, rels: &Rels, depth: usize) -> Paragraph {
let mut pb = PB::default();
let mut para = Paragraph::new();
let mut mark_moves: [Option<&El>; 2] = [None, None];
self.moves.closed_here = [None, None];
for o in std::mem::take(&mut sc.pending) {
pb.push_obj(o, &CharProps::default());
}
@@ -266,8 +269,12 @@ impl Reader<'_> {
if let Some(rpr) = ppr.child("w:rPr") {
for k in rpr.els() {
match k.name.as_str() {
"w:ins" | "w:moveTo" => para.mark.ins = Some(self.revision(RevisionKind::Insert, k)),
"w:del" | "w:moveFrom" => para.mark.del = Some(self.revision(RevisionKind::Delete, k)),
"w:ins" => para.mark.ins = Some(self.revision(RevisionKind::Insert, k)),
"w:del" => para.mark.del = Some(self.revision(RevisionKind::Delete, k)),
// A moved paragraph mark (§17.13.5.21, §17.13.5.24): its move range
// usually starts in the paragraph's content, so it is read after that.
"w:moveTo" => mark_moves[1] = Some(k),
"w:moveFrom" => mark_moves[0] = Some(k),
_ => {}
}
}
@@ -292,6 +299,12 @@ impl Reader<'_> {
let ctx = RunCtx::default();
self.read_inline_children(sc, &mut pb, e, rels, &ctx, depth + 1);
self.spill_fields(sc, &mut pb);
if let Some(k) = mark_moves[0] {
para.mark.del = Some(self.move_revision(false, k, true));
}
if let Some(k) = mark_moves[1] {
para.mark.ins = Some(self.move_revision(true, k, true));
}
para.text = pb.text;
para.runs = pb.runs;
para.objects = pb.objects;
@@ -357,14 +370,15 @@ impl Reader<'_> {
}
"w:ins" | "w:moveTo" => {
let mut c = ctx.clone();
c.ins = Some(self.revision(RevisionKind::Insert, k));
c.ins = Some(if k.name == "w:moveTo" { self.move_revision(true, k, false) } else { self.revision(RevisionKind::Insert, k) });
self.read_inline_children(sc, pb, k, rels, &c, depth + 1);
}
"w:del" | "w:moveFrom" => {
let mut c = ctx.clone();
c.del = Some(self.revision(RevisionKind::Delete, k));
c.del = Some(if k.name == "w:moveFrom" { self.move_revision(false, k, false) } else { self.revision(RevisionKind::Delete, k) });
self.read_inline_children(sc, pb, k, rels, &c, depth + 1);
}
"w:moveFromRangeStart" | "w:moveFromRangeEnd" | "w:moveToRangeStart" | "w:moveToRangeEnd" => self.move_range(k),
"w:fldSimple" => {
let instr = k.attr("w:instr").unwrap_or("").to_string();
let props = k.child("w:r").and_then(|r| r.child("w:rPr")).map(|p| self.run_props(p, ctx)).unwrap_or_else(|| self.run_props_none(ctx));
+3
View File
@@ -68,6 +68,8 @@ pub(crate) struct Writer<'d> {
media_files: BTreeMap<String, String>,
bookmarks: HashMap<String, u32>,
next_rev_id: u32,
/// Move ranges open in the output: (revision, range `w:id`, destination?).
open_moves: Vec<(u32, String, bool)>,
docpr: u32,
z: u32,
/// Note part id → file note id, in first-reference order.
@@ -125,6 +127,7 @@ pub fn write_as(doc: &Document, flavor: Flavor) -> Result<Vec<u8>, DocxError> {
media_files: BTreeMap::new(),
bookmarks: HashMap::new(),
next_rev_id: 0,
open_moves: Vec::new(),
docpr: 0,
z: 251_658_240,
footnotes: Vec::new(),
+63 -3
View File
@@ -58,11 +58,52 @@ impl Writer<'_> {
}
}
}
if depth == 0 {
self.move_ranges(w, &[]);
}
if bl.last().is_none_or(|b| matches!(**b, Block::Table(_))) {
w.raw("<w:p/>");
}
}
/// The moves (revision, destination?) in `props`.
fn moves_of(&self, props: &CharProps) -> Vec<(u32, bool)> {
[(props.del, false), (props.ins, true)]
.into_iter()
.filter_map(|(r, to)| r.filter(|r| self.doc.move_name(Some(*r)).is_some()).map(|r| (r, to)))
.collect()
}
/// Bring the open move ranges to `want` (ECMA-376 §17.13.5.22–27): end the ranges of moves
/// that stop here, start those that begin. A range is written around each stretch of a
/// move's text (and paragraph marks), with the move's name, author and date.
fn move_ranges(&mut self, w: &mut W, want: &[(u32, bool)]) {
let mut k = 0;
while k < self.open_moves.len() {
match self.open_moves.get(k) {
Some((rev, id, to)) if !want.contains(&(*rev, *to)) => {
w.empty(if *to { "w:moveToRangeEnd" } else { "w:moveFromRangeEnd" }, &[("w:id", id)]);
self.open_moves.remove(k);
}
_ => k += 1,
}
}
for &(rev, to) in want {
if self.open_moves.iter().any(|(r, _, t)| *r == rev && *t == to) {
continue;
}
let name = self.doc.move_name(Some(rev)).unwrap_or("").to_string();
let (id, author, date) = self.rev_attrs(rev);
let tag = if to { "w:moveToRangeStart" } else { "w:moveFromRangeStart" };
if date.is_empty() {
w.empty(tag, &[("w:id", &id), ("w:name", &name), ("w:author", &author)]);
} else {
w.empty(tag, &[("w:id", &id), ("w:name", &name), ("w:author", &author), ("w:date", &date)]);
}
self.open_moves.push((rev, id, to));
}
}
fn para(&mut self, w: &mut W, p: &Paragraph, rels: &mut PartRels, top: bool, depth: usize) {
// A drop cap is a separate framed paragraph holding the first character.
if let Some(lines) = p.props.drop_cap.filter(|l| *l > 0)
@@ -99,6 +140,7 @@ impl Writer<'_> {
let mark_revs: Vec<(u32, &str)> = [(p.mark.ins, "w:ins"), (p.mark.del, "w:del")]
.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)))
.map(|(i, tag)| (i, self.rev_tag(i, tag)))
.collect();
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() {
@@ -162,6 +204,9 @@ impl Writer<'_> {
if toc_end {
w.raw(r#"<w:r><w:fldChar w:fldCharType="end"/></w:r>"#);
}
// Move ranges stay open across a moved paragraph mark; others end with the paragraph.
let mark_moves = self.moves_of(&p.mark);
self.move_ranges(w, &mark_moves);
w.close("w:p");
}
@@ -241,6 +286,8 @@ impl Writer<'_> {
j += 1;
}
let del = self.is_del(props);
let moves = self.moves_of(props);
self.move_ranges(w, &moves);
self.rev_open(w, props);
w.open("w:r", &[]);
self.rpr(w, props);
@@ -270,8 +317,18 @@ impl Writer<'_> {
idx.and_then(|i| self.doc.revisions.get(i as usize)).map(|r| r.kind)
}
/// Deleted text goes in `w:delText`; text moved away stays in `w:t` (§17.13.5.22).
fn is_del(&self, props: &CharProps) -> bool {
props.del.is_some() && self.rev_kind(props.del) != Some(RevisionKind::Format)
props.del.is_some() && self.rev_kind(props.del) != Some(RevisionKind::Format) && self.doc.move_name(props.del).is_none()
}
/// `w:ins` / `w:del`, or `w:moveTo` / `w:moveFrom` when revision `idx` is one end of a move.
fn rev_tag(&self, idx: u32, tag: &'static str) -> &'static str {
match (self.doc.move_name(Some(idx)).is_some(), tag) {
(true, "w:ins") => "w:moveTo",
(true, "w:del") => "w:moveFrom",
_ => tag,
}
}
fn rev_attrs(&mut self, idx: u32) -> (String, String, String) {
@@ -316,6 +373,7 @@ impl Writer<'_> {
if self.rev_kind(Some(idx)) == Some(RevisionKind::Format) {
continue;
}
let tag = self.rev_tag(idx, tag);
let (id, author, date) = self.rev_attrs(idx);
if date.is_empty() {
w.open(tag, &[("w:id", &id), ("w:author", &author)]);
@@ -327,8 +385,10 @@ impl Writer<'_> {
fn rev_close(&mut self, w: &mut W, props: &CharProps) {
for (idx, tag) in [(props.del, "w:del"), (props.ins, "w:ins")] {
if idx.is_some() && self.rev_kind(idx) != Some(RevisionKind::Format) {
w.close(tag);
if let Some(i) = idx
&& self.rev_kind(idx) != Some(RevisionKind::Format)
{
w.close(self.rev_tag(i, tag));
}
}
}
+44 -1
View File
@@ -4,7 +4,7 @@ use std::io::Write;
use wordcraft_doc::para::{Anchor, FloatAlign, NoteKind, ShapeKind, Wrap};
use wordcraft_doc::props::{Align, Border, BorderStyle, Rgb, TextColor, VMerge};
use wordcraft_doc::{Block, Document, InlineObject, Paragraph};
use wordcraft_doc::{Block, Document, InlineObject, Paragraph, RevisionKind};
const W_NS: &str = r#"xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships" xmlns:wp="http://schemas.openxmlformats.org/drawingml/2006/wordprocessingDrawing" xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main" xmlns:pic="http://schemas.openxmlformats.org/drawingml/2006/picture" xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" xmlns:wps="http://schemas.microsoft.com/office/word/2010/wordprocessingShape" xmlns:wpg="http://schemas.microsoft.com/office/word/2010/wordprocessingGroup" xmlns:v="urn:schemas-microsoft-com:vml" xmlns:o="urn:schemas-microsoft-com:office:office""#;
@@ -1082,6 +1082,49 @@ fn formatting_revisions_round_trip() {
assert_eq!(again.revisions, d.revisions);
}
/// Issue #400: moved text (ECMA-376 §17.13.5.21–28). `w:moveFrom` / `w:moveTo` inside named
/// move ranges are the two ends of one move, paragraph marks included; the ranges are written
/// back around them. A move missing an end, or whose range never ends, is a plain deletion and
/// insertion.
#[test]
fn move_revisions_round_trip() {
let body = r#"
<w:p><w:pPr><w:rPr><w:moveTo w:id="1" w:author="Ana" w:date="2026-10-03T00:00:00Z"/></w:rPr></w:pPr><w:moveToRangeStart w:id="2" w:name="move1" w:author="Ana" w:date="2026-10-03T00:00:00Z"/><w:moveTo w:id="3" w:author="Ana" w:date="2026-10-03T00:00:00Z"><w:r><w:t>Moved here.</w:t></w:r></w:moveTo></w:p>
<w:moveToRangeEnd w:id="2"/>
<w:p><w:r><w:t xml:space="preserve">Stays. </w:t></w:r><w:moveFromRangeStart w:id="4" w:name="move1" w:author="Ana" w:date="2026-10-03T00:00:00Z"/><w:moveFrom w:id="5" w:author="Ana" w:date="2026-10-03T00:00:00Z"><w:r><w:t>Moved here.</w:t></w:r></w:moveFrom><w:moveFromRangeEnd w:id="4"/></w:p>
<w:p><w:moveFromRangeStart w:id="6" w:name="lonely" w:author="Bo"/><w:moveFrom w:id="7" w:author="Bo"><w:r><w:t>No destination</w:t></w:r></w:moveFrom><w:moveFromRangeEnd w:id="6"/><w:moveToRangeStart w:id="8" w:name="open" w:author="Bo"/><w:moveTo w:id="9" w:author="Bo"><w:r><w:t>never ends</w:t></w:r></w:moveTo><w:moveTo w:id="10" w:author="Cy"><w:r><w:t>no range</w:t></w:r></w:moveTo><w:moveFromRangeEnd w:id="404"/></w:p>"#;
let check = |d: &Document| {
let p = paras(d);
let name = |r: Option<u32>| d.move_name(r).map(str::to_string);
assert_eq!(name(p[0].mark.ins).as_deref(), Some("move1"), "a moved paragraph mark");
let to = p[0].runs[0].props.ins;
assert_eq!((name(to).as_deref(), d.revisions[to.unwrap() as usize].kind), (Some("move1"), RevisionKind::Insert));
let from = p[1].run_ranges().find(|(_, c)| c.del.is_some()).map(|(_, c)| c.del).unwrap();
assert_eq!((name(from).as_deref(), d.revisions[from.unwrap() as usize].kind), (Some("move1"), RevisionKind::Delete));
assert_eq!(d.revisions[from.unwrap() as usize].author, "Ana");
// Hostile ends: each is a plain revision, still tracked.
for (r, c) in p[2].run_ranges() {
assert!(c.ins.or(c.del).is_some(), "{r:?} keeps its revision");
assert_eq!(name(c.ins.or(c.del)), None, "{r:?} is no move");
}
};
let d = read_body(body);
check(&d);
let out = wordcraft_docx::write(&d).unwrap();
let xml = text(&unzip(&out), "word/document.xml");
assert_eq!(attr_values(&xml, "w:moveFromRangeStart", "w:name"), ["move1"]);
assert_eq!(attr_values(&xml, "w:moveToRangeStart", "w:name"), ["move1"]);
assert_eq!(attr_values(&xml, "w:moveToRangeStart", "w:author"), ["Ana"]);
assert_eq!(attr_values(&xml, "w:moveToRangeStart", "w:id"), attr_values(&xml, "w:moveToRangeEnd", "w:id"));
assert_eq!(attr_values(&xml, "w:moveFromRangeStart", "w:id"), attr_values(&xml, "w:moveFromRangeEnd", "w:id"));
assert!(xml.contains("<w:moveFrom ") && !xml.contains("<w:delText xml:space=\"preserve\">Moved"), "moved-away text stays in w:t");
// The range around the moved paragraph includes its mark: it ends in the next paragraph.
let to_start = xml.find("<w:moveToRangeStart").unwrap();
let to_end = xml.find("<w:moveToRangeEnd").unwrap();
assert!(xml[to_start..to_end].contains("</w:p>"), "{xml}");
check(&wordcraft_docx::read(&out).unwrap());
}
/// A document's own footnote separators and continuation notice are kept (ECMA-376 §17.11), and
/// written back as Word numbers them: -1, 0 and 1, the notes after them.
#[test]
+5 -5
View File
@@ -769,8 +769,8 @@ fn toc_field_skips_nested_stories() {
#[test]
fn tracked_changes_round_trip() {
let mut d = Document::new();
d.revisions.push(Revision { kind: RevisionKind::Insert, author: "Alice".into(), date: "2026-05-01T10:00:00Z".into() });
d.revisions.push(Revision { kind: RevisionKind::Delete, author: "Bob".into(), date: "2026-05-02T10:00:00Z".into() });
d.revisions.push(Revision { kind: RevisionKind::Insert, author: "Alice".into(), date: "2026-05-01T10:00:00Z".into(), move_name: None });
d.revisions.push(Revision { kind: RevisionKind::Delete, author: "Bob".into(), date: "2026-05-02T10:00:00Z".into(), move_name: None });
d.settings.track_changes = true;
let ins = CharProps { ins: Some(0), ..Default::default() };
let del = CharProps { del: Some(1), bold: Some(true), ..Default::default() };
@@ -794,8 +794,8 @@ fn tracked_paragraph_marks_round_trip() {
// Issue #229: an inserted / deleted paragraph mark is `w:ins` / `w:del` in the mark's
// `w:rPr` (ECMA-376 §17.13.5.16, §17.13.5.15), alone or with mark formatting.
let mut d = Document::new();
d.revisions.push(Revision { kind: RevisionKind::Insert, author: "Alice".into(), date: "2026-05-01T10:00:00Z".into() });
d.revisions.push(Revision { kind: RevisionKind::Delete, author: "Bob".into(), date: "2026-05-02T10:00:00Z".into() });
d.revisions.push(Revision { kind: RevisionKind::Insert, author: "Alice".into(), date: "2026-05-01T10:00:00Z".into(), move_name: None });
d.revisions.push(Revision { kind: RevisionKind::Delete, author: "Bob".into(), date: "2026-05-02T10:00:00Z".into(), move_name: None });
let mut split = Paragraph::with_text("Owned ALPHA ", CharProps::default());
split.mark.ins = Some(0);
let mut joined = Paragraph::with_text("Owned BETA", CharProps::default());
@@ -1156,7 +1156,7 @@ fn kitchen_sink() -> Document {
let f = d.add_part(PartKind::Footnote, vec![para_block(Paragraph::with_text("Note", CharProps::default()))]);
let c = d.add_part(PartKind::Comment, vec![para_block(Paragraph::with_text("Comment", CharProps::default()))]);
d.comments.insert(3, Comment { author: "X".into(), part: c, ..Default::default() });
d.revisions.push(Revision { kind: RevisionKind::Insert, author: "A".into(), date: String::new() });
d.revisions.push(Revision { kind: RevisionKind::Insert, author: "A".into(), date: String::new(), move_name: None });
d.last_section.headers.default = Some(h);
let mut blocks: Blocks = Vec::new();
let mut p = para_runs(&[
+4 -1
View File
@@ -109,7 +109,10 @@ fn copy(s: &mut Session, _: &Value) -> CmdResult {
fn cut(s: &mut Session, v: &Value) -> CmdResult {
let r = copy(s, v)?;
delete_selection(s)?;
// A tracked cut of a paragraph or sentence may become a move when pasted.
if !super::moves::cut_tracked(s)? {
delete_selection(s)?;
}
Ok(r)
}
+1 -1
View File
@@ -49,7 +49,7 @@ impl Recorder<'_> {
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.push(Revision { kind: RevisionKind::Format, author: self.author.clone(), date: self.date.clone(), move_name: None });
self.revisions.len().saturating_sub(1) as u32
}
};
+17 -2
View File
@@ -16,6 +16,7 @@ pub mod insert;
pub mod inspector;
pub mod lists;
pub mod mailings;
pub mod moves;
pub mod objects;
pub mod page;
pub mod para;
@@ -116,10 +117,19 @@ pub fn delete_selection(s: &mut Session) -> Result<Pos, CmdError> {
/// A revision record for the current author.
pub fn new_revision(s: &mut Session, kind: RevisionKind) -> u32 {
let date = now_iso();
if let Some((i, _)) = s.doc.revisions.iter().enumerate().rev().find(|(_, r)| r.kind == kind && r.author == s.author && r.date == date) {
// Never one end of a move, nor the deletion a cut may still turn into one.
let held = s.pending_move.as_ref().map(|m| m.rev);
if let Some((i, _)) = s
.doc
.revisions
.iter()
.enumerate()
.rev()
.find(|(i, r)| r.kind == kind && r.author == s.author && r.date == date && r.move_name.is_none() && held != Some(*i as u32))
{
return i as u32;
}
s.doc.revisions.push(Revision { kind, author: s.author.clone(), date });
s.doc.revisions.push(Revision { kind, author: s.author.clone(), date, move_name: None });
(s.doc.revisions.len() - 1) as u32
}
@@ -150,6 +160,11 @@ pub fn join_next_para(s: &mut Session, story: StoryRef, path: &wordcraft_doc::Pa
/// Tracked deletion: own insertions are removed, other text is marked deleted.
pub(crate) fn track_delete(s: &mut Session, a: &Pos, b: &Pos) -> Result<Pos, CmdError> {
let rid = new_revision(s, RevisionKind::Delete);
track_delete_as(s, a, b, rid)
}
/// [`track_delete`] marking the deleted text with revision `rid`.
pub(crate) fn track_delete_as(s: &mut Session, a: &Pos, b: &Pos, rid: u32) -> Result<Pos, CmdError> {
let author = s.author.clone();
// Remove text this author inserted (it never existed for the reader); mark the rest.
let paths = s.doc.paths_between(a, b);
+262
View File
@@ -0,0 +1,262 @@
//! Tracked moves (Review › Track Changes, ECMA-376 §17.13.5.21–28): with Track Changes on, a
//! paragraph or sentence cut and pasted elsewhere is recorded as a move, its source (`w:moveFrom`,
//! a deletion revision) and destination (`w:moveTo`, an insertion revision) linked by a move
//! name. Accepting or rejecting either end resolves both.
use std::collections::BTreeSet;
use wordcraft_doc::edit::Fragment;
use wordcraft_doc::{Pos, Revision, RevisionKind, StoryRef};
use super::{now_iso, track_delete_as};
use crate::{CmdError, Selection, Session};
/// The deletion the last tracked cut recorded, which the paste right after it makes a move.
#[derive(Clone, Debug)]
pub struct PendingMove {
/// Its revision (made for this cut alone).
pub rev: u32,
/// The text that was cut.
pub text: String,
pub author: String,
}
/// Whether cut text is big enough to be moved rather than deleted and inserted, as Word does: a
/// whole paragraph (it ends in, or holds, a paragraph mark) or at least a sentence.
pub fn move_sized(text: &str) -> bool {
let t = text.trim_end_matches([' ', '\u{a0}']);
if t.contains('\n') {
return true;
}
let t = t.trim_end_matches(['"', '\'', '\u{201d}', '\u{2019}', ')']);
t.ends_with(['.', '!', '?', '\u{2026}']) && t.split_whitespace().nth(1).is_some()
}
/// Every story.
fn stories(s: &Session) -> Vec<StoryRef> {
std::iter::once(StoryRef::Body).chain(s.doc.parts.keys().map(|k| StoryRef::Part(*k))).collect()
}
/// Whether some text or paragraph mark carries revision `rev` as its insertion or deletion.
fn in_use(s: &Session, rev: u32) -> bool {
stories(s).into_iter().any(|st| {
s.doc.para_paths(st).iter().any(|path| {
s.doc.para(st, path).is_some_and(|p| {
p.mark.ins == Some(rev) || p.mark.del == Some(rev) || p.runs.iter().any(|r| r.props.ins == Some(rev) || r.props.del == Some(rev))
})
})
})
}
/// Cut's deletion under Track Changes: a cut big enough to be moved gets a revision of its own
/// and is remembered, so the paste right after it can make it a move. Returns `false` (nothing
/// done) when it isn't such a cut.
pub fn cut_tracked(s: &mut Session) -> Result<bool, CmdError> {
let (a, b) = s.sel.ordered();
if !s.doc.settings.track_changes || a == b || a.story != b.story || !move_sized(&s.clipboard_text) {
return Ok(false);
}
s.doc.revisions.push(Revision::new(RevisionKind::Delete, s.author.clone(), now_iso()));
let rid = (s.doc.revisions.len() - 1) as u32;
let at = track_delete_as(s, &a, &b, rid)?;
s.sel = Selection::caret(at);
// Only the author's own insertions were cut: they are gone, not moved.
if in_use(s, rid) {
s.pending_move = Some(PendingMove { rev: rid, text: s.clipboard_text.clone(), author: s.author.clone() });
}
Ok(true)
}
/// A tracked paste of what the last command cut: makes that cut the source of a move and
/// returns the revision for the pasted text (the destination). `None` = an ordinary insertion.
pub fn take_paste(s: &mut Session, frag: &Fragment) -> Option<u32> {
let m = s.pending_move.take()?;
if m.author != s.author || frag.plain_text() != m.text || !in_use(s, m.rev) {
return None;
}
let src = s.doc.revisions.get(m.rev as usize).filter(|r| r.kind == RevisionKind::Delete && r.move_name.is_none())?;
let date = src.date.clone();
let taken: BTreeSet<&str> = s.doc.revisions.iter().filter_map(|r| r.move_name.as_deref()).collect();
let name = (1..).map(|n: u64| format!("move{n}")).find(|n| !taken.contains(n.as_str()))?;
if let Some(r) = s.doc.revisions.get_mut(m.rev as usize) {
r.move_name = Some(name.clone());
}
s.doc.revisions.push(Revision { kind: RevisionKind::Insert, author: s.author.clone(), date, move_name: Some(name) });
Some((s.doc.revisions.len() - 1) as u32)
}
/// The move names of the insertions and deletions in `a..b` (and of the paragraph marks it
/// covers; `caret_mark` = the mark of the paragraph at that path, for a caret before it).
pub fn names_in(s: &Session, a: &Pos, b: &Pos, caret_mark: Option<&wordcraft_doc::Path>) -> BTreeSet<String> {
let mut out = BTreeSet::new();
let mut add = |r: Option<u32>| {
if let Some(n) = s.doc.move_name(r) {
out.insert(n.to_string());
}
};
for path in s.doc.paths_between(a, b) {
let Some(p) = s.doc.para(a.story, &path) else { continue };
let from = if path == a.path { a.off } else { 0 };
let to = if path == b.path { b.off } else { p.len() };
for (r, c) in p.run_ranges() {
if (r.end > from && r.start < to) || (from == to && r.start <= from && from <= r.end) {
add(c.ins);
add(c.del);
}
}
if path != b.path || caret_mark == Some(&path) {
add(p.mark.ins);
add(p.mark.del);
}
}
out
}
/// Accept or reject every end of the moves named `names`: accepting removes the source and
/// keeps the destination, rejecting removes the destination and restores the source.
pub fn resolve(s: &mut Session, names: &BTreeSet<String>, accept: bool) -> Result<(), CmdError> {
if names.is_empty() {
return Ok(());
}
let named = |s: &Session, r: Option<u32>| s.doc.move_name(r).is_some_and(|n| names.contains(n));
for st in stories(s) {
for path in s.doc.para_paths(st).into_iter().rev() {
let Some(p) = s.doc.para(st, &path) else { continue };
let ranges: Vec<(usize, usize, bool, bool)> =
p.run_ranges().map(|(r, c)| (r.start, r.end, named(s, c.ins), named(s, c.del))).filter(|(_, _, ins, del)| *ins || *del).collect();
let (mark_ins, mark_del) = (named(s, p.mark.ins), named(s, p.mark.del));
if ranges.is_empty() && !mark_ins && !mark_del {
continue;
}
let para = s.doc.para_mut(st, &path)?;
for (a, b, ins, del) in ranges.into_iter().rev() {
if (del && accept) || (ins && !accept) {
para.delete(a, b)?;
} else {
para.format(a, b, &|c| {
if ins {
c.ins = None;
}
if del {
c.del = None;
}
})?;
}
}
if !mark_ins && !mark_del {
continue;
}
// A moved paragraph mark that goes away joins the next paragraph to this one.
if ((mark_del && accept) || (mark_ins && !accept)) && super::join_next_para(s, st, &path)? {
continue;
}
let para = s.doc.para_mut(st, &path)?;
if mark_ins {
para.mark.ins = None;
}
if mark_del {
para.mark.del = None;
}
para.touch();
}
}
Ok(())
}
/// Whether the body has moved text (cheap: stops at the first).
pub fn has_moves(doc: &wordcraft_doc::Document) -> bool {
if !doc.revisions.iter().any(|r| r.move_name.is_some()) {
return false;
}
doc.para_paths(StoryRef::Body).iter().any(|path| {
doc.para(StoryRef::Body, path).is_some_and(|p| {
doc.move_name(p.mark.ins).is_some()
|| doc.move_name(p.mark.del).is_some()
|| p.runs.iter().any(|r| doc.move_name(r.props.ins).is_some() || doc.move_name(r.props.del).is_some())
})
})
}
#[cfg(test)]
mod tests {
use serde_json::{Value, json};
use wordcraft_doc::{Document, Pos};
use crate::Session;
fn texts(s: &Session) -> Vec<String> {
s.doc.body.iter().filter_map(|b| b.as_para()).map(|p| p.text.clone()).collect()
}
/// Three paragraphs; with Track Changes on, the second (with its mark) cut and pasted
/// before the first.
fn moved() -> Session {
let mut s = Session::new(Document::new());
s.run("document.setText", &json!({"text": "First one.\nSecond one.\nThird one."})).unwrap();
s.run("file.setAuthor", &json!({"name": "Ana"})).unwrap();
s.run("review.trackChanges", &json!({"value": true})).unwrap();
s.run("select.range", &json!({"anchor": {"block": 1, "off": 0}, "focus": {"block": 2, "off": 0}})).unwrap();
s.run("edit.cut", &json!({})).unwrap();
s.run("caret.set", &json!({"pos": {"block": 0, "off": 0}})).unwrap();
s.run("edit.paste", &json!({})).unwrap();
s
}
fn kinds(s: &mut Session) -> Vec<(String, Value)> {
let ch = s.run("review.changes", &json!({})).unwrap();
ch.as_array().unwrap().iter().map(|c| (c["kind"].as_str().unwrap().to_string(), c["move"].clone())).collect()
}
#[test]
fn a_tracked_cut_and_paste_of_a_paragraph_is_a_move() {
let mut s = moved();
assert_eq!(texts(&s), ["Second one.", "First one.", "Second one.", "Third one."]);
let p = |i: usize| s.doc.para_at(&Pos::body(i, 0)).unwrap().clone();
let name = |r: Option<u32>| s.doc.move_name(r).map(str::to_string);
let (to, from) = (p(0), p(2));
assert_eq!(name(to.runs[0].props.ins).as_deref(), Some("move1"));
assert_eq!(name(to.mark.ins).as_deref(), Some("move1"), "the pasted paragraph mark is moved too");
assert_eq!(name(from.runs[0].props.del).as_deref(), Some("move1"));
assert_eq!(name(from.mark.del).as_deref(), Some("move1"));
assert_eq!(p(1).runs[0].props.ins, None);
let k = kinds(&mut s);
assert!(k.iter().all(|(k, m)| k.starts_with("move") && m == "move1"), "{k:?}");
// Saved and opened again, it is still a move.
let back = wordcraft_docx::read(&wordcraft_docx::write(&s.doc).unwrap()).unwrap();
let b0 = back.body[0].as_para().unwrap();
assert!(back.move_name(b0.runs[0].props.ins).is_some() && back.move_name(b0.mark.ins).is_some());
let b2 = back.body[2].as_para().unwrap();
assert_eq!(back.move_name(b2.runs[0].props.del), back.move_name(b0.runs[0].props.ins));
// A word is no move: a deletion and an insertion.
let mut s = Session::new(Document::new());
s.run("document.setText", &json!({"text": "one two"})).unwrap();
s.run("review.trackChanges", &json!({"value": true})).unwrap();
s.run("select.range", &json!({"anchor": {"block": 0, "off": 0}, "focus": {"block": 0, "off": 4}})).unwrap();
s.run("edit.cut", &json!({})).unwrap();
s.run("caret.docEnd", &json!({})).unwrap();
s.run("edit.paste", &json!({})).unwrap();
let k: Vec<String> = kinds(&mut s).into_iter().map(|(k, _)| k).collect();
assert_eq!(k, ["delete", "insert"]);
}
#[test]
fn accepting_one_end_of_a_move_accepts_both() {
let mut s = moved();
// The caret in the text moved away.
s.run("caret.set", &json!({"pos": {"block": 2, "off": 3}})).unwrap();
s.run("review.accept", &json!({})).unwrap();
assert_eq!(texts(&s), ["Second one.", "First one.", "Third one."]);
assert!(kinds(&mut s).is_empty());
}
#[test]
fn rejecting_a_move_puts_the_text_back() {
let mut s = moved();
// The caret in the moved text.
s.run("caret.set", &json!({"pos": {"block": 0, "off": 3}})).unwrap();
s.run("review.reject", &json!({})).unwrap();
assert_eq!(texts(&s), ["First one.", "Second one.", "Third one."]);
assert!(kinds(&mut s).is_empty());
}
}
+28 -6
View File
@@ -129,19 +129,41 @@ fn paste_special(s: &mut Session, v: &Value) -> CmdResult {
/// Replace the selection with `frag`, marking it as an insertion under Track Changes.
pub fn insert(s: &mut Session, mut frag: Fragment) -> Result<(), CmdError> {
let at = delete_selection(s)?;
if s.doc.settings.track_changes {
let rid = super::new_revision(s, wordcraft_doc::RevisionKind::Insert);
let track = s.doc.settings.track_changes;
let mut rid = None;
if track {
// What the last command cut, pasted again: the two ends of a move.
let r = match super::moves::take_paste(s, &frag) {
Some(r) => r,
None => super::new_revision(s, wordcraft_doc::RevisionKind::Insert),
};
rid = Some(r);
for b in &mut frag.blocks {
each_para(b, 0, &mut |p| {
for r in &mut p.runs {
r.props.ins = Some(rid);
r.props.del = None;
r.props.fmt_change = None;
for run in &mut p.runs {
run.props.ins = Some(r);
run.props.del = None;
run.props.fmt_change = None;
}
p.mark.ins = None;
p.mark.del = None;
});
}
}
let end = s.doc.insert_fragment(&at, &frag)?;
// The paragraph marks the paste added are inserted too.
if let Some(r) = rid
&& at.story == end.story
&& at.path.parent() == end.path.parent()
{
for i in at.path.last()..end.path.last() {
if let Ok(p) = s.doc.para_mut(at.story, &at.path.with_last(i)) {
p.mark.ins = Some(r);
p.mark.del = None;
p.touch();
}
}
}
s.sel = Selection::caret(end);
Ok(())
}
+45 -13
View File
@@ -370,18 +370,48 @@ impl Change {
/// 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<Change> {
let p = s.doc.para(story, path)?;
let kind = if p.mark.ins.is_some() {
"insert"
} else if p.mark.del.is_some() {
"delete"
} else {
let kind = run_kind(&s.doc, p.mark.ins, p.mark.del);
if kind.is_empty() {
return None;
};
}
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(Change { a, b, kind, rev: p.mark.ins.or(p.mark.del), props: Vec::new() })
}
/// The kind of an insertion / deletion change: `insert`, `delete`, or for one end of a move
/// `moveTo` / `moveFrom`; empty when neither.
fn run_kind(doc: &wordcraft_doc::Document, ins: Option<u32>, del: Option<u32>) -> &'static str {
match (ins, del) {
(Some(_), _) if doc.move_name(ins).is_some() => "moveTo",
(Some(_), _) => "insert",
(None, Some(_)) if doc.move_name(del).is_some() => "moveFrom",
(None, Some(_)) => "delete",
_ => "",
}
}
/// The moves in the body, for balloons: where each end starts, its author, and whether it is
/// the source (`true`) or the destination.
pub fn move_changes(s: &Session) -> Vec<(Pos, String, bool)> {
let mut seen = std::collections::BTreeSet::new();
let mut out = Vec::new();
for c in changes(s) {
let from = match c.kind {
"moveFrom" => true,
"moveTo" => false,
_ => continue,
};
let rev = c.rev.and_then(|r| s.doc.revisions.get(r as usize));
let name = rev.and_then(|r| r.move_name.clone()).unwrap_or_default();
// One balloon per end (an end runs on across paragraphs).
if seen.insert((name, from)) {
out.push((c.a, rev.map(|r| r.author.clone()).unwrap_or_default(), from));
}
}
out
}
fn resolve_all(s: &mut Session, accept: bool) -> CmdResult {
let stories: Vec<StoryRef> = std::iter::once(StoryRef::Body).chain(s.doc.parts.keys().map(|k| StoryRef::Part(*k))).collect();
for st in stories {
@@ -438,6 +468,8 @@ fn resolve_sel(s: &mut Session, accept: bool) -> CmdResult {
} else {
(a, b)
};
// Moves with an end in the range: their other ends go with them.
let moves = super::moves::names_in(s, &a, &b, caret_mark.as_ref());
for path in s.doc.paths_between(&a, &b).into_iter().rev() {
let len = s.doc.para(a.story, &path).map(|p| p.len()).unwrap_or(0);
let from = if path == a.path { a.off } else { 0 };
@@ -450,6 +482,7 @@ fn resolve_sel(s: &mut Session, accept: bool) -> CmdResult {
resolve_cell(s, a.story, &path, accept)?;
}
}
super::moves::resolve(s, &moves, accept)?;
s.sel = Selection::caret(a);
s.clamp_selection();
sel_result(s)
@@ -480,13 +513,7 @@ fn changes(s: &Session) -> Vec<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 {
""
};
let kind = run_kind(&s.doc, c.ins, 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() });
}
@@ -578,6 +605,11 @@ fn list_changes(s: &mut Session, _: &Value) -> CmdResult {
v["description"] = json!(fmt_revisions::describe(&c.props));
v["props"] = json!(c.props);
}
// One end of a move: its name links it with the other end.
if let Some(name) = rev.and_then(|r| r.move_name.as_deref()).filter(|_| c.kind.starts_with("move")) {
v["move"] = json!(name);
v["description"] = json!(if c.kind == "moveFrom" { "Moved from" } else { "Moved to" });
}
v
})
.collect(),
+2 -2
View File
@@ -608,9 +608,9 @@ fn compare(s: &mut Session, v: &Value) -> CmdResult {
let new: Vec<Paragraph> = revised.body.iter().filter_map(|b| b.as_para().cloned()).collect();
let author = "Compare".to_string();
let date = super::now_iso();
s.doc.revisions.push(wordcraft_doc::Revision { kind: RevisionKind::Insert, author: author.clone(), date: date.clone() });
s.doc.revisions.push(wordcraft_doc::Revision { kind: RevisionKind::Insert, author: author.clone(), date: date.clone(), move_name: None });
let ins = (s.doc.revisions.len() - 1) as u32;
s.doc.revisions.push(wordcraft_doc::Revision { kind: RevisionKind::Delete, author, date });
s.doc.revisions.push(wordcraft_doc::Revision { kind: RevisionKind::Delete, author, date, move_name: None });
let del = (s.doc.revisions.len() - 1) as u32;
let ops = lcs_ops(&old.iter().map(|p| p.plain_text()).collect::<Vec<_>>(), &new.iter().map(|p| p.plain_text()).collect::<Vec<_>>());
let mut out: Vec<Paragraph> = Vec::new();
+9
View File
@@ -197,6 +197,8 @@ pub struct Session {
pub path: Option<std::path::PathBuf>,
pub dirty: bool,
pub clipboard: Option<Fragment>,
/// A tracked cut the next paste can turn into a move (see `cmd::moves`).
pub pending_move: Option<crate::cmd::moves::PendingMove>,
/// Plain text mirror of the clipboard (for the system clipboard).
pub clipboard_text: String,
/// Items collected by Copy and Cut this session, for the Clipboard pane.
@@ -324,6 +326,7 @@ impl Session {
path: None,
dirty: false,
clipboard: None,
pending_move: None,
clipboard_text: String::new(),
clip_history: Default::default(),
find: FindState::default(),
@@ -600,6 +603,8 @@ impl Session {
path: _,
dirty,
clipboard: _,
// Like the clipboard: what the next paste can make a move of.
pending_move: _,
clipboard_text: _,
clip_history: _,
find: _,
@@ -774,6 +779,10 @@ impl Session {
} else if spec.id.starts_with("caret.") {
self.typing_open = false;
}
// Only the paste right after a tracked cut makes it a move.
if (spec.mutates || matches!(id, "edit.undo" | "edit.redo")) && !matches!(id, "edit.cut" | "edit.paste" | "edit.pasteSpecial") {
self.pending_move = None;
}
let sel_before = self.sel.clone();
let run = spec.run;
let mutates = spec.mutates;
+11 -2
View File
@@ -295,6 +295,9 @@ impl MarkupOptions {
}
}
/// Colour of moved text (both ends of a tracked move).
pub const MOVE_COLOR: Rgb = Rgb(0x1A, 0x7F, 0x37);
/// Colour for revisions by author index.
pub fn revision_color(i: u32) -> Rgb {
const C: [Rgb; 6] =
@@ -646,13 +649,18 @@ fn lines(
let del = rc.del.filter(|_| shown);
let ins = rc.ins.filter(|_| shown && del.is_none());
let author = |r: u32| revision_color(doc.revisions.get(r as usize).map(|v| author_index(doc, &v.author)).unwrap_or(0));
// Moved text: green, double strikethrough where it was, double underline where it went.
let moved_to = doc.move_name(ins).is_some();
let moved_from = del.is_some() && doc.move_name(del).is_some();
let color = match (ins, del) {
(_, Some(_)) if moved_from => MOVE_COLOR,
(Some(_), _) if moved_to => MOVE_COLOR,
(Some(r), _) => m.insert_color.unwrap_or_else(|| author(r)),
(None, Some(r)) => m.delete_color.unwrap_or_else(|| author(r)),
_ => text_color(&rc.color, rc.shading.or(rc.highlight)),
};
let ins_mark = ins.map(|_| m.insert_mark);
let del_mark = del.map(|_| m.delete_mark);
let ins_mark = ins.map(|_| if moved_to { InsertMark::DoubleUnderline } else { m.insert_mark });
let del_mark = del.map(|_| if moved_from { DeleteMark::DoubleStrikethrough } else { m.delete_mark });
if del_mark == Some(DeleteMark::Caret) {
// A caret where the text was, instead of the text.
if let Some(cx) = line.cl_left(run_start).map(|c| x + c) {
@@ -918,6 +926,7 @@ fn lines(
// A tracked (inserted or deleted) paragraph mark is drawn in its author's colour.
let m = &opts.revisions;
let color = para.filter(|_| opts.markup && m.insertions_deletions).and_then(|p| match (p.mark.ins, p.mark.del) {
(ins, del) if doc.move_name(del).is_some() || (del.is_none() && doc.move_name(ins).is_some()) => Some(MOVE_COLOR),
(_, Some(r)) => Some(
m.delete_color
.unwrap_or_else(|| revision_color(doc.revisions.get(r as usize).map(|v| author_index(doc, &v.author)).unwrap_or(0))),
+6 -1
View File
@@ -694,7 +694,12 @@ fn display_has_glyphs_and_marks() {
#[test]
fn a_tracked_paragraph_mark_shows_in_its_authors_colour() {
let mut d = Document::from_text("Split\nhere");
d.revisions.push(wordcraft_doc::Revision { kind: wordcraft_doc::RevisionKind::Insert, author: "Ana".into(), date: String::new() });
d.revisions.push(wordcraft_doc::Revision {
kind: wordcraft_doc::RevisionKind::Insert,
author: "Ana".into(),
date: String::new(),
move_name: None,
});
d.para_mut(wordcraft_doc::StoryRef::Body, &wordcraft_doc::Path::top(0)).unwrap().mark.ins = Some(0);
let l = lay(&d);
let marks = |markup: bool| -> Vec<Option<wordcraft_doc::props::Rgb>> {
+3 -3
View File
@@ -221,8 +221,8 @@ fn hostile_documents_export() {
fn tracked_deletions_are_left_out_without_markup() {
use wordcraft_doc::{Revision, RevisionKind};
let mut d = Document::new();
d.revisions.push(Revision { kind: RevisionKind::Delete, author: "Ada".into(), date: String::new() });
d.revisions.push(Revision { kind: RevisionKind::Insert, author: "Ada".into(), date: String::new() });
d.revisions.push(Revision { kind: RevisionKind::Delete, author: "Ada".into(), date: String::new(), move_name: None });
d.revisions.push(Revision { kind: RevisionKind::Insert, author: "Ada".into(), date: String::new(), move_name: None });
let mut p = Paragraph::with_text("Keep ", CharProps::default());
let n = p.len();
p.insert_text(n, "DELETEDTEXT ", &CharProps { del: Some(0), ..Default::default() }).unwrap();
@@ -241,7 +241,7 @@ fn tracked_deletions_are_left_out_without_markup() {
fn deleted_heading_text_stays_out_of_bookmarks_and_tags() {
use wordcraft_doc::{Revision, RevisionKind};
let mut d = Document::new();
d.revisions.push(Revision { kind: RevisionKind::Delete, author: "Ada".into(), date: String::new() });
d.revisions.push(Revision { kind: RevisionKind::Delete, author: "Ada".into(), date: String::new(), move_name: None });
let del = CharProps { del: Some(0), ..Default::default() };
// "Intro SECRET Part" with "SECRET " deleted, then a heading deleted as a whole.
let mut h = Paragraph::with_text("Intro ", CharProps::default()).styled("Heading1");
+11 -2
View File
@@ -157,7 +157,9 @@ pub fn markup_width(app: &WordApp) -> f32 {
let m = &app.session.prefs.markup;
let on = on && m.balloons != wordcraft_layout::display::BalloonMode::Inline;
let comments = m.comments && !app.session.doc.comments.is_empty();
if on && (comments || (m.formatting && wordcraft_engine::cmd::review::has_format_changes(&app.session.doc))) { 216.0 } else { 0.0 }
let formats = m.formatting && wordcraft_engine::cmd::review::has_format_changes(&app.session.doc);
let moves = m.insertions_deletions && wordcraft_engine::cmd::moves::has_moves(&app.session.doc);
if on && (comments || formats || moves) { 216.0 } else { 0.0 }
}
/// A page dimension (points) safe to lay out: finite, at least 1 pt, at most `cap`.
@@ -779,6 +781,13 @@ fn balloons(app: &mut WordApp, ui: &mut Ui, painter: &egui::Painter, rects: &[Re
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));
}
// Each end of a tracked move.
let moves = if m.insertions_deletions { wordcraft_engine::cmd::review::move_changes(&app.session) } else { Vec::new() };
for (pos, author, from) in moves.into_iter().take(500) {
let Some(c) = layout.caret_on(&pos, app.session.page_hint) else { continue };
let text = if from { tl!("Moved from") } else { tl!("Moved to") }.to_string();
by_page.entry(c.page).or_default().push((c.x, c.top + c.height, Balloon::Format(author, text), pos));
}
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<String> = Vec::new();
let mut clicked: Option<(u32, Pos)> = None;
@@ -913,7 +922,7 @@ 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: …".
/// A tracked formatting change or move: its author and "Formatted: …" / "Moved from".
Format(String, String),
}
+2
View File
@@ -1412,6 +1412,8 @@
New Group Nuevo grupo
Rename… Cambiar nombre…
Ribbon and Quick Access Toolbar: Cinta de opciones y barra de acceso rápido:
Moved from Movido desde
Moved to Movido a
Right-to-Left Table Tabla de derecha a izquierda
Right-to-Left Section Sección de derecha a izquierda
Table direction: Dirección de la tabla:
Can't render this file because it contains an unexpected character in line 6 and column 25.
+2
View File
@@ -1433,6 +1433,8 @@
Right (vertical) Paremal (vertikaalne)
Ruby text: Ruby-tekst:
Size (pt): Suurus (pt):
Moved from Teisaldatud kohast
Moved to Teisaldatud kohta
Align Text Joonda tekst
Break Link Katkesta link
Click the empty text box the text should continue in (Esc cancels) Klõpsa tühjal tekstiväljal, kus tekst peaks jätkuma (Esc tühistab)
Can't render this file because it contains an unexpected character in line 421 and column 13.
+2
View File
@@ -1396,6 +1396,8 @@
New Group 新しいグループ
Rename… 名前の変更…
Ribbon and Quick Access Toolbar: リボンとクイック アクセス ツール バー:
Moved from 移動元
Moved to 移動先
Right-to-Left Table 右から左の表
Right-to-Left Section 右から左のセクション
Table direction: 表の方向:
Can't render this file because it contains an unexpected character in line 504 and column 13.
+2
View File
@@ -1408,6 +1408,8 @@
New Group Novo Grupo
Rename… Renomear…
Ribbon and Quick Access Toolbar: Faixa de Opções e Barra de Acesso Rápido:
Moved from Movido de
Moved to Movido para
Right-to-Left Table Tabela da direita para a esquerda
Right-to-Left Section Seção da direita para a esquerda
Table direction: Direção da tabela:
Can't render this file because it contains an unexpected character in line 6 and column 25.
+2
View File
@@ -1790,6 +1790,8 @@ preview Title Пример
Left Only Только слева
Right Only Только справа
Largest Only Только по большей стороне
Moved from Перемещено из
Moved to Перемещено в
Right-to-Left Table Таблица справа налево
Right-to-Left Section Раздел справа налево
Table direction: Направление таблицы:
Can't render this file because it contains an unexpected character in line 7 and column 25.
+2
View File
@@ -1406,6 +1406,8 @@
New Group Nova grupa
Rename… Preimenuj…
Ribbon and Quick Access Toolbar: Traka i traka sa alatkama za brzi pristup:
Moved from Premešteno odavde
Moved to Premešteno ovde
Right-to-Left Table Tabela sa desna na levo
Right-to-Left Section Odeljak sa desna na levo
Table direction: Smer tabele:
Can't render this file because it contains an unexpected character in line 6 and column 25.
+2
View File
@@ -1410,6 +1410,8 @@
New Group Нова група
Rename… Преименуј…
Ribbon and Quick Access Toolbar: Трака и трака са алаткама за брзи приступ:
Moved from Премештено одавде
Moved to Премештено овде
Right-to-Left Table Табела са десна на лево
Right-to-Left Section Одељак са десна на лево
Table direction: Смер табеле:
Can't render this file because it contains an unexpected character in line 11 and column 25.
+2
View File
@@ -1408,6 +1408,8 @@
Left Only Yalnızca Sol
Right Only Yalnızca Sağ
Largest Only Yalnızca En Geniş
Moved from Taşındı (kaynak)
Moved to Taşındı (hedef)
Right-to-Left Table Sağdan Sola Tablo
Right-to-Left Section Sağdan Sola Bölüm
Table direction: Tablo yönü:
Can't render this file because it contains an unexpected character in line 430 and column 13.
+2
View File
@@ -1408,6 +1408,8 @@
New Group Нова група
Rename… Перейменувати…
Ribbon and Quick Access Toolbar: Стрічка й панель швидкого доступу:
Moved from Переміщено звідси
Moved to Переміщено сюди
Right-to-Left Table Таблиця справа наліво
Right-to-Left Section Розділ справа наліво
Table direction: Напрямок таблиці:
Can't render this file because it contains an unexpected character in line 6 and column 31.
+2
View File
@@ -1408,6 +1408,8 @@
New Group 新建组
Rename… 重命名…
Ribbon and Quick Access Toolbar: 功能区和快速访问工具栏:
Moved from 移动自
Moved to 移动到
Right-to-Left Table 从右向左表格
Right-to-Left Section 从右向左节
Table direction: 表格方向:
Can't render this file because it contains an unexpected character in line 7 and column 25.
+2
View File
@@ -1397,6 +1397,8 @@
New Group 新增群組
Rename… 重新命名…
Ribbon and Quick Access Toolbar: 功能區和快速存取工具列:
Moved from 移動自
Moved to 移動到
Right-to-Left Table 從右至左表格
Right-to-Left Section 從右至左節
Table direction: 表格方向:
Can't render this file because it contains an unexpected character in line 505 and column 13.
+2 -1
View File
@@ -52,7 +52,7 @@ Measured by grepping `crates/docx/src/read` and `src/write` for the OOXML elemen
| Footnotes, endnotes, comments | ✅ | ✅ | The document's own note separators and continuation notice (`w:type` `separator`, `continuationSeparator`, `continuationNotice`) round-trip as ids -1, 0 and 1 (#352); note options (`w:pos`, `w:numFmt`, `w:numStart`, `w:numRestart`) round-trip in the settings and in each `w:sectPr` (#385); `commentsExtended` written; `commentsIds`, 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`, `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 |
| Move tracking (`w:moveFrom`/`w:moveTo`, `w:moveFromRangeStart/End`, `w:moveToRangeStart/End`), moved paragraph marks | ✅ | ✅ | #400: both ends linked by the range name; ranges rewritten around each moved stretch (ids renumbered); a move missing an end or a range end opens as a plain deletion/insertion. RTF/ODT/PDF export the result (moved-away text hidden like deletions) |
| Fields (`w:fldChar`, `w:fldSimple`), TOC, cross-references, `ADDIN` citations | ✅ | ✅ | Field codes Word supports but we don't evaluate keep their cached result. `EQ \o` (overstrike, enclosed characters) is drawn; enclosed characters are written as Word stores them, their style in the code's run sizes (#297) |
| Content controls (`w:sdt`) | 🟡 | ❌ | Content kept, the control (type, binding, placeholder, lock) dropped |
| Legacy form fields (`w:ffData`), check boxes, drop-downs | ❌ | ❌ | |
@@ -85,6 +85,7 @@ Measured by grepping `crates/docx/src/read` and `src/write` for the OOXML elemen
| Date | Change | Summary |
|---|---|---|
| 2026-10-11 | trivial | Move tracking (`w:moveFrom`/`w:moveTo` and their ranges) read and written (#400) |
| 2026-10-11 | trivial | DOCX text-box `bodyPr` `vert`/`anchor` and linked text boxes (`wps:linkedTxbx`) read and written (#369) |
| 2026-10-10 | trivial | Right-to-left tables (`w:bidiVisual`) read and written (#362) |
| 2026-10-10 | trivial | Drawing canvas (`wpc:wpc`) read and written (#344); group shapes row brought up to date (#267) |
+2 -1
View File
@@ -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; 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 | **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) |
| 5 | **Content controls** are lost (`w:sdt` unwrapped). Move tracking landed (#400): `w:moveFrom`/`w:moveTo` with their named ranges round-trip, a tracked cut and paste of a paragraph or sentence records a move, either end accepts/rejects both, green double strike/underline markup and "Moved from/to" balloons; still missing: drag-and-drop moves (no text drag yet) and move options in Track Changes Options. Formatting revisions landed (#41): `w:rPrChange`/`w:pPrChange`/`w:tblPrChange`/`w:trPrChange`/`w:tcPrChange`/`w:sectPrChange`/`w:numberingChange` round-trip, are recorded while tracking, accepted/rejected and shown in balloons; still missing: tracked style-definition changes and table/section changes made in WordCraft | B | Issues #41, #400; source grep | Legal and editorial review workflows; templates with form controls | 5–8 | [file-format-parity.md](file-format-parity.md) |
| 6 | **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**: 35 modal dialogs vs Word's ~100 (Caption, Index, TOC options, Citation, Compare, Protect, Language, Hyphenation, Line Numbers, Envelopes, Labels, AutoCorrect, 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) |
| 8 | **Proofing is English only**; no language per run, no dictionaries for other languages | | `crates/proof` has one dictionary; Word ships 120 proofing tools; issues #25, #40, #100 | Every non-English writer | 25–45 | [localization-parity.md](localization-parity.md) |
@@ -64,6 +64,7 @@ The same gaps grouped the way the parity documents are, for agents working in on
| Date | Change | Summary |
|---|---|---|
| 2026-10-11 | trivial | Move tracking landed (#400); gap #5 narrowed to content controls, 5–8 h |
| 2026-10-11 | trivial | #4: note options per section (below text, end of section, restart each section/page) and the Footnote and Endnote dialog (#385) |
| 2026-10-11 | trivial | #10: Shape Format › Text: text direction, align text and linked text boxes (#369) |
| 2026-10-11 | trivial | #16: right-to-left tables (`w:bidiVisual`) and sections (`w:bidi`) laid out mirrored, with `table.rtl` and `layout.sectionRtl` (#362) |
+4 -3
View File
@@ -65,7 +65,7 @@ full parity for the area.
| Headers, footers, page numbers, fields | 5 | 70% | 70% | 8–12 | First/even/odd, link to previous, TOC as an updatable field (#52), cross-references to real bookmarks (#227). Field coverage is a subset of Word's ~90 field codes; Field dialog missing. |
| Footnotes and endnotes | 3 | 60% | 60% | 3–5 | Placed and editable, numbers from Word files (#103); long notes continue onto the next page under the continuation separator (#352). Footnote and Endnote dialog with options per section: below text, end of section, number format, start at, restart each section or page, custom mark, convert (#385). |
| References (TOC, citations, bibliography, captions, index, TOA) | 5 | 65% | 60% | 12–20 | Working first versions, Zotero integration (#189), Zotero/Mendeley `ADDIN` fields round-trip. All 12 of Word's bibliography styles (APA, MLA, Chicago, IEEE, Harvard, ISO 690 author-date and numerical, Turabian, GB/T 7714, GOST name and title sort, SIST02; #383); 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. 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). |
| 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. Moves (#400): kept in DOCX, recorded on a tracked cut and paste, accepted/rejected at either end, green markup and "Moved from/to" balloons. Missing: drag-and-drop moves, insert/delete balloons, Translate, Block Authors, modern comment threads/mentions. Lowered: these gaps are confirmed by source (no `rPrChange` anywhere). |
| Proofing (spelling, grammar, thesaurus, languages) | 5 | 35% | (in Review) | 25–45 | English only: one dictionary (Moby list plus our supplement of modern words such as "desktop", #520), a rule-based grammar checker. Word ships 120 proofing tools for ~50 languages plus Editor (style refinements, similarity). Issues #25, #40, #100. |
| Mailings | 2 | 70% | 70% | 6–10 | Mail merge from CSV, fields, rules (IF, SKIPIF, NEXT, NEXTIF, evaluated alike in preview and merge), fields in headers/footers (one section per letter), preview, envelopes, labels. Select Recipients takes a typed list, CSV (#247) or a sheet of an Excel (.xlsx/.xlsm) or OpenDocument (.ods) workbook, with Select Table to pick the sheet (#334); no Outlook/Contacts/Access 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), text-box text direction (90°, 270°, stacked), Align Text and linked text boxes (#369), Icons: an original icon library drawn in code with a searchable picker, inserted as pictures and recoloured with Icon Color (#309), Drawing Canvas with Fit/Expand/Scale and DOCX `wpc:wpc` (#344; members edited by command, not yet dragged inside the canvas; the canvas rotates and flips as a whole). Missing: tight/through contour wrap, text in a freely rotated text box, group (#267 open), shape effects, WordArt, connectors, 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, 125 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, 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) |
| 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, 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), move tracking 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) 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, 125 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 | Move tracking (#400): Review row updated; no percentage change |
| 2026-10-11 | trivial | Footnotes and endnotes: Footnote and Endnote dialog, options per section (position, format, start, restart each section/page), convert notes (#385); no percentage change |
| 2026-10-11 | trivial | Pictures, shapes, text boxes: text direction, Align Text and linked text boxes (Create Link / Break Link), DOCX `bodyPr` `vert`/`anchor` and `wps:linkedTxbx` (#369); no percentage change |
| 2026-10-10 | trivial | Drawing Canvas (#344): Insert › Shapes › New Drawing Canvas and Draw › Drawing Canvas live; catalog 402/434 → 404/434 (93.1%) |