diff --git a/README.md b/README.md index 479b462ef..def9173ae 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 25ecc0fe5..7e18ed67f 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 @@ -59,12 +59,15 @@ 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. `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`. `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 93394f21e..e899952d4 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() ); } } @@ -766,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| { @@ -778,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()); } @@ -863,6 +901,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 54e451e40..bf85fbcf7 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 e4cdd98fa..26b58ba91 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), @@ -761,23 +770,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 +1132,138 @@ 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 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/crates/rdocx-py/python/rdocx/_rdocx.pyi b/crates/rdocx-py/python/rdocx/_rdocx.pyi index f5e4ef18c..86a042390 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 b81167c6f..52b5d1407 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 0f8ce9271..1be6a1e3c 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 51ebfa052..090dfca9b 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/crates/rdocx/src/comparison.rs b/crates/rdocx/src/comparison.rs index 19c557ed0..f688d1a81 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 5c4b2b468..ec16e704a 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 3e78e4db8..191f2380c 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 1c3269019..fc9a4dc2e 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 d143b3c74..9953bb002 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 97ce19c68..d420acc21 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 a6a75eb7c..1241834e3 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 @@ -1164,9 +1166,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` 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 @@ -1263,11 +1273,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 @@ -2062,14 +2074,14 @@ 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 -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. +surface also includes nested comment thread commands, all-story revision +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 diff --git a/scripts/readme_doctests.py b/scripts/readme_doctests.py index e24c643be..3462348c7 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),