Skip to content

Python and CLI: expose ComparisonOptions (granularity, ignored stories) in compare() #161

Description

@hadim

Document::compare_with_options (rdocx/src/comparison.rs:236) takes a ComparisonOptions with a
granularity (Run, Word, Character), ignore_formatting, ignore_whitespace, ignore_fields,
ignore_comments and ignored_stories. Neither Document.compare() in Python nor rdocx compare has a
way to pass them, so both always run with the default, and two defaults bite in practice. #76 brought
compare to the CLI and Python with the default options; this asks to open them.

Whole-run granularity. With ComparisonGranularity::Run, the default ("Preserve the legacy whole-run
comparison behavior"), one word changed in a paragraph held in a single run marks the whole paragraph as
deleted and re-inserted. Single-run paragraphs are the norm in files exported by Google Docs and in any
paragraph typed in one go, so a redline of a light editing pass reads as a rewrite of every touched
paragraph. Both sides below are written by python-docx; rdocx only compares:

import os, re, subprocess, sys, zipfile
from docx import Document

RDOCX = sys.argv[1] if len(sys.argv) > 1 else "rdocx"
TEXT = ("Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut "
        "labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco.")


def build(path, text, pieces=1):
    d = Document()
    p = d.add_paragraph()
    if pieces == 1:
        p.add_run(text)
    else:
        for part in (text[:40], text[40:120], text[120:]):
            p.add_run(part)
    d.save(path)
    return path


def run(label, pieces):
    a = build("gr_a.docx", TEXT, pieces)
    b = build("gr_b.docx", TEXT.replace("magna", "MAGNA"), pieces)
    if os.path.exists("gr_out.docx"):
        os.remove("gr_out.docx")
    subprocess.run([RDOCX, "compare", a, b, "--author", "R", "--timestamp", "2026-09-27T12:00:00Z", "-o", "gr_out.docx"],
                   capture_output=True, text=True, check=True)
    x = zipfile.ZipFile("gr_out.docx").read("word/document.xml").decode()
    dels = re.findall(r"<w:delText[^>]*>([^<]*)</w:delText>", x)
    print(f"{label:<44} deleted chars={sum(map(len, dels)):4d} of {len(TEXT)}")


run("one run, one word changed", 1)
run("three runs, one word changed in the second", 3)
one run, one word changed                    deleted chars= 183 of 183
three runs, one word changed in the second   deleted chars=  80 of 183

Through a ten-line Rust example calling compare_with_options on the same pair, Word gives
deleted ['magna'] inserted ['MAGNA'], which is what a reviewer expects to see.

Comments on one side only. compare() refuses a pair whose comment parts differ:

Error: document comparison requires identical related-story shells

In a review cycle the edited version is exactly the one that carries new comments or replies, so the
default makes the redline of two consecutive passes impossible. Two more refusals of the same family, both
with a comment on each side: one comment added on the edited side gives comparison cannot revise paragraph boundary structures at body/paragraph[1] (the message of #135, but neither side has an empty w:pPr
here: the paragraph now holds a comment range), and a comment whose date changed (w:date, written since
#117) gives comments owner shell changed at /word/comments.xml[0]. With ignore_comments: true (comparison.rs:437 to 440; adding
Comment to ignored_stories is not needed) the pair compares (Rust example again), but the redline then
carries the original's comments and drops the edited side's: in the test, the added comment is gone.

So exposing the option is necessary and not sufficient. What a reviewer needs from a redline of two passes is
the edited version's comments, replies and resolved states, next to the tracked changes of the text.
Acceptance: compare() never refuses a pair over comments (added, removed, replied, resolved, re-dated),
and the redline carries the edited side's comment threads.

The same contract should hold for a rebuilt table of contents: after rebuild_toc() on the edited side, the
umbrella's workflow (#158) gets comparison cannot revise paragraph boundary structures at body/content-control[7]/content[2] rather than a revision of the TOC entries. The rebuild moves the field's
begin and separate into a paragraph of their own and writes each entry as a hyperlink with a PAGEREF
field; the refusal stays when every identity attribute is stripped from the fixture first. Both this and
the added comment above fall in the case PR #153 left out of scope: "Paragraphs with bookmarks, comment
ranges, hyperlinks, inline controls or unknown children still report a paragraph property change as a
diagnostic, and refuse it when the text also changes." #127 replaced a refusal on table grids with a
tracked replacement; a tracked replacement of the paragraph would do here too.

What I would like:

  • Document.compare(edited, author, timestamp, *, granularity="run"|"word"|"character", ignore_formatting=False, ignore_whitespace=False, ignore_fields=False, ignore_comments=False, ignored_stories=()) in Python;
  • the same as flags on rdocx compare (--granularity word, --ignore-comments, --ignore-story header...);
  • and, if you agree, word as the CLI default, since the CLI is where one produces a redline for a person.

Environment: main at 9a7ed714 (S75), release build, linux x86_64, python-docx 1.2.0 for the fixtures.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions