Skip to content
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,102,999 compressed bytes, 6,548,573 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 |
| 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
9 changes: 6 additions & 3 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 | 34,584 compressed bytes, 148,741 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 |

## Use it when

Expand Down Expand Up @@ -56,8 +56,11 @@ rdocx toc rebuild report.docx -o refreshed.docx
```

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.
start is inclusive and the end is exclusive. Run boundaries count the runs that
`text --json` lists, including the runs inside inline content controls and
tracked insertions. A range that cannot be anchored exactly, such as one that
crosses the edge of an inline content control, is refused. 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`,
Expand Down
6 changes: 4 additions & 2 deletions crates/rdocx-cli/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -246,13 +246,15 @@ struct CommentRangeArgs {
/// Zero-based body paragraph index at the inclusive start
#[arg(long)]
start_paragraph: usize,
/// Zero-based run boundary at the inclusive start
/// Zero-based run boundary at the inclusive start, counting the runs that
/// `text --json` lists
#[arg(long)]
start_run: usize,
/// Zero-based body paragraph index at the exclusive end
#[arg(long)]
end_paragraph: usize,
/// Zero-based run boundary at the exclusive end
/// Zero-based run boundary at the exclusive end, counting the runs that
/// `text --json` lists
#[arg(long)]
end_run: usize,
}
Expand Down
85 changes: 85 additions & 0 deletions crates/rdocx-cli/tests/integration.rs
Original file line number Diff line number Diff line change
Expand Up @@ -963,6 +963,91 @@ fn comment_commands_round_trip_one_resolved_thread() {
assert_eq!(Document::open(&input).unwrap().comments().len(), 0);
}

/// GitHub issue #172: `comment add` counts runs the way `text --json` lists
/// them, including the runs of an inline content control.
#[test]
fn comment_add_counts_the_runs_that_text_json_lists() {
let temp = TempWorkspace::new("comment-inline-control");
let input = temp.path.join("input.docx");
let mut document = fixture_document(&[]);
let mut paragraph = document.add_paragraph("");
for text in ["before ", "TAR", "GET", " after"] {
paragraph.add_run(text);
}
document.save(&input).unwrap();
let mut package = OpcPackage::open(&input).unwrap();
let part = package.main_document_part().unwrap();
let xml = String::from_utf8(package.get_part(&part).unwrap().to_vec()).unwrap();
let start = xml[..xml.find(">TAR<").unwrap()].rfind("<w:r>").unwrap();
let end =
xml.find(">GET<").unwrap() + xml[xml.find(">GET<").unwrap()..].find("</w:r>").unwrap();
let xml = format!(
"{}<w:sdt><w:sdtPr/><w:sdtContent>{}</w:r></w:sdtContent></w:sdt>{}",
&xml[..start],
&xml[start..end],
&xml[end + "</w:r>".len()..]
);
package.set_part(&part, xml.into_bytes());
package.save(&input).unwrap();

let text = cli(&["text", path_text(&input), "--json"]);
assert_success(&text, "text --json");
let value: serde_json::Value = serde_json::from_slice(&text.stdout).unwrap();
let runs = value["paragraphs"][0]["runs"]
.as_array()
.unwrap()
.iter()
.map(|run| run["text"].as_str().unwrap())
.collect::<Vec<_>>();
assert_eq!(runs, ["before ", "TAR", "GET", " after"]);

let add = |start_run: &str, end_run: &str, output: &Path| {
cli(&[
"comment",
"add",
path_text(&input),
"--start-paragraph",
"0",
"--start-run",
start_run,
"--end-paragraph",
"0",
"--end-run",
end_run,
"--author",
"Alice",
"--text",
"Here",
"--output",
path_text(output),
])
};
let added = temp.path.join("added.docx");
assert_success(&add("1", "3", &added), "comment add");
let package = OpcPackage::open(&added).unwrap();
let xml = String::from_utf8(package.get_part(&part).unwrap().to_vec()).unwrap();
let anchored =
&xml[xml.find("<w:commentRangeStart").unwrap()..xml.find("<w:commentRangeEnd").unwrap()];
let anchored_text = anchored
.split("<w:t>")
.skip(1)
.map(|text| &text[..text.find("</w:t>").unwrap()])
.collect::<String>();
assert_eq!(anchored_text, "TARGET");

// From inside the control to after it cannot be anchored exactly.
let refused = temp.path.join("refused.docx");
let output = add("2", "4", &refused);
assert_eq!(output.status.code(), Some(1));
assert!(
String::from_utf8_lossy(&output.stderr)
.contains("crosses the edge of an inline content control"),
"{}",
String::from_utf8_lossy(&output.stderr)
);
assert!(!refused.exists());
}

#[test]
fn revision_filters_change_only_matching_revisions() {
let temp = TempWorkspace::new("revision-filters");
Expand Down
2 changes: 1 addition & 1 deletion crates/rdocx-oxml/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ schema order, and retains unmodelled XML alongside typed edits.

| Measurement | Value | Version | Platform | Build mode | Input | Command | Statistic | Measured on |
|---|---|---|---|---|---|---|---|---|
| Crates.io archive: rdocx-oxml | 367,500 compressed bytes, 2,380,047 member bytes, 32 members | 0.14.0 | macOS 26.6.2, Apple M5 Max, arm64 | `cargo package --locked --no-verify` | Tracked `rdocx-oxml` 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-oxml | 374,656 compressed bytes, 2,413,911 member bytes, 32 members | 0.14.0 | macOS 26.6.2, Apple M5 Max, arm64 | `cargo package --locked --no-verify` | Tracked `rdocx-oxml` package inventory | `python3 scripts/readme_doctests.py --record-measurements` | gzip archive bytes, tar member bytes, tar member count | 2026-09-19 |

## Use it when

Expand Down
86 changes: 86 additions & 0 deletions crates/rdocx-oxml/src/content_control.rs
Original file line number Diff line number Diff line change
Expand Up @@ -806,6 +806,92 @@ impl CT_Sdt {
}
}

/// Insert direct `w:sdtContent` children before `index`, keeping the
/// revision and retained run source positions after it aligned.
pub(crate) fn insert_content(&mut self, index: usize, children: Vec<SdtContent>) -> bool {
if index > self.content.len() {
return false;
}
let count = children.len();
for (boundary, _) in &mut self.revisions {
if *boundary >= index {
*boundary += count;
}
}
for source in &mut self.inline_run_sources {
if source.content_index >= index {
source.content_index += count;
}
}
self.content.splice(index..index, children);
true
}

/// Remove the selected comment range markers and the reference runs
/// that are direct `w:sdtContent` children.
///
/// A run loses only the selected references and is removed when nothing
/// else remains in it. Nested controls and block content are left to the
/// caller.
#[doc(hidden)]
pub fn remove_comment_anchors(&mut self, ids: &[i32]) {
// Markers added in this session carry the fixed `w` prefix whatever
// prefix the source document gave the Word namespace.
let mut marker_prefixes = vec!["w".to_owned()];
marker_prefixes.extend(self.content_word_prefixes.iter().cloned());
let removed = self
.content
.iter_mut()
.map(|child| match child {
SdtContent::Run(run) => {
run.remove_comment_references(ids)
&& run.content.is_empty()
&& run.extra_xml.is_empty()
&& run.alt_drawings.is_empty()
}
SdtContent::RawXml(raw) => {
crate::text::raw_comment_marker_id(raw, &marker_prefixes)
.is_some_and(|id| ids.contains(&id))
}
_ => false,
})
.collect::<Vec<_>>();
if !removed.contains(&true) {
return;
}
let kept_before = |index: usize| removed[..index].iter().filter(|remove| !**remove).count();
self.revisions
.retain(|(boundary, _)| !removed.get(*boundary).copied().unwrap_or(false));
for (boundary, _) in &mut self.revisions {
*boundary = kept_before(*boundary);
}
self.inline_run_sources
.retain(|source| !removed.get(source.content_index).copied().unwrap_or(false));
for source in &mut self.inline_run_sources {
source.content_index = kept_before(source.content_index);
}
self.content = std::mem::take(&mut self.content)
.into_iter()
.zip(removed)
.filter_map(|(child, remove)| (!remove).then_some(child))
.collect();
}

/// Remap the facade-authored bookmark markers among the direct and nested
/// inline `w:sdtContent` children.
pub(crate) fn remap_authored_bookmark_ids(
&mut self,
remap: &std::collections::HashMap<i32, i32>,
) {
for content in &mut self.content {
match content {
SdtContent::RawXml(raw) => crate::text::remap_authored_bookmark_marker(raw, remap),
SdtContent::ContentControl(control) => control.remap_authored_bookmark_ids(remap),
_ => {}
}
}
}

pub(crate) fn word_prefixes(&self) -> &[String] {
&self.word_prefixes
}
Expand Down
Loading
Loading