Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down
17 changes: 10 additions & 7 deletions crates/rdocx-cli/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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,
Expand Down
78 changes: 69 additions & 9 deletions crates/rdocx-cli/src/commands.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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};
Expand Down Expand Up @@ -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| {
Expand All @@ -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::<Vec<_>>();
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()
);
}
}
Expand Down Expand Up @@ -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| {
Expand All @@ -778,14 +794,36 @@ pub fn compare(
.collect::<Vec<_>>();
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::<Vec<_>>();
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());
}
Expand Down Expand Up @@ -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)])
Expand Down
2 changes: 1 addition & 1 deletion crates/rdocx-cli/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
150 changes: 149 additions & 1 deletion crates/rdocx-cli/tests/integration.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand All @@ -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,
},
],
})
Expand Down Expand Up @@ -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::<Vec<_>>();
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");
Expand Down
Loading
Loading