diff --git a/README.md b/README.md
index 479b462ef..48357e2df 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,101,657 compressed bytes, 6,540,109 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-layout/README.md b/crates/rdocx-layout/README.md
index 8e18a7d23..d9bbc330d 100644
--- a/crates/rdocx-layout/README.md
+++ b/crates/rdocx-layout/README.md
@@ -18,7 +18,7 @@ sections, and retains source provenance.
| Measurement | Value | Version | Platform | Build mode | Input | Command | Statistic | Measured on |
|---|---|---|---|---|---|---|---|---|
-| Crates.io archive: rdocx-layout | 255,752 compressed bytes, 1,385,701 member bytes, 15 members | 0.14.0 | macOS 26.6.2, Apple M5 Max, arm64 | `cargo package --locked --no-verify` | Tracked `rdocx-layout` 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-layout | 256,067 compressed bytes, 1,386,937 member bytes, 15 members | 0.14.0 | macOS 26.6.2, Apple M5 Max, arm64 | `cargo package --locked --no-verify` | Tracked `rdocx-layout` 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-layout/src/engine.rs b/crates/rdocx-layout/src/engine.rs
index 497200c13..5e2cc8b5a 100644
--- a/crates/rdocx-layout/src/engine.rs
+++ b/crates/rdocx-layout/src/engine.rs
@@ -3617,7 +3617,9 @@ fn table_is_cache_safe(table: &CT_Tbl, styles: &CT_Styles) -> bool {
properties.change.is_none() && properties.revision_xml.is_empty()
})
&& table.rows.iter().all(|row| {
- row.extra_xml.is_empty()
+ row.extra_xml
+ .iter()
+ .all(|(position, raw)| CT_Row::raw_is_root_attributes(*position, raw))
&& row.content_controls.is_empty()
&& row.properties.as_ref().is_none_or(|properties| {
properties.revision_markers.is_empty() && properties.revision_xml.is_empty()
@@ -14416,6 +14418,24 @@ mod tests {
.extra_xml
.push((0, br#""#.to_vec()));
assert!(!table_is_cache_safe(&preserved_cell, &input.styles));
+
+ // Word writes revision-save and paragraph identities on every row.
+ // They carry no content, so the row stays cache safe, unlike a raw
+ // child of the row.
+ let document = rdocx_oxml::CT_Document::from_xml(
+ br#"identified row"#,
+ )
+ .expect("identified row document parses");
+ let Some(BodyContent::Table(mut identified_row)) = document.body.content.into_iter().next()
+ else {
+ panic!("identified row table parses");
+ };
+ assert!(!identified_row.rows[0].extra_xml.is_empty());
+ assert!(table_is_cache_safe(&identified_row, &input.styles));
+ identified_row.rows[0]
+ .extra_xml
+ .push((0, br#""#.to_vec()));
+ assert!(!table_is_cache_safe(&identified_row, &input.styles));
}
fn restart_input() -> LayoutInput {
diff --git a/crates/rdocx-oxml/README.md b/crates/rdocx-oxml/README.md
index 11fef2416..2f82abc82 100644
--- a/crates/rdocx-oxml/README.md
+++ b/crates/rdocx-oxml/README.md
@@ -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 | 372,614 compressed bytes, 2,400,815 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-27 |
## Use it when
diff --git a/crates/rdocx-oxml/src/comments.rs b/crates/rdocx-oxml/src/comments.rs
index 05edd9237..85c83497f 100644
--- a/crates/rdocx-oxml/src/comments.rs
+++ b/crates/rdocx-oxml/src/comments.rs
@@ -10,7 +10,7 @@ use crate::namespace::{W_NS, matches_local_name};
use crate::numbering::word_prefixes_at;
use crate::properties::is_word_element;
use crate::raw_xml::{capture_element, capture_empty_element};
-use crate::text::CT_P;
+use crate::text::{CT_P, declare_w14_on_part_root};
const W14_NS: &str = "http://schemas.microsoft.com/office/word/2010/wordml";
@@ -137,7 +137,9 @@ impl CT_Comments {
write_raw_at(&mut writer, &self.extra_xml, index + 1)?;
}
writer.write_event(Event::End(BytesEnd::new("w:comments")))?;
- Ok(writer.into_inner())
+ let mut xml = writer.into_inner();
+ declare_w14_on_part_root(&mut xml)?;
+ Ok(xml)
}
}
@@ -447,6 +449,19 @@ mod tests {
assert!(output.contains(r#"ext:item="kept""#));
}
+ #[test]
+ fn a_retained_text_identity_stays_bound_without_a_paragraph_identity() {
+ // The root declares `w14` for an authored paragraph identity only, so
+ // a paragraph that carries `w14:textId` alone relies on the check of
+ // the written part.
+ let xml = br#"thread"#;
+ let comments = CT_Comments::from_xml(xml).unwrap();
+ let output = comments.to_xml().unwrap();
+ let reparsed = CT_Comments::from_xml(&output)
+ .unwrap_or_else(|error| panic!("{error}: {}", String::from_utf8_lossy(&output)));
+ assert_eq!(reparsed.to_xml().unwrap(), output);
+ }
+
#[test]
fn malformed_comment_id_is_rejected() {
let xml = br#""#;
diff --git a/crates/rdocx-oxml/src/content_control.rs b/crates/rdocx-oxml/src/content_control.rs
index 8ff31cd52..3fbcfec1d 100644
--- a/crates/rdocx-oxml/src/content_control.rs
+++ b/crates/rdocx-oxml/src/content_control.rs
@@ -1242,6 +1242,7 @@ fn parse_content(
reader,
&prefixes,
&owner_bindings,
+ Some(&child),
)?,
));
} else if is_word_element(child.name().as_ref(), b"tc", &prefixes) {
@@ -1298,7 +1299,7 @@ fn parse_content(
} else if is_word_element(child.name().as_ref(), b"tbl", &prefixes) {
content.push(SdtContent::Table(CT_Tbl::new()));
} else if is_word_element(child.name().as_ref(), b"tr", &prefixes) {
- content.push(SdtContent::Row(CT_Row::new()));
+ content.push(SdtContent::Row(CT_Row::from_empty_root(&child, &prefixes)?));
} else if is_word_element(child.name().as_ref(), b"tc", &prefixes) {
content.push(SdtContent::Cell(CT_Tc {
properties: None,
diff --git a/crates/rdocx-oxml/src/document.rs b/crates/rdocx-oxml/src/document.rs
index 3fc302e93..00e823531 100644
--- a/crates/rdocx-oxml/src/document.rs
+++ b/crates/rdocx-oxml/src/document.rs
@@ -17,7 +17,8 @@ use crate::revision::CT_Revision;
use crate::shared::{ST_PageOrientation, ST_SectionType};
use crate::table::{CT_Tbl, ST_VerticalJc};
use crate::text::{
- CT_P, capture_root_attribute_record, is_root_attribute_record, push_root_attribute_record,
+ CT_P, capture_root_attribute_record, declare_w14_on_part_root, is_root_attribute_record,
+ push_root_attribute_record,
};
use crate::units::Twips;
@@ -2998,7 +2999,9 @@ impl CT_Document {
writer.write_event(Event::End(BytesEnd::new("w:document")))?;
- Ok(writer.into_inner())
+ let mut xml = writer.into_inner();
+ declare_w14_on_part_root(&mut xml)?;
+ Ok(xml)
}
}
diff --git a/crates/rdocx-oxml/src/drawing.rs b/crates/rdocx-oxml/src/drawing.rs
index bcac872b5..8fd49f92e 100644
--- a/crates/rdocx-oxml/src/drawing.rs
+++ b/crates/rdocx-oxml/src/drawing.rs
@@ -1,7 +1,7 @@
//! Drawing elements for inline and anchor images: `CT_Drawing`, `CT_Inline`, `CT_Anchor`.
use quick_xml::events::{BytesEnd, BytesStart, BytesText, Event};
-use quick_xml::name::{Namespace, ResolveResult};
+use quick_xml::name::{Namespace, PrefixDeclaration, ResolveResult};
use quick_xml::reader::NsReader;
use quick_xml::{Reader, Writer, XmlVersion};
@@ -698,29 +698,7 @@ impl CT_Anchor {
Ok(Event::Start(ref ie))
if matches_local_name(ie.name().as_ref(), b"p") =>
{
- let raw = capture_ns_element(reader, ie)?;
- let mut paragraph_reader = Reader::from_reader(raw.as_slice());
- let mut paragraph_buffer = Vec::new();
- loop {
- match paragraph_reader
- .read_event_into(&mut paragraph_buffer)?
- {
- Event::Start(ref paragraph_start)
- if matches_local_name(
- paragraph_start.name().as_ref(),
- b"p",
- ) =>
- {
- paragraphs.push(crate::text::CT_P::from_xml(
- &mut paragraph_reader,
- )?);
- break;
- }
- Event::Eof => break,
- _ => {}
- }
- paragraph_buffer.clear();
- }
+ paragraphs.extend(text_box_paragraph(reader, ie)?);
}
Ok(Event::End(ref ie))
if matches_local_name(ie.name().as_ref(), b"txbxContent") =>
@@ -1384,6 +1362,54 @@ fn canonical_wp_element(
namespace_matches(&namespace, drawing_ns::WP, b"wp") && local.as_ref() == expected_local
}
+/// Parse a text-box paragraph out of its anchor.
+///
+/// The paragraph is parsed on its own, so its start tag gets the bindings in
+/// scope here, on top of the scope `CT_P::from_xml` assumes. A run attribute
+/// under any prefix the part binds then resolves as it does in the body.
+fn text_box_paragraph(
+ reader: &mut NsReader<&[u8]>,
+ start: &BytesStart<'_>,
+) -> Result