Skip to content

Stop compare() refusing pairs over differences that are not content - #190

Open
hadim wants to merge 10 commits into
tensorbee:mainfrom
hadim:fix/compare-diagnostics-instead-of-refusals
Open

hadim wants to merge 10 commits into
tensorbee:mainfrom
hadim:fix/compare-diagnostics-instead-of-refusals

Conversation

@hadim

@hadim hadim commented Sep 27, 2026

Copy link
Copy Markdown
Contributor

Stacked on #184 (branch fix/compare-producer-noise). The first five
commits of this branch are #184 and are reviewed there. Please review the
last five commits only, or the diff against fix/compare-producer-noise.
I will rebase once #184 lands.

Decision proposed to the maintainer: three classes of content-control
properties.
A control's w:id is identity and is ignored (done in #184).
Its w:tag, w:alias, w:lock, w:placeholder and w:docPartGallery
are metadata: the pair compares, the redline keeps the original w:sdtPr,
and each difference becomes a ComparisonDiagnostic whose message starts
with the stable prefix content-control <name> differs. The control type
and w:dataBinding decide what a control holds, so they stay structural and
a difference still refuses the pair. Diagnostics keep the existing
{ location, message } struct and use message prefixes rather than a new
kind field, because ComparisonDiagnostic is a published struct without
#[non_exhaustive] and a new field would break every struct literal and the
Python __new__ signature. A kind enum can come with a breaking release.
With this, a Google Docs export whose goog_rdk_N tags were renumbered
compares with no revision and one diagnostic per control. Only the second
commit depends on this decision. The other three stand on their own.

Summary

  • Follow hyperlink and inline-control boundaries through the word and
    character alignment. The attributed path compared each hyperlink by the
    absolute number of units before it, so a word inserted or deleted earlier
    in the paragraph refused the pair. Shells are now compared without their
    place, the units are aligned separately between consecutive shell
    boundaries, and the redline writes each segment after the original bytes
    that open it.
  • Report content-control metadata changes as diagnostics instead of
    refusing the pair, as proposed above. On the word and character paths, a
    paragraph whose runs all match now keeps them whole and compares only the
    controls that differ. The pinned test that expected a changed tag to
    refuse now expects the diagnostic, and HLD 03 and 10 describe the three
    classes.
  • Compare the root, the owner start tags and the separator notes of the
    comments, footnotes and endnotes parts as namespace-resolved XML trees
    instead of bytes, so a part written again by another producer, rdocx
    included, no longer refuses the pair.
  • Rename only w:t when a compared field result is deleted. The rename
    matched every <w:t prefix, so a tab in a deleted field result became
    <w:delTextab/>.
  • Re-record the rdocx crates.io archive row.

Part of #159. With #184 this closes section 2 (the w:id half there, the
w:tag half here, and cmp==0 on the w:tag changed on content control
row of the identity matrix). Section 1 (TOC rebuild, #183), section 3
(table-row identity attributes after an edit) and the request to keep the
matrix as a test remain.

Part of #160. This fixes the compare half of section 3: compare() of a
file against its own rdocx save no longer refuses on a rewritten comments
part (self=2 on the empty comments part row of the producer-trait
matrix). Keeping the part's bytes and mc:Ignorable on save is #185.
Section 1, the fidelity paragraph at the end of section 2 and the matrix
request remain.

Part of #161. This fixes the word and character granularity refusal for a
word inserted or deleted beside a hyperlink or an inline control, which is
the path the requested granularity option opens. Text inserted between
two shells with no original run between them still refuses, see Notes.
Exposing the options (#176 for Python, the CLI flags and default), the
comment contract and the rebuilt TOC remain.

Why

compare() refused pairs over differences that carry no content, which
the #158 contract rules out.

  • compare_control_from_xml refused any pair whose
    control_property_signature differed, and that signature held the tag
    and alias. Google Docs numbers goog_rdk_N tags per export. The lock,
    placeholder and gallery sat outside the signature and were dropped
    without a word, which PR Compare paragraphs with identity attributes, empty properties and moved pictures #153 had noted.
  • compare_granular_paragraph compared hyperlink_shells, which counted
    the attributed units before each link's start and end, and it compared
    inline controls by run index. "Visit site" against "Please visit site"
    refused with "cannot revise paragraph boundary structures", and so did a
    word that Word writes in a run of its own before a control.
  • compare_owned_story compared the comments, footnotes and endnotes root
    (owners replaced by a placeholder) and each owner start tag as bytes. A
    Google Docs empty self-closed comments part against the open and close
    pair that an rdocx save writes, or Word's w:id-first attribute order
    against rdocx's, refused the pair.
  • deleted_text_xml used replace("<w:t", "<w:delText"), found in the
    review of Keep producer serialization out of compare results #184.

Notes

  • Metadata scan. CT_SdtPr keeps the lock, the placeholder and the gallery
    as private raw slots, so control_metadata reads them from the serialized
    properties by local name, only when the two w:sdtPr differ. I avoided
    adding an accessor to rdocx-oxml so its public API does not change.
    Other w:sdtPr children (w:showingPlcHdr, w:temporary, w15:color,
    list items, date format, checkbox state, w:docPartCategory,
    w:docPartUnique) are still kept from the original without a
    diagnostic, as before. Adding one of them is one more name in
    CONTROL_METADATA if you want it reported.
  • Diagnostics per property. A control whose tag and lock both changed gives
    two diagnostics at the same location, in the order tag, alias, lock,
    placeholder, docPartGallery. Accepting the redline and comparing it with
    the edited side gives the same diagnostics again, since the redline keeps
    the original metadata. Rejecting gives the original with none.
  • Word and character paths, runs kept whole. This was needed for the
    Google Docs shape: a heading with an h. bookmark and an inline
    goog_rdk control. When a control differed, the unit rewrite split the
    unchanged runs around it, the run-indexed bookmark moved and the
    acceptance check failed. That already happened on main for a changed
    word inside such a control, and the same change fixes it. Output differs
    from before only in that unchanged runs are no longer split into
    single-unit runs.
  • Shell boundaries, the rule. The units are aligned separately between
    consecutive hyperlink and inline-control boundaries, taken in document
    order on both sides, so no unit matches across a shell. Without this the
    space before a control could match the space after it on the edited side,
    and at character granularity the s of "see" before a link could match
    the s of "site" inside it, which put the shell on the wrong side of the
    edit and refused the pair. The redline copies the original bytes before
    the first run of each segment, shell tags included, before anything
    written for that segment, so text inserted at the start of a segment
    lands after the shell that opens it: after a hyperlink or a control, at
    the start inside a hyperlink, and after a shell that ends the paragraph.
    For a paragraph without a hyperlink or an inline control the alignment is
    the one it was. Every existing comparison test passes unchanged. A word
    moved across a shell boundary, "see" in a link and " site" after it
    becoming one link "see site", used to refuse and is now a deletion outside
    the link and an insertion inside it, which accepts and rejects to the two
    sides.
  • Shell boundaries, what still refuses. Text inserted where two shell
    boundaries, or the paragraph start and a boundary, have no original run
    between them, because there are no original bytes to split there: before
    a hyperlink or a control that opens its paragraph, between two adjacent
    hyperlinks, between a control and a hyperlink right after it, and inside
    an empty hyperlink.
    Placing text there needs the byte position of each shell tag, which the
    paragraph model does not keep, so I left it out. Shells in a different
    order on the two sides refuse too. Bookmarks and comment ranges are still
    compared by run index on these paths, so a paragraph with a bookmark
    whose runs the word path splits fails the acceptance check, with or
    without a shell, as it did before this PR. The whole-run default path is
    unchanged, so a word Word writes in a run of its own before a link still
    refuses at Run granularity.
  • Story shells. The canonical reading resolves element and attribute names
    to namespaces, sorts attributes, reads an empty element as a start and an
    end, and leaves out the XML declaration, comments, processing
    instructions, whitespace-only text, namespace declarations and Markup
    Compatibility attributes. The redline still writes the original bytes. A
    root child, an owner attribute value or an owner count that differs still
    refuses. Headers and footers had no such check (their root is not
    compared and their content is compared by model), and the new test pins a
    rewritten header root next to the comments and notes cases. Text-box host
    shells inside a story are still compared as bytes. They are not related
    story shells, and I left them alone to keep the diff small.
  • The Python and CLI: expose ComparisonOptions (granularity, ignored stories) in compare() #161 comment reproductions are not in these classes. A comment added
    on an existing paragraph of the edited side still refuses with "cannot
    revise paragraph boundary structures at body/paragraph[1]" (comment ranges
    compared by run index), a comment added in a new paragraph refuses with
    "comments story shell count changed", and a re-dated comment with
    "comments owner shell changed", which the new test pins. They need the
    redline to carry the edited side's comment anchors and threads. That
    work, and the tracked replacement of the paragraphs of a rebuilt TOC, are
    left to a later PR.
  • deleted_text_xml now matches the w:t start, end and empty tags only.
    As before it handles the w: prefix only, and a replaced field keeps
    w:instrText inside w:del where Word writes w:delInstrText. Both
    predate this PR and are left out.
  • API and behaviour. No public type or signature changes.
    ComparisonDiagnostic keeps its two fields and its doc comment now lists
    the stable prefixes. compare() and compare_with_options() now succeed,
    with diagnostics, where they refused a tag or alias change. They report a
    lock, placeholder or gallery change they used to drop. They succeed for a
    rewritten comments or notes shell, and at word and character
    granularity for a word inserted or deleted beside a hyperlink or an
    inline control, text inserted at the start of a hyperlink, and a word
    moved into a hyperlink. The Python stub needs no change.
  • Other open PRs. Keep comments bytes and mc:Ignorable roots on save, and validate them #185 keeps the comments bytes on save, so after it the
    no-op case no longer rewrites the part, and this PR still covers other
    producers. Expose comparison options as keywords of Python Document.compare #176 exposes the granularity option in Python. The duplicate
    w:sdt id note of Keep producer serialization out of compare results #184 is unchanged.
  • HLD: 03 now states the three content-control classes, the canonical story
    shells, the shell boundaries on the attributed path and the whole-run
    shortcut. 10 now describes diagnostics beyond formatting and their
    prefixes.

Tests

Added. The behaviour tests failed before their fix, as quoted below. The
two refusal pins passed before and after.

  • granular_edits_beside_a_shell_move_its_boundaries (next to
    granular_hyperlink_edits_preserve_the_owner_shell), at word and
    character granularity: sixteen pairs with a word inserted or deleted
    before, after or between hyperlinks and inline controls, in the run it
    extends and in a run of its own. They include the edits right next to a
    shell ("Enter [control] today" to "Enter it [control] today", "see the
    [control] now" to "see [control] now", "see [site], now" to "see [site]
    today, now", "see [site]" to "[site]") and text after a shell that ends
    the paragraph. Each compares with no diagnostic and no revision inside a
    shell, keeps the shell count, and accepting and rejecting compare again
    with no revision. Text inserted at the start of a hyperlink lands inside
    it, a word moved into a hyperlink is deleted outside and inserted inside,
    and text between two adjacent hyperlinks or before a hyperlink that opens
    its paragraph still refuses with the boundary message. Before the fix
    the first case refused with "cannot revise paragraph boundary
    structures".
  • In the compare_producer_noise module of Keep producer serialization out of compare results #184:
    • content_control_metadata_is_reported_and_the_original_kept: a block
      control around two paragraphs with the tag renumbered (goog_rdk_0 to
      goog_rdk_3, next to a docPartObj Table of Contents gallery as in
      Identity attributes (w:rsid*, w14:paraId, content-control w:id / w:tag) still break toc rebuild, compare() and editing: a matrix to close the class #159 section 2), the alias, a new lock, the placeholder and the gallery
      changed, each with the content unchanged (no revision) and with one
      word changed (a deletion and an insertion), one diagnostic at
      body/content-control[1] and the original w:sdtPr in the redline. A tag and a lock changed together give two diagnostics.
      The Google Docs inline shape, with an h. bookmark around the heading,
      gives one diagnostic at body/paragraph[0]/content-control[0] at run,
      word and character granularity.
    • a_content_control_type_or_binding_change_still_refuses: w:text
      against w:richText, and a changed w:dataBinding xpath, refuse and
      leave the document untouched.
    • a_reserialized_story_shell_is_not_a_change: the Producer traits: a matrix over every operation, and what still fails in it and around it #160 section 3 empty
      Google Docs comments part against its rdocx no-op save and against the
      rewritten form the issue describes, then a populated comments part with
      rdocx's attribute order, a footnotes part with a rewritten separator and
      a header with another root, with and without a comment text change.
      Before the fix it refused with "comments story root shell changed".
    • a_changed_story_shell_still_refuses: a re-dated comment and a foreign
      root child still refuse with their messages.
  • comparison_deletes_field_results_without_corrupting_tabs (next to
    comparison_deletes_text_without_corrupting_tabs): a field result with a
    tab, refreshed and with a new instruction. Before the fix the redline held
    <w:delTextab/>.
  • Unit tests in comparison.rs: only_text_elements_become_deleted_text
    and an_owned_story_shell_reads_the_same_in_every_serialization.
  • Updated comparison_revises_nested_control_content_without_replacing_its_shell
    from the refusal to the tag diagnostic.

Run on macOS arm64:

  • cargo fmt --all --check, cargo clippy -p rdocx --all-targets --all-features -- -D warnings: clean.
  • cargo test -p rdocx --no-fail-fast: lib 468 passed, integration 314
    passed, regression 572 passed, doc-tests 2 passed. The failures are the
    known environment-only ones: large_word_and_presentation_pdfs_preserve_logical_reading_order,
    word_and_powerpoint_chart_pixels_are_identical,
    section_page_semantics_match_pinned_libreoffice_render,
    every_conditional_table_region_matches_word,
    odt_reader_matches_pinned_libreoffice_structure,
    sanitized_public_authoring_fixture_passes_every_conformance_stage and
    public_authored_theme_and_fonts_match_pinned_word_resolution (pinned
    tool versions).
  • cargo test -p rdocx-cli: 19 passed.
  • python3 scripts/hash_harness.py --check: 49 entries match.
  • python3 scripts/prose_check.py: 0 violations.
    python3 scripts/readme_doctests.py: passed after the archive re-record.
  • The Production readiness for editing real docx and pptx files: an acceptance contract, two realistic fixtures and two matrices #158 matrices with the debug CLI and a maturin build of rdocx-py,
    run before the review changes to the word and character path, which the
    matrices do not use: identity matrix, w:tag changed on content control gives cmp==0 and
    cmp+1=2. The cells left failing belong to other PRs (the TOC column of
    Identity attributes (w:rsid*, w14:paraId, content-control w:id / w:tag) still break toc rebuild, compare() and editing: a matrix to close the class #159 section 1 and table-row saves of section 3). Producer-trait matrix,
    every cell passes, empty comments part included.
  • The Python gate was not run: no binding or stub changed.

compare() refused any pair whose content controls differed only by
w:sdtPr/w:id, with "comparison cannot revise content-control
properties". Word and Google Docs renumber that id when they save, so
two versions of a document with a table-of-contents control could not
be compared at all, although nothing in the control had changed.

The id was part of control_property_signature, the one tuple that drives
control alignment, the refusal and the accept and reject postconditions.
Leaving it out of that tuple keeps the three consistent. Controls that
differ only by id now align as unchanged and the redline keeps the
original w:sdtPr bytes, and a change inside such a control is revised
like any other. A w:sdtPr that holds none of the compared properties
reads like no w:sdtPr, so a control that gains or loses an id-only
w:sdtPr compares too. A differing w:tag, alias, control type or data
binding still refuses the pair. HLD 03 now says the id is not part of
the control shell.

GitHub issue tensorbee#159.
A run rewritten by try_replace_text lost xml:space="preserve" on its
w:t, because replace_in_single_run and replace_across_runs recomputed
the flag from the new text alone. Google Docs writes the flag on every
w:t, so a no-op replacement, common in a scripted pass that normalizes
wording, turned an unchanged run into a deletion and an insertion once
the edited copy was compared against its source.

The three rewrite sites, which regex replacement shares, now keep a flag
the producer wrote and still add one when the new text starts or ends
with a space.

compare() also read the flag as content wherever it appeared, because
the run signature formatted CT_Text whole. The flag only changes how
text reads when whitespace sits at an edge, so the text signature now
counts it only there. The same signature serves alignment and the
accept and reject postconditions, so they stay consistent, and a run
that matches keeps the original bytes.

Word and character granularity and the ignore options compare text
units, which the redline writes back as their own w:t. Each unit copied
the flag of its text, so a space split out of a flagged text differed
from the same space in an unflagged one. A unit with whitespace at an
edge now always carries the flag, so on that path whitespace reads as
whitespace whatever the source flag was, and the flag is never content.
Files from two producers that place the flag differently then compare
the same way at every granularity, and a space split out of a text
keeps the flag in the redline, where Word used to drop it.

On that path, the shortcut that keeps runs whole when every run of a
paragraph matches reads the flag the same way. Otherwise a run that
differed only by the flag at an edge was rewritten one unit per run,
which moved a bookmark end indexed by run and refused the pair.

GitHub issue tensorbee#160.
Comparing a file whose w:pgSz carries w:orient="portrait" against its
rdocx-edited copy reported a section_property_change next to the real
edit. The pgSz writer emits w:orient only for landscape, so a modelled
edit drops the default value, and section_properties_xml compared the
two sections as whole CT_SectPr values, Some(Portrait) against None.

Portrait is the schema default, so both modelled sections now read it
as absent before the equality test. A paragraph that holds a bookmark,
hyperlink, comment range or control compares its whole paragraph
properties, section break included, so the same rule applies there.
Without it such a section-break paragraph kept a formatting
diagnostic, or refused the pair when its text changed. A real change to
landscape is still a section property revision. The serializer is left
alone, because the typed defaults of every generated document carry
Some(Portrait) and writing it would move the document.xml hash
baselines.

GitHub issue tensorbee#160.
compare() failed with "complex field source has no end boundary" on a
file against its own copy once update_page_fields had refreshed a field
packed in one run, as Google Docs writes footer fields. The refreshed
result lands inside that run and changes w:dirty, so compare_field_xml
extracts the result on both sides, and complex_field_result looked for
the end character only after the end of the run holding separate. In a
packed run the end sits inside that same run.

complex_field_result now splits the run so that separate ends a run and
end starts one before it reads the result. The new runs repeat the
original start tag and run properties, and a run with nothing on the
far side of the character is left alone, so a field whose separate and
end each have a run of their own yields the same bytes as before. The
postconditions read a field as its owner only, so the split does not
reach them.

The same split fixes a quieter loss. update_page_fields writes the
result of an uncached one-run-per-part field into the run of end, and
the redline then carried an empty insertion without the new page
number. It now inserts the result.

GitHub issue tensorbee#160.
The comparison fixes, the kept xml:space flag and their tests grow the
rdocx and rdocx-oxml packages, so the crates.io archive rows in
README.md and crates/rdocx-oxml/README.md and their
ARCHIVE_MEASUREMENTS entries are re-measured.

GitHub issues tensorbee#159 and tensorbee#160.
compare() at word or character granularity refused a pair whose only
difference was a word inserted or deleted beside a hyperlink or an
inline control, with "comparison cannot revise paragraph boundary
structures". The attributed path compared each hyperlink by the
absolute number of units before its start and end, so any edit earlier
in the paragraph moved both. An inline control was compared by its run
index, so a word that Word writes in a run of its own before the
control refused the same way, and a redline whose runs were split into
units could not be compared again against the edited side.

Hyperlinks and inline controls are now compared without their place in
the paragraph. The units are aligned separately between consecutive
shell boundaries, so the space before a control never matches the
space after it, and the redline copies the original bytes before the
first run of each segment, shell tags included, before anything written
for that segment. Words inserted or deleted on either side of a shell,
and text inserted at the start of a hyperlink, land where the edited
side has them. A word moved into a hyperlink becomes a deletion outside
and an insertion inside. Text inserted between two shells with no
original run between them, such as before a hyperlink that opens its
paragraph, still refuses the pair. The whole-run path is unchanged.

GitHub issue tensorbee#161.
compare() refused any pair whose content controls differed by w:tag or
w:alias, with "comparison cannot revise content-control properties".
Google Docs numbers its goog_rdk_N tags per export, so two exports of
one document could not be compared at all. A change to w:lock,
w:placeholder or the docPartGallery was the opposite problem: it sat
outside the compared properties and was dropped without a word.

These five properties name, protect or file a control without changing
what it holds. They leave the signature that drives alignment, refusal
and the accept and reject postconditions, the redline keeps the
original w:sdtPr, and each difference becomes a ComparisonDiagnostic
whose message starts with "content-control <name> differs". The
control type and its data binding decide what a control holds, so a
difference there still refuses the pair. ComparisonDiagnostic keeps its
two fields, so no struct literal breaks, and its doc comment now lists
the stable message prefixes.

On the word and character paths a paragraph whose runs all match now
keeps them whole and compares only the controls that differ. Before, a
control that differed sent the paragraph through the unit rewrite, which
split unchanged runs and moved the run-indexed bookmarks Google Docs
writes around headings, so the acceptance check failed.

The pinned test that expected a changed tag to refuse the pair now
expects the diagnostic, and HLD 03 and 10 describe the three classes.

GitHub issue tensorbee#159.
compare() refused a file against its own rdocx save whenever the save
wrote its comments part again, with "comments story root shell
changed". The part root, with each comment or note replaced by a
placeholder, and each owner start tag were compared as bytes. The XML
declaration, the order of attributes and namespace declarations, a
dropped declaration and an empty root written as a start and an end tag
all refused the pair. A Google Docs export carries an empty self-closed
comments part, and rdocx writes the w:comment attributes in another
order than Word does.

The root, the owner start tags and the footnote and endnote separators
are now read as trees with namespace-resolved names and a sorted set of
attributes. The declaration, comments, processing instructions,
whitespace-only text, namespace declarations and Markup Compatibility
attributes are left out, since they say how the part is written, and
the redline keeps the original bytes as before. A root child or an
owner attribute whose value differs still refuses the pair, so a
re-dated comment keeps waiting for the redline to carry the edited
comment threads.

Headers and footers had no such check. Their root is not compared and
their content is compared by model, which the new test pins next to the
comments and notes cases.

GitHub issue tensorbee#160.
compare() wraps an old field result, or a field whose instruction
changed, in w:del and turns its text into deleted text. It did so by
renaming every "<w:t" prefix, so a tab in the result became
<w:delTextab/> and the w:textInput of a legacy text form became
w:delTextextInput. The accept and reject checks read a field as its
owner only, so the broken element reached the redline unseen. A
refreshed result that holds a tab takes this path for a field written
one run per part, and since the packed-field fix for a field packed in
one run as well.

The rename now matches the w:t start, end and empty tags alone.

GitHub issue tensorbee#160.
The comparison changes, their doc comments and the new unit tests grow
the rdocx package, so the crates.io archive row in README.md and its
ARCHIVE_MEASUREMENTS entry are re-measured on top of the stacked
comparison branch.

GitHub issues tensorbee#159, tensorbee#160 and tensorbee#161.

This branch has not been deployed

No deployments
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.

1 participant