Skip to content

Keep producer serialization out of compare results - #184

Open
hadim wants to merge 5 commits into
tensorbee:mainfrom
hadim:fix/compare-producer-noise
Open

hadim wants to merge 5 commits into
tensorbee:mainfrom
hadim:fix/compare-producer-noise

Conversation

@hadim

@hadim hadim commented Sep 27, 2026 •

Copy link
Copy Markdown
Contributor

Summary

  • Ignore the content-control w:id when comparing documents. It no longer
    takes part in control_property_signature, and a w:sdtPr that holds none
    of the compared properties reads like no w:sdtPr. A pair whose controls
    differ only by id, including an id-only w:sdtPr against none, compares,
    and the redline keeps the original w:sdtPr. HLD 03 now says the id is not
    part of the control shell.
  • Keep xml:space="preserve" on runs rewritten by try_replace_text and
    regex replacement. In compare, count the flag in the whole-run signature
    only when the text has whitespace at an edge, and give every word,
    character or ignore-option unit with edge whitespace the flag, so the flag
    is never content on that attributed path. The shortcut of that path that
    keeps matching runs whole reads the flag the same way.
  • Treat the default w:orient="portrait" as absent when comparing section
    properties, both for a section on its own and for the section break of a
    paragraph that takes the complex path (bookmark, hyperlink, comment range
    or control). The orientation that an edited save drops is no longer a
    section_property_change, a formatting diagnostic or a refusal.
  • Split a field packed in one run so that separate ends a run and end
    starts one before compare extracts its result. Such a field is then read
    like the same field written one run per part.
  • Re-record the crates.io archive rows of rdocx and rdocx-oxml.

Part of #159. This fixes the w:id half of section 2. Section 1 (TOC
rebuild), the w:tag half of section 2, section 3 (table-row identity
attributes) and the request to keep the matrix as a test remain.

Part of #160. This fixes section 2 (both cases) and section 4. Section 1
(content controls reached by replace and text), section 3 (empty
comments.xml), the fidelity paragraph at the end of section 2 and the
request to keep the matrix as a test remain.

Why

Each case turned producer serialization into a refused pair or a spurious
revision.

  • compare_control_from_xml refused the whole pair when
    control_property_signature differed, and the signature held the numeric
    w:id, which Word and Google Docs renumber on save. A TOC control that
    gained an id between two saves made the two versions impossible to compare.
  • The three rewrite sites in placeholder.rs recomputed preserve_space from
    the new text only, and run_content_signature formatted CT_Text whole,
    flag included. A no-op replacement on a Google Docs file then showed an
    unchanged run as deleted and inserted.
  • The pgSz writer emits w:orient only for landscape, and compare looked
    at whole CT_SectPr values, so Some(Portrait) against None became a
    section property revision.
  • complex_field_result searched for the end character only after the run
    holding separate. In a packed run, the end sits inside that same run,
    so compare failed with "complex field source has no end boundary" once
    update_page_fields had written a result into the run.

Notes

  • Content-control id. One tuple drives
    alignment, the refusal and the accept and reject postconditions, so removing
    the id there keeps all three consistent. A w:sdtPr with none of the tuple
    properties now maps to None, which makes an id-only w:sdtPr equal to a
    missing one. Properties outside the tuple, such as a lock, were already
    ignored when both sides had a w:sdtPr. The w:tag behaviour is unchanged
    on purpose. A differing tag, alias, control type or data binding still
    refuses the pair. That refusal is a documented contract (HLD 03 on control
    shells, pinned by comparison_revises_nested_control_content_without_replacing_its_shell),
    and 159-2b needs the maintainer to classify each w:sdtPr property as
    identity, metadata that becomes a diagnostic, or structure. That belongs to
    a later PR.
  • Content-control id collisions. A matched control keeps the original id
    while an inserted control keeps its edited-side id, so the redline can hold
    two w:sdt with the same id (original A with id 5, edited A with id 7 and a
    new B with id 5). The postconditions ignore ids and Word renumbers
    duplicates on open. The pair used to be refused. Renumbering a colliding
    inserted control is left out to keep the diff small.
  • xml:space, whole-run path. The default path counts the flag only when the
    text starts or ends with XML whitespace (space, tab, CR or LF), where it
    decides whether Word keeps that whitespace. Internal whitespace does not
    make it count. The producer side keeps a flag the source had and, as
    before, adds one when the new text starts or ends with a space, which
    matches CT_Text::new.
  • xml:space, attributed path (word or character granularity, or any ignore
    option). granular_text used to copy the text flag onto every unit. With
    the new signature, a whitespace unit split out of a flagged text then
    differed from the same unit of an unflagged text, and the granular
    postcondition refused pairs that the whole-run fast path had matched (for
    example <w:t xml:space="preserve">two words</w:t> against
    <w:t>two words</w:t> at word granularity). Each unit now carries the flag
    when it has edge whitespace, as the review suggested. On this path the flag
    is therefore never content, and a split-out space is written with the flag
    in the redline, where it used to be written as <w:t> </w:t> and Word
    dropped it. A position-aware variant (keep the source flag only on the
    first and last unit) was tried and rejected: it breaks
    granular_hyperlink_edits_preserve_the_owner_shell and
    inline_control_runs_use_granularity_and_left_biased_ignores, which pin an
    unflagged <w:t>alpha </w:t><w:t>beta</w:t> as equal to
    <w:t>alpha beta</w:t>. One consequence to know about: a pair that differs
    only by the flag on edge whitespace, such as WORD flagged against
    unflagged, gives a deletion and an insertion with default options and no
    revision on the attributed path.
  • xml:space, whole-run shortcut of the attributed path (found in review).
    compare_granular_paragraph keeps every run whole when the run signatures
    of both paragraphs match, and otherwise writes each run one unit per run.
    With the edge-aware signature alone, a run that differed only by the flag
    at an edge failed the shortcut while all of its units aligned as equal, so
    the unchanged run was rewritten one unit per run. Bookmark ends are indexed
    by run, so in a bookmarked paragraph the accept postcondition then refused
    the pair at word and character granularity. The shortcut now uses
    attributed_run_signature, which reads the flag the way granular_text
    writes it on a unit, so that run stays whole and keeps its original
    bytes. The review proposed a wider rule, treating runs as equal whenever
    their attributed unit signatures match. It was narrowed to the flag
    because, with ignore_whitespace, the wider rule lets runs with different
    whitespace take the shortcut, where compared_run_xml writes the edited
    run when properties differ and so breaks the left-biased ignore rule.
  • A limit that remains, and predates this PR: a real text edit inside a run
    of a paragraph that holds bookmarks or comment ranges refuses at word and
    character granularity, because the edited run is written one unit per run
    and the run-indexed markers move. main refuses two words against
    two wordy there with or without the flag on both sides. main only let
    that edit through at word granularity when the flag also differed on
    edge whitespace, because every unit then differed and the whole run
    became one replacement. This branch refuses that pair like the same edit
    without a flag difference. In exchange, the flag-only pair that main
    refused at character granularity in one direction now compares. Fixing
    the run-splitting limit means remapping bookmark and comment markers to
    the split runs, which is beyond this PR.
  • Portrait orientation. The normalization is on the compare side, through one clear_default_orientation helper used by
    section_properties_xml, paragraph_formatting and
    paragraph_properties_differ. The last two compare the whole CT_PPr of a
    complex paragraph, section break included. The serializer is untouched
    because default_letter and default_a4 carry Some(Portrait), so writing
    it would move every document.xml hash baseline. The equal-section branch
    still writes the original section, and a real change to landscape is still
    a section_property_change.
  • Packed fields. Each new run repeats the original run start tag and run
    properties. A run with nothing on the far side of the character is not
    split, so a field whose separate and end each have their own run gives
    the same bytes as before. The accept and reject postconditions read a field
    as field-owner, so the split does not reach them. The split also fixes a
    silent loss. update_page_fields writes the result of an uncached
    one-run-per-part field into the run of end, and the redline used to carry
    an empty w:ins without the new page number. It now inserts the result. A
    nested field inside the result still ends the result at its own end, as
    before. An uncached field still gets an empty w:del in front of the
    insertion, as tracked_field_result always wrote, and the new test pins
    that output.
  • Found in review, left out: deleted_text_xml renames every <w:t prefix,
    so a <w:tab/> inside a field result becomes <w:delTextab/>. It predates
    this PR and one-run-per-part fields hit it on main. A packed field whose
    cached result holds a tab now reaches it too, where it used to fail. It
    deserves its own issue: the rename should match <w:t>, <w:t and
    <w:t/> only.
  • Behaviour changes. compare() succeeds where it used to fail for a w:id
    difference, for a refreshed packed field and for a default-portrait
    difference on a complex section-break paragraph. It reports fewer
    revisions for a flag-only xml:space difference and for a default-portrait
    difference, and it now shows the inserted result of a refreshed uncached
    field. At word and character granularity, a whitespace unit is written
    with xml:space="preserve". try_replace_text, replace_text and regex
    replacement keep xml:space="preserve" on a rewritten w:t that had it.
  • API impact: none. All changes are in private functions.
  • HLD: only the control-shell sentence of HLD 03 became false, so only it
    changed. It still holds with the id-only w:sdtPr case, since those
    controls differ only by the id. The other rules leave every HLD statement
    true.
  • Acceptance matrices of Production readiness for editing real docx and pptx files: an acceptance contract, two realistic fixtures and two matrices #158, run against the first version of this branch
    with the debug CLI and a maturin build of rdocx-py: in the producer-trait
    matrix, w:orient="portrait" gives self=2 and packed footer fields, no cached result gives cmpfld=ok. In the identity matrix, w:id on content control gives cmp==0. The cells left failing belong to other PRs:
    empty comments part (160-3), inline content control on first runs
    (160-1), the TOC column (159-1), w:tag changed (159-2b) and table-row
    saves (159-3). The review rounds changed no default-option path those cells
    use.

Tests

  • Added compare_producer_noise in crates/rdocx/tests/regression_test.rs,
    a named module placed next to
    comparison_treats_empty_paragraph_properties_as_absent. Every case checks
    the revision kinds, no diagnostics, and that accepting gives the edited side
    and rejecting the original, each compared again with the same options and
    no revision.
    • a_content_control_identity_is_not_content: the 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 TOC-shaped block
      control with an id added, removed or renumbered gives no revision and
      keeps the original id. With one word changed inside it gives a deletion
      and an insertion inside the one control. The Google Docs inline shape
      (goog_rdk_0 tag and w:id) behaves the same. A control with no
      w:sdtPr against one with an id-only w:sdtPr, both ways, gives no
      revision and keeps the original shell.
    • a_rewritten_run_keeps_its_producer_space_flag: the Producer traits: a matrix over every operation, and what still fails in it and around it #160 case 2, a
      WORD to WORD replacement on a file with the flag on every w:t,
      keeps the flag and compares with no revision. A replacement that removes
      the edge space keeps the flag too.
    • the_space_flag_is_content_only_at_a_text_edge: each pair runs in both
      directions with default options, with ignore_formatting, and at word
      and character granularity. A flag-only difference without edge
      whitespace, on a multiword text, or with internal double spaces gives no
      revision anywhere. A flag-only difference with a trailing space gives a
      deletion and an insertion on the whole-run path and nothing on the
      attributed path. A Google Docs style pair (flag on every w:t) against a
      Word style copy with one real edit in another paragraph gives exactly
      that edit. At word and character granularity the redline writes a
      split-out space as <w:t xml:space="preserve"> </w:t>. A bookmarked
      paragraph whose first run differs only by the flag on edge whitespace
      gives a deletion and an insertion with default options and no revision
      on the attributed path, both ways.
    • the_default_page_orientation_is_not_a_section_change: the Producer traits: a matrix over every operation, and what still fails in it and around it #160 case 1,
      a portrait file against its rdocx-edited copy, gives exactly a deletion
      and an insertion. Portrait against no w:orient gives nothing in both
      directions, and portrait against landscape gives one
      SectionPropertyChange. A two-section body whose bookmarked
      section-break paragraph differs only by portrait gives nothing, and with
      its text changed a deletion and an insertion, in both directions.
    • a_packed_field_compares_like_the_same_field_split_into_runs: the Producer traits: a matrix over every operation, and what still fails in it and around it #160
      section 4 script, a footer PAGE field packed or one run per part, cached
      or not, compared against its copy after update_page_fields. All four
      succeed, the result and end part of the footer is byte-identical between
      packed and split, and it is pinned to one deletion and one insertion that
      carries the new page number.
  • Added comparison::tests::a_packed_field_result_is_read_as_one_run_per_part
    in crates/rdocx/src/comparison.rs, which pins the result extraction for a
    packed run, a mixed run and one run per part, cached and uncached, with the
    w prefix and another prefix, run attributes and run properties.
  • Added placeholder::tests::replace_keeps_the_producer_space_flag in
    crates/rdocx-oxml/src/placeholder.rs for the single-run and cross-run
    rewrite sites.
  • Every new test fails on origin/main and passes on this branch. The rows
    added in review were checked against the first version of the branch: the
    id-only w:sdtPr row fails with "cannot revise content-control
    properties", the word and character rows fail with "comparison acceptance
    does not reproduce the edited stories", and the bookmarked section-break
    row fails with a formatting diagnostic. The bookmarked row added in the
    second review fails on the round-2 head 13003d0f with "comparison
    acceptance does not reproduce the edited stories". Each commit builds and
    passes the regression binary on its own.
  • The issue scripts 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 2 and Producer traits: a matrix over every operation, and what still fails in it and around it #160 sections 2 and 4, run on the
    round-2 branch with the debug CLI and a maturin build of rdocx-py (the
    last change only reaches the attributed path, which these scripts do not
    use): the w:id cases give 0 revisions (2 with the inner word change),
    case 1 gives ['deletion', 'insertion'], case 2 gives [], and every
    field row is ok.
  • cargo fmt --all --check, cargo clippy -p rdocx-oxml -p rdocx --all-targets --all-features -- -D warnings, python3 scripts/hash_harness.py --check (49 entries match), python3 scripts/prose_check.py, python3 scripts/sync_agent_skills.py --check and
    python3 scripts/readme_doctests.py are clean.
  • cargo test -p rdocx-oxml -p rdocx -p rdocx-cli --no-fail-fast: rdocx-oxml
    (544 lib tests and doc tests) and rdocx-cli pass, the rdocx regression
    suite passes (566 tests). The failures seen are environmental only: lib
    large_word_and_presentation_pdfs_preserve_logical_reading_order and
    word_and_powerpoint_chart_pixels_are_identical (pinned Poppler), and
    integration odt_reader_matches_pinned_libreoffice_structure,
    public_authored_theme_and_fonts_match_pinned_word_resolution,
    sanitized_public_authoring_fixture_passes_every_conformance_stage,
    f267_table_style_conditional_tests::every_conditional_table_region_matches_word
    and f269_section_page_semantics::section_page_semantics_match_pinned_libreoffice_render.
    The last two assert the pinned LibreOffice 26.2.5.2 against the local
    26.8.0.3 and fail the same way on origin/main.
  • No binding or stub changed. As a consumer check, the rdocx-py pytest suite
    ran against a maturin build of the round-2 branch: 65 passed, and the two
    test_rendering_threads.py tests failed on the pinned pdfinfo version only.

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.

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