From c650b4b78649161a1dd1112f1aeee2f160aa99d4 Mon Sep 17 00:00:00 2001 From: Hadrien Mary Date: Sun, 27 Sep 2026 19:46:07 +0200 Subject: [PATCH 1/5] List tracked revisions in every story with the story that holds them Document::revisions walks the typed main-document projection only, while accept_all, reject_all and compare act on headers, footers, comments, footnotes, endnotes and text boxes. A redline whose only change is in a footer therefore listed nothing while accept_all resolved two revision elements, and a text box revision in the main part was resolved but never listed either. Add Document::story_revisions, which returns owned StoryRevision snapshots. It stages a copy of the document as revision resolution does, then scans the main part and the related story parts left in the staged package with the same element inventory the resolver counts, so the list length equals the accept and reject count by construction. That includes a main part whose typed serialization drops a namespace binding a revision depends on, such as a default namespace or a text box prefix declared on w:body, w:p or w:r, and a document that cannot be staged fails the listing as it fails accept. Each revision carries the StoryId of the innermost owner that Document::stories reports around it, with table cells folded into the story that holds the table. Where the staged bytes and the typed serialization disagree on the owners, root owners pair by index and text boxes by identical bytes, and a text box stories() does not report folds into the story around it. A revision outside every owner, such as one in a footnote separator, is an error rather than a silent omission. Document::revisions and RevisionRef are unchanged. GitHub issue #165. --- crates/rdocx/src/comparison.rs | 22 +- crates/rdocx/src/document.rs | 115 +++++++++- crates/rdocx/src/lib.rs | 2 +- crates/rdocx/src/revision.rs | 80 ++++++- crates/rdocx/tests/regression_test.rs | 292 +++++++++++++++++++++++++- docs/hld/03-architecture.md | 13 +- docs/hld/10-bindings-spec.md | 26 ++- 7 files changed, 529 insertions(+), 21 deletions(-) diff --git a/crates/rdocx/src/comparison.rs b/crates/rdocx/src/comparison.rs index 19c557ed..f688d1a8 100644 --- a/crates/rdocx/src/comparison.rs +++ b/crates/rdocx/src/comparison.rs @@ -16,7 +16,7 @@ use rdocx_oxml::text::{CT_P, CT_R, CT_Text, RunContent}; use sha2::{Digest, Sha256}; use crate::revision::validate_revision_timestamp; -use crate::{Document, Error, Result}; +use crate::{Document, Error, Result, StoryKind}; use oxml_opc::OpcPackage; use oxml_opc::relationship::{Relationship, rel_types}; @@ -571,8 +571,24 @@ fn story_parts_with_options( Ok(stories) } -pub(crate) fn related_story_part_names(document: &Document) -> Result> { - story_parts(document).map(|stories| stories.into_iter().map(|story| story.part_name).collect()) +pub(crate) fn revision_story_parts(document: &Document) -> Result> { + story_parts(document).map(|stories| { + stories + .into_iter() + .map(|story| { + let kind = match story.kind { + ComparisonStoryKind::Main => StoryKind::Body, + ComparisonStoryKind::Header => StoryKind::Header, + ComparisonStoryKind::Footer => StoryKind::Footer, + ComparisonStoryKind::Comment => StoryKind::Comment, + ComparisonStoryKind::TextBox => StoryKind::TextBox, + ComparisonStoryKind::Footnote => StoryKind::Footnote, + ComparisonStoryKind::Endnote => StoryKind::Endnote, + }; + (kind, story.part_name) + }) + .collect() + }) } fn story_xml<'a>(document: &'a Document, story: &StoryPart) -> Result<&'a [u8]> { diff --git a/crates/rdocx/src/document.rs b/crates/rdocx/src/document.rs index 5c4b2b46..ec16e704 100644 --- a/crates/rdocx/src/document.rs +++ b/crates/rdocx/src/document.rs @@ -63,7 +63,7 @@ use crate::Length; use crate::content_control::ContentControlRef; use crate::error::{Error, Result}; use crate::paragraph::{Paragraph, ParagraphRef}; -use crate::revision::RevisionRef; +use crate::revision::{RevisionRef, StoryRevision}; use crate::run::{FieldDisplaySegmentRef, RunRef}; use crate::style::{self, Style, StyleBuilder}; use crate::table::{Table, TableRef, row_cell_ranges, validate_table_topology}; @@ -7349,6 +7349,68 @@ fn scan_story_owners(xml: &[u8], root_kind: StoryKind) -> Result, +) -> Result, StoryId)>> { + let non_cell_owners = |xml: &[u8]| -> Result> { + Ok(scan_story_owners(xml, root_kind)? + .into_iter() + .filter(|owner| owner.kind != StoryKind::TableCell) + .collect()) + }; + let scanned_owners = non_cell_owners(scanned)?; + let reported_owners = match reported { + Some(xml) if xml == scanned => scanned_owners.clone(), + Some(xml) => non_cell_owners(xml)?, + None => Vec::new(), + }; + let story = |owner: &StoryOwnerSpan| StoryId { + kind: owner.kind, + part_name: part_name.to_owned(), + owner_index: owner.owner_index, + fingerprint: owner.fingerprint, + }; + let same_kinds = scanned_owners.len() == reported_owners.len() + && scanned_owners + .iter() + .zip(&reported_owners) + .all(|(scanned, reported)| scanned.kind == reported.kind); + if same_kinds { + return Ok(scanned_owners + .iter() + .zip(&reported_owners) + .map(|(scanned, reported)| (scanned.full.clone(), story(reported))) + .collect()); + } + let mut unpaired = reported_owners.iter().collect::>(); + Ok(scanned_owners + .iter() + .filter_map(|scanned| { + let position = unpaired.iter().position(|reported| { + reported.kind == scanned.kind + && if scanned.kind == root_kind { + reported.owner_index == scanned.owner_index + } else { + reported.fingerprint == scanned.fingerprint + } + })?; + Some((scanned.full.clone(), story(unpaired.remove(position)))) + }) + .collect()) +} + fn structural_fingerprint(xml: &[u8]) -> Result { const OFFSET: u64 = 0xcbf29ce484222325; const PRIME: u64 = 0x100000001b3; @@ -14616,6 +14678,9 @@ impl Document { } /// Return every valid modeled main-document revision in document order. + /// + /// This borrowed projection covers the main body, its tables, cells, and + /// content controls. [`Document::story_revisions`] lists every story. pub fn revisions(&self) -> Vec> { self.document .revisions() @@ -14624,6 +14689,54 @@ impl Document { .collect() } + /// Return every modeled revision that accepting or rejecting all revisions + /// resolves, each with the story that holds it. + /// + /// The list covers the main document, headers, footers, comments, normal + /// footnotes, endnotes, and the text boxes inside them. It has one entry per + /// revision element, so its length is the count [`Document::accept_all`] + /// and [`Document::reject_all`] report. The main document comes first, then + /// headers and footers in section order, then comments, footnotes, and + /// endnotes. Revisions keep document order within each part. + /// + /// Each story is one returned by [`Document::stories`]. A revision in a + /// table cell belongs to the story that holds the table. A text box that + /// `stories` does not report, such as one under `mc:AlternateContent` or + /// one whose namespace binding the typed serialization drops, belongs to + /// the story that holds its drawing. A revision outside every story owner, + /// such as one inside a footnote separator, is an error, and so is a + /// document that resolution cannot stage. + pub fn story_revisions(&self) -> Result> { + // Resolution scans the parts staging leaves in the package. The main + // part there keeps its original bytes, or its namespace declarations + // replayed, which the typed serialization that `stories` scans lacks. + let mut staged = self.clone_for_staging(); + staged.prepare_staged_package()?; + let mut parts = vec![(StoryKind::Body, staged.doc_part_name.clone())]; + parts.extend(crate::comparison::revision_story_parts(&staged)?); + let mut seen = HashSet::new(); + let mut revisions = Vec::new(); + for (root_kind, part_name) in parts { + if !seen.insert(part_name.clone()) { + continue; + } + let scanned = staged + .package + .get_part(&part_name) + .ok_or_else(|| Error::Other(format!("missing revision story part {part_name}")))?; + let reported = if part_name == self.doc_part_name { + Some(Cow::Owned(self.document.to_xml()?)) + } else { + self.package.get_part(&part_name).map(Cow::Borrowed) + }; + let owners = paired_story_owners(&part_name, root_kind, scanned, reported.as_deref())?; + revisions.extend(crate::revision::story_part_revisions( + &part_name, scanned, &owners, + )?); + } + Ok(revisions) + } + /// Return valid document-protection metadata recorded in the settings part. /// /// This reports author intent and password-verification metadata. It does diff --git a/crates/rdocx/src/lib.rs b/crates/rdocx/src/lib.rs index 3e78e4db..191f2380 100644 --- a/crates/rdocx/src/lib.rs +++ b/crates/rdocx/src/lib.rs @@ -128,7 +128,7 @@ pub use rdocx_oxml::text::{ SpecialCharacter, }; pub use redaction::RedactionReport; -pub use revision::{RevisionKind, RevisionRef}; +pub use revision::{RevisionKind, RevisionRef, StoryRevision}; pub use rtf::{RtfDiagnostic, RtfReadResult, RtfWriteResult}; pub use run::{ BreakKind, DrawingKind, DrawingRef, DrawingRelationshipKind, FieldDisplaySegmentRef, FieldKind, diff --git a/crates/rdocx/src/revision.rs b/crates/rdocx/src/revision.rs index 1c326901..fc9a4dc2 100644 --- a/crates/rdocx/src/revision.rs +++ b/crates/rdocx/src/revision.rs @@ -1,13 +1,14 @@ //! Native tracked-revision inspection and atomic resolution. use std::collections::{HashMap, HashSet}; +use std::ops::Range; use quick_xml::events::{BytesStart, Event}; use quick_xml::{Reader, XmlVersion}; pub use rdocx_oxml::RevisionKind; use rdocx_oxml::{CT_Document, CT_Revision}; -use crate::{Document, Error, ParagraphRef, Result}; +use crate::{Document, Error, ParagraphRef, Result, StoryId}; const WORD_NS: &str = "http://schemas.openxmlformats.org/wordprocessingml/2006/main"; @@ -48,6 +49,45 @@ impl RevisionRef<'_> { } } +/// One owned tracked revision and the Word story that holds it. +/// +/// [`Document::story_revisions`] returns one snapshot for each revision +/// element that accepting or rejecting every revision resolves. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct StoryRevision { + story: StoryId, + id: i32, + author: String, + timestamp: Option, + kind: RevisionKind, +} + +impl StoryRevision { + /// Return the story that holds this revision. + /// + /// Table cells fold into the story that holds the table. The identity is + /// one of those returned by [`Document::stories`] for the same document. + pub fn story(&self) -> &StoryId { + &self.story + } + + pub fn id(&self) -> i32 { + self.id + } + + pub fn author(&self) -> &str { + &self.author + } + + pub fn timestamp(&self) -> Option<&str> { + self.timestamp.as_deref() + } + + pub fn kind(&self) -> RevisionKind { + self.kind + } +} + #[derive(Clone, Copy)] enum Resolution { Accept, @@ -177,8 +217,8 @@ impl Document { candidate.package.set_part(&candidate.doc_part_name, output); } - let story_parts = crate::comparison::related_story_part_names(&candidate)?; - for part_name in story_parts { + let story_parts = crate::comparison::revision_story_parts(&candidate)?; + for (_, part_name) in story_parts { let part = candidate .package .get_part(&part_name) @@ -228,6 +268,40 @@ pub(crate) fn modeled_revision_count(source: &[u8]) -> Result { .count()) } +/// List the modeled revisions of one story part in document order. +/// +/// These are the elements that resolving the part counts. Each one belongs to +/// the innermost span in `owners` that holds its start tag. +pub(crate) fn story_part_revisions( + part_name: &str, + source: &[u8], + owners: &[(Range, StoryId)], +) -> Result> { + let tree = XmlTree::parse(source)?; + tree.elements + .iter() + .filter_map(|element| Some((element.start, element.revision.as_ref()?))) + .map(|(start, metadata)| { + let (_, story) = owners + .iter() + .filter(|(span, _)| span.contains(&start)) + .max_by_key(|(span, _)| span.start) + .ok_or_else(|| { + Error::Other(format!( + "tracked revision at byte {start} of {part_name} has no story owner" + )) + })?; + Ok(StoryRevision { + story: story.clone(), + id: metadata.id, + author: metadata.author.clone(), + timestamp: metadata.timestamp.clone(), + kind: metadata.kind, + }) + }) + .collect() +} + fn date_scope(start: &str, end: &str) -> Result> { let start = Instant::parse(start)?; let end = Instant::parse(end)?; diff --git a/crates/rdocx/tests/regression_test.rs b/crates/rdocx/tests/regression_test.rs index d143b3c7..9953bb00 100644 --- a/crates/rdocx/tests/regression_test.rs +++ b/crates/rdocx/tests/regression_test.rs @@ -19,10 +19,10 @@ use rdocx::{ FragmentConflictPolicy, HdrFtrType, HeaderFooterKind, HyperlinkItemRef, HyperlinkRef, Length, ListLevel, MailMergeControl, MailMergeData, MailMergeFormattedText, MailMergeImage, MailMergeRecord, MailMergeValue, ParagraphFrame, ParagraphItemRef, ParagraphRef, RasterFormat, - RasterOptions, RasterOutput, RenderOptions, RevisionView, RunItemRef, RunPosition, RunRange, - RunRef, StoryId, StoryItemKind, StoryKind, StoryRunPosition, StoryRunRange, StyleBuilder, - StyleType, TableRef, TcField, TocEntrySelection, TocField, TocRebuildReport, UnderlineStyle, - UnsupportedXmlRef, WordCreationProfile, WordPackageClass, + RasterOptions, RasterOutput, RenderOptions, RevisionKind, RevisionView, RunItemRef, + RunPosition, RunRange, RunRef, StoryId, StoryItemKind, StoryKind, StoryRunPosition, + StoryRunRange, StyleBuilder, StyleType, TableRef, TcField, TocEntrySelection, TocField, + TocRebuildReport, UnderlineStyle, UnsupportedXmlRef, WordCreationProfile, WordPackageClass, }; use rdocx_oxml::content_control::SdtContent; use rdocx_oxml::document::{BodyContent, CT_Body, CT_SectPr}; @@ -21291,6 +21291,290 @@ fn scoped_revision_resolution_visits_every_compared_story_once() { assert!(resolved.contains(r#"w:id="72""#), "{resolved}"); } +fn story_revision_resolution_counts(document: &mut Document) -> (usize, usize) { + let bytes = document + .to_bytes() + .expect("serialize tracked story document"); + let accepted = Document::from_bytes(&bytes) + .expect("open accepted story copy") + .accept_all() + .expect("accept every story revision"); + let rejected = Document::from_bytes(&bytes) + .expect("open rejected story copy") + .reject_all() + .expect("reject every story revision"); + (accepted, rejected) +} + +fn story_revision_rows(document: &Document) -> Vec<(StoryKind, String, usize, i32, RevisionKind)> { + let stories = document.stories().expect("inventory stories"); + document + .story_revisions() + .expect("list story revisions") + .into_iter() + .map(|revision| { + assert!( + stories.contains(revision.story()), + "{:?} is not a reported story", + revision.story() + ); + ( + revision.story().kind(), + revision.story().part_name().to_owned(), + revision.story().owner_index(), + revision.id(), + revision.kind(), + ) + }) + .collect() +} + +#[test] +fn story_revisions_list_a_compared_footer_that_the_main_listing_omits() { + let footer_document = |text: &str| { + let mut document = Document::new(); + document.add_paragraph("Body."); + document.set_footer(text); + Document::from_bytes(&document.to_bytes().expect("serialize footer fixture")) + .expect("open footer fixture") + }; + let mut tracked = footer_document("Footer lorem ipsum"); + let edited = footer_document("Footer lorem IPSUM"); + tracked + .compare(&edited, "R", "2026-09-27T12:00:00Z") + .expect("compare footer-only edit"); + + assert!(tracked.revisions().is_empty()); + let revisions = tracked.story_revisions().expect("list story revisions"); + assert_eq!(revisions.len(), 2, "{revisions:?}"); + let footer = tracked + .stories() + .expect("inventory stories") + .into_iter() + .find(|story| story.kind() == StoryKind::Footer) + .expect("footer story"); + assert!(revisions.iter().all(|revision| revision.story() == &footer)); + assert!(revisions.iter().all(|revision| revision.author() == "R")); + assert!( + revisions + .iter() + .all(|revision| revision.timestamp() == Some("2026-09-27T12:00:00Z")) + ); + let mut kinds = revisions + .iter() + .map(|revision| revision.kind()) + .collect::>(); + kinds.sort_by_key(|kind| format!("{kind:?}")); + assert_eq!(kinds, [RevisionKind::Deletion, RevisionKind::Insertion]); + assert_eq!(story_revision_resolution_counts(&mut tracked), (2, 2)); + + let reopened = Document::from_bytes(&tracked.to_bytes().expect("serialize redline")) + .expect("reopen redline"); + assert_eq!( + reopened.story_revisions().expect("list reopened revisions"), + revisions + ); +} + +#[test] +fn story_revisions_name_every_compared_story_and_match_resolution_counts() { + let mut tracked = document_with_comparison_stories("original"); + let edited = document_with_comparison_stories("edited"); + tracked + .compare(&edited, "Word", "2026-09-04T09:00:00Z") + .expect("full-story comparison"); + + let rows = story_revision_rows(&tracked); + let mut per_story = Vec::<(StoryKind, String, usize, usize)>::new(); + for (kind, part_name, owner_index, _, _) in &rows { + match per_story + .iter_mut() + .find(|(k, p, o, _)| k == kind && p == part_name && o == owner_index) + { + Some(entry) => entry.3 += 1, + None => per_story.push((*kind, part_name.clone(), *owner_index, 1)), + } + } + let expected_stories = [ + (StoryKind::Body, "/word/document.xml"), + (StoryKind::Header, "/word/header1.xml"), + (StoryKind::Footer, "/word/footer1.xml"), + (StoryKind::Comment, "/word/comments.xml"), + (StoryKind::Footnote, "/word/footnotes.xml"), + (StoryKind::Endnote, "/word/endnotes.xml"), + ]; + assert_eq!( + per_story + .iter() + .map(|(kind, part_name, owner_index, _)| (*kind, part_name.as_str(), *owner_index)) + .collect::>(), + expected_stories + .iter() + .map(|(kind, part_name)| (*kind, *part_name, 0)) + .collect::>(), + "{rows:?}" + ); + assert!(per_story.iter().all(|(.., count)| *count >= 2), "{rows:?}"); + assert_eq!( + rows.iter() + .filter(|(kind, ..)| *kind == StoryKind::Body) + .count(), + tracked.revisions().len() + ); + assert_eq!( + story_revision_resolution_counts(&mut tracked), + (rows.len(), rows.len()) + ); +} + +#[test] +fn story_revisions_fold_cells_and_report_text_boxes_as_their_own_story() { + let mut header = document_with_comparison_header(concat!( + r#"cell"#, + r#"marked"#, + r#"boxed"#, + )); + let header_row = |kind, id, revision| (kind, "/word/header1.xml".to_owned(), 0, id, revision); + assert_eq!( + story_revision_rows(&header), + [ + header_row(StoryKind::Header, 81, RevisionKind::Insertion), + header_row(StoryKind::Header, 82, RevisionKind::Insertion), + header_row(StoryKind::Header, 82, RevisionKind::RunPropertyChange), + header_row(StoryKind::TextBox, 83, RevisionKind::Deletion), + ] + ); + assert_eq!(story_revision_resolution_counts(&mut header), (4, 4)); + + let mut body = document_with_content_controls(&wrap_word_body(concat!( + r#"body"#, + r#"vml box"#, + r#"choicefallback"#, + ))); + assert_eq!(body.revisions().len(), 1); + let body_row = |kind, id| { + let revision = RevisionKind::Insertion; + (kind, "/word/document.xml".to_owned(), 0, id, revision) + }; + assert_eq!( + story_revision_rows(&body), + [ + body_row(StoryKind::Body, 91), + body_row(StoryKind::TextBox, 92), + body_row(StoryKind::Body, 93), + body_row(StoryKind::Body, 93), + ] + ); + assert_eq!(story_revision_resolution_counts(&mut body), (4, 4)); +} + +#[test] +fn story_revisions_refuse_a_revision_outside_every_story_owner() { + let mut document = document_with_comparison_stories("same"); + let bytes = document.to_bytes().expect("serialize note fixture"); + let mut package = oxml_opc::OpcPackage::from_reader(std::io::Cursor::new(bytes)) + .expect("open note fixture package"); + let footnotes = String::from_utf8( + package + .get_part("/word/footnotes.xml") + .expect("footnotes part") + .to_vec(), + ) + .expect("footnotes are UTF-8") + .replacen( + "", + r#"edited separator"#, + 1, + ); + assert!(footnotes.contains(r#"w:id="95""#), "{footnotes}"); + package.set_part("/word/footnotes.xml", footnotes.into_bytes()); + let mut output = std::io::Cursor::new(Vec::new()); + package.write_to(&mut output).expect("write note fixture"); + let mut document = Document::from_bytes(output.get_ref()).expect("open note fixture"); + + assert!(document.stories().is_ok()); + let error = document + .story_revisions() + .expect_err("a separator revision has no story"); + assert!(error.to_string().contains("has no story owner"), "{error}"); + // Resolution still reaches the separator, so omitting it would undercount. + assert_eq!(story_revision_resolution_counts(&mut document), (1, 1)); +} + +#[test] +fn story_revisions_scan_the_main_part_bytes_that_resolution_scans() { + let word = "http://schemas.openxmlformats.org/wordprocessingml/2006/main"; + let listed_and_resolved = |mut document: Document| { + let rows = story_revision_rows(&document); + assert_eq!( + story_revision_resolution_counts(&mut document), + (rows.len(), rows.len()), + "{rows:?}" + ); + assert_eq!(document.accept_all().expect("accept in memory"), rows.len()); + rows + }; + let body_row = |id| { + let revision = RevisionKind::Insertion; + ( + StoryKind::Body, + "/word/document.xml".to_owned(), + 0, + id, + revision, + ) + }; + + // The typed serialization writes the default namespace as `w:`, so a + // listing of it would miss the unprefixed revision that accept resolves. + let default_namespace = document_with_content_controls(&format!( + r#"

added

"# + )); + assert_eq!(listed_and_resolved(default_namespace), [body_row(30)]); + + // The typed serialization drops `xmlns:x` from a modeled ancestor, so + // `stories()` reports no text box and the revision belongs to the body. + // A modified document replays the declaration on a paragraph or run and + // cannot be staged with it on the body, and the listing follows accept. + for owner in ["body", "p", "r"] { + let declaration = |element| { + if element == owner { + format!(r#" xmlns:x="{word}""#) + } else { + String::new() + } + }; + let xml = format!( + r#"boxed"#, + declaration("body"), + declaration("p"), + declaration("r"), + ); + let document = document_with_content_controls(&xml); + assert!(document.revisions().is_empty()); + assert_eq!(listed_and_resolved(document), [body_row(31)], "{xml}"); + + let mut modified = document_with_content_controls(&xml); + modified.add_paragraph("after"); + if owner == "body" { + let listed = modified.story_revisions().expect_err("unstaged listing"); + let accepted = modified.accept_all().expect_err("unstaged accept"); + assert_eq!(listed.to_string(), accepted.to_string()); + } else { + assert_eq!(listed_and_resolved(modified), [body_row(31)], "{xml}"); + } + } + + // A text box that both serializations report keeps its own story when + // one before it loses its binding. + let mixed = document_with_content_controls(&format!( + r#"lostkept"# + )); + let mut kept = body_row(32); + kept.0 = StoryKind::TextBox; + assert_eq!(listed_and_resolved(mixed), [body_row(31), kept]); +} + #[test] fn comparison_preserves_word_source_paths_in_every_revision_view() { let mut original = diff --git a/docs/hld/03-architecture.md b/docs/hld/03-architecture.md index 97ce19c6..d420acc2 100644 --- a/docs/hld/03-architecture.md +++ b/docs/hld/03-architecture.md @@ -1452,7 +1452,18 @@ Revision traversal follows that ownership tree through the main body, tables, cells, and content controls. `Document::revisions` reports every valid modeled revision once in document order as a borrowed `RevisionRef`. The facade does not copy or reparse the raw subtree, and revisions outside the main document -part remain outside this traversal. +part remain outside this traversal. `Document::story_revisions` covers every +story instead. It stages a copy of the document as revision resolution does +and scans the main part and each related story part left in the staged +package with the element inventory that resolution counts, so its length +equals the accept and reject counts, text boxes included, and it fails where +staging fails. Each revision belongs to the innermost owner that +`Document::stories` reports around it, with table cells folded into their +story. Where the typed serialization that `stories` scans drops a namespace +binding the staged part keeps, owners pair across the two, and a text box +that `stories` does not report folds into the story around it. A revision +outside every owner, such as one in a footnote separator, is an error rather +than a silent omission. Revision mutation uses explicit all, exact-author, inclusive RFC 3339 instant, and id selectors. One id operation resolves every modeled element carrying the diff --git a/docs/hld/10-bindings-spec.md b/docs/hld/10-bindings-spec.md index a6a75eb7..d5c12077 100644 --- a/docs/hld/10-bindings-spec.md +++ b/docs/hld/10-bindings-spec.md @@ -1164,9 +1164,17 @@ Each immutable `RevisionRef` exposes the revision id, author, optional timestamp, and `RevisionKind`. Results recursively cover the main document body in document order, including tables, cells, and content controls. The facade reads a typed projection while serialization continues to use the -captured raw WordprocessingML subtree. `rdocx-cli revision list` exposes this -main-story projection with an explicit scope field. Python and WASM load and -save paths preserve the revision XML without a revision inspection method. +captured raw WordprocessingML subtree. The additive +`Document::story_revisions` returns owned `StoryRevision` snapshots for every +story that revision resolution reaches: the main document, headers, footers, +comments, normal footnotes, endnotes, and the text boxes inside them. Each +snapshot adds the `StoryId` of its Word story, with table cells folded into the +story that holds the table. It scans the parts as resolution stages them, so +the list has one entry per revision element that `accept_all` and +`reject_all` resolve and its length equals their count. +`rdocx-cli revision list` exposes the main-story projection with an explicit +scope field. Python and WASM load and save paths preserve the revision XML +without a revision inspection method. Native Word paragraph handles expose `Paragraph::add_run_inheriting_mark(&mut self, text)`. The method appends one @@ -1263,11 +1271,13 @@ ids select every matching placement, author matching is case-sensitive, and missing dates do not match a date range. Invalid bounds and malformed selected changes return an error before mutation. Resolution covers the main document, headers, footers, comments, normal footnotes, endnotes, and nested text boxes. -`Document::revisions` remains main-story-only. These eight methods are additive -on `rdocx::Document`. `rdocx-cli revision accept|reject` exposes the all-story -resolution boundary with mutually exclusive id, exact-author, or paired date -selectors. An omitted selector resolves all modeled revisions. Python and WASM -continue to preserve the resulting document when they save it. +`Document::revisions` remains main-story-only, while +`Document::story_revisions` lists exactly the elements these methods resolve. +These eight methods are additive on `rdocx::Document`. +`rdocx-cli revision accept|reject` exposes the all-story resolution boundary +with mutually exclusive id, exact-author, or paired date selectors. An omitted +selector resolves all modeled revisions. Python and WASM continue to preserve +the resulting document when they save it. Native callers generate tracked changes with `Document::compare`, supplying an edited document, author, and RFC 3339 timestamp. The additive From a50dabace7b80c248663516e1014bb1fb4488ace Mon Sep 17 00:00:00 2001 From: Hadrien Mary Date: Sun, 27 Sep 2026 19:46:20 +0200 Subject: [PATCH 2/5] Widen Python Document.revisions to every story and add Revision.story Document.revisions mapped the main-only native listing, so a redline whose only change sat in a footer returned an empty tuple while accept_all() reported two resolved revision elements. A caller checking that no tracked change is left before releasing a file could not trust the tuple, and Revision had no way to say where a change lives. Build the tuple from Document::story_revisions instead, so its length is the count accept_all and reject_all return, and give Revision an optional story carrying the same Story snapshot as StoryItem.story. The constructor keeps its four keywords and takes story=None as an extra keyword. The getter now raises RdocxError when the story graph cannot be inventoried, as Document.stories already does, or when the document cannot be staged for resolution, as accept_all already does. The stub, typing smoke test and HLD 10 follow. GitHub issue #165. --- crates/rdocx-py/python/rdocx/_rdocx.pyi | 10 ++++++- crates/rdocx-py/src/document.rs | 32 +++++++++++++-------- crates/rdocx-py/tests/test_core.py | 37 +++++++++++++++++++++++++ crates/rdocx-py/tests/typing_smoke.py | 1 + docs/hld/10-bindings-spec.md | 13 +++++---- 5 files changed, 76 insertions(+), 17 deletions(-) diff --git a/crates/rdocx-py/python/rdocx/_rdocx.pyi b/crates/rdocx-py/python/rdocx/_rdocx.pyi index f5e4ef18..86a04239 100644 --- a/crates/rdocx-py/python/rdocx/_rdocx.pyi +++ b/crates/rdocx-py/python/rdocx/_rdocx.pyi @@ -171,7 +171,13 @@ class TocRebuildReport: @_final class Revision: def __new__( - cls, *, id: int, author: str, timestamp: str | None, kind: str + cls, + *, + id: int, + author: str, + timestamp: str | None, + kind: str, + story: Story | None = None, ) -> Revision: ... @property def id(self) -> int: ... @@ -181,6 +187,8 @@ class Revision: def timestamp(self) -> str | None: ... @property def kind(self) -> str: ... + @property + def story(self) -> Story | None: ... @_final diff --git a/crates/rdocx-py/src/document.rs b/crates/rdocx-py/src/document.rs index b81167c6..52b5d140 100644 --- a/crates/rdocx-py/src/document.rs +++ b/crates/rdocx-py/src/document.rs @@ -259,18 +259,26 @@ pub struct PyRevision { pub author: String, pub timestamp: Option, pub kind: String, + pub story: Option, } #[pymethods] impl PyRevision { #[new] - #[pyo3(signature = (*, id, author, timestamp, kind))] - fn new(id: i32, author: String, timestamp: Option, kind: String) -> Self { + #[pyo3(signature = (*, id, author, timestamp, kind, story=None))] + fn new( + id: i32, + author: String, + timestamp: Option, + kind: String, + story: Option>, + ) -> Self { Self { id, author, timestamp, kind, + story: story.map(|story| story.clone()), } } } @@ -1630,17 +1638,19 @@ impl PyDocument { #[getter(revisions)] fn revision_snapshots<'py>(&self, py: Python<'py>) -> PyResult> { + let revisions = self + .inner + .story_revisions() + .map_err(|error| rdocx_to_pyerr(py, error))?; PyTuple::new( py, - self.inner - .revisions() - .into_iter() - .map(|revision| PyRevision { - id: revision.id(), - author: revision.author().to_owned(), - timestamp: revision.timestamp().map(str::to_owned), - kind: revision_kind_name(revision.kind()).to_owned(), - }), + revisions.iter().map(|revision| PyRevision { + id: revision.id(), + author: revision.author().to_owned(), + timestamp: revision.timestamp().map(str::to_owned), + kind: revision_kind_name(revision.kind()).to_owned(), + story: Some(story_snapshot(revision.story())), + }), ) } diff --git a/crates/rdocx-py/tests/test_core.py b/crates/rdocx-py/tests/test_core.py index 0f8ce927..1be6a1e3 100644 --- a/crates/rdocx-py/tests/test_core.py +++ b/crates/rdocx-py/tests/test_core.py @@ -104,6 +104,43 @@ def test_reject_revision_id_then_reject_all_restores_the_original(): assert [paragraph.text for paragraph in document.paragraphs] == ["alpha"] +def test_revisions_list_every_story_and_name_the_story_that_holds_them(): + import rdocx + + def footer_document(text): + document = rdocx.Document() + document.add_paragraph("Body.") + document.set_footer(text) + return rdocx.Document.from_bytes(document.to_bytes()) + + document = footer_document("Footer lorem ipsum") + document.compare( + footer_document("Footer lorem IPSUM"), "R", "2026-09-27T12:00:00Z" + ) + revisions = document.revisions + footer = next(story for story in document.stories if story.kind == "footer") + assert footer.part_name == "/word/footer1.xml" + assert len(revisions) == 2 + assert all(revision.story == footer for revision in revisions) + assert sorted(revision.kind for revision in revisions) == ["deletion", "insertion"] + assert {revision.author for revision in revisions} == {"R"} + + body = rdocx.Story(kind="body", part_name="/word/document.xml", owner_index=0) + assert {revision.story == body for revision in _tracked_document().revisions} == { + True + } + unscoped = rdocx.Revision(id=1, author="R", timestamp=None, kind="insertion") + assert unscoped.story is None + scoped = rdocx.Revision( + id=1, author="R", timestamp=None, kind="insertion", story=footer + ) + assert scoped.story == footer + assert scoped != unscoped + + assert document.accept_all() == len(revisions) + assert document.revisions == () + + def test_counted_replacement_spans_runs_and_a_bad_regex_changes_nothing(): import rdocx diff --git a/crates/rdocx-py/tests/typing_smoke.py b/crates/rdocx-py/tests/typing_smoke.py index 51ebfa05..090dfca9 100644 --- a/crates/rdocx-py/tests/typing_smoke.py +++ b/crates/rdocx-py/tests/typing_smoke.py @@ -145,6 +145,7 @@ def exercise_rdocx_types(path: Path) -> None: file_name="report.docx", merge_fields={"Name": "Ada"} ) assert_type(revisions[0].timestamp, str | None) + assert_type(revisions[0].story, Story | None) document.set_header("Header") document.set_footer("Footer") document.set_story_text(story_items[0], "edited") diff --git a/docs/hld/10-bindings-spec.md b/docs/hld/10-bindings-spec.md index d5c12077..e5b096aa 100644 --- a/docs/hld/10-bindings-spec.md +++ b/docs/hld/10-bindings-spec.md @@ -245,9 +245,11 @@ field cache update operations. `RunPosition` and constructors and call shape remain unchanged. `Comment`, `ComparisonDiagnostic`, `BoundingBox`, `LayoutFragment`, `LayoutPage`, `TocRebuildReport`, and `Revision` are frozen typed snapshots. -`Document.revisions` lists main-document revisions with a snake_case `kind`, -while the accept and reject methods resolve revisions in every story and return -how many they resolved. `try_replace_text` and `replace_all_regex` return their +`Document.revisions` lists the revisions of every story that the accept and +reject methods resolve, each with a snake_case `kind` and the `Story` that +holds it, so its length equals the count they return. A `Revision` built +directly keeps its four-field constructor and has no story unless one is +passed. `try_replace_text` and `replace_all_regex` return their replacement counts. `update_fields` takes the native evaluation context as keyword arguments, reads the wall-clock fields of `now` as given, and returns the number of updated fields. Comments are @@ -1173,8 +1175,9 @@ story that holds the table. It scans the parts as resolution stages them, so the list has one entry per revision element that `accept_all` and `reject_all` resolve and its length equals their count. `rdocx-cli revision list` exposes the main-story projection with an explicit -scope field. Python and WASM load and save paths preserve the revision XML -without a revision inspection method. +scope field. Python `Document.revisions` exposes the all-story listing. WASM +load and save paths preserve the revision XML without a revision inspection +method. Native Word paragraph handles expose `Paragraph::add_run_inheriting_mark(&mut self, text)`. The method appends one From 811e7100e3ce9b6a994a8f8924645ad88bb752d3 Mon Sep 17 00:00:00 2001 From: Hadrien Mary Date: Sun, 27 Sep 2026 19:47:04 +0200 Subject: [PATCH 3/5] List every story in rdocx revision list and name each revision story rdocx revision list read the main-only typed listing and printed "(no revisions in main story)" for a redline whose changes sat in a footer, while rdocx revision accept on the same file resolved two revision elements. Its schema-1 record also stated scope "main" with no way to tell where a change lives. Build the command on Document::story_revisions. The JSON record now states scope "all-supported-stories" and gives each revision a story object with its kebab-case kind, part name and owner index. Text mode appends the same three values as trailing tab columns, so the existing column indexes keep their meaning, and prints "(no revisions)" when there is none. The list length now equals the count revision accept and reject report. The clap help, the CLI README and HLD 10 follow. GitHub issue #165. --- crates/rdocx-cli/README.md | 13 ++-- crates/rdocx-cli/src/commands.rs | 39 ++++++++-- crates/rdocx-cli/src/main.rs | 2 +- crates/rdocx-cli/tests/integration.rs | 100 +++++++++++++++++++++++++- docs/hld/10-bindings-spec.md | 9 ++- 5 files changed, 144 insertions(+), 19 deletions(-) diff --git a/crates/rdocx-cli/README.md b/crates/rdocx-cli/README.md index 25ecc0fe..cc5be2cd 100644 --- a/crates/rdocx-cli/README.md +++ b/crates/rdocx-cli/README.md @@ -59,12 +59,13 @@ Comment `add` ranges use zero-based body paragraph and run boundaries. The start is inclusive and the end is exclusive. Comment replies, resolution, and removal select a decimal comment id. -Revision `list` reports the main story. Revision `accept` and `reject` operate -across every supported story and accept at most one selector: `--id`, -`--author`, or the paired `--start-date` and `--end-date` RFC 3339 bounds. -Omitting a selector resolves all modeled revisions. Every mutation, comparison, -and TOC rebuild requires `-o/--output`, publishes only a complete validated -DOCX, and supports a schema-1 record through `--json`. +Revision `list` reports every supported story and names the story of each +revision. Revision `accept` and `reject` operate across every supported story +and accept at most one selector: `--id`, `--author`, or the paired +`--start-date` and `--end-date` RFC 3339 bounds. Omitting a selector resolves +all modeled revisions. Every mutation, comparison, and TOC rebuild requires +`-o/--output`, publishes only a complete validated DOCX, and supports a +schema-1 record through `--json`. `text --json` reports accepted-view paragraphs in source order. Each paragraph has a zero-based `body_index`, a typed zero-based path within that body item, diff --git a/crates/rdocx-cli/src/commands.rs b/crates/rdocx-cli/src/commands.rs index 93394f21..307ac093 100644 --- a/crates/rdocx-cli/src/commands.rs +++ b/crates/rdocx-cli/src/commands.rs @@ -7,6 +7,7 @@ use oxml_cli_support::{ }; use rdocx::{ BodyItemRef, Document, RasterFormat, RasterOptions, RasterOutput, RevisionKind, RunRange, + StoryId, StoryKind, }; use rdocx_oxml::content_control::{CT_Sdt, SdtContent}; use rdocx_oxml::document::{BodyContent, CT_Document}; @@ -666,10 +667,10 @@ pub fn comment_remove(file: &Path, id: i32, output: &Path, json_output: bool) -> ) } -/// List modeled revisions from the main story. +/// List modeled revisions from every supported story. pub fn revision_list(file: &Path, json_output: bool) -> Result<()> { let doc = Document::open(file)?; - let revisions = doc.revisions(); + let revisions = doc.story_revisions()?; let records = revisions .iter() .map(|revision| { @@ -678,24 +679,28 @@ pub fn revision_list(file: &Path, json_output: bool) -> Result<()> { "author": revision.author(), "timestamp": revision.timestamp(), "kind": revision_kind_label(revision.kind()), + "story": story_json(revision.story()), }) }) .collect::>(); if json_output { print_json(json!({ - "scope": "main", + "scope": "all-supported-stories", "revisions": records, }))?; } else if revisions.is_empty() { - println!("(no revisions in main story)"); + println!("(no revisions)"); } else { for revision in revisions { println!( - "{}\t{}\t{}\t{}", + "{}\t{}\t{}\t{}\t{}\t{}\t{}", revision.id(), revision.author(), revision.timestamp().unwrap_or(""), - revision_kind_label(revision.kind()) + revision_kind_label(revision.kind()), + story_kind_label(revision.story().kind()), + revision.story().part_name(), + revision.story().owner_index() ); } } @@ -863,6 +868,28 @@ fn revision_kind_label(kind: RevisionKind) -> &'static str { } } +fn story_kind_label(kind: StoryKind) -> &'static str { + match kind { + StoryKind::Body => "body", + StoryKind::TableCell => "table-cell", + StoryKind::Header => "header", + StoryKind::Footer => "footer", + StoryKind::Footnote => "footnote", + StoryKind::Endnote => "endnote", + StoryKind::Comment => "comment", + StoryKind::TextBox => "text-box", + _ => "unknown", + } +} + +fn story_json(story: &StoryId) -> Value { + json!({ + "kind": story_kind_label(story.kind()), + "part_name": story.part_name(), + "owner_index": story.owner_index(), + }) +} + fn publish_document(doc: &mut Document, output: &Path) -> Result<()> { let bytes = doc.to_bytes()?; stage_and_publish(&[(output.to_path_buf(), bytes)]) diff --git a/crates/rdocx-cli/src/main.rs b/crates/rdocx-cli/src/main.rs index 54e451e4..bf85fbcf 100644 --- a/crates/rdocx-cli/src/main.rs +++ b/crates/rdocx-cli/src/main.rs @@ -259,7 +259,7 @@ struct CommentRangeArgs { #[derive(Subcommand)] enum RevisionCommand { - /// List modeled revisions from the main story + /// List modeled revisions from every supported story List { /// Path to the DOCX file file: PathBuf, diff --git a/crates/rdocx-cli/tests/integration.rs b/crates/rdocx-cli/tests/integration.rs index e4cdd98f..8252ab44 100644 --- a/crates/rdocx-cli/tests/integration.rs +++ b/crates/rdocx-cli/tests/integration.rs @@ -761,23 +761,30 @@ fn cli_collaboration_commands_are_schema_stable_and_atomic() { let revisions = cli(&["revision", "list", path_text(&redline), "--json"]); assert_success(&revisions, "revision list JSON"); let value: serde_json::Value = serde_json::from_slice(&revisions.stdout).unwrap(); + let body = json!({ + "kind": "body", + "part_name": "/word/document.xml", + "owner_index": 0, + }); assert_eq!( value, json!({ "schema": 1, - "scope": "main", + "scope": "all-supported-stories", "revisions": [ { "id": 0, "author": "Alice", "timestamp": "2026-09-13T12:00:00Z", "kind": "deletion", + "story": body, }, { "id": 1, "author": "Alice", "timestamp": "2026-09-13T12:00:00Z", "kind": "insertion", + "story": body, }, ], }) @@ -1116,6 +1123,97 @@ fn compare_accept_and_reject_reproduce_each_input() { ); } +fn write_footer_revision_inputs(temp: &TempWorkspace) -> (PathBuf, PathBuf) { + let original = temp.path.join("original.docx"); + let edited = temp.path.join("edited.docx"); + for (path, footer) in [ + (&original, "Footer lorem ipsum"), + (&edited, "Footer lorem IPSUM"), + ] { + let mut document = fixture_document(&["Body."]); + document.set_footer(footer); + document.save(path).expect("write footer fixture"); + } + (original, edited) +} + +fn compare_footer_inputs(original: &Path, edited: &Path, output: &Path, json: bool) -> Output { + let mut args = vec![ + "compare", + path_text(original), + path_text(edited), + "--author", + "R", + "--timestamp", + "2026-09-27T12:00:00Z", + "--output", + path_text(output), + ]; + if json { + args.push("--json"); + } + cli(&args) +} + +#[test] +fn revision_list_names_the_story_of_compared_footer_revisions() { + let temp = TempWorkspace::new("footer-revision-list"); + let (original, edited) = write_footer_revision_inputs(&temp); + let redline = temp.path.join("redline.docx"); + let accepted = temp.path.join("accepted.docx"); + + let unchanged = cli(&["revision", "list", path_text(&original)]); + assert_success(&unchanged, "revision list without revisions"); + assert_eq!( + String::from_utf8_lossy(&unchanged.stdout), + "(no revisions)\n" + ); + + let compared = compare_footer_inputs(&original, &edited, &redline, false); + assert_success(&compared, "compare footer"); + + let listed = cli(&["revision", "list", path_text(&redline), "--json"]); + assert_success(&listed, "revision list footer JSON"); + let value: serde_json::Value = serde_json::from_slice(&listed.stdout).unwrap(); + assert_eq!(value["scope"], "all-supported-stories"); + let records = value["revisions"].as_array().expect("revision records"); + assert_eq!(records.len(), 2, "{value}"); + for record in records { + assert_eq!( + record["story"], + json!({ + "kind": "footer", + "part_name": "/word/footer1.xml", + "owner_index": 0, + }) + ); + assert_eq!(record["author"], "R"); + } + + let listed = cli(&["revision", "list", path_text(&redline)]); + assert_success(&listed, "revision list footer text"); + let lines = String::from_utf8_lossy(&listed.stdout).into_owned(); + assert_eq!(lines.lines().count(), 2, "{lines}"); + for line in lines.lines() { + let columns = line.split('\t').collect::>(); + assert_eq!(columns.len(), 7, "{line}"); + assert_eq!(columns[1..3], ["R", "2026-09-27T12:00:00Z"], "{line}"); + assert_eq!(columns[4..], ["footer", "/word/footer1.xml", "0"], "{line}"); + } + + let resolved = cli(&[ + "revision", + "accept", + path_text(&redline), + "--output", + path_text(&accepted), + "--json", + ]); + assert_success(&resolved, "revision accept footer"); + let value: serde_json::Value = serde_json::from_slice(&resolved.stdout).unwrap(); + assert_eq!(value["resolved"], records.len()); +} + #[test] fn cli_structured_text_layout_and_guarded_replace_preserve_exact_contracts() { let temp = TempWorkspace::new("structured-automation"); diff --git a/docs/hld/10-bindings-spec.md b/docs/hld/10-bindings-spec.md index e5b096aa..42266802 100644 --- a/docs/hld/10-bindings-spec.md +++ b/docs/hld/10-bindings-spec.md @@ -1174,10 +1174,9 @@ snapshot adds the `StoryId` of its Word story, with table cells folded into the story that holds the table. It scans the parts as resolution stages them, so the list has one entry per revision element that `accept_all` and `reject_all` resolve and its length equals their count. -`rdocx-cli revision list` exposes the main-story projection with an explicit -scope field. Python `Document.revisions` exposes the all-story listing. WASM -load and save paths preserve the revision XML without a revision inspection -method. +`rdocx-cli revision list` and Python `Document.revisions` expose this all-story +listing. WASM load and save paths preserve the revision XML without a revision +inspection method. Native Word paragraph handles expose `Paragraph::add_run_inheriting_mark(&mut self, text)`. The method appends one @@ -2075,7 +2074,7 @@ unlaid items retain an empty fragment list. `replace --expect N` checks the run-aware replacement count before staged publication. A mismatch creates no output and leaves an existing destination untouched. Both the selected page and all-page `render` paths use bundled deterministic fonts. The compiled -surface also includes nested comment thread commands, main-story revision +surface also includes nested comment thread commands, all-story revision inspection, all-story filtered revision resolution, whole-run comparison, and TOC rebuild. Every new mutation requires an explicit output and publishes through the shared staged output set. Their schema-1 records state `main` or From 328424050bf49b59f2aa0beaee5fc4c070fcfbab Mon Sep 17 00:00:00 2001 From: Hadrien Mary Date: Sun, 27 Sep 2026 19:47:18 +0200 Subject: [PATCH 4/5] Count the revisions rdocx compare creates in each story rdocx compare counted Document::revisions, the main-only typed listing, so a footer-only edit reported "main_story_revisions": 0 next to "scope": "all-supported-stories" and the text summary said it created 0 main-story revision elements, although the redline carried a deletion and an insertion in the footer. Count Document::story_revisions after the comparison instead. The schema-1 JSON record gains "revisions", the total that revision accept and reject resolve, and "stories", one entry per story with at least one revision giving its kind, part name, owner index and count in listing order. "main_story_revisions" keeps its previous meaning for existing consumers. The text summary names the total and prints one indented line per story. The CLI README and HLD 10 follow. GitHub issue #165. --- crates/rdocx-cli/README.md | 4 ++- crates/rdocx-cli/src/commands.rs | 39 +++++++++++++++++++-- crates/rdocx-cli/tests/integration.rs | 50 +++++++++++++++++++++++++++ docs/hld/10-bindings-spec.md | 14 ++++---- 4 files changed, 96 insertions(+), 11 deletions(-) diff --git a/crates/rdocx-cli/README.md b/crates/rdocx-cli/README.md index cc5be2cd..5efea91b 100644 --- a/crates/rdocx-cli/README.md +++ b/crates/rdocx-cli/README.md @@ -63,7 +63,9 @@ Revision `list` reports every supported story and names the story of each revision. Revision `accept` and `reject` operate across every supported story and accept at most one selector: `--id`, `--author`, or the paired `--start-date` and `--end-date` RFC 3339 bounds. Omitting a selector resolves -all modeled revisions. Every mutation, comparison, and TOC rebuild requires +all modeled revisions. `compare` reports how many revisions it created in each +story, and its JSON record keeps `main_story_revisions` for the main-body +projection. Every mutation, comparison, and TOC rebuild requires `-o/--output`, publishes only a complete validated DOCX, and supports a schema-1 record through `--json`. diff --git a/crates/rdocx-cli/src/commands.rs b/crates/rdocx-cli/src/commands.rs index 307ac093..e899952d 100644 --- a/crates/rdocx-cli/src/commands.rs +++ b/crates/rdocx-cli/src/commands.rs @@ -771,7 +771,18 @@ pub fn compare( let mut original_doc = Document::open(original)?; let edited_doc = Document::open(edited)?; let diagnostics = original_doc.compare(&edited_doc, author, timestamp)?; - let revision_count = original_doc.revisions().len(); + let main_story_revisions = original_doc.revisions().len(); + let revisions = original_doc.story_revisions()?; + let mut stories = Vec::<(&StoryId, usize)>::new(); + for revision in &revisions { + match stories + .iter_mut() + .find(|(story, _)| *story == revision.story()) + { + Some((_, count)) => *count += 1, + None => stories.push((revision.story(), 1)), + } + } let records = diagnostics .iter() .map(|diagnostic| { @@ -783,14 +794,36 @@ pub fn compare( .collect::>(); publish_document(&mut original_doc, output)?; if json_output { + let story_records = stories + .iter() + .map(|(story, count)| { + let mut record = story_json(story); + record["revisions"] = json!(count); + record + }) + .collect::>(); print_json(json!({ "scope": "all-supported-stories", - "main_story_revisions": revision_count, + "revisions": revisions.len(), + "stories": story_records, + "main_story_revisions": main_story_revisions, "diagnostics": records, "output": output.display().to_string(), }))?; } else { - println!("Created {revision_count} main-story revision element(s)"); + println!( + "Created {} revision element(s) in {} story(ies)", + revisions.len(), + stories.len() + ); + for (story, count) in &stories { + println!( + " {}\t{}\t{}\t{count}", + story_kind_label(story.kind()), + story.part_name(), + story.owner_index() + ); + } println!("Diagnostics: {}", diagnostics.len()); println!("Written to {}", output.display()); } diff --git a/crates/rdocx-cli/tests/integration.rs b/crates/rdocx-cli/tests/integration.rs index 8252ab44..26b58ba9 100644 --- a/crates/rdocx-cli/tests/integration.rs +++ b/crates/rdocx-cli/tests/integration.rs @@ -752,6 +752,15 @@ fn cli_collaboration_commands_are_schema_stable_and_atomic() { json!({ "schema": 1, "scope": "all-supported-stories", + "revisions": 2, + "stories": [ + { + "kind": "body", + "part_name": "/word/document.xml", + "owner_index": 0, + "revisions": 2, + }, + ], "main_story_revisions": 2, "diagnostics": [], "output": path_text(&redline), @@ -1214,6 +1223,47 @@ fn revision_list_names_the_story_of_compared_footer_revisions() { assert_eq!(value["resolved"], records.len()); } +#[test] +fn compare_counts_the_revisions_it_creates_in_each_story() { + let temp = TempWorkspace::new("footer-compare"); + let (original, edited) = write_footer_revision_inputs(&temp); + let redline = temp.path.join("redline.docx"); + let text_redline = temp.path.join("text-redline.docx"); + + let compared = compare_footer_inputs(&original, &edited, &redline, true); + assert_success(&compared, "compare footer JSON"); + let value: serde_json::Value = serde_json::from_slice(&compared.stdout).unwrap(); + assert_eq!( + value, + json!({ + "schema": 1, + "scope": "all-supported-stories", + "revisions": 2, + "stories": [ + { + "kind": "footer", + "part_name": "/word/footer1.xml", + "owner_index": 0, + "revisions": 2, + }, + ], + "main_story_revisions": 0, + "diagnostics": [], + "output": path_text(&redline), + }) + ); + + let compared = compare_footer_inputs(&original, &edited, &text_redline, false); + assert_success(&compared, "compare footer text"); + assert_eq!( + String::from_utf8_lossy(&compared.stdout), + format!( + "Created 2 revision element(s) in 1 story(ies)\n footer\t/word/footer1.xml\t0\t2\nDiagnostics: 0\nWritten to {}\n", + path_text(&text_redline) + ) + ); +} + #[test] fn cli_structured_text_layout_and_guarded_replace_preserve_exact_contracts() { let temp = TempWorkspace::new("structured-automation"); diff --git a/docs/hld/10-bindings-spec.md b/docs/hld/10-bindings-spec.md index 42266802..1241834e 100644 --- a/docs/hld/10-bindings-spec.md +++ b/docs/hld/10-bindings-spec.md @@ -2075,13 +2075,13 @@ run-aware replacement count before staged publication. A mismatch creates no output and leaves an existing destination untouched. Both the selected page and all-page `render` paths use bundled deterministic fonts. The compiled surface also includes nested comment thread commands, all-story revision -inspection, all-story filtered revision resolution, whole-run comparison, and -TOC rebuild. Every new mutation requires an explicit output and publishes -through the shared staged output set. Their schema-1 records state `main` or -`all-supported-stories` scope. Revision selectors are mutually exclusive, and -RFC 3339 start and end bounds must be paired. The complete compiled surface is -covered by one integration binary, with fixtures constructed in code and no -command-only test dependency. +inspection, all-story filtered revision resolution, whole-run comparison with +per-story revision counts, and TOC rebuild. Every new mutation requires an +explicit output and publishes through the shared staged output set. Their +schema-1 records state `main` or `all-supported-stories` scope. Revision +selectors are mutually exclusive, and RFC 3339 start and end bounds must be +paired. The complete compiled surface is covered by one integration binary, +with fixtures constructed in code and no command-only test dependency. Rust release tags also distribute the selected CLI as prebuilt archives. Stable `v*` tags carry only `rdocx`, and incubating `rpptx-v*` tags carry only From a83d62911c62f63f2607fb6af036d73e21b7a57a Mon Sep 17 00:00:00 2001 From: Hadrien Mary Date: Sun, 27 Sep 2026 19:47:51 +0200 Subject: [PATCH 5/5] Re-record the archive measurements of rdocx and rdocx-cli The all-story revision listing, the widened CLI records and their tests grow the rdocx and rdocx-cli packages, so the crates.io archive rows in README.md and crates/rdocx-cli/README.md and their ARCHIVE_MEASUREMENTS entries are re-measured, with both crates dated 2026-09-27 in ARCHIVE_REMEASUREMENT_DATES. GitHub issue #165. --- README.md | 2 +- crates/rdocx-cli/README.md | 2 +- scripts/readme_doctests.py | 7 ++++--- 3 files changed, 6 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index 479b462e..def9173a 100644 --- a/README.md +++ b/README.md @@ -39,7 +39,7 @@ rows are the enforced release-mode bounds plus one dated observation. | Measurement | Value | Version | Platform | Build mode | Input | Command | Statistic | Measured on | |---|---|---|---|---|---|---|---|---| -| Crates.io archive: rdocx | 1,092,256 compressed bytes, 6,498,484 member bytes, 36 members | 0.14.0 | macOS 26.6.2, Apple M5 Max, arm64 | `cargo package --locked --no-verify` | Tracked `rdocx` package inventory | `python3 scripts/readme_doctests.py --record-measurements` | gzip archive bytes, tar member bytes, tar member count | 2026-09-26 | +| Crates.io archive: rdocx | 1,097,252 compressed bytes, 6,519,795 member bytes, 36 members | 0.14.0 | macOS 26.6.2, Apple M5 Max, arm64 | `cargo package --locked --no-verify` | Tracked `rdocx` package inventory | `python3 scripts/readme_doctests.py --record-measurements` | gzip archive bytes, tar member bytes, tar member count | 2026-09-27 | | Large-document layout throughput | minimum 250 pages/s, observed 31,019.1 pages/s | rdocx 0.14.0 | macOS 26.6.2, Apple M5 Max, arm64 | release, one test thread | 1,000 one-page paragraphs with deterministic fonts | `cargo test -p rdocx --test regression_test --release a_thousand_page_document_paginates_and_renders_within_the_declared_limits -- --ignored --exact --nocapture --test-threads=1` | pages per wall-clock second | 2026-09-19 | | Large-document layout peak allocation | maximum 64 MiB, observed 29.03 MiB | rdocx 0.14.0 | macOS 26.6.2, Apple M5 Max, arm64 | release, one test thread | 1,000 one-page paragraphs with deterministic fonts | `cargo test -p rdocx --test regression_test --release a_thousand_page_document_paginates_and_renders_within_the_declared_limits -- --ignored --exact --nocapture --test-threads=1` | peak live allocation | 2026-09-19 | | Large-document PDF throughput | minimum 1,000 pages/s, observed 60,058.0 pages/s | rdocx 0.14.0 | macOS 26.6.2, Apple M5 Max, arm64 | release, one test thread | 1,000 deterministic layout pages | `cargo test -p rdocx --test regression_test --release a_thousand_page_document_paginates_and_renders_within_the_declared_limits -- --ignored --exact --nocapture --test-threads=1` | pages per wall-clock second | 2026-09-19 | diff --git a/crates/rdocx-cli/README.md b/crates/rdocx-cli/README.md index 5efea91b..7e18ed67 100644 --- a/crates/rdocx-cli/README.md +++ b/crates/rdocx-cli/README.md @@ -22,7 +22,7 @@ and produces fixed or flow output without an Office host. | Measurement | Value | Version | Platform | Build mode | Input | Command | Statistic | Measured on | |---|---|---|---|---|---|---|---|---| -| Crates.io archive: rdocx-cli | 33,805 compressed bytes, 145,256 member bytes, 8 members | 0.14.0 | macOS 26.6.2, Apple M5 Max, arm64 | `cargo package --locked --no-verify` | Tracked `rdocx-cli` package inventory | `python3 scripts/readme_doctests.py --record-measurements` | gzip archive bytes, tar member bytes, tar member count | 2026-09-19 | +| Crates.io archive: rdocx-cli | 35,216 compressed bytes, 152,480 member bytes, 8 members | 0.14.0 | macOS 26.6.2, Apple M5 Max, arm64 | `cargo package --locked --no-verify` | Tracked `rdocx-cli` package inventory | `python3 scripts/readme_doctests.py --record-measurements` | gzip archive bytes, tar member bytes, tar member count | 2026-09-27 | ## Use it when diff --git a/scripts/readme_doctests.py b/scripts/readme_doctests.py index e24c643b..3462348c 100644 --- a/scripts/readme_doctests.py +++ b/scripts/readme_doctests.py @@ -367,7 +367,8 @@ class ReadmeCase: ) MEASUREMENT_DATE = "2026-09-19" ARCHIVE_REMEASUREMENT_DATES = { - "rdocx": "2026-09-26", + "rdocx": "2026-09-27", + "rdocx-cli": "2026-09-27", "rdocx-layout": "2026-09-26", "rpptx": "2026-09-26", } @@ -383,8 +384,8 @@ class ReadmeCase: "oxml-opc": (92_122, 355_510, 12), "oxml-pdf": (66_015, 304_432, 14), "oxml-sml": (12_511, 49_803, 6), - "rdocx": (1_092_256, 6_498_484, 36), - "rdocx-cli": (33_805, 145_256, 8), + "rdocx": (1_097_252, 6_519_795, 36), + "rdocx-cli": (35_216, 152_480, 8), "rdocx-html": (15_486, 63_894, 11), "rdocx-layout": (255_752, 1_385_701, 15), "rdocx-opc": (3_655, 9_668, 6),