diff --git a/Cargo.lock b/Cargo.lock
index 503ba4373..9c4db4969 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -5238,6 +5238,7 @@ name = "wordcraft-formats"
version = "0.7.0"
dependencies = [
"image",
+ "js-sys",
"log",
"proptest",
"quick-xml",
diff --git a/ROADMAP.md b/ROADMAP.md
index df85ead13..1bed44d9c 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-14 · **Last updated:** 2026-10-14 · **Change:** trivial (progress log: All Caps text in PDFs, #1473; previously trivial: progress log: Ink to Shape arrows; previously trivial: progress log: privacy options on save, #1062; previously trivial: progress log: equation pictures in theme colours on Windows, #1002; previously trivial: symbol fonts, #776; previously trivial: the save prompt answers from the keyboard, #861; previously trivial: table column and row markers on the rulers, #700; previously trivial: table borders and the resize handle drag on the page, #217; previously trivial: formatting revisions landed, #41; previously trivial: Draw tab ink, #307, and Draw Table and Eraser, #303, landed: catalog 402/434; previously minor: readiness table with hours per audience; full number back to the additive weighted sum, 60%; mainstream and essentials numbers added; alpha gate checked; previously major: full re-measure against Word for Mac 16.113.4; restructured to the craftrules progress-docs standard) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4
+> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-14 · **Change:** trivial (progress log: All Caps text in PDFs, #1473; previously trivial: progress log: ODT tracked changes, gap 19; previously trivial: progress log: RTF tracked changes, gap 19; previously trivial: progress log: symbol fonts, #776; previously trivial: the save prompt answers from the keyboard, #861; previously trivial: table column and row markers on the rulers, #700; previously trivial: table borders and the resize handle drag on the page, #217; previously trivial: formatting revisions landed, #41; previously trivial: Draw tab ink, #307, and Draw Table and Eraser, #303, landed: catalog 402/434; previously minor: readiness table with hours per audience; full number back to the additive weighted sum, 60%; mainstream and essentials numbers added; alpha gate checked; previously major: full re-measure against Word for Mac 16.113.4; restructured to the craftrules progress-docs standard) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4
> **Printing update:** 2026-10-12 · direct desktop printing through craft-print; details below.
@@ -156,6 +156,7 @@ Ranked, to beta (detail and remaining milestones in [docs/roadmap.md](docs/roadm
| 2026-10-14 | Web: Save in the "save changes?" prompt before New or Open now waits for the browser's save picker: the new or opened document replaces yours only once the file is written and closed. Cancelling the picker, or a write that fails, keeps your document open with its edits and undo, still unsaved, and the prompt comes back with the reason; a save that ends after another document has replaced it no longer runs the New/Open on that one or marks it unsaved, and New/Open while a picker save is still in flight ask first (#1315) |
| 2026-10-13 | Closing with unsaved changes → Save that can't write the file (no permission, locked by another app, folder gone) now says so plainly instead of looking like Cancel: the prompt comes back with the reason in it ("Couldn't save … so it is still open and nothing was lost") for Save again, Don't Save or Cancel, the status bar says it too, and `ui.saveChanges` returns that error; the same for a Save As from the prompt; the document stays open with its edits (#1445) |
| 2026-10-14 | File › Info › Inspect Document finds hidden text the way Word does (#1344): hidden by direct formatting, a character style or a paragraph style, in the body, headers, footers, footnotes and endnotes, comments and text boxes, and Remove All deletes all of it (and nothing visible) in one undo step, whole hidden paragraphs and the notes of hidden references included. The result lists anything it couldn't reach (`residual`), so a sanitised file is never reported clean by mistake |
+| 2026-10-14 | ODT files keep their tracked changes (gap 19): insertions and deletions made in LibreOffice open as revisions with their author and date, deleted paragraph breaks and text inserted then deleted included, so Accept All and Reject All give the right text; saving to ODT writes them back, and the Track Changes setting goes both ways |
| 2026-10-14 | RTF keeps tracked changes (gap 19): insertions and deletions open as tracked changes with their authors and dates (`\revtbl`, `\revised`, `\deleted`, `\revdttm`) instead of plain text, so deleted text no longer reappears as normal text; saving to RTF writes them back, and Accept All / Reject All work after reopening |
| 2026-10-14 | Alignment guides: moving a picture, shape or text box snaps its edges or centre to the page's edges and centre, the margins and the other floating objects on the page, with a thin line across the page for each guide while dragging; Alt moves freely; File › Options › Advanced › Editing turns them off (`ui.alignmentGuides`). Our own design: Word for Mac shows no guides |
| 2026-10-14 | ODT files open with their sections (#1379): a paragraph that switches to another page style starts a new section on a new page, with that page's size, orientation, margins and columns and, when the file asks, page numbering restarting; a two-column `text:section` becomes a continuous two-column section. Before, the whole document was one portrait section, so landscape pages, columns and restarts were lost on open and on save to DOCX |
@@ -337,6 +338,7 @@ Ranked, to beta (detail and remaining milestones in [docs/roadmap.md](docs/roadm
| 2026-10-14 | trivial | Progress log: the web's Save before New/Open waits for the save picker (#1315) |
| 2026-10-13 | trivial | Progress log: a failed save from the close prompt says so (#1445) |
| 2026-10-14 | trivial | Progress log: Inspect Document removes hidden text in every story (#1344) |
+| 2026-10-14 | trivial | Progress log: ODT tracked changes (gap 19) |
| 2026-10-14 | trivial | Progress log: RTF tracked insertions and deletions (gap 19) |
| 2026-10-14 | trivial | Progress log: alignment guides while dragging pictures and shapes |
| 2026-10-14 | trivial | Progress log: ODT sections on import (#1379) |
diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs
index db442f84d..136d52cdb 100644
--- a/crates/engine/src/tests.rs
+++ b/crates/engine/src/tests.rs
@@ -5903,3 +5903,89 @@ fn exported_page_images_draw_headers_at_full_strength() {
assert!(darkest < 60, "header drawn dimmed: darkest pixel {darkest}");
let _ = std::fs::remove_dir_all(&dir);
}
+
+/// Gap 19, review: text inserted by one author and deleted by another keeps both marks through
+/// an ODT save, so Reject All (undo the deletion, then the insertion) and Accept All both remove it.
+#[test]
+fn odt_inserted_then_deleted_text_round_trips() {
+ use std::io::Write;
+ let ns = r#"xmlns:office="urn:oasis:names:tc:opendocument:xmlns:office:1.0" xmlns:text="urn:oasis:names:tc:opendocument:xmlns:text:1.0" xmlns:dc="http://purl.org/dc/elements/1.1/""#;
+ let info = |who: &str| format!("{who}2024-03-01T10:00:00");
+ let content = format!(
+ r#"{}{}goneKeep this."#,
+ info("Alice"),
+ info("Bob")
+ );
+ let mut z = zip::ZipWriter::new(std::io::Cursor::new(Vec::new()));
+ let o = zip::write::SimpleFileOptions::default().compression_method(zip::CompressionMethod::Stored);
+ for (name, data) in [("mimetype", "application/vnd.oasis.opendocument.text"), ("content.xml", content.as_str())] {
+ z.start_file(name, o).unwrap();
+ z.write_all(data.as_bytes()).unwrap();
+ }
+ let doc = crate::io::open_bytes("t.odt", &z.finish().unwrap().into_inner()).unwrap();
+ let doc = crate::io::open_bytes("t.odt", &wordcraft_formats::odt::export(&doc).unwrap()).unwrap();
+ for cmd in ["review.rejectAll", "review.acceptAll"] {
+ let mut s = Session::new(doc.clone());
+ run(&mut s, cmd, json!({}));
+ assert_eq!(text(&s), "Keep this.", "{cmd}");
+ }
+}
+
+/// Gap 19: an ODT's tracked insertion and multi-paragraph deletion come in as revisions that
+/// Reject All turns back into the original and Accept All into the edited text.
+#[test]
+fn odt_tracked_changes_accept_and_reject_all() {
+ use std::io::Write;
+ let ns = r#"xmlns:office="urn:oasis:names:tc:opendocument:xmlns:office:1.0" xmlns:text="urn:oasis:names:tc:opendocument:xmlns:text:1.0" xmlns:dc="http://purl.org/dc/elements/1.1/""#;
+ let info = |who: &str| format!("{who}2024-03-01T10:00:00");
+ let content = format!(
+ r#"{}{}tailheadFirst rest added.End."#,
+ info("Alice"),
+ info("Bob")
+ );
+ let mut z = zip::ZipWriter::new(std::io::Cursor::new(Vec::new()));
+ let o = zip::write::SimpleFileOptions::default().compression_method(zip::CompressionMethod::Stored);
+ for (name, data) in [("mimetype", "application/vnd.oasis.opendocument.text"), ("content.xml", content.as_str())] {
+ z.start_file(name, o).unwrap();
+ z.write_all(data.as_bytes()).unwrap();
+ }
+ let bytes = z.finish().unwrap().into_inner();
+ let doc = crate::io::open_bytes("t.odt", &bytes).unwrap();
+
+ let mut sr = Session::new(doc.clone());
+ run(&mut sr, "review.rejectAll", json!({}));
+ assert_eq!(text(&sr), "First tail\nhead rest .\nEnd.");
+ assert!(sr.doc.revisions.is_empty() || run(&mut sr, "review.changes", json!({})).as_array().unwrap().is_empty());
+
+ let mut sa = Session::new(doc);
+ run(&mut sa, "review.acceptAll", json!({}));
+ assert_eq!(text(&sa), "First rest added.\nEnd.");
+ assert!(run(&mut sa, "review.changes", json!({})).as_array().unwrap().is_empty());
+}
+
+/// Gap 19, review 2: a paragraph break deleted inside an insertion range is not itself
+/// inserted, so Reject All keeps the two paragraphs (after an ODT save, too).
+#[test]
+fn odt_deleted_mark_inside_an_insertion_survives_reject_all() {
+ use std::io::Write;
+ let ns = r#"xmlns:office="urn:oasis:names:tc:opendocument:xmlns:office:1.0" xmlns:text="urn:oasis:names:tc:opendocument:xmlns:text:1.0" xmlns:dc="http://purl.org/dc/elements/1.1/""#;
+ let info = |who: &str| format!("{who}2024-03-01T10:00:00");
+ let content = format!(
+ r#"{}{}One AC two"#,
+ info("Alice"),
+ info("Bob")
+ );
+ let mut z = zip::ZipWriter::new(std::io::Cursor::new(Vec::new()));
+ let o = zip::write::SimpleFileOptions::default().compression_method(zip::CompressionMethod::Stored);
+ for (name, data) in [("mimetype", "application/vnd.oasis.opendocument.text"), ("content.xml", content.as_str())] {
+ z.start_file(name, o).unwrap();
+ z.write_all(data.as_bytes()).unwrap();
+ }
+ let doc = crate::io::open_bytes("t.odt", &z.finish().unwrap().into_inner()).unwrap();
+ let saved = crate::io::open_bytes("t.odt", &wordcraft_formats::odt::export(&doc).unwrap()).unwrap();
+ for d in [doc, saved] {
+ let mut s = Session::new(d);
+ run(&mut s, "review.rejectAll", json!({}));
+ assert_eq!(text(&s), "One \n two");
+ }
+}
diff --git a/crates/formats/Cargo.toml b/crates/formats/Cargo.toml
index 393599097..a892251ed 100644
--- a/crates/formats/Cargo.toml
+++ b/crates/formats/Cargo.toml
@@ -16,5 +16,9 @@ quick-xml = { workspace = true }
zip = { workspace = true }
log = { workspace = true }
+[target.'cfg(target_arch = "wasm32")'.dependencies]
+# The browser clock (`SystemTime::now()` panics on wasm32): ODT change dates.
+js-sys = "0.3"
+
[dev-dependencies]
proptest = { workspace = true }
diff --git a/crates/formats/src/model.rs b/crates/formats/src/model.rs
index feb78a0b4..8ab44b70c 100644
--- a/crates/formats/src/model.rs
+++ b/crates/formats/src/model.rs
@@ -48,6 +48,10 @@ pub struct Fmt {
pub struct RevMark {
pub author: String,
pub date: String,
+ /// The source's identity of the change, when it has one (an ODT changed region; the
+ /// revision index in [`from_doc_tracked`]): marks with different ids stay separate
+ /// revisions. `None` (RTF) = one revision per kind, author and date.
+ pub id: Option,
}
/// An embedded picture.
@@ -125,6 +129,11 @@ pub struct Para {
pub borders: Option,
/// The section this paragraph ends (RTF `\sect`), as the document's last paragraph of it.
pub section: Option>,
+ /// Tracked insertion of the paragraph mark.
+ pub mark_ins: Option>,
+ /// Tracked deletion of the paragraph mark (only [`from_doc_tracked`] and formats that keep
+ /// revisions set it).
+ pub mark_del: Option>,
}
impl Para {
@@ -414,7 +423,7 @@ impl<'a> Builder<'a> {
let mut revs = std::collections::HashMap::new();
for (i, r) in doc.revisions.iter().enumerate() {
if r.move_name.is_none() {
- let mark = RevMark { author: r.author.clone(), date: r.date.clone() };
+ let mark = RevMark { author: r.author.clone(), date: r.date.clone(), id: None };
revs.entry((rev_key(r.kind), mark)).or_insert(i as u32);
}
}
@@ -500,6 +509,8 @@ impl<'a> Builder<'a> {
}
}
}
+ out.mark.ins = p.mark_ins.as_deref().map(|m| self.revision(RevisionKind::Insert, m));
+ out.mark.del = p.mark_del.as_deref().map(|m| self.revision(RevisionKind::Delete, m));
out.normalize();
out.touch();
out
@@ -744,7 +755,7 @@ fn fmt_of(doc: &Document, cp: &CharProps, in_code: bool) -> Fmt {
/// The insertion and deletion `cp` tracks, as flow marks.
fn rev_marks(doc: &Document, cp: &CharProps) -> (Option, Option) {
let mark = |i: Option| {
- i.map(|i| doc.revisions.get(i as usize).map(|r| RevMark { author: r.author.clone(), date: r.date.clone() }).unwrap_or_default())
+ i.map(|i| doc.revisions.get(i as usize).map(|r| RevMark { author: r.author.clone(), date: r.date.clone(), id: Some(i) }).unwrap_or_default())
};
(mark(cp.ins), mark(cp.del))
}
@@ -807,6 +818,8 @@ fn flow_paras_in(doc: &Document, p: &Paragraph, tracked: bool) -> Vec {
spacing: Spacing::default(),
borders: None,
section: None,
+ mark_ins: None,
+ mark_del: None,
};
let in_code = kind == Kind::Code;
let mut k = 0usize;
@@ -833,6 +846,8 @@ fn flow_paras_in(doc: &Document, p: &Paragraph, tracked: bool) -> Vec {
spacing: Spacing::default(),
borders: None,
section: None,
+ mark_ins: None,
+ mark_del: None,
};
let prev = std::mem::replace(&mut out, next);
if prev.inlines.is_empty() {
@@ -898,6 +913,10 @@ fn flow_paras_in(doc: &Document, p: &Paragraph, tracked: bool) -> Vec {
out.push_text(&buf, &f);
}
}
+ if tracked {
+ let (ins, del) = rev_marks(doc, &p.mark);
+ (out.mark_ins, out.mark_del) = (ins.map(Box::new), del.map(Box::new));
+ }
done.push(out);
done
}
diff --git a/crates/formats/src/odt.rs b/crates/formats/src/odt.rs
index fca92ab99..b13ca181a 100644
--- a/crates/formats/src/odt.rs
+++ b/crates/formats/src/odt.rs
@@ -8,8 +8,14 @@
//! Quotations, Preformatted Text, page layout) and `meta.xml`.
//!
//! Import reads the same subset back from `content.xml` / `styles.xml` (style inheritance
-//! resolved, both automatic and common styles), skipping notes, annotations, text boxes and
-//! tracked-change records.
+//! resolved, both automatic and common styles), skipping notes, annotations and text boxes.
+//!
+//! Tracked changes (ODF 1.3 Part 3 §5.5) go both ways: `text:tracked-changes` holds one
+//! `text:changed-region` per change; an insertion is the body text between
+//! `text:change-start`/`text:change-end`, a deletion keeps the deleted paragraphs in its region
+//! and leaves a `text:change` point in the body. They map to `Document::revisions` and the
+//! `ins`/`del` marks of runs and paragraph marks; a deleted paragraph mark is a region of
+//! several paragraphs. Format changes are read as no change.
use std::collections::HashMap;
use std::io::{Cursor, Read, Write};
@@ -17,9 +23,9 @@ use std::io::{Cursor, Read, Write};
use quick_xml::XmlVersion;
use quick_xml::events::{BytesStart, Event};
use wordcraft_doc::section::{Columns, SectionStart};
-use wordcraft_doc::{Align, Document, Rgb, SectionProps};
+use wordcraft_doc::{Align, Document, RevisionKind, Rgb, SectionProps};
-use crate::model::{self, Cell, FBlock, FTable, Flow, Fmt, Inline, Kind, ListInfo, Meta, Para, make_img, mime_of};
+use crate::model::{self, Cell, FBlock, FTable, Flow, Fmt, Inline, Kind, ListInfo, Meta, Para, RevMark, make_img, mime_of};
/// Largest zip entry we inflate.
const MAX_ENTRY: u64 = 256 << 20;
@@ -27,6 +33,12 @@ const MAX_ENTRY: u64 = 256 << 20;
const MAX_XML_DEPTH: usize = 512;
/// Most sections an import makes (page-style changes and column sections); later ones are ignored.
const MAX_SECTIONS: usize = 10_000;
+/// Most `text:changed-region`s read (hostile input).
+const MAX_REGIONS: usize = 10_000;
+/// Most insertions open at once (`text:change-start` without its end yet).
+const MAX_OPEN_INSERTIONS: usize = 64;
+/// Longest change author or date kept, in characters.
+const MAX_INFO_CHARS: usize = 256;
// ---------------------------------------------------------------------------------------------
// Export
@@ -79,6 +91,16 @@ struct Writer {
/// Cell styles by shading.
cell_styles: Vec