Conversation
Document::revisions walks the typed main-document projection only, while accept_all, reject_all and compare act on headers, footers, comments, footnotes, endnotes and text boxes. A redline whose only change is in a footer therefore listed nothing while accept_all resolved two revision elements, and a text box revision in the main part was resolved but never listed either. Add Document::story_revisions, which returns owned StoryRevision snapshots. It stages a copy of the document as revision resolution does, then scans the main part and the related story parts left in the staged package with the same element inventory the resolver counts, so the list length equals the accept and reject count by construction. That includes a main part whose typed serialization drops a namespace binding a revision depends on, such as a default namespace or a text box prefix declared on w:body, w:p or w:r, and a document that cannot be staged fails the listing as it fails accept. Each revision carries the StoryId of the innermost owner that Document::stories reports around it, with table cells folded into the story that holds the table. Where the staged bytes and the typed serialization disagree on the owners, root owners pair by index and text boxes by identical bytes, and a text box stories() does not report folds into the story around it. A revision outside every owner, such as one in a footnote separator, is an error rather than a silent omission. Document::revisions and RevisionRef are unchanged. GitHub issue tensorbee#165.
Document.revisions mapped the main-only native listing, so a redline whose only change sat in a footer returned an empty tuple while accept_all() reported two resolved revision elements. A caller checking that no tracked change is left before releasing a file could not trust the tuple, and Revision had no way to say where a change lives. Build the tuple from Document::story_revisions instead, so its length is the count accept_all and reject_all return, and give Revision an optional story carrying the same Story snapshot as StoryItem.story. The constructor keeps its four keywords and takes story=None as an extra keyword. The getter now raises RdocxError when the story graph cannot be inventoried, as Document.stories already does, or when the document cannot be staged for resolution, as accept_all already does. The stub, typing smoke test and HLD 10 follow. GitHub issue tensorbee#165.
rdocx revision list read the main-only typed listing and printed "(no revisions in main story)" for a redline whose changes sat in a footer, while rdocx revision accept on the same file resolved two revision elements. Its schema-1 record also stated scope "main" with no way to tell where a change lives. Build the command on Document::story_revisions. The JSON record now states scope "all-supported-stories" and gives each revision a story object with its kebab-case kind, part name and owner index. Text mode appends the same three values as trailing tab columns, so the existing column indexes keep their meaning, and prints "(no revisions)" when there is none. The list length now equals the count revision accept and reject report. The clap help, the CLI README and HLD 10 follow. GitHub issue tensorbee#165.
rdocx compare counted Document::revisions, the main-only typed listing, so a footer-only edit reported "main_story_revisions": 0 next to "scope": "all-supported-stories" and the text summary said it created 0 main-story revision elements, although the redline carried a deletion and an insertion in the footer. Count Document::story_revisions after the comparison instead. The schema-1 JSON record gains "revisions", the total that revision accept and reject resolve, and "stories", one entry per story with at least one revision giving its kind, part name, owner index and count in listing order. "main_story_revisions" keeps its previous meaning for existing consumers. The text summary names the total and prints one indented line per story. The CLI README and HLD 10 follow. GitHub issue tensorbee#165.
The all-story revision listing, the widened CLI records and their tests grow the rdocx and rdocx-cli packages, so the crates.io archive rows in README.md and crates/rdocx-cli/README.md and their ARCHIVE_MEASUREMENTS entries are re-measured, with both crates dated 2026-09-27 in ARCHIVE_REMEASUREMENT_DATES. GitHub issue tensorbee#165.
This branch has not been deployed
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
rdocx: addDocument::story_revisions(), which returns ownedStoryRevisionsnapshots (story(),id(),author(),timestamp(),kind()) for every story thataccept_allandreject_allreach: the maindocument, headers, footers, comments, normal footnotes, endnotes and the
text boxes inside them. It stages the document as resolution does and scans
the staged parts with the resolver's own element inventory, so its length is
the accept and reject count by construction. Each revision carries the
StoryIdthatDocument::storiesreports for its Word story, with tablecells folded into the story that holds the table.
Document::revisionsandRevisionRefare unchanged.rdocx-py:Document.revisionsnow lists every story throughstory_revisions, andRevisiongains an optionalstory(the sameStorysnapshot as
StoryItem.story). The constructor keeps its four keywords andtakes
story=Noneas a fifth. Stub,typing_smoke.pyand HLD 10 updated.rdocx-cli:rdocx revision listcovers every story. The schema-1 JSONrecord says
"scope": "all-supported-stories"and each revision gains"story": {"kind", "part_name", "owner_index"}. Text mode appends the storykind, part name and owner index as three trailing tab columns and prints
(no revisions)when there is none.rdocx-cli:rdocx comparecounts what it created per story. The JSONrecord gains
"revisions"(the total) and"stories"(kind, part name,owner index and count for each story with a revision) and keeps
"main_story_revisions"unchanged. The text summary printsCreated N revision element(s) in K story(ies)and one indented line perstory.
rdocxandrdocx-clire-recorded and dated 2026-09-27 inARCHIVE_REMEASUREMENT_DATES.Closes #165
Why
Document.revisionsandrdocx revision listread the typed main-documentprojection only, while
compare()writes revisions into every story andaccept_all()/rdocx revision acceptresolve them there. With the issuescript, a footer-only edit gave:
and
rdocx compare --jsonreported"main_story_revisions": 0next to"scope": "all-supported-stories". A caller checking that no tracked changeis left before releasing a file could not rely on the listing.
The gap also existed inside the main part. The resolver treats
w:txbxContentas modeled anywhere, and the typed walk never entersdrawings, so a text box revision in the body was resolved but never listed.
After this branch the same script prints:
Notes
story_revisions()of ownedsnapshots at the Word story level (cells fold into their story), a length
equal to the accept count, text boxes in the main part included, Python
Document.revisionswidened in place with an optionalstory, and CLIschema 1 kept with
scopeupdated andmain_story_revisionskept.story_revisionsstages a copy of thedocument exactly as
resolve_revisionsdoes (clone_for_stagingthenprepare_staged_package) and walks the staged main part and then the partsthe resolver visits, taken from the comparison story-part inventory, which
now also returns each part's kind (
related_story_part_namesbecamerevision_story_parts, crate-private). Each part is parsed with theresolver's
XmlTree, and every element with revision metadata is oneentry. The main part is scanned as staged, not as the typed serialization,
because staging keeps the original bytes of an unchanged document and
replays nested namespace declarations on a modified one. The typed
serialization drops some of those bindings, for example a default-namespace
main part or a text box prefix declared on
w:body,w:porw:r, and arevision behind such a binding was resolved but not listed. A document
that cannot be staged fails the listing with the error accept returns.
scan_story_ownersruns on the staged bytesfor the spans and on the bytes
stories()scans (the typed mainserialization, the package bytes for other parts) for the identities, so
every
StoryIdis onestories()returns, fingerprint included. The twoowner lists pair in order when their kinds match. When they differ, root
owners pair by index and text boxes by identical bytes, and a staged text
box that
stories()does not report is left unpaired, so its revisionsfold into the story around it. The innermost paired non-cell owner span
holding the element's start tag names its story. A part reached twice (for
example one part referenced as a header and a footer) is listed once, which
matches the resolver, whose second pass finds nothing left.
stories()inventory directly.story_sourceserrors ona header reference to an external or wrong-type relationship, which the
resolver skips, and it also collects header references from
w:sectPrinnested paragraphs, which the resolver does not visit. Building on the
resolver's part list keeps the listing working exactly when accept works
and keeps the count identical.
story owner is an error (
tracked revision at byte N of <part> has no story owner). In practice this is a revision inside a footnote or endnoteseparator (id 0 or below, or a separator type), which
stories()does notexpose but accept resolves. Skipping it would silently break the
count-equals-accept contract, which is the point of the listing. The
alternative is an
Option<StoryId>, which every caller would have tohandle for a case Word only produces when a user edits a separator in
Draft view with tracking on. The test pins that accept still resolves it.
main_story_revisionskeeps its previous computation,
Document::revisions().len(), so existingconsumers read the same number. It excludes main-part text boxes, while the
new
storiesentry for the body does not, so the two can differ on a bodythat holds a text box revision.
A text box written as
mc:AlternateContentholds its content twice(
mc:Choiceandmc:Fallback). The resolver counts both copies, so thelisting lists both.
stories()does not report a text box undermc:AlternateContentas its own owner (the scanner treats that wrapper asopaque), so both copies belong to the story that holds the drawing. A bare
w:drawingor VMLw:picttext box is reported as aTextBoxstory,unless the typed serialization drops its namespace binding, in which case
stories()does not report it either and it belongs to the story thatholds the drawing.
mode keeps the four existing columns in place and appends the story, so a
script reading columns 0 to 3 is unaffected. Kind labels are kebab-case
like the revision kinds (
text-box,table-cell), withunknownfor afuture
StoryKindsince the enum is non-exhaustive.Document.revisionsreturns more entries for documents withheader, footer, note, comment or text box revisions. This is the
requested change, but the Production readiness for editing real docx and pptx files: an acceptance contract, two realistic fixtures and two matrices #158 acceptance matrices that count
len(Document.revisions)will now count footer revisions (Identity attributes (w:rsid*,w14:paraId, content-controlw:id/w:tag) still breaktoc rebuild,compare()and editing: a matrix to close the class #159 and Producer traits: a matrix over every operation, and what still fails in it and around it #160footer rows included).
RdocxErrorwhere the story inventoryfails (a malformed story part or the separator case above) or where the
document cannot be staged for resolution (for example a modified
document with a shadowed namespace on
w:body, whichaccept_allandsaving refuse with the same message). It never raised before.
accept_allcall costs before it resolves: aclone of the document and a staging pass.
Document.revisions,rdocx revision listandrdocx compareeach list once.Revisionequality includesstory, so a listed revision no longerequals a
Revisionbuilt withoutstory.w:inswithoutw:authorwas listed by the typed projection but is never resolved byaccept. It is no longer listed (probed locally: typed 2, listed 1,
accepted 1).
revision listchanges its scope value, gains the story field andthree text columns, and its empty message.
comparegains two JSONfields and changes its text summary wording.
Document::story_revisions,StoryRevisionre-exported at the crate root). No public type or signature changed.
crates/rdocx-cli/src/commands.rs,main.rs,tests/integration.rsand
README.mdare also touched by End the CLIs cleanly on a closed pipe and refuse silent overwrites #174. This branch keeps its code changesinside
revision_list,compareand two new label helpers next torevision_kind_label, and puts its new tests and their helpers aftercompare_accept_and_reject_reproduce_each_input. The rdocx-py files arealso touched by Expose comparison options as keywords of Python Document.compare #176 and Align split_run, bookmarks and story comments on the direct body index #179, in different functions.
The
rdocxandrdocx-cliarchive rows conflict with every PR touchingthose crates and need a re-record after rebasing.
Tests
crates/rdocx/tests/regression_test.rs, as a group placed rightafter
scoped_revision_resolution_visits_every_compared_story_once:story_revisions_list_a_compared_footer_that_the_main_listing_omits: theissue reproduction in Rust.
revisions()stays empty,story_revisions()has a deletion and an insertion by
Rat the given timestamp, both in theFooterstory returned bystories(), accept and reject each resolve 2,and the listing is identical after a save and reopen.
story_revisions_name_every_compared_story_and_match_resolution_counts:a full-story comparison (body, header, footer, comment, footnote,
endnote) lists each story in resolution order with at least two
revisions, the body count equals
revisions().len(), and the totalequals both the accept and reject counts.
story_revisions_fold_cells_and_report_text_boxes_as_their_own_story: aheader with a table-cell insertion, a paragraph-mark insertion and a run
property change sharing one id, and a VML text box deletion. The cell and
mark revisions belong to the header, the text box revision to the
header's
TextBoxstory. A body with a VML text box and anmc:AlternateContenttext box (Choice and Fallback) lists 4 entrieswhere
revisions()lists 1, and accept and reject each resolve 4.story_revisions_refuse_a_revision_outside_every_story_owner: arevision inside a footnote separator makes
story_revisions()fail withthe owner error while
stories()succeeds and accept and reject stillresolve it.
story_revisions_scan_the_main_part_bytes_that_resolution_scans: adefault-namespace main part with an unprefixed
ins, and a VML text boxwritten as
x:txbxContent/x:inswithxmlns:xbound to the Wordnamespace on
w:body,w:porw:r, each list one body revision wherethe typed serialization lists none, and the list length equals the
accept and reject counts (after a save and reopen, and in memory). After
a typed edit, the paragraph and run cases replay the binding and still
list and resolve one, and the body case fails the listing with the same
error accept returns. A lost text box followed by an ordinary one keeps
the ordinary one in its
TextBoxstory and folds the lost one into thebody.
crates/rdocx-py/tests/test_core.py, next to the other revisiontests:
test_revisions_list_every_story_and_name_the_story_that_holds_them(footer-only compare lists two revisions whose
storyequals the footerStory, body revisions carry the bodyStory, the constructor withoutstorygivesNone, withstorykeeps it, andaccept_all()equals thelisted count).
typing_smoke.pychecksRevision.storyisStory | None.crates/rdocx-cli/tests/integration.rs:revision_list_names_the_story_of_compared_footer_revisions(empty textoutput, JSON story objects, seven text columns, accept count equal to the
list length) and
compare_counts_the_revisions_it_creates_in_each_story(exact JSON record and exact text summary). The existing
cli_collaboration_commands_are_schema_stable_and_atomicnow pins the bodystory in both records.
accept_allandreject_alltemporarilylogging
story_revisions()next to their result, the wholeregression_testbinary made 621 such calls. All 614 where both succeededmatched (495 with a nonzero count). The others are by design: 2 listing
errors with a successful resolution (the separator test), 4 resolution
errors on malformed property revisions that the listing still lists, and 1
where both fail to stage (the new body case). The lib and
integration_testbinaries showed only their environment failures, andthe 19 calls from the CLI suite all matched.
cargo fmt --all --check: clean.cargo clippy -p rdocx -p rdocx-cli -p rdocx-py --all-targets --all-features -- -D warnings: clean.cargo test -p rdocx --no-fail-fast:regression_test566 passed, 7ignored. Lib 465 passed with 2 environment failures
(
large_word_and_presentation_pdfs_preserve_logical_reading_order,word_and_powerpoint_chart_pixels_are_identical).integration_test314passed with 5 environment failures (
every_conditional_table_region_matches_word,section_page_semantics_match_pinned_libreoffice_render,odt_reader_matches_pinned_libreoffice_structure,sanitized_public_authoring_fixture_passes_every_conformance_stage,public_authored_theme_and_fonts_match_pinned_word_resolution). Allseven pin local tool versions and fail on
maintoo.cargo test -p rdocx-cli: 2 and 19 passed.cargo test -p rdocx-pywithDYLD_LIBRARY_PATHset to the Pythonlibrary directory: 2 passed (without it the binary cannot load
libpython, an environment limit).
RUSTDOCFLAGS="-D rustdoc::broken_intra_doc_links" cargo doc -p rdocx --no-deps: clean.python3 scripts/hash_harness.py --check: 49 entries match.python3 scripts/prose_check.py: 0 violations.maturin develop, pytest 9.1.1, mypy2.3.0, python-docx 1.2.0):
pytest crates/rdocx-py/tests66 passed with2 environment failures in
test_rendering_threads.py(pinnedpdfinfo 26.01.0, local 26.09.0).mypy --strictontyping_smoke.pyand the package: no issues.
mypy.stubtest rdocx: no issues. Run beforethe staged-bytes fix, which changes no binding code.
validate_measurement_evidence()passes and bothre-recorded archives match their rows.
binding and the CLI) gives the output shown under Why. The Production readiness for editing real docx and pptx files: an acceptance contract, two realistic fixtures and two matrices #158
fixture-report.docxlists(no revisions)with its 390 table cells, andcomparing it against an edited copy still stops on the comments root shell
(Producer traits: a matrix over every operation, and what still fails in it and around it #160, section 3), which this PR does not touch.