Skip to content

rdocx and rpptx: validate a schema subset, references and the stored package, refuse a ZIP name stored twice, open shared bookmark ids - #439

Closed
hadim wants to merge 8 commits into
tensorbee:mainfrom
hadim:fix/435-validate-robustness
Closed

hadim wants to merge 8 commits into
tensorbee:mainfrom
hadim:fix/435-validate-robustness

Conversation

@hadim

@hadim hadim commented Oct 31, 2026 •

Copy link
Copy Markdown
Contributor

Part of #435

Fixed: cases 1, 2, 3, 4, 6 and 7, and the validate half of case 5. Left: the open and save half of case 5 (see Limits). Case 2's script also builds its base document with add_footnote, whose run order validate now reports, so its first line passes once #432 fixes that writer.

rdocx validate checks every XML part for well-formed XML 1.0, and the body, header, footer, note and comment parts against a subset of the WordprocessingML schema and against the package: 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 required w:id and w:author of a tracked change, wp:extent and a:ext sizes, the required children of m:rad, r: ids that name no relationship of the part, footnote and endnote references to no note, and deleted text in w:t. Each finding names the part and the byte offset.

rpptx validate now reads the stored package before the deck: XML parts that are not well-formed, a slide, layout, master or notes part without its own content type, presentation.xml children out of the CT_Presentation order, unknown shape tree children, a p:cNvPr without id or name, and table rows whose cells, each covering its gridSpan, cover fewer columns than the a:gridCol count. LibreOffice writes a spanning cell without the hMerge cells after it, so the count of a:tc alone is not the rule.

Both libraries refuse to open a ZIP that stores one entry name twice, naming it. rdocx opens a document whose bookmarks share an id, and validate warns about the shared id. add_textbox and add_shape refuse a negative width or height, as add_table does.

Cause

  • Cases 1 and 2: validate_document_package (crates/rdocx/src/document.rs) checked the relationship targets, the content types, well-formedness of the parts the main document relates to and the style ids, and no content-model, value or reference rule. Document::from_package opens the header case, and only the story readers (rdocx inspect, the Python constructor) refuse its dangling w:headerReference, so validate never met it.
  • Case 3: ZipArchive (zip 9) indexes the central directory by name, so the second entry of a repeated name replaces the first and OpcPackage::from_reader_with_limits never saw the repetition. Its DuplicatePartName check only met aliases that normalize to one name. rdocx only checked the parts the main document relates to, and rpptx no part, for well-formedness.
  • Case 4: Presentation::validate checks the deck as rpptx parsed it, serialized again from its model. A misordered presentation.xml or a p:cNvPr without name reads into the same model as a valid one, and the save keeps the stored bytes of every part it does not change, so the stored fault survived. A slide part that falls back to the xml default content type has a content type, so part_has_content_type passed it.
  • Case 5: quick-xml does not check the XML 1.0 Char production, so the well-formedness scan accepted U+0001.
  • Case 6: DocumentIdentifiers::define refused a repeated w:bookmarkStart/@w:id with duplicate bookmark id N in imported or preserved XML, so every open of such a file failed.
  • Case 7: textbox_member and preset_member (crates/rpptx/src/lib.rs) wrote the size into a:ext unchecked. CT_Table::new refuses a non-positive size for add_table.

Change

  • oxml-opc, fix(opc): from_reader_with_limits reads the end record's entry count on every open. When the ZIP index holds fewer names, it reads the central directory names and refuses the package with DuplicatePartName naming the first repeated one. Without an entry limit, an end record the scan cannot read is left to the ZIP reader, as before. rdocx validate, rpptx validate, Document() and Presentation() all report duplicate OPC part name: word/document.xml.
  • rdocx, feat(validate): document_xml_lexical_fault runs oxml_core::xml::validate_strict_xml_1_0 on every UTF-8 XML part and names the first forbidden character with its line and column. Every XML part, docProps included, is checked for well-formedness. document_story_findings scans the main part and each story part once for the schema subset above (at most 10 findings per part listed), and document_note_ids reads the note ids. The CLI rustdoc and crates/rdocx-cli/README.md say what is checked and that it is not a full schema validator. Four rdocx-cli validation tests built schema-invalid fixtures: content after w:sectPr, and w:ins, w:del and w:moveFrom without w:id and w:author. They now insert before w:sectPr and carry the attributes, and their footnote and endnote reference cases (which name a note the package does not hold) expect the new error.
  • rpptx, feat(validate): new rpptx::validate_presentation_file(path) checks the stored package (stored_package_issues) and then appends Presentation::validate(). If the deck does not open and the stored checks found something, it returns those. Findings reuse ValidationIssue::InvalidXmlChild and MissingContentTypeOverride, so the enum is unchanged. rpptx validate calls it. crates/rpptx-cli/README.md gets a Validation section.
  • rdocx, fix: a repeated bookmark id is counted in repeated_bookmark_ids instead of refused, as repeated_drawing_ids already does for wp:docPr/@id. A staged edit that adds a repetition the opened document did not have is still refused (duplicate bookmark id N in authored XML), through reject_new_repeated_bookmark_ids next to reject_new_repeated_drawing_ids. Ids are kept as they are, no renumbering. New bookmarks take an unused id.
  • rpptx, fix: sized_transform refuses a negative width or height for add_textbox, add_shape and the unstyled preset shapes of HTML and ODP imports, before anything is written. A zero extent stays allowed (ST_PositiveCoordinate includes 0), so a flat line shape still works.

Limits

  • The schema check is a subset, chosen to cover the issue's cases and the rules rdocx itself must respect, not an XSD validation. Children outside w:body, w:p and w:r are not checked against their content model, nor enums other than w:jc and w:color, nor required children of math elements other than m:rad. Elements in other namespaces (mc:, w14: and later) are not checked. On the five pinned corpus documents of scripts/docx-corpus-manifest.tsv (Word and LibreOffice) and the seven samples, validate reports nothing new.
  • Case 5, open and save: a part holding U+0001 still opens and is saved back as it was read. docs/hld/04-opc-and-packaging.md states this on purpose ("An entry that already held such a character when the package was read is written back while its bytes are unchanged, so producer content passes through verbatim"), and an edit that re-serializes the part already refuses the save. Refusing such a file at open, or rewriting the character, reverses that rule, so I left the choice to you. validate now reports the part.
  • Case 1, writers: rdocx's own add_equation(r"\sqrt{x}") (an m:rad without m:deg) and add_footnote (w:rPr after w:footnoteRef) now fail validate. Both writers are fixed under rdocx: markup the library writes breaks the schema or differs from Word (equations, notes, hyperlinks, cells, numbering, TOC, replace) #432. Until then a document that holds such a note or radical fails rdocx validate and Document.validate().
  • w:t inside w:del is reported because ECMA-376 Part 1 17.3.3.7 (delText) says deleted run text uses w:delText, as I remember it. I could not check what Word does with such a file.
  • rpptx::validate_presentation_file is new public API in rpptx. The Python Presentation.validate() and Document.validate() keep their signatures. Document.validate() gets the new rdocx checks, and Presentation.validate() still checks the deck as parsed, not the stored package.

Tests

New tests, one per case:

  • crates/oxml-opc/src/package.rs issue435_an_entry_name_stored_twice_is_refused_naming_the_part (case 3), with and without an entry limit. Fails without the fix: the package opens holding the second copy.
  • crates/rdocx/tests/regression_test.rs:
    • issue435_validate_reports_wordprocessingml_schema_faults (case 1): the base document validates, and each of the ten faults is an error naming /word/document.xml.
    • issue435_validate_reports_dangling_references (case 2): picture, header, footnote and w:t in w:del.
    • issue435_validate_reports_a_malformed_document_properties_part (case 3).
    • issue435_validate_reports_a_character_xml_forbids (case 5).
    • issue435_bookmarks_sharing_an_id_open_and_render (case 6): the same part and body plus header, open, text, layout, validate (valid, with the warning), edit and reopen.
    • imported_and_preserved_collisions_fail_before_mutation now opens its bookmark collision. Two document.rs unit tests on identifier scan order are updated, and the authored repetition case now expects duplicate bookmark id 1 in authored XML.
  • crates/rpptx/tests/integration.rs: issue435_validate_presentation_file_reports_stored_faults (case 4, the six faults), issue435_validate_presentation_file_reports_a_malformed_document_properties_part (case 3), issue435_validate_presentation_file_reports_a_character_xml_forbids (case 5) and issue435_new_shapes_refuse_a_negative_size (case 7, including a zero-height shape), and issue435_a_spanned_row_without_hmerge_cells_covers_its_grid (a gridSpan="2" cell with no hMerge cell after it, as LibreOffice writes it, is not a short row).
  • crates/rdocx-py/tests/test_core.py test_issue435_bookmarks_sharing_an_id_open (case 6, both layouts) and crates/rpptx-py/tests/test_documented_examples.py test_issue435_add_textbox_and_add_shape_refuse_a_negative_size (case 7).

Runs on linux-x86_64, debug, RUST_MIN_STACK=8388608:

  • cargo test -p oxml-opc: 89 passed, 1 ignored.
  • cargo test -p rdocx --test regression_test: 2474 passed, 7 ignored. --test integration_test: 467 passed, 4 failed, all four on this machine's LibreOffice 26.2.6.3 against the pinned 26.2.5.2. --lib: 821 passed, 1 failed, fx195_variable_svg_paint_matches_independent_static_instances, which needs hb-subset 14.4.0, not installed here. body_lookup_work_is_linear_at_250_and_1000 was skipped in debug (over 30 minutes) and passes with --release.
  • cargo test -p rdocx-cli: 101 passed.
  • cargo test -p rpptx, with the fetched corpus/ and uv on PATH for the python-pptx oracle: lib 199 passed, integration 437 passed, 25 ignored.
  • cargo test -p rpptx-cli with the corpus: 61 passed, validate_rejects_corruption_and_accepts_the_pinned_corpus included. A first version counted a:tc without gridSpan and failed corpus/pptx/LIBRE_OFFICE-100610-0.pptx (slide 8, a row of 10 cells, one spanning 3, over 12 columns). fix(validate): count gridSpan when rpptx validate checks a table row fixes it.
  • rpptx validate on the 51 decks of corpus/ gives the same verdict as on main.
  • cargo clippy -p oxml-opc -p rdocx -p rpptx -p rdocx-cli -p rpptx-cli --all-targets --all-features -- -D warnings, cargo fmt --all --check and scripts/prose_check.py: clean.
  • Python 3.13 on a maturin debug build, with python-docx 1.2.0, python-pptx 1.0.2 and Pillow 12.3.0: test_core.py, test_formatting_tables.py, test_python_docx_parity.py, test_shared.py and test_documented_examples.py give 2105 passed, 2 skipped, 56 failed. 48 are fx195 and fx206 tests that need the F_X195_* oracle environment, 3 need the pinned LibreOffice 26.2.5.2, 1 needs a coordinator-authenticated RDOCX_CLI, and 4 are test_fx268_picture_normalization_reuses_actual_checked_rounds, whose producer path reports producer_current instead of producer.seed_eligible here. It fails the same way on main 24af95d in this repository's own CI (Python bindings job, KeyError: 'producer.seed_eligible'), and nothing in this branch touches picture normalization.
  • scripts/hash_harness.py --check: 49 entries match. No sample output changes.
  • scripts/readme_doctests.py passes after re-recording the oxml-opc, rdocx, rdocx-cli, rpptx and rpptx-cli archives, and the rpptx archive again after the gridSpan fix, in the measurement commits.
  • Delta outside the harness: rdocx validate on the five pinned corpus documents of scripts/docx-corpus-manifest.tsv and the seven samples reports nothing new. rdocx validate and rpptx validate give a new verdict only on the issue's fault files, among the files the issue's scripts write.

Repro

The issue's seven scripts on this branch (debug CLIs and bindings):

case_1_docx_schema.py            11 PASS
case_2_docx_dangling_refs.py     4 PASS, 1 FAIL: base document, exit 1 from add_footnote's run order (#432)
case_3_package_level.py          6 PASS
case_4_pptx_validate.py          7 PASS
case_5_control_char.py           2 PASS (validate), 2 FAIL (save keeps U+0001, see Limits)
case_6_duplicate_bookmark_id.py  9 PASS
case_7_pptx_negative_size.py     3 PASS

hadim added 8 commits October 31, 2026 16:04
The ZIP index of zip 9 keeps the last of two central directory entries
that share a name, and other readers keep the first, so a review made
with one tool and an edit made with another saw different parts. The
open now compares the end record's entry count with the index, and when
an entry is missing reads the central directory names and refuses the
package with DuplicatePartName, naming the first repeated one.

Without an entry limit, an end record the scan cannot read is left to
the ZIP reader, as before.

Part of tensorbee#435 (case 3).
…et in rdocx

rdocx validate checked the relationship targets, the content types, the
well-formedness of the parts the main document relates to and the style
ids, and no content-model, value or reference rule.

Every XML part, docProps included, is now checked for well-formed XML
1.0 through oxml_core::xml::validate_strict_xml_1_0, so a literal
U+0001 is reported with its line and column. The main part and every
header, footer, note and comment part are scanned once for a subset of
the schema and for references: child order and 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, wp:extent and
a:ext sizes, the required children of m:rad, r: ids that name no
relationship of the part, note references to no note, and w:t inside
w:del. Each finding names the part and the byte offset, at most ten per
part. Two bookmarks that share an id are a warning.

Part of tensorbee#435 (cases 1, 2, 3 and 5).
Presentation::validate checks the deck as rpptx parsed it, serialized
again from its model. A misordered presentation.xml or a p:cNvPr without
name reads into the same model as a valid one, and a save keeps the
stored bytes of every part it does not change, so such faults passed
and survived a save.

The new rpptx::validate_presentation_file reads the stored package
first: XML parts that are not well-formed XML 1.0, a slide, layout,
master, notes or handout part without its own content type,
presentation.xml children out of the CT_Presentation order, unknown
shape tree children, a p:cNvPr without id or name, and table rows whose
a:tc count differs from the a:gridCol count. It then appends
Presentation::validate. The findings reuse InvalidXmlChild and
MissingContentTypeOverride, so ValidationIssue is unchanged. rpptx
validate calls it.

Part of tensorbee#435 (cases 3, 4 and 5).
DocumentIdentifiers::define refused a repeated w:bookmarkStart/@w:id
with "duplicate bookmark id N in imported or preserved XML", so opening,
text, render and validate all failed on such a file. Word has saved
them (the _GoBack bookmark reusing the id of a _Toc bookmark of the body
or of a header), and Word and LibreOffice open them. The id only pairs a
start with its end, and the names stay distinct.

A repeated id is now counted in repeated_bookmark_ids, as
repeated_drawing_ids already counts wp:docPr ids. The ids are kept as
they are. A staged edit that adds a repetition the opened document did
not have is still refused, with "duplicate bookmark id N in authored
XML", and new bookmarks take an unused id.

Part of tensorbee#435 (case 6).
add_textbox and add_shape wrote a negative width or height into a:ext,
which ST_PositiveCoordinate forbids, so rpptx validate rejected the saved
deck and rpptx could not open it again. add_table already refuses it.

sized_transform now refuses a negative width or height for textboxes and
preset shapes, styled or not, before anything is written. A zero extent
stays allowed, as the schema allows it.

Part of tensorbee#435 (case 7).
Re-record the oxml-opc, rdocx, rdocx-cli, rpptx and rpptx-cli archives, whose member bytes change. The other rows are kept.
The stored-package check reported a row whose a:tc count differed from
the a:gridCol count. LibreOffice writes a spanning cell with gridSpan
and leaves out the hMerge cells after it, so a valid row of its pinned
corpus deck LIBRE_OFFICE-100610-0.pptx was reported as short.

Each cell now covers its gridSpan, and a row is reported only when the
cells cover fewer columns than the grid has.

Part of tensorbee#435 (case 4).
Re-record the rpptx archive, whose member bytes change with the gridSpan fix. The other rows are kept.
hadim added a commit to hadim/rdocx that referenced this pull request Oct 31, 2026
The rpptx archive with the gridSpan rule of tensorbee#439 on top of the integration.

Tests, python3 scripts/readme_doctests.py --record-measurements
Harness, unchanged, measurements only
@mantissaman

Copy link
Copy Markdown
Contributor

Released in rpptx-v0.15.0 (tag), v0.17.0 (tag).

The releases include a hardened equivalent of the stored-validation reference work. Bounded Word/PowerPoint schema and reference checks use existing channels, exact duplicate decoded ZIP names and forbidden XML characters refuse, and negative authored dimensions refuse while zero remains valid. Valid opaque payloads stay preserved. Complete OOXML validation is outside this delivered subset.

Thank you @hadim for contributing the reference implementation.

@mantissaman

Copy link
Copy Markdown
Contributor

Thank you @hadim for the stored-validation reference implementation and for identifying its remaining open/save gap. F-X280 contract and evidence and children integrated a hardened equivalent, including forbidden-character opening refusal, exact duplicate ZIP-name rejection, the declared stored-schema/reference checks, shared-bookmark tolerance and negative-dimension refusal.

Closing this PR as superseded by eb980e94, released in Word 0.17.0 and shared OOXML/PowerPoint 0.15.0 and qualified by successful integrated CI. It was not merged unchanged. #435's seven finite cases are addressed, including the missing case-5 opening protection. Full OOXML schema validation remains outside this subset.

@mantissaman mantissaman closed this Nov 9, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants