redlines.processor

  1from __future__ import annotations
  2
  3import re
  4from abc import ABC, abstractmethod
  5from dataclasses import dataclass
  6from difflib import SequenceMatcher
  7from typing import TYPE_CHECKING, Any, Literal
  8
  9if TYPE_CHECKING:
 10    from collections.abc import Sequence
 11    from typing import Callable
 12
 13try:
 14    from nupunkt import sent_tokenize
 15    NUPUNKT_AVAILABLE = True
 16except ImportError:
 17    NUPUNKT_AVAILABLE = False
 18    sent_tokenize: Callable[[str], Any] = lambda x: []  # type: ignore[no-redef]
 19
 20try:
 21    import Levenshtein
 22    LEVENSHTEIN_AVAILABLE = True
 23except ImportError:
 24    LEVENSHTEIN_AVAILABLE = False
 25
 26from .document import Document
 27
 28__all__: tuple[str, ...] = (
 29    "RedlinesProcessor",
 30    "WholeDocumentProcessor",
 31    "NupunktProcessor",
 32    "Redline",
 33    "Stats",
 34    "DiffOperation",
 35    "Chunk",
 36)
 37
 38tokenizer = re.compile(r"((?:[^()\s]+|[().?!-])\s*)")
 39r"""
 40This regular expression matches a group of characters that can include any character except for parentheses
 41and whitespace characters (which include spaces, tabs, and line breaks) or any character
 42that is a parenthesis or punctuation mark (.?!-).
 43The group can also include any whitespace characters that follow these characters.
 44
 45Breaking it down further:
 46
 47* `(` and `)` indicate a capturing group
 48* `(?: )` is a non-capturing group, meaning it matches the pattern but doesn't capture the matched text
 49* `[^()\s]+` matches one or more characters that are not parentheses or whitespace characters
 50* `|` indicates an alternative pattern
 51* `[().?!-]` matches any character that is a parenthesis or punctuation mark `(.?!-)`
 52* `\s*` matches zero or more whitespace characters (spaces, tabs, or line breaks) that follow the previous pattern.
 53"""
 54# This pattern matches one or more newline characters `\n`, and any spaces between them.
 55
 56paragraph_pattern = re.compile(r"((?:\n *)+)")
 57r"""
 58It is used to split the text into paragraphs.
 59
 60* `(?:\\n *)` is a non-capturing group that must start with a `\\n`   and be followed by zero or more spaces.
 61* `((?:\\n *)+)` is the previous non-capturing group repeated one or more times.
 62"""
 63
 64space_pattern = re.compile(r"(\s+)")
 65"""It is used to detect space."""
 66
 67PARAGRAPH_MARKER = "¶"
 68"""
 69The character (U+00B6 PILCROW SIGN) used internally to mark a paragraph boundary.
 70Renderers convert '¶ ' into '\\n\\n'. Input text that literally contains '¶'
 71collides with this convention and may render incorrectly.
 72"""
 73
 74SENTENCE_MARKER = "¦"
 75"""
 76The character (U+00A6 BROKEN BAR) used internally to mark a sentence boundary
 77within a paragraph in sentence-level tokenization (`NupunktProcessor`).
 78
 79Because '¦' is neither whitespace nor a parenthesis, the tokenizer emits '¦ '
 80as a single token — exactly like '¶ ' — so it anchors `SequenceMatcher` at
 81sentence boundaries (a change cannot silently span sentences) without encoding
 82fake paragraph structure. Renderers strip it entirely, so it never appears in
 83output. As with '¶', input text that literally contains '¦ ' collides with this
 84convention and will be silently dropped from rendered output.
 85"""
 86
 87punctuation_token_pattern = re.compile(r"[^\w\s]+")
 88r"""
 89It is used to detect a normalized token that consists entirely of punctuation.
 90
 91* `[^\w\s]` matches any character that is neither a word character (`\w`) nor a
 92  whitespace character (`\s`), which covers parentheses, punctuation marks
 93  (.?!-) and other unicode punctuation such as em-dashes and quotes.
 94* `[^\w\s]+` is the previous character class repeated one or more times.
 95"""
 96
 97
 98def _is_punctuation_only(token: str) -> bool:
 99    """
100    Returns True if a normalized (whitespace-stripped) token consists entirely of
101    punctuation, or is empty.
102
103    The paragraph boundary token '¶' is explicitly excluded: although it is a
104    punctuation character, merging edits across it would pull paragraph breaks
105    into deletions/insertions and break paragraph handling in the output.
106
107    :param token: The normalized token to test.
108    :type token: str
109    :return: True if the token is empty or punctuation-only (and not '¶').
110    :rtype: bool
111    """
112    if token == "¶":
113        return False
114    return token == "" or punctuation_token_pattern.fullmatch(token) is not None
115
116
117def _merge_ops_split_by_punctuation(
118    opcodes: Sequence[tuple[str, int, int, int, int]],
119    source_normalized: list[str],
120) -> list[tuple[str, int, int, int, int]]:
121    """
122    Cleanup pass over `SequenceMatcher` opcodes that merges adjacent non-equal
123    operations separated only by an 'equal' run of punctuation-only tokens.
124
125    Without this pass, a change such as "thirty (30)" -> "forty (40)" is reported
126    as two separate changes because the '(' between "thirty"/"forty" and
127    "30"/"40" matches as equal. Merging the two edits (and absorbing the
128    punctuation-only equal run between them) reports the single, human-visible
129    change instead.
130
131    Because a merged operation is itself non-equal, chains of edits separated by
132    punctuation collapse naturally into a single operation. Equal runs at the
133    document boundaries are never absorbed, since they are not flanked by
134    non-equal operations on both sides. Paragraph boundary tokens ('¶') are
135    never merged across (see `_is_punctuation_only`).
136
137    :param opcodes: The opcodes returned by `SequenceMatcher.get_opcodes`.
138    :type opcodes: Sequence[tuple[str, int, int, int, int]]
139    :param source_normalized: The normalized source tokens the matcher compared.
140    :type source_normalized: list[str]
141    :return: The opcodes with punctuation-separated edits merged.
142    :rtype: list[tuple[str, int, int, int, int]]
143    """
144    result: list[tuple[str, int, int, int, int]] = []
145
146    for op in opcodes:
147        tag, i1, i2, j1, j2 = op
148        if (
149            tag != "equal"
150            and len(result) >= 2
151            and result[-1][0] == "equal"
152            and result[-2][0] != "equal"
153            and all(
154                _is_punctuation_only(token)
155                for token in source_normalized[result[-1][1] : result[-1][2]]
156            )
157        ):
158            # Merge the previous non-equal op, the punctuation-only equal run,
159            # and the current non-equal op into a single operation.
160            result.pop()
161            prev = result.pop()
162            merged_i1, merged_i2 = prev[1], i2
163            merged_j1, merged_j2 = prev[3], j2
164            if merged_i1 == merged_i2:
165                merged_tag = "insert"
166            elif merged_j1 == merged_j2:
167                merged_tag = "delete"
168            else:
169                merged_tag = "replace"
170            result.append((merged_tag, merged_i1, merged_i2, merged_j1, merged_j2))
171        else:
172            result.append(op)
173
174    return result
175
176
177def tokenize_text(text: str) -> list[str]:
178    """
179    Tokenizes a string into a list of tokens. A token is defined as a group of characters that can include any character except for parentheses
180    and whitespace characters (which include spaces, tabs, and line breaks) or any character that is a parenthesis or punctuation mark (.?!-).
181    The group can also include any whitespace characters that follow these characters.
182    For example, if the text is "Hello, world! This is a test.", the result will be:
183    ['Hello, ', 'world! ', 'This ', 'is ', 'a ', 'test.']
184
185    :param text: The text to tokenize.
186    :type text: str
187    :return: a list of tokens.
188    :rtype: list[str]
189    """
190    # NOTE: Single capturing group hence findall returns list of strings
191    matches: list[str] = re.findall(tokenizer, text)
192    return matches
193
194
195def split_paragraphs(text: str) -> list[str]:
196    """
197    Splits a string into a list of paragraphs. One or more `\n` splits the paragraphs.
198    For example, if the text is "Hello\nWorld\nThis is a test", the result will be:
199    ['Hello', 'World', 'This is a test']
200
201    :param text: The text to split.
202    :type text: str
203    :return: a list of paragraphs.
204    :rtype: list[str]
205    """
206    # NOTE: Single capturing group hence split returns list of strings
207    split_text: list[str] = re.split(paragraph_pattern, text)
208    result: list[str] = []
209    for s in split_text:
210        if s and not re.fullmatch(space_pattern, s):
211            result.append(s.strip())
212
213    return result
214
215
216def concatenate_paragraphs_and_add_chr_182(text: str) -> str:
217    """
218    Split paragraphs and concatenate them. Then add a character '¶' between paragraphs.
219    For example, if the text is "Hello\nWorld\nThis is a test", the result will be:
220    "Hello¶World¶This is a test"
221
222    :param text: The text to split.
223    :type text: str
224    :return: a list of paragraphs.
225    :rtype: str
226    """
227    paragraphs = split_paragraphs(text)
228
229    result: list[str] = []
230    for p in paragraphs:
231        result.append(p)
232        result.append(" ¶ ")
233        # Add a string ' ¶ ' between paragraphs.
234    if len(paragraphs) > 0:
235        result.pop()
236
237    return "".join(result)
238
239
240def concatenate_sentences_and_add_chr_182(text: str) -> str:
241    """
242    Split text into paragraphs and sentences, marking the boundaries.
243
244    Paragraph boundaries (one or more newlines) are preserved and marked with
245    '¶' (`PARAGRAPH_MARKER`), exactly as in paragraph-level tokenization.
246    Within each paragraph, sentences are detected with nupunkt and their
247    boundaries are marked with '¦' (`SENTENCE_MARKER`), which renderers strip
248    so the input's real paragraph structure is not reflowed.
249
250    Uses intelligent sentence boundary detection that handles:
251    - Abbreviations (Dr., Mr., etc.)
252    - Decimals and numbers (3.14, $5.99)
253    - URLs and email addresses
254    - Complex punctuation
255
256    For example: "One. Two.\\n\\nThree."
257    Returns: "One. ¦ Two. ¶ Three."
258
259    Note: Requires nupunkt to be installed (Python 3.11+)
260
261    :param text: The text to split into sentences.
262    :type text: str
263    :return: Text with sentences separated by ' ¦ ' markers within paragraphs,
264        and paragraphs separated by ' ¶ ' markers.
265    :rtype: str
266    :raises ImportError: If nupunkt is not installed.
267    """
268    if not NUPUNKT_AVAILABLE:
269        raise ImportError(
270            "Missing required package: nupunkt.\n"
271            "\n"
272            "Cause: The nupunkt package is required for sentence-level tokenization but is not installed.\n"
273            "\n"
274            "To fix: Install nupunkt (requires Python 3.11+):\n"
275            "  # Using pip\n"
276            "  pip install nupunkt>=0.6.0\n"
277            "\n"
278            "  # Using uv\n"
279            "  uv pip install nupunkt>=0.6.0\n"
280            "\n"
281            "  # Install redlines with nupunkt support\n"
282            "  pip install redlines[nupunkt]\n"
283        )
284
285    paragraphs = split_paragraphs(text)
286
287    paragraph_results: list[str] = []
288    for paragraph in paragraphs:
289        sentences = sent_tokenize(paragraph)
290
291        sentence_results: list[str] = []
292        for sentence in sentences:
293            # sent_tokenize can return either strings or tuples (text, score)
294            # We only care about the text
295            if isinstance(sentence, tuple):
296                text_part = sentence[0]
297            else:
298                text_part = sentence
299            sentence_results.append(text_part.strip())
300        paragraph_results.append(" ¦ ".join(sentence_results))
301
302    return " ¶ ".join(paragraph_results)
303
304
305@dataclass
306class Chunk:
307    """A chunk of text that is being compared. In some cases, it may be the whole document"""
308
309    text: list[str]
310    """The tokens of the chunk"""
311    chunk_location: str | None
312    """An optional string describing the location of the chunk in the document. For example, a PDF page number"""
313
314
315@dataclass
316class DiffOperation:
317    """Internal representation of a diff operation (includes 'equal' operations for rendering)"""
318
319    source_chunk: Chunk
320    test_chunk: Chunk
321    """The chunk of text that is being compared"""
322    opcodes: tuple[str, int, int, int, int]
323    """The opcodes that describe the operation. See the difflib documentation for more information"""
324
325
326@dataclass
327class Redline:
328    """
329    A structured representation of a single change between source and test text.
330
331    This class provides a user-friendly interface for accessing diff information,
332    with direct access to the changed text and position information.
333    """
334
335    operation: Literal["delete", "insert", "replace"]
336    """The type of change: 'delete', 'insert', or 'replace'"""
337
338    source_text: str | None
339    """The text from the source document. Present for 'delete' and 'replace' operations."""
340
341    test_text: str | None
342    """The text from the test document. Present for 'insert' and 'replace' operations."""
343
344    source_position: tuple[int, int] | None
345    """Position in source tokens as (start, end). None for 'insert' operations."""
346
347    test_position: tuple[int, int] | None
348    """Position in test tokens as (start, end). None for 'delete' operations."""
349
350
351@dataclass
352class Stats:
353    """
354    Statistics about the changes between source and test text.
355
356    Provides a comprehensive summary of all changes including counts by operation type,
357    change size metrics, character-level statistics, and optional Levenshtein distance.
358    """
359
360    total_changes: int
361    """Total number of changes (deletions + insertions + replacements)"""
362
363    deletions: int
364    """Number of deletion operations"""
365
366    insertions: int
367    """Number of insertion operations"""
368
369    replacements: int
370    """Number of replacement operations"""
371
372    # Advanced analytics fields
373    longest_change_length: int
374    """Length of the longest change in characters"""
375
376    shortest_change_length: int | None
377    """Length of the shortest change in characters (None if no changes)"""
378
379    average_change_length: float
380    """Average length of all changes in characters"""
381
382    change_ratio: float
383    """Ratio of changed characters to total characters (0.0 to 1.0)"""
384
385    chars_added: int
386    """Total number of characters added"""
387
388    chars_deleted: int
389    """Total number of characters deleted"""
390
391    chars_net_change: int
392    """Net change in characters (added - deleted)"""
393
394    levenshtein_distance: int | None = None
395    """Levenshtein distance between source and test text (None if library not available)"""
396
397
398class RedlinesProcessor(ABC):
399    """
400    An abstract class that defines the interface for a redlines processor.
401    A redlines processor is a class that takes two documents and generates diff operations from them.
402    Use this class as a base class if you want to create a custom redlines processor.
403    See `WholeDocumentProcessor` for an example of a redlines processor.
404    """
405
406    @abstractmethod
407    def process(
408        self, source: Document | str, test: Document | str
409    ) -> list[DiffOperation]:
410        pass
411
412
413class WholeDocumentProcessor(RedlinesProcessor):
414    """
415    A redlines processor that compares two documents. It compares the entire documents as a single chunk.
416
417    A cleanup pass merges adjacent edits separated only by punctuation, so a change
418    such as "thirty (30)" -> "forty (40)" is reported as a single replace instead of
419    two separate changes (see `_merge_ops_split_by_punctuation`).
420
421    By default, ``difflib``'s ``autojunk`` heuristic is disabled. With autojunk on,
422    any comparison of 200+ tokens treats tokens occurring in more than 1% of positions
423    as unmatchable "popular junk", which silently degrades diffs of repetitive documents
424    (schedules, price lists, "Intentionally omitted" runs) into whole-document replaces.
425    """
426
427    def __init__(self, *, autojunk: bool = False) -> None:
428        """
429        :param autojunk: Passed to difflib.SequenceMatcher. Defaults to False because the
430            default heuristic silently degrades diffs of repetitive documents of 200+ tokens
431            (see ADR-0010); set True to restore difflib's default popular-token heuristic,
432            which can be faster on large repetitive documents.
433        :type autojunk: bool
434        """
435        self.autojunk = autojunk
436
437    def process(
438        self, source: Document | str, test: Document | str
439    ) -> list[DiffOperation]:
440        """
441        Compare two documents as a single chunk.
442
443        :param source: The source document to compare.
444        :type source: Document | str
445        :param test: The test document to compare.
446        :type test: Document | str
447        :return: A list of `DiffOperation` that describe the differences between the two documents.
448        :rtype: list[DiffOperation]
449        """
450        # Extract text from documents if needed
451        source_text = source.text if isinstance(source, Document) else source
452        test_text = test.text if isinstance(test, Document) else test
453
454        # Tokenize the texts
455        source_tokens = tokenize_text(
456            concatenate_paragraphs_and_add_chr_182(source_text)
457        )
458        test_tokens = tokenize_text(concatenate_paragraphs_and_add_chr_182(test_text))
459
460        # Normalize tokens by stripping whitespace for comparison
461        # This allows the matcher to focus on content differences rather than whitespace variations
462        # while still preserving the original tokens (including whitespace) for display in the output
463        seq_source_normalized = [token.strip() for token in source_tokens]
464        seq_test_normalized = [token.strip() for token in test_tokens]
465
466        matcher = SequenceMatcher(
467            None, seq_source_normalized, seq_test_normalized, autojunk=self.autojunk
468        )
469
470        # Merge adjacent edits separated only by punctuation-only equal runs,
471        # so e.g. "thirty (30)" -> "forty (40)" is reported as a single change.
472        opcodes = _merge_ops_split_by_punctuation(
473            matcher.get_opcodes(), seq_source_normalized
474        )
475
476        return [
477            DiffOperation(
478                source_chunk=Chunk(text=source_tokens, chunk_location=None),
479                test_chunk=Chunk(text=test_tokens, chunk_location=None),
480                opcodes=opcode,
481            )
482            for opcode in opcodes
483        ]
484
485
486class NupunktProcessor(RedlinesProcessor):
487    """
488    A redlines processor that uses nupunkt for intelligent sentence boundary detection.
489
490    This processor splits documents into sentences using nupunkt's advanced tokenization,
491    which better handles:
492    - Abbreviations (Dr., Mr., etc.)
493    - Decimals and numbers (3.14, $5.99)
494    - URLs and email addresses
495    - Complex punctuation
496
497    The result is sentence-level granularity in diffs, providing more precise change detection
498    compared to paragraph-level comparison. Paragraph boundaries in the input are preserved:
499    sentences are anchored within their paragraph with an invisible marker, so rendered output
500    keeps the document's real paragraph structure instead of reflowing one sentence per paragraph.
501
502    A cleanup pass merges adjacent edits separated only by punctuation, so a change
503    such as "thirty (30)" -> "forty (40)" is reported as a single replace instead of
504    two separate changes (see `_merge_ops_split_by_punctuation`).
505
506    Note: Requires nupunkt>=0.6.0 (Python 3.11+)
507
508    Example:
509        ```python
510        from redlines import Redlines
511        from redlines.processor import NupunktProcessor
512
513        processor = NupunktProcessor()
514        r = Redlines(source, test, processor=processor)
515        ```
516    """
517
518    def __init__(self, *, autojunk: bool = False) -> None:
519        """
520        :param autojunk: Passed to difflib.SequenceMatcher. Defaults to False because the
521            default heuristic silently degrades diffs of repetitive documents of 200+ tokens
522            (see ADR-0010); set True to restore difflib's default popular-token heuristic,
523            which can be faster on large repetitive documents.
524        :type autojunk: bool
525        """
526        self.autojunk = autojunk
527
528    def process(self, source: Document | str, test: Document | str) -> list[DiffOperation]:
529        """
530        Compare two documents using sentence-level tokenization.
531
532        Paragraph boundaries are preserved ('¶' markers), and sentence boundaries
533        within each paragraph are anchored with render-invisible '¦' markers.
534
535        :param source: The source document to compare.
536        :type source: Document | str
537        :param test: The test document to compare.
538        :type test: Document | str
539        :return: A list of `DiffOperation` that describe the differences between the two documents.
540        :rtype: list[DiffOperation]
541        :raises ImportError: If nupunkt is not installed.
542        """
543        # Extract text from documents if needed
544        source_text = source.text if isinstance(source, Document) else source
545        test_text = test.text if isinstance(test, Document) else test
546
547        # Tokenize the texts using nupunkt sentence boundaries
548        source_tokens = tokenize_text(
549            concatenate_sentences_and_add_chr_182(source_text)
550        )
551        test_tokens = tokenize_text(concatenate_sentences_and_add_chr_182(test_text))
552
553        # Normalize tokens by stripping whitespace for comparison
554        # This allows the matcher to focus on content differences rather than whitespace variations
555        # while still preserving the original tokens (including whitespace) for display in the output
556        seq_source_normalized = [token.strip() for token in source_tokens]
557        seq_test_normalized = [token.strip() for token in test_tokens]
558
559        matcher = SequenceMatcher(
560            None, seq_source_normalized, seq_test_normalized, autojunk=self.autojunk
561        )
562
563        # Merge adjacent edits separated only by punctuation-only equal runs,
564        # so e.g. "thirty (30)" -> "forty (40)" is reported as a single change.
565        opcodes = _merge_ops_split_by_punctuation(
566            matcher.get_opcodes(), seq_source_normalized
567        )
568
569        return [
570            DiffOperation(
571                source_chunk=Chunk(text=source_tokens, chunk_location=None),
572                test_chunk=Chunk(text=test_tokens, chunk_location=None),
573                opcodes=opcode,
574            )
575            for opcode in opcodes
576        ]
class RedlinesProcessor(abc.ABC):
399class RedlinesProcessor(ABC):
400    """
401    An abstract class that defines the interface for a redlines processor.
402    A redlines processor is a class that takes two documents and generates diff operations from them.
403    Use this class as a base class if you want to create a custom redlines processor.
404    See `WholeDocumentProcessor` for an example of a redlines processor.
405    """
406
407    @abstractmethod
408    def process(
409        self, source: Document | str, test: Document | str
410    ) -> list[DiffOperation]:
411        pass

An abstract class that defines the interface for a redlines processor. A redlines processor is a class that takes two documents and generates diff operations from them. Use this class as a base class if you want to create a custom redlines processor. See WholeDocumentProcessor for an example of a redlines processor.

@abstractmethod
def process( self, source: redlines.document.Document | str, test: redlines.document.Document | str) -> list[DiffOperation]:
407    @abstractmethod
408    def process(
409        self, source: Document | str, test: Document | str
410    ) -> list[DiffOperation]:
411        pass
class WholeDocumentProcessor(RedlinesProcessor):
414class WholeDocumentProcessor(RedlinesProcessor):
415    """
416    A redlines processor that compares two documents. It compares the entire documents as a single chunk.
417
418    A cleanup pass merges adjacent edits separated only by punctuation, so a change
419    such as "thirty (30)" -> "forty (40)" is reported as a single replace instead of
420    two separate changes (see `_merge_ops_split_by_punctuation`).
421
422    By default, ``difflib``'s ``autojunk`` heuristic is disabled. With autojunk on,
423    any comparison of 200+ tokens treats tokens occurring in more than 1% of positions
424    as unmatchable "popular junk", which silently degrades diffs of repetitive documents
425    (schedules, price lists, "Intentionally omitted" runs) into whole-document replaces.
426    """
427
428    def __init__(self, *, autojunk: bool = False) -> None:
429        """
430        :param autojunk: Passed to difflib.SequenceMatcher. Defaults to False because the
431            default heuristic silently degrades diffs of repetitive documents of 200+ tokens
432            (see ADR-0010); set True to restore difflib's default popular-token heuristic,
433            which can be faster on large repetitive documents.
434        :type autojunk: bool
435        """
436        self.autojunk = autojunk
437
438    def process(
439        self, source: Document | str, test: Document | str
440    ) -> list[DiffOperation]:
441        """
442        Compare two documents as a single chunk.
443
444        :param source: The source document to compare.
445        :type source: Document | str
446        :param test: The test document to compare.
447        :type test: Document | str
448        :return: A list of `DiffOperation` that describe the differences between the two documents.
449        :rtype: list[DiffOperation]
450        """
451        # Extract text from documents if needed
452        source_text = source.text if isinstance(source, Document) else source
453        test_text = test.text if isinstance(test, Document) else test
454
455        # Tokenize the texts
456        source_tokens = tokenize_text(
457            concatenate_paragraphs_and_add_chr_182(source_text)
458        )
459        test_tokens = tokenize_text(concatenate_paragraphs_and_add_chr_182(test_text))
460
461        # Normalize tokens by stripping whitespace for comparison
462        # This allows the matcher to focus on content differences rather than whitespace variations
463        # while still preserving the original tokens (including whitespace) for display in the output
464        seq_source_normalized = [token.strip() for token in source_tokens]
465        seq_test_normalized = [token.strip() for token in test_tokens]
466
467        matcher = SequenceMatcher(
468            None, seq_source_normalized, seq_test_normalized, autojunk=self.autojunk
469        )
470
471        # Merge adjacent edits separated only by punctuation-only equal runs,
472        # so e.g. "thirty (30)" -> "forty (40)" is reported as a single change.
473        opcodes = _merge_ops_split_by_punctuation(
474            matcher.get_opcodes(), seq_source_normalized
475        )
476
477        return [
478            DiffOperation(
479                source_chunk=Chunk(text=source_tokens, chunk_location=None),
480                test_chunk=Chunk(text=test_tokens, chunk_location=None),
481                opcodes=opcode,
482            )
483            for opcode in opcodes
484        ]

A redlines processor that compares two documents. It compares the entire documents as a single chunk.

A cleanup pass merges adjacent edits separated only by punctuation, so a change such as "thirty (30)" -> "forty (40)" is reported as a single replace instead of two separate changes (see _merge_ops_split_by_punctuation).

By default, difflib's autojunk heuristic is disabled. With autojunk on, any comparison of 200+ tokens treats tokens occurring in more than 1% of positions as unmatchable "popular junk", which silently degrades diffs of repetitive documents (schedules, price lists, "Intentionally omitted" runs) into whole-document replaces.

WholeDocumentProcessor(*, autojunk: bool = False)
428    def __init__(self, *, autojunk: bool = False) -> None:
429        """
430        :param autojunk: Passed to difflib.SequenceMatcher. Defaults to False because the
431            default heuristic silently degrades diffs of repetitive documents of 200+ tokens
432            (see ADR-0010); set True to restore difflib's default popular-token heuristic,
433            which can be faster on large repetitive documents.
434        :type autojunk: bool
435        """
436        self.autojunk = autojunk
Parameters
  • autojunk: Passed to difflib.SequenceMatcher. Defaults to False because the default heuristic silently degrades diffs of repetitive documents of 200+ tokens (see ADR-0010); set True to restore difflib's default popular-token heuristic, which can be faster on large repetitive documents.
autojunk
def process( self, source: redlines.document.Document | str, test: redlines.document.Document | str) -> list[DiffOperation]:
438    def process(
439        self, source: Document | str, test: Document | str
440    ) -> list[DiffOperation]:
441        """
442        Compare two documents as a single chunk.
443
444        :param source: The source document to compare.
445        :type source: Document | str
446        :param test: The test document to compare.
447        :type test: Document | str
448        :return: A list of `DiffOperation` that describe the differences between the two documents.
449        :rtype: list[DiffOperation]
450        """
451        # Extract text from documents if needed
452        source_text = source.text if isinstance(source, Document) else source
453        test_text = test.text if isinstance(test, Document) else test
454
455        # Tokenize the texts
456        source_tokens = tokenize_text(
457            concatenate_paragraphs_and_add_chr_182(source_text)
458        )
459        test_tokens = tokenize_text(concatenate_paragraphs_and_add_chr_182(test_text))
460
461        # Normalize tokens by stripping whitespace for comparison
462        # This allows the matcher to focus on content differences rather than whitespace variations
463        # while still preserving the original tokens (including whitespace) for display in the output
464        seq_source_normalized = [token.strip() for token in source_tokens]
465        seq_test_normalized = [token.strip() for token in test_tokens]
466
467        matcher = SequenceMatcher(
468            None, seq_source_normalized, seq_test_normalized, autojunk=self.autojunk
469        )
470
471        # Merge adjacent edits separated only by punctuation-only equal runs,
472        # so e.g. "thirty (30)" -> "forty (40)" is reported as a single change.
473        opcodes = _merge_ops_split_by_punctuation(
474            matcher.get_opcodes(), seq_source_normalized
475        )
476
477        return [
478            DiffOperation(
479                source_chunk=Chunk(text=source_tokens, chunk_location=None),
480                test_chunk=Chunk(text=test_tokens, chunk_location=None),
481                opcodes=opcode,
482            )
483            for opcode in opcodes
484        ]

Compare two documents as a single chunk.

Parameters
  • source: The source document to compare.
  • test: The test document to compare.
Returns

A list of DiffOperation that describe the differences between the two documents.

class NupunktProcessor(RedlinesProcessor):
487class NupunktProcessor(RedlinesProcessor):
488    """
489    A redlines processor that uses nupunkt for intelligent sentence boundary detection.
490
491    This processor splits documents into sentences using nupunkt's advanced tokenization,
492    which better handles:
493    - Abbreviations (Dr., Mr., etc.)
494    - Decimals and numbers (3.14, $5.99)
495    - URLs and email addresses
496    - Complex punctuation
497
498    The result is sentence-level granularity in diffs, providing more precise change detection
499    compared to paragraph-level comparison. Paragraph boundaries in the input are preserved:
500    sentences are anchored within their paragraph with an invisible marker, so rendered output
501    keeps the document's real paragraph structure instead of reflowing one sentence per paragraph.
502
503    A cleanup pass merges adjacent edits separated only by punctuation, so a change
504    such as "thirty (30)" -> "forty (40)" is reported as a single replace instead of
505    two separate changes (see `_merge_ops_split_by_punctuation`).
506
507    Note: Requires nupunkt>=0.6.0 (Python 3.11+)
508
509    Example:
510        ```python
511        from redlines import Redlines
512        from redlines.processor import NupunktProcessor
513
514        processor = NupunktProcessor()
515        r = Redlines(source, test, processor=processor)
516        ```
517    """
518
519    def __init__(self, *, autojunk: bool = False) -> None:
520        """
521        :param autojunk: Passed to difflib.SequenceMatcher. Defaults to False because the
522            default heuristic silently degrades diffs of repetitive documents of 200+ tokens
523            (see ADR-0010); set True to restore difflib's default popular-token heuristic,
524            which can be faster on large repetitive documents.
525        :type autojunk: bool
526        """
527        self.autojunk = autojunk
528
529    def process(self, source: Document | str, test: Document | str) -> list[DiffOperation]:
530        """
531        Compare two documents using sentence-level tokenization.
532
533        Paragraph boundaries are preserved ('¶' markers), and sentence boundaries
534        within each paragraph are anchored with render-invisible '¦' markers.
535
536        :param source: The source document to compare.
537        :type source: Document | str
538        :param test: The test document to compare.
539        :type test: Document | str
540        :return: A list of `DiffOperation` that describe the differences between the two documents.
541        :rtype: list[DiffOperation]
542        :raises ImportError: If nupunkt is not installed.
543        """
544        # Extract text from documents if needed
545        source_text = source.text if isinstance(source, Document) else source
546        test_text = test.text if isinstance(test, Document) else test
547
548        # Tokenize the texts using nupunkt sentence boundaries
549        source_tokens = tokenize_text(
550            concatenate_sentences_and_add_chr_182(source_text)
551        )
552        test_tokens = tokenize_text(concatenate_sentences_and_add_chr_182(test_text))
553
554        # Normalize tokens by stripping whitespace for comparison
555        # This allows the matcher to focus on content differences rather than whitespace variations
556        # while still preserving the original tokens (including whitespace) for display in the output
557        seq_source_normalized = [token.strip() for token in source_tokens]
558        seq_test_normalized = [token.strip() for token in test_tokens]
559
560        matcher = SequenceMatcher(
561            None, seq_source_normalized, seq_test_normalized, autojunk=self.autojunk
562        )
563
564        # Merge adjacent edits separated only by punctuation-only equal runs,
565        # so e.g. "thirty (30)" -> "forty (40)" is reported as a single change.
566        opcodes = _merge_ops_split_by_punctuation(
567            matcher.get_opcodes(), seq_source_normalized
568        )
569
570        return [
571            DiffOperation(
572                source_chunk=Chunk(text=source_tokens, chunk_location=None),
573                test_chunk=Chunk(text=test_tokens, chunk_location=None),
574                opcodes=opcode,
575            )
576            for opcode in opcodes
577        ]

A redlines processor that uses nupunkt for intelligent sentence boundary detection.

This processor splits documents into sentences using nupunkt's advanced tokenization, which better handles:

  • Abbreviations (Dr., Mr., etc.)
  • Decimals and numbers (3.14, $5.99)
  • URLs and email addresses
  • Complex punctuation

The result is sentence-level granularity in diffs, providing more precise change detection compared to paragraph-level comparison. Paragraph boundaries in the input are preserved: sentences are anchored within their paragraph with an invisible marker, so rendered output keeps the document's real paragraph structure instead of reflowing one sentence per paragraph.

A cleanup pass merges adjacent edits separated only by punctuation, so a change such as "thirty (30)" -> "forty (40)" is reported as a single replace instead of two separate changes (see _merge_ops_split_by_punctuation).

Note: Requires nupunkt>=0.6.0 (Python 3.11+)

Example:

from redlines import Redlines
from redlines.processor import NupunktProcessor

processor = NupunktProcessor()
r = Redlines(source, test, processor=processor)
NupunktProcessor(*, autojunk: bool = False)
519    def __init__(self, *, autojunk: bool = False) -> None:
520        """
521        :param autojunk: Passed to difflib.SequenceMatcher. Defaults to False because the
522            default heuristic silently degrades diffs of repetitive documents of 200+ tokens
523            (see ADR-0010); set True to restore difflib's default popular-token heuristic,
524            which can be faster on large repetitive documents.
525        :type autojunk: bool
526        """
527        self.autojunk = autojunk
Parameters
  • autojunk: Passed to difflib.SequenceMatcher. Defaults to False because the default heuristic silently degrades diffs of repetitive documents of 200+ tokens (see ADR-0010); set True to restore difflib's default popular-token heuristic, which can be faster on large repetitive documents.
autojunk
def process( self, source: redlines.document.Document | str, test: redlines.document.Document | str) -> list[DiffOperation]:
529    def process(self, source: Document | str, test: Document | str) -> list[DiffOperation]:
530        """
531        Compare two documents using sentence-level tokenization.
532
533        Paragraph boundaries are preserved ('¶' markers), and sentence boundaries
534        within each paragraph are anchored with render-invisible '¦' markers.
535
536        :param source: The source document to compare.
537        :type source: Document | str
538        :param test: The test document to compare.
539        :type test: Document | str
540        :return: A list of `DiffOperation` that describe the differences between the two documents.
541        :rtype: list[DiffOperation]
542        :raises ImportError: If nupunkt is not installed.
543        """
544        # Extract text from documents if needed
545        source_text = source.text if isinstance(source, Document) else source
546        test_text = test.text if isinstance(test, Document) else test
547
548        # Tokenize the texts using nupunkt sentence boundaries
549        source_tokens = tokenize_text(
550            concatenate_sentences_and_add_chr_182(source_text)
551        )
552        test_tokens = tokenize_text(concatenate_sentences_and_add_chr_182(test_text))
553
554        # Normalize tokens by stripping whitespace for comparison
555        # This allows the matcher to focus on content differences rather than whitespace variations
556        # while still preserving the original tokens (including whitespace) for display in the output
557        seq_source_normalized = [token.strip() for token in source_tokens]
558        seq_test_normalized = [token.strip() for token in test_tokens]
559
560        matcher = SequenceMatcher(
561            None, seq_source_normalized, seq_test_normalized, autojunk=self.autojunk
562        )
563
564        # Merge adjacent edits separated only by punctuation-only equal runs,
565        # so e.g. "thirty (30)" -> "forty (40)" is reported as a single change.
566        opcodes = _merge_ops_split_by_punctuation(
567            matcher.get_opcodes(), seq_source_normalized
568        )
569
570        return [
571            DiffOperation(
572                source_chunk=Chunk(text=source_tokens, chunk_location=None),
573                test_chunk=Chunk(text=test_tokens, chunk_location=None),
574                opcodes=opcode,
575            )
576            for opcode in opcodes
577        ]

Compare two documents using sentence-level tokenization.

Paragraph boundaries are preserved ('¶' markers), and sentence boundaries within each paragraph are anchored with render-invisible '¦' markers.

Parameters
  • source: The source document to compare.
  • test: The test document to compare.
Returns

A list of DiffOperation that describe the differences between the two documents.

Raises
  • ImportError: If nupunkt is not installed.
@dataclass
class Redline:
327@dataclass
328class Redline:
329    """
330    A structured representation of a single change between source and test text.
331
332    This class provides a user-friendly interface for accessing diff information,
333    with direct access to the changed text and position information.
334    """
335
336    operation: Literal["delete", "insert", "replace"]
337    """The type of change: 'delete', 'insert', or 'replace'"""
338
339    source_text: str | None
340    """The text from the source document. Present for 'delete' and 'replace' operations."""
341
342    test_text: str | None
343    """The text from the test document. Present for 'insert' and 'replace' operations."""
344
345    source_position: tuple[int, int] | None
346    """Position in source tokens as (start, end). None for 'insert' operations."""
347
348    test_position: tuple[int, int] | None
349    """Position in test tokens as (start, end). None for 'delete' operations."""

A structured representation of a single change between source and test text.

This class provides a user-friendly interface for accessing diff information, with direct access to the changed text and position information.

Redline( operation: Literal['delete', 'insert', 'replace'], source_text: str | None, test_text: str | None, source_position: tuple[int, int] | None, test_position: tuple[int, int] | None)
operation: Literal['delete', 'insert', 'replace']

The type of change: 'delete', 'insert', or 'replace'

source_text: str | None

The text from the source document. Present for 'delete' and 'replace' operations.

test_text: str | None

The text from the test document. Present for 'insert' and 'replace' operations.

source_position: tuple[int, int] | None

Position in source tokens as (start, end). None for 'insert' operations.

test_position: tuple[int, int] | None

Position in test tokens as (start, end). None for 'delete' operations.

@dataclass
class Stats:
352@dataclass
353class Stats:
354    """
355    Statistics about the changes between source and test text.
356
357    Provides a comprehensive summary of all changes including counts by operation type,
358    change size metrics, character-level statistics, and optional Levenshtein distance.
359    """
360
361    total_changes: int
362    """Total number of changes (deletions + insertions + replacements)"""
363
364    deletions: int
365    """Number of deletion operations"""
366
367    insertions: int
368    """Number of insertion operations"""
369
370    replacements: int
371    """Number of replacement operations"""
372
373    # Advanced analytics fields
374    longest_change_length: int
375    """Length of the longest change in characters"""
376
377    shortest_change_length: int | None
378    """Length of the shortest change in characters (None if no changes)"""
379
380    average_change_length: float
381    """Average length of all changes in characters"""
382
383    change_ratio: float
384    """Ratio of changed characters to total characters (0.0 to 1.0)"""
385
386    chars_added: int
387    """Total number of characters added"""
388
389    chars_deleted: int
390    """Total number of characters deleted"""
391
392    chars_net_change: int
393    """Net change in characters (added - deleted)"""
394
395    levenshtein_distance: int | None = None
396    """Levenshtein distance between source and test text (None if library not available)"""

Statistics about the changes between source and test text.

Provides a comprehensive summary of all changes including counts by operation type, change size metrics, character-level statistics, and optional Levenshtein distance.

Stats( total_changes: int, deletions: int, insertions: int, replacements: int, longest_change_length: int, shortest_change_length: int | None, average_change_length: float, change_ratio: float, chars_added: int, chars_deleted: int, chars_net_change: int, levenshtein_distance: int | None = None)
total_changes: int

Total number of changes (deletions + insertions + replacements)

deletions: int

Number of deletion operations

insertions: int

Number of insertion operations

replacements: int

Number of replacement operations

longest_change_length: int

Length of the longest change in characters

shortest_change_length: int | None

Length of the shortest change in characters (None if no changes)

average_change_length: float

Average length of all changes in characters

change_ratio: float

Ratio of changed characters to total characters (0.0 to 1.0)

chars_added: int

Total number of characters added

chars_deleted: int

Total number of characters deleted

chars_net_change: int

Net change in characters (added - deleted)

levenshtein_distance: int | None = None

Levenshtein distance between source and test text (None if library not available)

@dataclass
class DiffOperation:
316@dataclass
317class DiffOperation:
318    """Internal representation of a diff operation (includes 'equal' operations for rendering)"""
319
320    source_chunk: Chunk
321    test_chunk: Chunk
322    """The chunk of text that is being compared"""
323    opcodes: tuple[str, int, int, int, int]
324    """The opcodes that describe the operation. See the difflib documentation for more information"""

Internal representation of a diff operation (includes 'equal' operations for rendering)

DiffOperation( source_chunk: Chunk, test_chunk: Chunk, opcodes: tuple[str, int, int, int, int])
source_chunk: Chunk
test_chunk: Chunk

The chunk of text that is being compared

opcodes: tuple[str, int, int, int, int]

The opcodes that describe the operation. See the difflib documentation for more information

@dataclass
class Chunk:
306@dataclass
307class Chunk:
308    """A chunk of text that is being compared. In some cases, it may be the whole document"""
309
310    text: list[str]
311    """The tokens of the chunk"""
312    chunk_location: str | None
313    """An optional string describing the location of the chunk in the document. For example, a PDF page number"""

A chunk of text that is being compared. In some cases, it may be the whole document

Chunk(text: list[str], chunk_location: str | None)
text: list[str]

The tokens of the chunk

chunk_location: str | None

An optional string describing the location of the chunk in the document. For example, a PDF page number