diff --git a/crates/rdocx-py/python/rdocx/_rdocx.pyi b/crates/rdocx-py/python/rdocx/_rdocx.pyi index f5e4ef18c..205b29748 100644 --- a/crates/rdocx-py/python/rdocx/_rdocx.pyi +++ b/crates/rdocx-py/python/rdocx/_rdocx.pyi @@ -1,6 +1,6 @@ import datetime as _datetime import os as _os -from collections.abc import Iterator as _Iterator +from collections.abc import Iterator as _Iterator, Sequence as _Sequence from typing import Literal as _Literal, NoReturn as _Never, final as _final, overload as _overload from . import shared as _shared @@ -443,8 +443,31 @@ class Document: pages: list[int] | None = None, ) -> list[bytes] | bytes: ... def compare( - self, edited: Document, author: str, timestamp: str - ) -> tuple[ComparisonDiagnostic, ...]: ... + self, + edited: Document, + author: str, + timestamp: str, + *, + granularity: _Literal["run", "word", "character"] = "run", + ignore_formatting: bool = False, + ignore_whitespace: bool = False, + ignore_fields: bool = False, + ignore_comments: bool = False, + ignored_stories: _Sequence[ + _Literal["body", "header", "footer", "comment", "text_box", "footnote", "endnote"] + ] | None = None, + ) -> tuple[ComparisonDiagnostic, ...]: + """Record how ``edited`` differs from this document as tracked changes. + + ``granularity`` selects the unit of a text change. The default ``"run"`` + replaces a changed run whole, while ``"word"`` and ``"character"`` mark + only the changed words or characters. ``ignored_stories`` takes + ``Story.kind`` names. The ignore options and ``ignored_stories`` are + left-biased: an ignored difference or story keeps this document's + content. With ``ignore_comments=True`` the result keeps this document's + comments and anchors, and the comments of ``edited`` are not carried + over. An unknown option value raises ``RdocxError``. + """ @property def comments(self) -> tuple[Comment, ...]: ... @property diff --git a/crates/rdocx-py/src/document.rs b/crates/rdocx-py/src/document.rs index b81167c6f..d047ca165 100644 --- a/crates/rdocx-py/src/document.rs +++ b/crates/rdocx-py/src/document.rs @@ -1275,17 +1275,50 @@ impl PyDocument { } } + #[pyo3(signature = ( + edited, + author, + timestamp, + *, + granularity = "run", + ignore_formatting = false, + ignore_whitespace = false, + ignore_fields = false, + ignore_comments = false, + ignored_stories = None, + ))] + #[allow(clippy::too_many_arguments)] fn compare<'py>( &mut self, edited: &PyDocument, author: &str, timestamp: &str, py: Python<'py>, + granularity: &str, + ignore_formatting: bool, + ignore_whitespace: bool, + ignore_fields: bool, + ignore_comments: bool, + ignored_stories: Option>, ) -> PyResult> { let (diagnostics, changed) = py .detach(|| { + let options = rdocx::ComparisonOptions { + granularity: parse_comparison_granularity(granularity)?, + ignore_formatting, + ignore_whitespace, + ignore_fields, + ignore_comments, + ignored_stories: ignored_stories + .iter() + .flatten() + .map(|name| parse_comparison_story(name)) + .collect::>()?, + }; let before = self.inner.to_bytes()?; - let diagnostics = self.inner.compare(&edited.inner, author, timestamp)?; + let diagnostics = + self.inner + .compare_with_options(&edited.inner, author, timestamp, &options)?; let changed = self.inner.to_bytes()? != before; Ok::<_, rdocx::Error>((diagnostics, changed)) }) @@ -1978,3 +2011,31 @@ fn parse_raster_format( ))), } } + +fn parse_comparison_granularity(name: &str) -> rdocx::Result { + match name { + "run" => Ok(rdocx::ComparisonGranularity::Run), + "word" => Ok(rdocx::ComparisonGranularity::Word), + "character" => Ok(rdocx::ComparisonGranularity::Character), + other => Err(rdocx::Error::Other(format!( + "unknown comparison granularity {other:?}, expected run, word, or character" + ))), + } +} + +/// Story names follow `Story.kind`, so `body` selects the main story. +fn parse_comparison_story(name: &str) -> rdocx::Result { + match name { + "body" => Ok(rdocx::ComparisonStoryKind::Main), + "header" => Ok(rdocx::ComparisonStoryKind::Header), + "footer" => Ok(rdocx::ComparisonStoryKind::Footer), + "comment" => Ok(rdocx::ComparisonStoryKind::Comment), + "text_box" => Ok(rdocx::ComparisonStoryKind::TextBox), + "footnote" => Ok(rdocx::ComparisonStoryKind::Footnote), + "endnote" => Ok(rdocx::ComparisonStoryKind::Endnote), + other => Err(rdocx::Error::Other(format!( + "unknown comparison story {other:?}, expected body, header, footer, comment, \ + text_box, footnote, or endnote" + ))), + } +} diff --git a/crates/rdocx-py/tests/test_core.py b/crates/rdocx-py/tests/test_core.py index 0f8ce9271..104e215cc 100644 --- a/crates/rdocx-py/tests/test_core.py +++ b/crates/rdocx-py/tests/test_core.py @@ -914,6 +914,211 @@ def test_priority_word_operations_return_typed_snapshots_and_remain_atomic(): assert live_after_noop.text == "no table of contents" +_COMPARE_TIMESTAMP = "2026-09-27T12:00:00Z" +_LOREM = ( + "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 _tracked_texts(xml): + xml = xml.decode() + deleted = [ + "".join(re.findall(r"]*)?>([^<]*)", wrapper)) + for wrapper in re.findall(r"]*(?.*?", xml) + ] + inserted = [ + "".join(re.findall(r"]*)?>([^<]*)", wrapper)) + for wrapper in re.findall(r"]*(?.*?", xml) + ] + return deleted, inserted + + +def _package_part(document, name): + with zipfile.ZipFile(io.BytesIO(document.to_bytes())) as archive: + return archive.read(name) + + +def test_compare_granularity_marks_only_the_changed_word(): + import rdocx + + edited_text = _LOREM.replace("magna", "MAGNA") + + def redline(**options): + original = rdocx.Document() + original.add_paragraph(_LOREM) + edited = rdocx.Document() + edited.add_paragraph(edited_text) + assert original.compare(edited, "Ada", _COMPARE_TIMESTAMP, **options) == () + return original + + whole_run = ([_LOREM], [edited_text]) + for options, expected in [ + ({}, whole_run), + ({"granularity": "run"}, whole_run), + ({"granularity": "word"}, (["magna"], ["MAGNA"])), + ({"granularity": "character"}, (["magna"], ["MAGNA"])), + ]: + compared = redline(**options) + assert _tracked_texts(_document_xml(compared)) == expected, options + accepted = rdocx.Document.from_bytes(compared.to_bytes()) + accepted.accept_all() + assert accepted.paragraphs[0].text == edited_text + rejected = rdocx.Document.from_bytes(compared.to_bytes()) + rejected.reject_all() + assert rejected.paragraphs[0].text == _LOREM + + explicit_defaults = redline( + granularity="run", + ignore_formatting=False, + ignore_whitespace=False, + ignore_fields=False, + ignore_comments=False, + ignored_stories=(), + ) + assert explicit_defaults.to_bytes() == redline().to_bytes() + + +def test_compare_ignore_options_keep_the_original_side(): + import rdocx + + def compared(original, edited, **options): + work = rdocx.Document.from_bytes(original.to_bytes()) + assert work.compare(edited, "Ada", _COMPARE_TIMESTAMP, **options) == () + return work + + plain = rdocx.Document() + plain.add_paragraph("plain") + bold = rdocx.Document.from_bytes(plain.to_bytes()) + bold.paragraphs[0].runs[0].font.bold = True + assert [item.kind for item in compared(plain, bold).revisions] == [ + "run_property_change" + ] + unformatted = compared(plain, bold, ignore_formatting=True) + assert unformatted.revisions == () + assert unformatted.paragraphs[0].runs[0].font.bold is None + + spaced = rdocx.Document() + spaced.add_paragraph("old tail") + single = rdocx.Document() + single.add_paragraph("old tail") + assert [item.kind for item in compared(spaced, single).revisions] == [ + "deletion", + "insertion", + ] + unspaced = compared(spaced, single, ignore_whitespace=True) + assert unspaced.revisions == () + assert unspaced.paragraphs[0].text == "old tail" + + def page_field(result): + document = rdocx.Document() + document.add_paragraph("placeholder") + return _replace_document_body( + document, + '' + ' PAGE ' + '' + f"{result}" + '', + ) + + first_page, second_page = page_field("1"), page_field("2") + assert [item.kind for item in compared(first_page, second_page).revisions] == [ + "deletion", + "insertion", + ] + unfielded = compared(first_page, second_page, ignore_fields=True) + assert unfielded.revisions == () + assert b"1" in _document_xml(unfielded) + + reviewed = rdocx.Document() + reviewed.add_paragraph("review this") + reviewed.add_paragraph("old ending") + commented = rdocx.Document.from_bytes(reviewed.to_bytes()) + commented.add_comment( + rdocx.RunRange( + start=rdocx.RunPosition(body_index=0, run_index=0), + end=rdocx.RunPosition(body_index=0, run_index=1), + ), + author="Bo", + text="edited side note", + ) + commented.paragraphs[1].runs[0].text = "new ending" + redline = compared(reviewed, commented, ignore_comments=True) + assert redline.comments == () + assert b" None: diagnostics: tuple[ComparisonDiagnostic, ...] = document.compare( opened, author="Ada", timestamp="2026-09-14T09:00:00Z" ) + optioned: tuple[ComparisonDiagnostic, ...] = document.compare( + opened, + "Ada", + "2026-09-14T09:00:00Z", + granularity="word", + ignore_formatting=True, + ignore_whitespace=True, + ignore_fields=True, + ignore_comments=True, + ignored_stories=("header", "text_box"), + ) fragments: tuple[LayoutFragment, ...] = document.layout() maybe_layout_page: LayoutPage | None = document.layout_page(0) report: TocRebuildReport = document.rebuild_toc() @@ -212,3 +223,5 @@ def exercise_rdocx_types(path: Path) -> None: RunCollection() # type: ignore[call-arg] Table() # type: ignore[call-arg] TableCollection() # type: ignore[call-arg] + Document().compare(Document(), "Ada", "2026-09-14T09:00:00Z", granularity="words") # type: ignore[arg-type] + Document().compare(Document(), "Ada", "2026-09-14T09:00:00Z", ignored_stories="header") # type: ignore[arg-type] diff --git a/docs/hld/10-bindings-spec.md b/docs/hld/10-bindings-spec.md index a6a75eb7c..416f408cf 100644 --- a/docs/hld/10-bindings-spec.md +++ b/docs/hld/10-bindings-spec.md @@ -1295,8 +1295,17 @@ and sibling fields from one physical run share that owner. It emits same-story moves and supported run, paragraph, table, and section property revisions. Diagnostic locations retain the actual story identity and stable owner path. `rdocx-cli compare` exposes the source-compatible whole-run -comparison with explicit author, RFC 3339 timestamp, and output. Python and -WASM preserve comparison output when they save their owned document. +comparison with explicit author, RFC 3339 timestamp, and output. Python +`Document.compare` takes the `ComparisonOptions` fields as keyword-only +arguments. `granularity` is `"run"`, `"word"`, or `"character"` and defaults to +the native `"run"`. `ignore_formatting`, `ignore_whitespace`, `ignore_fields`, +and `ignore_comments` default to false. `ignored_stories` takes `Story.kind` +names, where `body` selects the main story and `table_cell` is not a comparison +category. An unknown granularity or story name raises `RdocxError` before the +document changes, and a duplicated story keeps the native rejection. +`ignore_comments` also leaves comment anchors to the original, while ignoring +the `comment` story excludes only the comments part. WASM preserves comparison +output when it saves its owned document. Native Word rendering exposes `rdocx::RevisionView` and the concrete `rdocx::RenderOptions`, whose default selects the accepted view. Additive