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,095,346 compressed bytes, 6,513,066 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
7 changes: 6 additions & 1 deletion 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,666 compressed bytes, 148,140 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 @@ -83,4 +83,9 @@ fragment on each occupied page.
exactly `N`. A mismatch exits unsuccessfully without creating or replacing the
requested output.

`validate` exits unsuccessfully on a structural error: a relationship to a
missing part, a part without a content type, or a prefix that `mc:Ignorable`
or `mc:MustUnderstand` lists without a namespace declaration. Empty
paragraphs, heading level gaps, and missing metadata are warnings only.

Run `rdocx --help` or `rdocx <command> --help` for the complete option set.
26 changes: 26 additions & 0 deletions crates/rdocx-cli/src/commands.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1193,6 +1193,32 @@ pub fn validate(file: &Path) -> Result<bool> {
}
}

// Markup Compatibility requires every prefix that `mc:Ignorable` or
// `mc:MustUnderstand` lists to be declared, and a consumer may reject a
// part that breaks the rule. A part that does not parse as XML is
// outside this check.
let mut xml_parts = package
.parts
.iter()
.filter(|(part_name, _)| {
package
.content_types
.content_type_for(part_name)
.is_some_and(|content_type| content_type.ends_with("xml"))
})
.collect::<Vec<_>>();
xml_parts.sort();
for (part_name, xml) in xml_parts {
let Ok(findings) = rdocx_oxml::namespace::undeclared_compatibility_prefixes(xml) else {
continue;
};
for (attribute, prefix) in findings {
errors.push(format!(
"part {part_name} lists undeclared prefix `{prefix}` in mc:{attribute}"
));
}
}

// --- Advisory findings ---

if doc.content_count() == 0 {
Expand Down
37 changes: 37 additions & 0 deletions crates/rdocx-cli/tests/integration.rs
Original file line number Diff line number Diff line change
Expand Up @@ -376,6 +376,43 @@ fn validate_exit_status_is_a_verdict() {
);
}

/// #160: rdocx 0.14 rewrote an empty comments root without its `w14`
/// declaration while `mc:Ignorable` still listed `w14`, and validate passed
/// the part.
#[test]
fn validate_reports_an_undeclared_ignorable_prefix() {
let temp = TempWorkspace::new("validate-compatibility");
let valid = temp.path.join("valid.docx");
let broken = temp.path.join("broken.docx");
write_document(&valid, &["Valid content"]);

let mut package = OpcPackage::open(&valid).unwrap();
package.set_part(
"/word/comments.xml",
br#"<?xml version="1.0" encoding="UTF-8" standalone="yes"?><w:comments xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main" xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" xmlns:w15="http://schemas.microsoft.com/office/word/2012/wordml" mc:Ignorable="w14 w15"></w:comments>"#.to_vec(),
);
package.content_types.add_override(
"/word/comments.xml",
"application/vnd.openxmlformats-officedocument.wordprocessingml.comments+xml",
);
let document_part = package.main_document_part().unwrap();
package
.get_or_create_part_rels(&document_part)
.add(rel_types::COMMENTS, "comments.xml");
package.save(&broken).unwrap();

let output = cli(&["validate", path_text(&broken)]);
assert_eq!(output.status.code(), Some(1));
assert!(output.stderr.is_empty());
assert_eq!(
String::from_utf8(output.stdout).unwrap(),
format!(
"1 error(s) in {}:\n 1. part /word/comments.xml lists undeclared prefix `w14` in mc:Ignorable\n",
broken.display()
)
);
}

#[test]
fn render_uses_the_bundled_font_deterministic_path() {
let temp = TempWorkspace::new("render");
Expand Down
2 changes: 1 addition & 1 deletion crates/rdocx-layout/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 | 255,757 compressed bytes, 1,385,791 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 |
Expand Down
2 changes: 2 additions & 0 deletions crates/rdocx-layout/src/table.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2180,6 +2180,7 @@ mod tests {
extra_namespaces: Vec::new(),
background_xml: None,
background_extra_xml: Vec::new(),
root_attributes: Vec::new(),
},
styles: styles.clone(),
numbering: None,
Expand Down Expand Up @@ -2371,6 +2372,7 @@ mod tests {
extra_namespaces: Vec::new(),
background_xml: None,
background_extra_xml: Vec::new(),
root_attributes: Vec::new(),
},
styles: styles.clone(),
numbering: None,
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 | 369,949 compressed bytes, 2,391,227 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

Expand Down
89 changes: 80 additions & 9 deletions crates/rdocx-oxml/src/comments.rs
Original file line number Diff line number Diff line change
Expand Up @@ -119,12 +119,24 @@ impl CT_Comments {
Some("yes"),
)))?;

// Retained root attributes keep their source order. The fixed `w` and
// `w14` declarations are added ahead of them only when the source
// lacks them, so a producer declaration listed in `mc:Ignorable`
// stays on the root whether or not a paragraph id still uses it.
let declared = |name: &str| {
self.root_attributes
.iter()
.any(|(candidate, _)| candidate == name)
};
let mut root = BytesStart::new("w:comments");
root.push_attribute(("xmlns:w", W_NS));
if self
.comments
.iter()
.any(|comment| comment.paragraph_ids.iter().any(Option::is_some))
if !declared("xmlns:w") {
root.push_attribute(("xmlns:w", W_NS));
}
if !declared("xmlns:w14")
&& self
.comments
.iter()
.any(|comment| comment.paragraph_ids.iter().any(Option::is_some))
{
root.push_attribute(("xmlns:w14", W14_NS));
}
Expand Down Expand Up @@ -344,10 +356,14 @@ fn push_preserved_attributes(
root: bool,
) {
for (name, value) in attributes {
if root && (name == "xmlns:w" || name == "xmlns:w14") {
continue;
}
start.push_attribute((name.as_str(), value.as_str()));
// The serializer writes the fixed `w` and `w14` prefixes, so their
// root declarations bind the namespaces it writes.
let value = match name.as_str() {
"xmlns:w" if root => W_NS,
"xmlns:w14" if root => W14_NS,
_ => value.as_str(),
};
start.push_attribute((name.as_str(), value));
}
}

Expand Down Expand Up @@ -465,4 +481,59 @@ mod tests {
assert!(output.contains("w14:paraId=\"0000000A\""));
assert!(!output.contains("w14:paraId=\"foreign\""));
}

/// #160: an empty Word comments root that lists `w14` in `mc:Ignorable`
/// lost the `w14` declaration once a comment without a paragraph id was
/// written into it, and the kept declarations moved behind `xmlns:w`.
#[test]
fn rewritten_root_keeps_its_declarations_in_source_order() {
let comment = |para_id: Option<&str>| {
let mut paragraph = CT_P::new();
paragraph.add_run("note");
CT_Comment {
id: 0,
author: Some("Ada".to_owned()),
date: None,
initials: None,
paragraphs: vec![paragraph],
paragraph_ids: vec![para_id.map(str::to_owned)],
extra_attributes: Vec::new(),
extra_xml: Vec::new(),
}
};
let word_root = concat!(
r#"<w:comments xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" "#,
r#"xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main" "#,
r#"xmlns:w14="http://schemas.microsoft.com/office/word/2010/wordml" "#,
r#"xmlns:w15="http://schemas.microsoft.com/office/word/2012/wordml" "#,
r#"mc:Ignorable="w14 w15">"#,
);
let mut comments = CT_Comments::from_xml(word_root.replace('>', "/>").as_bytes()).unwrap();
comments.comments.push(comment(None));
let output = String::from_utf8(comments.to_xml().unwrap()).unwrap();
assert!(
output.contains(&format!("{word_root}<w:comment ")),
"{output}"
);

// Declarations the serializer needs and the source lacks come first.
let aliased_root = concat!(
r#"<comments xmlns="http://schemas.openxmlformats.org/wordprocessingml/2006/main" "#,
r#"xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" "#,
r#"xmlns:p14="http://schemas.microsoft.com/office/word/2010/wordml" "#,
r#"mc:Ignorable="p14"/>"#,
);
let mut comments = CT_Comments::from_xml(aliased_root.as_bytes()).unwrap();
comments.comments.push(comment(Some("0000000A")));
let output = String::from_utf8(comments.to_xml().unwrap()).unwrap();
let expected = concat!(
r#"<w:comments xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main" "#,
r#"xmlns:w14="http://schemas.microsoft.com/office/word/2010/wordml" "#,
r#"xmlns="http://schemas.openxmlformats.org/wordprocessingml/2006/main" "#,
r#"xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" "#,
r#"xmlns:p14="http://schemas.microsoft.com/office/word/2010/wordml" "#,
r#"mc:Ignorable="p14"><w:comment "#,
);
assert!(output.contains(expected), "{output}");
}
}
63 changes: 51 additions & 12 deletions crates/rdocx-oxml/src/document.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2813,6 +2813,10 @@ pub struct CT_Document {
/// Foreign same-local-name background children retained before the body.
#[doc(hidden)]
pub background_extra_xml: Vec<Vec<u8>>,
/// Non-namespace attributes of the original document element, such as
/// `mc:Ignorable`, in source order.
#[doc(hidden)]
pub root_attributes: Vec<(String, String)>,
}

#[allow(non_snake_case)]
Expand All @@ -2823,6 +2827,7 @@ impl CT_Document {
extra_namespaces: Vec::new(),
background_xml: None,
background_extra_xml: Vec::new(),
root_attributes: Vec::new(),
}
}

Expand All @@ -2835,6 +2840,7 @@ impl CT_Document {
let mut extra_namespaces = Vec::new();
let mut background_xml = None;
let mut background_extra_xml = Vec::new();
let mut root_attributes = Vec::new();
let mut buf = Vec::new();
let mut word_prefixes = Vec::new();
let mut document_open = false;
Expand All @@ -2854,17 +2860,18 @@ impl CT_Document {
}
for attr in e.attributes().flatten() {
let key = attr.key.as_ref();
if (key.starts_with(b"xmlns:") || key == b"xmlns")
&& !known_ns.contains(&key)
{
let key_str = std::str::from_utf8(key).unwrap_or("").to_string();
let val_str = attr
.decoded_and_normalized_value(
XmlVersion::Implicit1_0,
e.decoder(),
)?
.into_owned();
let is_namespace = key.starts_with(b"xmlns:") || key == b"xmlns";
if is_namespace && known_ns.contains(&key) {
continue;
}
let key_str = std::str::from_utf8(key).unwrap_or("").to_string();
let val_str = attr
.decoded_and_normalized_value(XmlVersion::Implicit1_0, e.decoder())?
.into_owned();
if is_namespace {
extra_namespaces.push((key_str, val_str));
} else {
root_attributes.push((key_str, val_str));
}
}
document_open = true;
Expand Down Expand Up @@ -2942,6 +2949,7 @@ impl CT_Document {
extra_namespaces,
background_xml,
background_extra_xml,
root_attributes,
})
}

Expand Down Expand Up @@ -2979,8 +2987,9 @@ impl CT_Document {
doc_start.push_attribute(("xmlns:wp", wp_ns));
}

// Replay captured extra namespaces
for (key, val) in &self.extra_namespaces {
// Replay captured extra namespaces, then the root attributes that
// may name their prefixes, such as `mc:Ignorable`.
for (key, val) in self.extra_namespaces.iter().chain(&self.root_attributes) {
doc_start.push_attribute((key.as_str(), val.as_str()));
}

Expand Down Expand Up @@ -3210,6 +3219,36 @@ mod tests {
}
}

/// #160: a typed rewrite dropped `mc:Ignorable` and every other
/// non-namespace attribute of the document root.
#[test]
fn root_attributes_survive_a_rewrite_after_the_namespace_declarations() {
let xml = format!(
r#"<w:document xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" xmlns:w="{W_NS}" xmlns:w14="http://schemas.microsoft.com/office/word/2010/wordml" mc:Ignorable="w14" xmlns:x="urn:producer" x:root="a &amp; b"><w:body><w:p/></w:body></w:document>"#
);
let parsed = CT_Document::from_xml(xml.as_bytes()).unwrap();
assert_eq!(
parsed.root_attributes,
[
("mc:Ignorable".to_owned(), "w14".to_owned()),
("x:root".to_owned(), "a & b".to_owned()),
]
);

let written = String::from_utf8(parsed.to_xml().unwrap()).unwrap();
let root = &written[written.find("<w:document").unwrap()..];
let root = &root[..=root.find('>').unwrap()];
assert!(
root.ends_with(
r#" xmlns:w14="http://schemas.microsoft.com/office/word/2010/wordml" xmlns:x="urn:producer" mc:Ignorable="w14" x:root="a &amp; b">"#
),
"{root}"
);
let reparsed = CT_Document::from_xml(written.as_bytes()).unwrap();
assert_eq!(reparsed.root_attributes, parsed.root_attributes);
assert_eq!(reparsed.to_xml().unwrap(), written.as_bytes());
}

#[test]
fn default_namespace_document_paragraph_properties_parse_in_scope() {
let xml = format!(
Expand Down
Loading
Loading