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 | 4,105,912 compressed bytes, 54,118,469 member bytes, 37 members | 0.16.4 | macOS 26.6.2, Apple M5 Max, arm64 | `cargo publish --workspace --dry-run --allow-dirty --locked` | Tracked `rdocx` package inventory | `python3 scripts/readme_doctests.py --record-measurements` | gzip archive bytes, tar member bytes, tar member count | 2026-10-28 |
| Crates.io archive: rdocx | 4,113,924 compressed bytes, 54,154,979 member bytes, 37 members | 0.16.4 | macOS 26.6.2, Apple M5 Max, arm64 | `cargo publish --workspace --dry-run --allow-dirty --locked` | Tracked `rdocx` package inventory | `python3 scripts/readme_doctests.py --record-measurements` | gzip archive bytes, tar member bytes, tar member count | 2026-10-28 |
| 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: 1 addition & 1 deletion crates/oxml-opc/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ The archive row is regenerated from the package that carries this README.

| Measurement | Value | Version | Platform | Build mode | Input | Command | Statistic | Measured on |
|---|---|---|---|---|---|---|---|---|
| Crates.io archive: oxml-opc | 117,614 compressed bytes, 477,731 member bytes, 12 members | 0.14.4 | macOS 26.6.2, Apple M5 Max, arm64 | `cargo publish --workspace --dry-run --allow-dirty --locked` | Tracked `oxml-opc` package inventory | `python3 scripts/readme_doctests.py --record-measurements` | gzip archive bytes, tar member bytes, tar member count | 2026-10-28 |
| Crates.io archive: oxml-opc | 118,426 compressed bytes, 480,988 member bytes, 12 members | 0.14.4 | macOS 26.6.2, Apple M5 Max, arm64 | `cargo publish --workspace --dry-run --allow-dirty --locked` | Tracked `oxml-opc` package inventory | `python3 scripts/readme_doctests.py --record-measurements` | gzip archive bytes, tar member bytes, tar member count | 2026-10-28 |

## Use it when

Expand Down
91 changes: 83 additions & 8 deletions crates/oxml-opc/src/package.rs
Original file line number Diff line number Diff line change
Expand Up @@ -122,17 +122,32 @@ impl OpcPackage {
mut reader: R,
limits: PackageReadLimits,
) -> Result<Self> {
if limits.max_entries != usize::MAX {
let entry_count = pre_index_entry_count(&mut reader)?;
if entry_count > limits.max_entries as u64 {
return Err(OpcError::PackageLimitExceeded {
kind: "entry count",
limit: limits.max_entries as u64,
});
}
// Without an entry limit, an end record this scan cannot read is left
// to the ZIP reader, as before, and the repeated-name check is skipped.
let entry_count = match pre_index_entry_count(&mut reader) {
Ok(count) => Some(count),
Err(error) if limits.max_entries != usize::MAX => return Err(error),
Err(_) => None,
};
if let Some(count) = entry_count
&& count > limits.max_entries as u64
{
return Err(OpcError::PackageLimitExceeded {
kind: "entry count",
limit: limits.max_entries as u64,
});
}

let mut archive = ZipArchive::new(reader)?;
// The ZIP reader keeps the last of two entries that share a name, and
// other readers keep the first, so a package that holds a name twice
// is refused rather than read as one of its copies.
if let Some(count) = entry_count.filter(|count| *count > archive.len() as u64) {
let directory_start = archive.central_directory_start();
let name = repeated_entry_name(&mut archive.into_inner(), directory_start, count)
.unwrap_or_else(|| "an entry stored twice".to_owned());
return Err(OpcError::DuplicatePartName(name));
}
if archive.len() > limits.max_entries {
return Err(OpcError::PackageLimitExceeded {
kind: "entry count",
Expand Down Expand Up @@ -987,6 +1002,31 @@ fn validate_zip_entry_identities(
Ok(())
}

/// The first entry name the central directory at `start` holds twice, read
/// from its `count` file headers, compared as part names are.
fn repeated_entry_name<R: Read + Seek>(reader: &mut R, start: u64, count: u64) -> Option<String> {
const CENTRAL_HEADER_SIGNATURE: [u8; 4] = [0x50, 0x4b, 0x01, 0x02];
reader.seek(SeekFrom::Start(start)).ok()?;
let mut seen = HashSet::new();
for _ in 0..count {
let mut header = [0; 46];
reader.read_exact(&mut header).ok()?;
if header[..4] != CENTRAL_HEADER_SIGNATURE {
return None;
}
let mut name = vec![0; usize::from(read_u16(&header, 28)?)];
reader.read_exact(&mut name).ok()?;
let skipped = i64::from(read_u16(&header, 30)?) + i64::from(read_u16(&header, 32)?);
reader.seek(SeekFrom::Current(skipped)).ok()?;
let name = normalize_part_name(&String::from_utf8_lossy(&name));
let name = name.strip_prefix('/').unwrap_or(&name).to_owned();
if !seen.insert(name.to_ascii_lowercase()) {
return Some(name);
}
}
None
}

fn pre_index_entry_count<R: Read + Seek>(reader: &mut R) -> Result<u64> {
let file_len = reader.seek(SeekFrom::End(0))?;
if file_len < EOCD_LEN {
Expand Down Expand Up @@ -2745,6 +2785,41 @@ mod tests {
assert!(package.parts.keys().all(|name| !name.contains("..")));
}

#[test]
fn issue435_an_entry_name_stored_twice_is_refused_naming_the_part() {
// The ZIP writer refuses a repeated name, so the second entry is
// written under another name of the same length and renamed.
let mut archive = package_zip(&[
("[Content_Types].xml", MINIMAL_CONTENT_TYPES),
("word/document.xml", b"<first/>"),
("word/documenX.xml", b"<second/>"),
])
.into_inner();
replace_zip_name(
&mut archive,
b"word/documenX.xml",
b"word/document.xml",
true,
);
for limits in [
PackageReadLimits::UNBOUNDED,
PackageReadLimits {
max_entries: 10,
..PackageReadLimits::UNBOUNDED
},
] {
let result = OpcPackage::from_reader_with_limits(
std::io::Cursor::new(archive.as_slice()),
limits,
);
assert!(
matches!(result, Err(OpcError::DuplicatePartName(ref name)) if name == "word/document.xml"),
"{:?}",
result.map(|package| package.get_part("/word/document.xml").map(<[u8]>::to_vec))
);
}
}

#[test]
fn duplicate_normalized_part_names_are_rejected() {
for alias in [
Expand Down
13 changes: 12 additions & 1 deletion crates/rdocx-cli/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,7 @@ Signing publishes before reporting, so this error can leave a valid new output.

| Measurement | Value | Version | Platform | Build mode | Input | Command | Statistic | Measured on |
|---|---|---|---|---|---|---|---|---|
| Crates.io archive: rdocx-cli | 111,071 compressed bytes, 516,134 member bytes, 8 members | 0.16.4 | macOS 26.6.2, Apple M5 Max, arm64 | `cargo publish --workspace --dry-run --allow-dirty --locked` | Tracked `rdocx-cli` package inventory | `python3 scripts/readme_doctests.py --record-measurements` | gzip archive bytes, tar member bytes, tar member count | 2026-10-28 |
| Crates.io archive: rdocx-cli | 111,494 compressed bytes, 517,637 member bytes, 8 members | 0.16.4 | macOS 26.6.2, Apple M5 Max, arm64 | `cargo publish --workspace --dry-run --allow-dirty --locked` | Tracked `rdocx-cli` package inventory | `python3 scripts/readme_doctests.py --record-measurements` | gzip archive bytes, tar member bytes, tar member count | 2026-10-28 |

## Use it when

Expand Down Expand Up @@ -154,6 +154,17 @@ stderr that names the part when it can, list no other story, and exit
successfully. `text --json` then states `main` scope. `validate` reports that
part as an error.

`validate` exits 1 on an error and names the part. It checks the package
(relationship targets, content types, a part stored twice), that every XML
part is well-formed XML 1.0, the style ids in use, and a subset of the
WordprocessingML schema in the body, header, footer, note and comment parts:
the child order and the allowed children of `w:body`, `w:p` and `w:r`, text
where only elements may stand, the `w:jc` and `w:color` values, the `w:id` and
`w:author` of a tracked change, extents, the required children of `m:rad`,
relationship ids that name no relationship, note references to no note, and
deleted text in `w:t`. It is not a full schema validator. Two bookmarks that
share an id are a warning.

`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,
its direct style and numbering, and accepted-view runs. Its `text` also holds
Expand Down
6 changes: 4 additions & 2 deletions crates/rdocx-cli/src/commands.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2386,8 +2386,10 @@ fn validate_dpi(dpi: f64) -> Result<()> {
///
/// Returns `Ok(false)` when a structural error was found, so the caller can
/// exit non-zero — a validator that always succeeds cannot gate anything in CI.
/// A related part that is not well-formed XML and a paragraph, character, or
/// table style id that no style defines are structural errors too.
/// An XML part that is not well-formed XML 1.0, a paragraph, character, or
/// table style id that no style defines, a dangling relationship id or note
/// reference, and a fault of the WordprocessingML schema subset that
/// [`rdocx::validate_document_file`] checks are structural errors too.
/// Advisory findings (empty paragraphs, missing metadata) are reported but do
/// not affect the exit status.
pub fn validate(file: &Path) -> Result<bool> {
Expand Down
33 changes: 21 additions & 12 deletions crates/rdocx-cli/tests/integration.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2038,12 +2038,12 @@ fn visible_nontext_content_and_empty_controls_keep_distinct_validation_warnings(
),
(
"inserted-drawing",
"<w:ins><w:r><w:drawing/></w:r></w:ins>",
"<w:ins w:id='90' w:author='Reviewer'><w:r><w:drawing/></w:r></w:ins>",
false,
),
(
"moved-away-drawing",
"<w:moveFrom><w:r><w:drawing/></w:r></w:moveFrom>",
"<w:moveFrom w:id='92' w:author='Reviewer'><w:r><w:drawing/></w:r></w:moveFrom>",
true,
),
(
Expand All @@ -2066,7 +2066,7 @@ fn visible_nontext_content_and_empty_controls_keep_distinct_validation_warnings(
),
(
"deleted",
"<w:del><w:r><w:delText>deleted</w:delText><w:drawing/></w:r></w:del>",
"<w:del w:id='91' w:author='Reviewer'><w:r><w:delText>deleted</w:delText><w:drawing/></w:r></w:del>",
true,
),
(
Expand All @@ -2092,8 +2092,17 @@ fn visible_nontext_content_and_empty_controls_keep_distinct_validation_warnings(
rewrite_run(&input, "CONTENT", |_| fragment.to_owned());
let before = fs::read(&input).unwrap();
let output = cli(&["validate", path_text(&input)]);
assert_success(&output, label);
let report = String::from_utf8(output.stdout).unwrap();
let report = String::from_utf8(output.stdout.clone()).unwrap();
if matches!(label, "footnote" | "endnote") {
// The reference names a note the package does not hold.
assert_eq!(output.status.code(), Some(1), "{label}: {report}");
assert!(
report.contains(&format!("names {label} 1, which no w:{label} defines")),
"{label}: {report}"
);
} else {
assert_success(&output, label);
}
assert_eq!(
report.contains("1 empty paragraph(s) found"),
empty,
Expand Down Expand Up @@ -2210,17 +2219,17 @@ fn validation_qualified_display_content_and_field_controls_preserve_source() {
),
(
"deleted-drawing",
"<w:del><w:r><w:drawing/></w:r></w:del>",
"<w:del w:id='91' w:author='Reviewer'><w:r><w:drawing/></w:r></w:del>",
false,
),
(
"moved-away",
"<w:moveFrom><w:r><w:drawing/></w:r></w:moveFrom>",
"<w:moveFrom w:id='92' w:author='Reviewer'><w:r><w:drawing/></w:r></w:moveFrom>",
false,
),
(
"inserted-drawing",
"<w:ins><w:r><w:drawing/></w:r></w:ins>",
"<w:ins w:id='90' w:author='Reviewer'><w:r><w:drawing/></w:r></w:ins>",
true,
),
(
Expand Down Expand Up @@ -2341,12 +2350,12 @@ fn validation_qualified_display_content_and_field_controls_preserve_source() {
),
(
"inserted-loose-marker",
r#"<w:ins><w:r><w:fldChar w:fldCharType="begin"/></w:r></w:ins>"#,
r#"<w:ins w:id='90' w:author='Reviewer'><w:r><w:fldChar w:fldCharType="begin"/></w:r></w:ins>"#,
false,
),
(
"deleted-loose-marker",
r#"<w:del><w:r><w:fldChar w:fldCharType="begin"/></w:r></w:del>"#,
r#"<w:del w:id='91' w:author='Reviewer'><w:r><w:fldChar w:fldCharType="begin"/></w:r></w:del>"#,
false,
),
(
Expand Down Expand Up @@ -2456,7 +2465,7 @@ fn validation_preserves_recursive_block_control_and_table_paragraph_scope() {
);
package.set_part(
&part,
xml.replace("</w:body>", &format!("{controls}</w:body>"))
xml.replacen("<w:sectPr", &format!("{controls}<w:sectPr"), 1)
.into_bytes(),
);
package.save(&input).unwrap();
Expand Down Expand Up @@ -2539,7 +2548,7 @@ fn validation_ignores_opaque_content_control_slots() {
let xml = String::from_utf8(package.get_part(&part).unwrap().to_vec()).unwrap();
package.set_part(
&part,
xml.replace("</w:body>", &format!("{control}</w:body>"))
xml.replacen("<w:sectPr", &format!("{control}<w:sectPr"), 1)
.into_bytes(),
);
package.save(&input).unwrap();
Expand Down
33 changes: 33 additions & 0 deletions crates/rdocx-py/tests/test_core.py
Original file line number Diff line number Diff line change
Expand Up @@ -15251,3 +15251,36 @@ def test_fx268_picture_normalization_reuses_actual_checked_rounds(publisher, par
assert staging[5] <= 3 * pieces + 20, staging
assert picture.bytes == _one_pixel_png()
assert len(rdocx.Document.from_bytes(document.to_bytes()).pictures()) == 1


@pytest.mark.parametrize('across_parts', [False, True])
def test_issue435_bookmarks_sharing_an_id_open(across_parts):
import rdocx
source = rdocx.Document()
source.add_paragraph('First heading')
source.add_paragraph('Some text')
source.set_header('Header text')
base = source.to_bytes()
names = zipfile.ZipFile(io.BytesIO(base)).namelist()
header = next(name for name in names if name.startswith('word/header'))
second = (header, 'Header text') if across_parts else ('word/document.xml', 'Some text')
edits = [('word/document.xml', 'First heading', '_Toc1'), (*second, '_GoBack')]
output = io.BytesIO()
with zipfile.ZipFile(io.BytesIO(base)) as zin, zipfile.ZipFile(output, 'w') as zout:
for item in zin.infolist():
data = zin.read(item)
for part, text, name in edits:
run = f'<w:r><w:t>{text}</w:t></w:r>'.encode()
if item.filename == part:
assert run in data
data = data.replace(
run,
f'<w:bookmarkStart w:id="2" w:name="{name}"/>'.encode() + run
+ b'<w:bookmarkEnd w:id="2"/>',
1,
)
zout.writestr(item, data)
document = rdocx.Document.from_bytes(output.getvalue())
assert [paragraph.text for paragraph in document.paragraphs] == ['First heading', 'Some text']
document.add_paragraph('New')
assert rdocx.Document.from_bytes(document.to_bytes()).paragraphs[-1].text == 'New'
Loading
Loading