smart-pdf-compare 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- smart_pdf_compare/__init__.py +14 -0
- smart_pdf_compare/__main__.py +3 -0
- smart_pdf_compare/alignment.py +121 -0
- smart_pdf_compare/api.py +82 -0
- smart_pdf_compare/cli.py +51 -0
- smart_pdf_compare/diff.py +53 -0
- smart_pdf_compare/exceptions.py +17 -0
- smart_pdf_compare/extraction.py +59 -0
- smart_pdf_compare/highlighting.py +107 -0
- smart_pdf_compare/normalization.py +39 -0
- smart_pdf_compare/results.py +74 -0
- smart_pdf_compare-0.1.0.dist-info/METADATA +204 -0
- smart_pdf_compare-0.1.0.dist-info/RECORD +16 -0
- smart_pdf_compare-0.1.0.dist-info/WHEEL +4 -0
- smart_pdf_compare-0.1.0.dist-info/entry_points.txt +2 -0
- smart_pdf_compare-0.1.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
"""SmartPDFCompare: structured text comparison and PDF highlighting."""
|
|
2
|
+
from .api import ComparisonOptions, compare_pdfs
|
|
3
|
+
from .exceptions import ConfigurationError, InvalidPDFError, MissingTextLayerError, PDFComparisonError
|
|
4
|
+
from .highlighting import HighlightOutputs, create_side_by_side_report, highlight_differences
|
|
5
|
+
from .normalization import NormalizationOptions
|
|
6
|
+
from .results import ChangeType, ComparisonResult, ComparisonSummary, Difference
|
|
7
|
+
|
|
8
|
+
__version__ = "0.1.0"
|
|
9
|
+
|
|
10
|
+
__all__ = [
|
|
11
|
+
"ChangeType", "ComparisonOptions", "ComparisonResult", "ComparisonSummary", "ConfigurationError",
|
|
12
|
+
"Difference", "HighlightOutputs", "InvalidPDFError", "MissingTextLayerError", "NormalizationOptions",
|
|
13
|
+
"PDFComparisonError", "compare_pdfs", "create_side_by_side_report", "highlight_differences",
|
|
14
|
+
]
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
"""Page correspondence strategies."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
from collections import defaultdict, deque
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from difflib import SequenceMatcher
|
|
7
|
+
from typing import Literal, Optional, Sequence
|
|
8
|
+
|
|
9
|
+
from .exceptions import ConfigurationError
|
|
10
|
+
from .extraction import ExtractedPage
|
|
11
|
+
|
|
12
|
+
AlignmentStrategy = Literal["content", "index", "sequential"]
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@dataclass(frozen=True)
|
|
16
|
+
class PagePair:
|
|
17
|
+
old: Optional[int]
|
|
18
|
+
new: Optional[int]
|
|
19
|
+
moved: bool = False
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _similarity(left: ExtractedPage, right: ExtractedPage) -> float:
|
|
23
|
+
if not left.words and not right.words:
|
|
24
|
+
return 1.0
|
|
25
|
+
if not left.words or not right.words:
|
|
26
|
+
return 0.0
|
|
27
|
+
return SequenceMatcher(None, left.signature.split(), right.signature.split(), autojunk=False).ratio()
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def align_pages(old: Sequence[ExtractedPage], new: Sequence[ExtractedPage], strategy: AlignmentStrategy, threshold: float, lookahead: int) -> list[PagePair]:
|
|
31
|
+
"""Return one-to-one page correspondence, including unpaired pages."""
|
|
32
|
+
if strategy not in ("content", "index", "sequential"):
|
|
33
|
+
raise ConfigurationError("alignment must be 'content', 'index', or 'sequential'")
|
|
34
|
+
if not 0.0 <= threshold <= 1.0:
|
|
35
|
+
raise ConfigurationError("similarity_threshold must be between 0 and 1")
|
|
36
|
+
if lookahead < 1:
|
|
37
|
+
raise ConfigurationError("lookahead must be at least 1")
|
|
38
|
+
if strategy == "index":
|
|
39
|
+
pairs = [PagePair(i, i) for i in range(min(len(old), len(new)))]
|
|
40
|
+
pairs.extend(PagePair(i, None) for i in range(len(new), len(old)))
|
|
41
|
+
pairs.extend(PagePair(None, j) for j in range(len(old), len(new)))
|
|
42
|
+
return pairs
|
|
43
|
+
if strategy == "sequential":
|
|
44
|
+
return _sequential(old, new, threshold, lookahead)
|
|
45
|
+
return _content(old, new, threshold)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _content(old: Sequence[ExtractedPage], new: Sequence[ExtractedPage], threshold: float) -> list[PagePair]:
|
|
49
|
+
# Exact fingerprints are paired first. Pairing repeated pages in source order
|
|
50
|
+
# is deterministic, while a unique matching page can be recognized as moved.
|
|
51
|
+
by_signature: dict[str, deque[int]] = defaultdict(deque)
|
|
52
|
+
for j, page in enumerate(new):
|
|
53
|
+
by_signature[page.signature].append(j)
|
|
54
|
+
matched: dict[int, int] = {}
|
|
55
|
+
used: set[int] = set()
|
|
56
|
+
for i, page in enumerate(old):
|
|
57
|
+
candidates = by_signature[page.signature]
|
|
58
|
+
if candidates:
|
|
59
|
+
j = candidates.popleft()
|
|
60
|
+
matched[i], used = j, used | {j}
|
|
61
|
+
# Pair sufficiently similar unmatched pages. This bounded greedy pass avoids
|
|
62
|
+
# a large stored matrix and makes every page participate at most once.
|
|
63
|
+
candidates: list[tuple[float, int, int]] = []
|
|
64
|
+
for i, left in enumerate(old):
|
|
65
|
+
if i in matched:
|
|
66
|
+
continue
|
|
67
|
+
for j, right in enumerate(new):
|
|
68
|
+
if j not in used:
|
|
69
|
+
score = _similarity(left, right)
|
|
70
|
+
if score >= threshold:
|
|
71
|
+
candidates.append((score, i, j))
|
|
72
|
+
for _score, i, j in sorted(candidates, key=lambda item: (-item[0], abs(item[1] - item[2]), item[1], item[2])):
|
|
73
|
+
if i not in matched and j not in used:
|
|
74
|
+
matched[i], used = j, used | {j}
|
|
75
|
+
# A changed page may have no similarity above the threshold. When the only
|
|
76
|
+
# remaining pages are positional counterparts, keep them paired as a change.
|
|
77
|
+
remaining_old = [i for i in range(len(old)) if i not in matched]
|
|
78
|
+
remaining_new = [j for j in range(len(new)) if j not in used]
|
|
79
|
+
if len(remaining_old) == len(remaining_new):
|
|
80
|
+
for i, j in zip(remaining_old, remaining_new):
|
|
81
|
+
matched[i], used = j, used | {j}
|
|
82
|
+
# A changed index alone is not a move: an insertion shifts every later page.
|
|
83
|
+
# A move is only reported when two matched pages reverse their relative order.
|
|
84
|
+
inverted: set[int] = set()
|
|
85
|
+
ordered_matches = sorted(matched.items())
|
|
86
|
+
for position, (i, j) in enumerate(ordered_matches):
|
|
87
|
+
for other_i, other_j in ordered_matches[position + 1:]:
|
|
88
|
+
if j > other_j:
|
|
89
|
+
inverted.update((i, other_i))
|
|
90
|
+
keyed: list[tuple[float, PagePair]] = []
|
|
91
|
+
reverse = {j: i for i, j in matched.items()}
|
|
92
|
+
for i in range(len(old)):
|
|
93
|
+
if i in matched:
|
|
94
|
+
j = matched[i]
|
|
95
|
+
keyed.append((float(i), PagePair(i, j, moved=i in inverted and old[i].signature == new[j].signature)))
|
|
96
|
+
else:
|
|
97
|
+
keyed.append((float(i), PagePair(i, None)))
|
|
98
|
+
for j in range(len(new)):
|
|
99
|
+
if j not in used:
|
|
100
|
+
before = [reverse[k] for k in range(j - 1, -1, -1) if k in reverse]
|
|
101
|
+
keyed.append(((before[0] + 0.5) if before else -0.5, PagePair(None, j)))
|
|
102
|
+
return [pair for _, pair in sorted(keyed, key=lambda item: item[0])]
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def _sequential(old: Sequence[ExtractedPage], new: Sequence[ExtractedPage], threshold: float, lookahead: int) -> list[PagePair]:
|
|
106
|
+
pairs: list[PagePair] = []
|
|
107
|
+
i = j = 0
|
|
108
|
+
while i < len(old) and j < len(new):
|
|
109
|
+
if _similarity(old[i], new[j]) >= threshold:
|
|
110
|
+
pairs.append(PagePair(i, j)); i += 1; j += 1; continue
|
|
111
|
+
new_anchor = next((k for k in range(j + 1, min(len(new), j + lookahead + 1)) if _similarity(old[i], new[k]) >= threshold), None)
|
|
112
|
+
old_anchor = next((k for k in range(i + 1, min(len(old), i + lookahead + 1)) if _similarity(old[k], new[j]) >= threshold), None)
|
|
113
|
+
if new_anchor is not None and (old_anchor is None or new_anchor - j <= old_anchor - i):
|
|
114
|
+
pairs.extend(PagePair(None, k) for k in range(j, new_anchor)); j = new_anchor
|
|
115
|
+
elif old_anchor is not None:
|
|
116
|
+
pairs.extend(PagePair(k, None) for k in range(i, old_anchor)); i = old_anchor
|
|
117
|
+
else:
|
|
118
|
+
pairs.append(PagePair(i, j)); i += 1; j += 1
|
|
119
|
+
pairs.extend(PagePair(k, None) for k in range(i, len(old)))
|
|
120
|
+
pairs.extend(PagePair(None, k) for k in range(j, len(new)))
|
|
121
|
+
return pairs
|
smart_pdf_compare/api.py
ADDED
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
"""The small public comparison API."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
from dataclasses import dataclass
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import Literal
|
|
7
|
+
|
|
8
|
+
from .alignment import AlignmentStrategy, align_pages
|
|
9
|
+
from .diff import compare_words
|
|
10
|
+
from .exceptions import ConfigurationError, MissingTextLayerError
|
|
11
|
+
from .extraction import ExtractedPage, extract_document, open_pdf
|
|
12
|
+
from .normalization import NormalizationOptions
|
|
13
|
+
from .results import ChangeType, ComparisonResult, ComparisonSummary, Difference, _PageMarkup
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@dataclass(frozen=True)
|
|
17
|
+
class ComparisonOptions:
|
|
18
|
+
"""Options for :func:`compare_pdfs`.
|
|
19
|
+
|
|
20
|
+
``content`` alignment matches equivalent pages wherever they occur;
|
|
21
|
+
``sequential`` resynchronizes within ``lookahead`` pages; ``index`` pairs
|
|
22
|
+
pages by position. The default is a conservative content strategy.
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
alignment: AlignmentStrategy = "content"
|
|
26
|
+
similarity_threshold: float = 0.60
|
|
27
|
+
lookahead: int = 5
|
|
28
|
+
max_differences: int | None = 1_000
|
|
29
|
+
normalization: NormalizationOptions = NormalizationOptions()
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def compare_pdfs(old_pdf: str | Path, new_pdf: str | Path, *, options: ComparisonOptions | None = None) -> ComparisonResult:
|
|
33
|
+
"""Compare two text-based local PDFs and return structured changes.
|
|
34
|
+
|
|
35
|
+
Textual identity means all extracted normalized words occur in the same page
|
|
36
|
+
order. It does not establish visual or pixel-perfect identity.
|
|
37
|
+
"""
|
|
38
|
+
settings = options or ComparisonOptions()
|
|
39
|
+
if settings.max_differences is not None and settings.max_differences < 0:
|
|
40
|
+
raise ConfigurationError("max_differences must be non-negative or None")
|
|
41
|
+
with open_pdf(old_pdf) as old_document, open_pdf(new_pdf) as new_document:
|
|
42
|
+
old_pages = extract_document(old_document, settings.normalization)
|
|
43
|
+
new_pages = extract_document(new_document, settings.normalization)
|
|
44
|
+
if not any(page.words for page in old_pages) or not any(page.words for page in new_pages):
|
|
45
|
+
missing = "original" if not any(page.words for page in old_pages) else "revised"
|
|
46
|
+
if not any(page.words for page in old_pages) and not any(page.words for page in new_pages):
|
|
47
|
+
missing = "original and revised"
|
|
48
|
+
raise MissingTextLayerError(f"No extractable text in {missing} PDF; OCR and visual comparison are not supported.")
|
|
49
|
+
pairs = align_pages(old_pages, new_pages, settings.alignment, settings.similarity_threshold, settings.lookahead)
|
|
50
|
+
entries: list[Difference] = []
|
|
51
|
+
markup: list[_PageMarkup] = []
|
|
52
|
+
added_pages = removed_pages = moved_pages = modified_pages = added_words = removed_words = 0
|
|
53
|
+
for pair in pairs:
|
|
54
|
+
if pair.old is None:
|
|
55
|
+
page = new_pages[pair.new] # type: ignore[index]
|
|
56
|
+
entries.append(Difference(ChangeType.PAGE_ADDED, None, pair.new + 1, "", " ".join(word.text for word in page.words)))
|
|
57
|
+
markup.append(_PageMarkup(None, pair.new, new_rects=[word.rect for word in page.words], changed=True))
|
|
58
|
+
added_pages += 1; added_words += len(page.words); continue
|
|
59
|
+
if pair.new is None:
|
|
60
|
+
page = old_pages[pair.old]
|
|
61
|
+
entries.append(Difference(ChangeType.PAGE_REMOVED, pair.old + 1, None, " ".join(word.text for word in page.words), ""))
|
|
62
|
+
markup.append(_PageMarkup(pair.old, None, old_rects=[word.rect for word in page.words], changed=True))
|
|
63
|
+
removed_pages += 1; removed_words += len(page.words); continue
|
|
64
|
+
old_page, new_page = old_pages[pair.old], new_pages[pair.new]
|
|
65
|
+
word_diff = compare_words(old_page.words, new_page.words, pair.old, pair.new)
|
|
66
|
+
entries.extend(word_diff.differences)
|
|
67
|
+
changed = bool(word_diff.differences)
|
|
68
|
+
markup.append(_PageMarkup(pair.old, pair.new, [old_page.words[index].rect for index in word_diff.old_indexes], [new_page.words[index].rect for index in word_diff.new_indexes], changed))
|
|
69
|
+
added_words += word_diff.added_words; removed_words += word_diff.removed_words
|
|
70
|
+
if changed:
|
|
71
|
+
modified_pages += 1
|
|
72
|
+
elif pair.moved:
|
|
73
|
+
entries.append(Difference(ChangeType.PAGE_MOVED, pair.old + 1, pair.new + 1))
|
|
74
|
+
moved_pages += 1
|
|
75
|
+
total = len(entries)
|
|
76
|
+
truncated = settings.max_differences is not None and total > settings.max_differences
|
|
77
|
+
visible = entries[:settings.max_differences] if truncated else entries
|
|
78
|
+
summary = ComparisonSummary(len(old_pages), len(new_pages), added_pages, removed_pages, moved_pages, modified_pages, added_words, removed_words, total)
|
|
79
|
+
warnings = []
|
|
80
|
+
if any(not page.words for page in old_pages + new_pages):
|
|
81
|
+
warnings.append("One or more blank or textless pages could not be compared word by word.")
|
|
82
|
+
return ComparisonResult(not entries, summary, visible, warnings, truncated, markup)
|
smart_pdf_compare/cli.py
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
"""Command-line interface backed entirely by the public library API."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import argparse
|
|
5
|
+
import json
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Sequence
|
|
8
|
+
|
|
9
|
+
from .api import ComparisonOptions, compare_pdfs
|
|
10
|
+
from .exceptions import PDFComparisonError
|
|
11
|
+
from .highlighting import highlight_differences
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
15
|
+
parser = argparse.ArgumentParser(prog="smart-pdf-compare", description="Compare the extracted text of two PDFs.")
|
|
16
|
+
parser.add_argument("original", help="original PDF")
|
|
17
|
+
parser.add_argument("revised", help="revised PDF")
|
|
18
|
+
parser.add_argument("--alignment", choices=("content", "index", "sequential"), default="content")
|
|
19
|
+
parser.add_argument("--threshold", type=float, default=0.60, help="page similarity threshold (0..1)")
|
|
20
|
+
parser.add_argument("--lookahead", type=int, default=5, help="sequential resynchronization window")
|
|
21
|
+
parser.add_argument("--max-differences", type=int, default=1000)
|
|
22
|
+
parser.add_argument("--json", dest="json_path", type=Path, help="write JSON result")
|
|
23
|
+
parser.add_argument("--output-dir", type=Path, help="write highlighted PDFs here")
|
|
24
|
+
parser.add_argument("--style", choices=("highlight", "box"), default="highlight")
|
|
25
|
+
parser.add_argument("--report", action="store_true", help="also write a side-by-side report")
|
|
26
|
+
return parser
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def main(argv: Sequence[str] | None = None) -> int:
|
|
30
|
+
args = build_parser().parse_args(argv)
|
|
31
|
+
try:
|
|
32
|
+
options = ComparisonOptions(args.alignment, args.threshold, args.lookahead, args.max_differences)
|
|
33
|
+
result = compare_pdfs(args.original, args.revised, options=options)
|
|
34
|
+
print(json.dumps(result.summary.__dict__, indent=2))
|
|
35
|
+
print("Textually identical." if result.is_identical else f"{result.summary.differences} difference(s) detected.")
|
|
36
|
+
if args.json_path:
|
|
37
|
+
args.json_path.parent.mkdir(parents=True, exist_ok=True)
|
|
38
|
+
args.json_path.write_text(json.dumps(result.to_dict(), indent=2), encoding="utf-8")
|
|
39
|
+
if args.output_dir:
|
|
40
|
+
outputs = highlight_differences(args.original, args.revised, args.output_dir, result=result, style=args.style, include_report=args.report)
|
|
41
|
+
print(f"Highlighted PDFs: {outputs.original}, {outputs.revised}")
|
|
42
|
+
if outputs.report:
|
|
43
|
+
print(f"Difference report: {outputs.report}")
|
|
44
|
+
return 0 if result.is_identical else 1
|
|
45
|
+
except (PDFComparisonError, OSError, ValueError) as exc:
|
|
46
|
+
print(f"error: {exc}")
|
|
47
|
+
return 2
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
if __name__ == "__main__":
|
|
51
|
+
raise SystemExit(main())
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
"""Word-level diffs with source-coordinate tracking."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
from dataclasses import dataclass
|
|
5
|
+
from difflib import SequenceMatcher
|
|
6
|
+
from typing import Sequence
|
|
7
|
+
|
|
8
|
+
from .extraction import Word
|
|
9
|
+
from .results import ChangeType, Difference
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
@dataclass(frozen=True)
|
|
13
|
+
class WordDiff:
|
|
14
|
+
differences: list[Difference]
|
|
15
|
+
old_indexes: list[int]
|
|
16
|
+
new_indexes: list[int]
|
|
17
|
+
added_words: int
|
|
18
|
+
removed_words: int
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _text(words: Sequence[Word], start: int, end: int) -> str:
|
|
22
|
+
return " ".join(word.text for word in words[start:end])
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _context(words: Sequence[Word], start: int, end: int) -> str:
|
|
26
|
+
if not words or start >= len(words):
|
|
27
|
+
return ""
|
|
28
|
+
anchor = min(start, len(words) - 1)
|
|
29
|
+
block, line = words[anchor].block, words[anchor].line
|
|
30
|
+
return " ".join(word.text for word in words if word.block == block and word.line == line)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def compare_words(old: Sequence[Word], new: Sequence[Word], old_page: int, new_page: int) -> WordDiff:
|
|
34
|
+
"""Diff normalized word streams and retain ranges for PDF markup."""
|
|
35
|
+
matcher = SequenceMatcher(None, [word.normalized for word in old], [word.normalized for word in new], autojunk=False)
|
|
36
|
+
entries: list[Difference] = []
|
|
37
|
+
old_indexes: list[int] = []
|
|
38
|
+
new_indexes: list[int] = []
|
|
39
|
+
added = removed = 0
|
|
40
|
+
for tag, i1, i2, j1, j2 in matcher.get_opcodes():
|
|
41
|
+
if tag == "equal":
|
|
42
|
+
continue
|
|
43
|
+
if tag == "delete":
|
|
44
|
+
entries.append(Difference(ChangeType.REMOVED, old_page + 1, new_page + 1, _text(old, i1, i2), "", _context(old, i1, i2), ""))
|
|
45
|
+
old_indexes.extend(range(i1, i2)); removed += i2 - i1
|
|
46
|
+
elif tag == "insert":
|
|
47
|
+
entries.append(Difference(ChangeType.ADDED, old_page + 1, new_page + 1, "", _text(new, j1, j2), "", _context(new, j1, j2)))
|
|
48
|
+
new_indexes.extend(range(j1, j2)); added += j2 - j1
|
|
49
|
+
else:
|
|
50
|
+
entries.append(Difference(ChangeType.REPLACED, old_page + 1, new_page + 1, _text(old, i1, i2), _text(new, j1, j2), _context(old, i1, i2), _context(new, j1, j2)))
|
|
51
|
+
old_indexes.extend(range(i1, i2)); new_indexes.extend(range(j1, j2))
|
|
52
|
+
removed += i2 - i1; added += j2 - j1
|
|
53
|
+
return WordDiff(entries, old_indexes, new_indexes, added, removed)
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
"""Exceptions raised by SmartPDFCompare."""
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class PDFComparisonError(Exception):
|
|
5
|
+
"""Base class for comparison and output errors."""
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class InvalidPDFError(PDFComparisonError):
|
|
9
|
+
"""A supplied file is missing, unreadable, or is not a PDF."""
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class MissingTextLayerError(PDFComparisonError):
|
|
13
|
+
"""A document has no extractable text and cannot be safely compared."""
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class ConfigurationError(PDFComparisonError):
|
|
17
|
+
"""An unsupported comparison or rendering option was requested."""
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
"""Extraction of positioned PDF words using PyMuPDF."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
from dataclasses import dataclass
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import Sequence
|
|
7
|
+
|
|
8
|
+
import pymupdf
|
|
9
|
+
|
|
10
|
+
from .exceptions import InvalidPDFError
|
|
11
|
+
from .normalization import NormalizationOptions, normalize_token
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@dataclass(frozen=True)
|
|
15
|
+
class Word:
|
|
16
|
+
text: str
|
|
17
|
+
normalized: str
|
|
18
|
+
rect: tuple[float, float, float, float]
|
|
19
|
+
block: int
|
|
20
|
+
line: int
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@dataclass(frozen=True)
|
|
24
|
+
class ExtractedPage:
|
|
25
|
+
number: int
|
|
26
|
+
words: tuple[Word, ...]
|
|
27
|
+
|
|
28
|
+
@property
|
|
29
|
+
def signature(self) -> str:
|
|
30
|
+
return " ".join(word.normalized for word in self.words if word.normalized)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def open_pdf(path: str | Path) -> pymupdf.Document:
|
|
34
|
+
"""Open a local PDF, converting PyMuPDF errors to a public exception."""
|
|
35
|
+
candidate = Path(path)
|
|
36
|
+
if not candidate.is_file():
|
|
37
|
+
raise InvalidPDFError(f"PDF file does not exist: {candidate}")
|
|
38
|
+
try:
|
|
39
|
+
document = pymupdf.open(candidate)
|
|
40
|
+
except (pymupdf.FileDataError, RuntimeError, OSError) as exc:
|
|
41
|
+
raise InvalidPDFError(f"Cannot read PDF '{candidate}': {exc}") from exc
|
|
42
|
+
if document.page_count == 0:
|
|
43
|
+
document.close()
|
|
44
|
+
raise InvalidPDFError(f"PDF has no pages: {candidate}")
|
|
45
|
+
return document
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def extract_document(document: pymupdf.Document, options: NormalizationOptions) -> list[ExtractedPage]:
|
|
49
|
+
"""Extract reading-order words and their coordinates from every page."""
|
|
50
|
+
pages: list[ExtractedPage] = []
|
|
51
|
+
for number, page in enumerate(document):
|
|
52
|
+
raw_words: Sequence[tuple] = page.get_text("words", sort=True)
|
|
53
|
+
words = tuple(
|
|
54
|
+
Word(str(item[4]), normalize_token(str(item[4]), options), tuple(float(v) for v in item[:4]), int(item[5]), int(item[6]))
|
|
55
|
+
for item in raw_words
|
|
56
|
+
if str(item[4]).strip()
|
|
57
|
+
)
|
|
58
|
+
pages.append(ExtractedPage(number, words))
|
|
59
|
+
return pages
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
"""Safe highlighted-copy and side-by-side PDF generation."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
from dataclasses import dataclass
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import Literal
|
|
7
|
+
|
|
8
|
+
import pymupdf
|
|
9
|
+
|
|
10
|
+
from .api import ComparisonOptions, compare_pdfs
|
|
11
|
+
from .exceptions import ConfigurationError
|
|
12
|
+
from .results import ComparisonResult, _PageMarkup
|
|
13
|
+
|
|
14
|
+
HighlightStyle = Literal["highlight", "box"]
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@dataclass(frozen=True)
|
|
18
|
+
class HighlightOutputs:
|
|
19
|
+
original: Path
|
|
20
|
+
revised: Path
|
|
21
|
+
report: Path | None = None
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _validate_style(style: HighlightStyle) -> None:
|
|
25
|
+
if style not in ("highlight", "box"):
|
|
26
|
+
raise ConfigurationError("style must be 'highlight' or 'box'")
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _annotate(page: pymupdf.Page, rects: list[tuple[float, float, float, float]], color: tuple[float, float, float], style: HighlightStyle) -> None:
|
|
30
|
+
for coords in rects:
|
|
31
|
+
rect = pymupdf.Rect(coords) & page.rect
|
|
32
|
+
if rect.is_empty or rect.is_infinite:
|
|
33
|
+
continue
|
|
34
|
+
annot = page.add_highlight_annot(rect) if style == "highlight" else page.add_rect_annot(rect)
|
|
35
|
+
annot.set_colors(stroke=color)
|
|
36
|
+
if style == "box":
|
|
37
|
+
annot.set_border(width=1.2)
|
|
38
|
+
annot.update(opacity=0.45)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def highlight_differences(old_pdf: str | Path, new_pdf: str | Path, output_dir: str | Path, *, result: ComparisonResult | None = None, options: ComparisonOptions | None = None, style: HighlightStyle = "highlight", include_report: bool = False, removed_color: tuple[float, float, float] = (1, 0.2, 0.2), added_color: tuple[float, float, float] = (0.1, 0.65, 0.2)) -> HighlightOutputs:
|
|
42
|
+
"""Write highlighted copies without modifying either source PDF."""
|
|
43
|
+
_validate_style(style)
|
|
44
|
+
comparison = result or compare_pdfs(old_pdf, new_pdf, options=options)
|
|
45
|
+
target = Path(output_dir); target.mkdir(parents=True, exist_ok=True)
|
|
46
|
+
old_output, new_output = target / "original_highlighted.pdf", target / "revised_highlighted.pdf"
|
|
47
|
+
with pymupdf.open(old_pdf) as old_document, pymupdf.open(new_pdf) as new_document:
|
|
48
|
+
for mark in comparison._markup:
|
|
49
|
+
if mark.old_page is not None:
|
|
50
|
+
_annotate(old_document[mark.old_page], mark.old_rects, removed_color, style)
|
|
51
|
+
if mark.new_page is not None:
|
|
52
|
+
_annotate(new_document[mark.new_page], mark.new_rects, added_color, style)
|
|
53
|
+
old_document.save(old_output, garbage=4, deflate=True)
|
|
54
|
+
new_document.save(new_output, garbage=4, deflate=True)
|
|
55
|
+
# Reopen output now: callers never receive a corrupt path silently.
|
|
56
|
+
with pymupdf.open(old_output), pymupdf.open(new_output):
|
|
57
|
+
pass
|
|
58
|
+
report = create_side_by_side_report(old_pdf, new_pdf, comparison, target / "difference_report.pdf", removed_color, added_color) if include_report and not comparison.is_identical else None
|
|
59
|
+
return HighlightOutputs(old_output, new_output, report)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def create_side_by_side_report(old_pdf: str | Path, new_pdf: str | Path, result: ComparisonResult, output_path: str | Path, removed_color: tuple[float, float, float] = (1, 0.2, 0.2), added_color: tuple[float, float, float] = (0.1, 0.65, 0.2)) -> Path | None:
|
|
63
|
+
"""Create a report containing only changed page pairs; return None if equal."""
|
|
64
|
+
changed = [mark for mark in result._markup if mark.changed]
|
|
65
|
+
if not changed:
|
|
66
|
+
return None
|
|
67
|
+
destination = Path(output_path); destination.parent.mkdir(parents=True, exist_ok=True)
|
|
68
|
+
report = pymupdf.open()
|
|
69
|
+
try:
|
|
70
|
+
with pymupdf.open(old_pdf) as old_document, pymupdf.open(new_pdf) as new_document:
|
|
71
|
+
for mark in changed:
|
|
72
|
+
old_page = old_document[mark.old_page] if mark.old_page is not None else None
|
|
73
|
+
new_page = new_document[mark.new_page] if mark.new_page is not None else None
|
|
74
|
+
old_width = old_page.rect.width if old_page else 300
|
|
75
|
+
new_width = new_page.rect.width if new_page else 300
|
|
76
|
+
height = max(old_page.rect.height if old_page else 200, new_page.rect.height if new_page else 200) + 28
|
|
77
|
+
page = report.new_page(width=old_width + new_width, height=height)
|
|
78
|
+
left = pymupdf.Rect(0, 28, old_width, height)
|
|
79
|
+
right = pymupdf.Rect(old_width, 28, old_width + new_width, height)
|
|
80
|
+
page.insert_text((6, 18), f"Original page {mark.old_page + 1}" if old_page else "Original: no matching page", fontsize=10)
|
|
81
|
+
page.insert_text((old_width + 6, 18), f"Revised page {mark.new_page + 1}" if new_page else "Revised: no matching page", fontsize=10)
|
|
82
|
+
if old_page:
|
|
83
|
+
page.show_pdf_page(left, old_document, mark.old_page)
|
|
84
|
+
for rect in mark.old_rects:
|
|
85
|
+
source = pymupdf.Rect(rect)
|
|
86
|
+
page.draw_rect(
|
|
87
|
+
pymupdf.Rect(source.x0, source.y0 + 28, source.x1, source.y1 + 28),
|
|
88
|
+
color=removed_color,
|
|
89
|
+
width=1.2,
|
|
90
|
+
overlay=True,
|
|
91
|
+
)
|
|
92
|
+
if new_page:
|
|
93
|
+
page.show_pdf_page(right, new_document, mark.new_page)
|
|
94
|
+
for rect in mark.new_rects:
|
|
95
|
+
source = pymupdf.Rect(rect)
|
|
96
|
+
page.draw_rect(
|
|
97
|
+
pymupdf.Rect(source.x0 + old_width, source.y0 + 28, source.x1 + old_width, source.y1 + 28),
|
|
98
|
+
color=added_color,
|
|
99
|
+
width=1.2,
|
|
100
|
+
overlay=True,
|
|
101
|
+
)
|
|
102
|
+
report.save(destination, garbage=4, deflate=True)
|
|
103
|
+
finally:
|
|
104
|
+
report.close()
|
|
105
|
+
with pymupdf.open(destination):
|
|
106
|
+
pass
|
|
107
|
+
return destination
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
"""Conservative normalization applied only to comparison tokens."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import re
|
|
5
|
+
import unicodedata
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
@dataclass(frozen=True)
|
|
10
|
+
class NormalizationOptions:
|
|
11
|
+
"""Policy for comparison tokens.
|
|
12
|
+
|
|
13
|
+
Punctuation is retained by default: removing it can hide meaningful values
|
|
14
|
+
such as ``1.2`` versus ``12``. Original PDF text is never altered.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
ignore_case: bool = True
|
|
18
|
+
ignore_punctuation: bool = False
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
_DASHES = str.maketrans({
|
|
22
|
+
"\u2010": "-", "\u2011": "-", "\u2012": "-", "\u2013": "-", "\u2014": "-", "\u2212": "-",
|
|
23
|
+
})
|
|
24
|
+
_PUNCTUATION = re.compile(r"[^\w\s'-]", re.UNICODE)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def normalize_token(text: str, options: NormalizationOptions = NormalizationOptions()) -> str:
|
|
28
|
+
"""Return a comparison key while preserving numeric signs and contractions."""
|
|
29
|
+
value = unicodedata.normalize("NFKC", text).translate(_DASHES).replace("\u00a0", " ").strip()
|
|
30
|
+
if options.ignore_case:
|
|
31
|
+
value = value.casefold()
|
|
32
|
+
if options.ignore_punctuation:
|
|
33
|
+
value = _PUNCTUATION.sub("", value)
|
|
34
|
+
return value
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def normalized_text(words: list[str], options: NormalizationOptions) -> str:
|
|
38
|
+
"""Create a stable page signature from extracted words."""
|
|
39
|
+
return " ".join(token for word in words if (token := normalize_token(word, options)))
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
"""Public, JSON-friendly comparison result models."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
from dataclasses import asdict, dataclass, field
|
|
5
|
+
from enum import Enum
|
|
6
|
+
from typing import Any, Optional
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class ChangeType(str, Enum):
|
|
10
|
+
ADDED = "added"
|
|
11
|
+
REMOVED = "removed"
|
|
12
|
+
REPLACED = "replaced"
|
|
13
|
+
PAGE_ADDED = "page_added"
|
|
14
|
+
PAGE_REMOVED = "page_removed"
|
|
15
|
+
PAGE_MOVED = "page_moved"
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@dataclass(frozen=True)
|
|
19
|
+
class Difference:
|
|
20
|
+
"""A readable text or page-level difference (page numbers are one-based)."""
|
|
21
|
+
|
|
22
|
+
change_type: ChangeType
|
|
23
|
+
old_page: Optional[int]
|
|
24
|
+
new_page: Optional[int]
|
|
25
|
+
old_text: str = ""
|
|
26
|
+
new_text: str = ""
|
|
27
|
+
old_context: str = ""
|
|
28
|
+
new_context: str = ""
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
@dataclass(frozen=True)
|
|
32
|
+
class ComparisonSummary:
|
|
33
|
+
old_pages: int
|
|
34
|
+
new_pages: int
|
|
35
|
+
added_pages: int
|
|
36
|
+
removed_pages: int
|
|
37
|
+
moved_pages: int
|
|
38
|
+
modified_pages: int
|
|
39
|
+
added_words: int
|
|
40
|
+
removed_words: int
|
|
41
|
+
differences: int
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
@dataclass
|
|
45
|
+
class _PageMarkup:
|
|
46
|
+
"""Internal coordinate data shared by comparison and rendering."""
|
|
47
|
+
|
|
48
|
+
old_page: Optional[int]
|
|
49
|
+
new_page: Optional[int]
|
|
50
|
+
old_rects: list[tuple[float, float, float, float]] = field(default_factory=list)
|
|
51
|
+
new_rects: list[tuple[float, float, float, float]] = field(default_factory=list)
|
|
52
|
+
changed: bool = False
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
@dataclass
|
|
56
|
+
class ComparisonResult:
|
|
57
|
+
"""Structured result of :func:`smart_pdf_compare.compare_pdfs`."""
|
|
58
|
+
|
|
59
|
+
is_identical: bool
|
|
60
|
+
summary: ComparisonSummary
|
|
61
|
+
differences: list[Difference]
|
|
62
|
+
warnings: list[str] = field(default_factory=list)
|
|
63
|
+
truncated: bool = False
|
|
64
|
+
_markup: list[_PageMarkup] = field(default_factory=list, repr=False)
|
|
65
|
+
|
|
66
|
+
def to_dict(self) -> dict[str, Any]:
|
|
67
|
+
"""Return JSON-compatible data without internal geometry."""
|
|
68
|
+
return {
|
|
69
|
+
"is_identical": self.is_identical,
|
|
70
|
+
"summary": asdict(self.summary),
|
|
71
|
+
"differences": [{**asdict(item), "change_type": item.change_type.value} for item in self.differences],
|
|
72
|
+
"warnings": list(self.warnings),
|
|
73
|
+
"truncated": self.truncated,
|
|
74
|
+
}
|
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: smart-pdf-compare
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Text-aware PDF comparison, highlighting, and reports.
|
|
5
|
+
Project-URL: Homepage, https://github.com/SmartPDFCompare/SmartPDFCompare
|
|
6
|
+
Project-URL: Issues, https://github.com/SmartPDFCompare/SmartPDFCompare/issues
|
|
7
|
+
Author-email: Abdelrahman Elsheikh <abdelrahmanelsheikh39@gmail.com>
|
|
8
|
+
Maintainer-email: Abdelrahman Elsheikh <abdelrahmanelsheikh39@gmail.com>
|
|
9
|
+
License: MIT
|
|
10
|
+
License-File: LICENSE
|
|
11
|
+
Keywords: comparison,diff,pdf,pymupdf
|
|
12
|
+
Classifier: Development Status :: 3 - Alpha
|
|
13
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
14
|
+
Classifier: Programming Language :: Python :: 3
|
|
15
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
19
|
+
Requires-Python: >=3.10
|
|
20
|
+
Requires-Dist: pymupdf>=2.0
|
|
21
|
+
Provides-Extra: dev
|
|
22
|
+
Requires-Dist: build>=1.0; extra == 'dev'
|
|
23
|
+
Requires-Dist: pytest>=7.4; extra == 'dev'
|
|
24
|
+
Requires-Dist: twine>=5.0; extra == 'dev'
|
|
25
|
+
Description-Content-Type: text/markdown
|
|
26
|
+
|
|
27
|
+
<div align="center">
|
|
28
|
+
<a href="https://github.com/SmartPDFCompare/SmartPDFCompare">
|
|
29
|
+
<img src="https://raw.githubusercontent.com/SmartPDFCompare/SmartPDFCompare/main/docs/assets/cover.svg" alt="SmartPDFCompare" width="760">
|
|
30
|
+
</a>
|
|
31
|
+
<br>
|
|
32
|
+
<a href="https://github.com/SmartPDFCompare/SmartPDFCompare">
|
|
33
|
+
<img src="https://readme-typing-svg.demolab.com?font=Inter&weight=700&size=25&duration=3200&pause=900&color=2563EB¢er=true&vCenter=true&width=650&lines=Find+the+words+that+changed+in+your+PDFs;Compare.+Highlight.+Review." alt="Animated SmartPDFCompare introduction">
|
|
34
|
+
</a>
|
|
35
|
+
<br><br>
|
|
36
|
+
<a href="https://github.com/SmartPDFCompare/SmartPDFCompare/actions/workflows/tests.yml"><img src="https://github.com/SmartPDFCompare/SmartPDFCompare/actions/workflows/tests.yml/badge.svg" alt="Tests"></a>
|
|
37
|
+
<a href="https://pypi.org/project/smart-pdf-compare/"><img src="https://img.shields.io/pypi/v/smart-pdf-compare.svg?color=2563eb" alt="PyPI"></a>
|
|
38
|
+
<a href="https://pypi.org/project/smart-pdf-compare/"><img src="https://img.shields.io/pypi/pyversions/smart-pdf-compare.svg" alt="Supported Python versions"></a>
|
|
39
|
+
<a href="LICENSE"><img src="https://img.shields.io/badge/license-MIT-2563eb.svg" alt="MIT license"></a>
|
|
40
|
+
<a href="https://pymupdf.readthedocs.io/en/latest/"><img src="https://img.shields.io/badge/powered%20by-PyMuPDF%202-16a34a.svg" alt="Powered by PyMuPDF 2"></a>
|
|
41
|
+
</div>
|
|
42
|
+
|
|
43
|
+
<p align="center">
|
|
44
|
+
<a href="#quick-start"><strong>Quick start</strong></a> ·
|
|
45
|
+
<a href="#features"><strong>Features</strong></a> ·
|
|
46
|
+
<a href="#command-line"><strong>CLI</strong></a> ·
|
|
47
|
+
<a href="docs/api.md"><strong>API</strong></a> ·
|
|
48
|
+
<a href="docs/architecture.md"><strong>Architecture</strong></a> ·
|
|
49
|
+
<a href="#limitations"><strong>Limitations</strong></a>
|
|
50
|
+
</p>
|
|
51
|
+
|
|
52
|
+
**SmartPDFCompare** is a lightweight Python library for comparing the extractable text in two PDF versions. It gives applications structured word and page changes, then uses the **same coordinates** to write readable highlighted PDFs and an optional side-by-side review report.
|
|
53
|
+
|
|
54
|
+
> [!IMPORTANT]
|
|
55
|
+
> SmartPDFCompare uses the current [PyMuPDF 2](https://pymupdf.readthedocs.io/en/latest/) API exclusively: `import pymupdf`.
|
|
56
|
+
|
|
57
|
+
## Features
|
|
58
|
+
|
|
59
|
+
- **[Structured comparison](docs/api.md)** — added, removed, replaced, added-page, removed-page, and moved-page records with JSON serialization.
|
|
60
|
+
- **[Unicode-aware text extraction](https://pymupdf.readthedocs.io/en/latest/textpage.html)** — PyMuPDF word coordinates, NFKC normalization, case folding, ligatures, NBSPs, and dash/minus variants.
|
|
61
|
+
- **[Practical page alignment](docs/architecture.md)** — content matching for inserted/moved pages, bounded sequential look-ahead, and index-by-index comparison.
|
|
62
|
+
- **[Visual review](#highlighted-output)** — native PDF highlights or boxes in safe output copies; optional side-by-side report pages.
|
|
63
|
+
- **[Small CLI](#command-line)** — terminal summary, JSON result, highlighted output, report, alignment policy, and style controls.
|
|
64
|
+
|
|
65
|
+
## Install
|
|
66
|
+
|
|
67
|
+
```bash
|
|
68
|
+
python -m pip install --upgrade smart-pdf-compare
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
The package tracks the current major [PyMuPDF](https://pypi.org/project/PyMuPDF/) release. Create a virtual environment first for a predictable setup:
|
|
72
|
+
|
|
73
|
+
```bash
|
|
74
|
+
python -m venv .venv
|
|
75
|
+
# Windows: .venv\Scripts\activate
|
|
76
|
+
# macOS/Linux: source .venv/bin/activate
|
|
77
|
+
python -m pip install --upgrade smart-pdf-compare
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
## Quick start
|
|
81
|
+
|
|
82
|
+
```python
|
|
83
|
+
from smart_pdf_compare import compare_pdfs, highlight_differences
|
|
84
|
+
|
|
85
|
+
result = compare_pdfs("original.pdf", "revised.pdf")
|
|
86
|
+
|
|
87
|
+
print(result.is_identical)
|
|
88
|
+
print(result.summary)
|
|
89
|
+
for change in result.differences:
|
|
90
|
+
print(f"{change.change_type}: {change.old_text!r} -> {change.new_text!r}")
|
|
91
|
+
|
|
92
|
+
outputs = highlight_differences(
|
|
93
|
+
"original.pdf",
|
|
94
|
+
"revised.pdf",
|
|
95
|
+
output_dir="diff-output",
|
|
96
|
+
result=result,
|
|
97
|
+
include_report=True,
|
|
98
|
+
)
|
|
99
|
+
print(outputs)
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
### Work with JSON
|
|
103
|
+
|
|
104
|
+
```python
|
|
105
|
+
import json
|
|
106
|
+
|
|
107
|
+
with open("comparison.json", "w", encoding="utf-8") as file:
|
|
108
|
+
json.dump(result.to_dict(), file, indent=2)
|
|
109
|
+
```
|
|
110
|
+
|
|
111
|
+
`result.truncated` is `True` when `max_differences` limits returned detail entries. Aggregate counts in `result.summary` always cover the full comparison.
|
|
112
|
+
|
|
113
|
+
## Highlighted output
|
|
114
|
+
|
|
115
|
+
<table>
|
|
116
|
+
<tr>
|
|
117
|
+
<td width="50%"><strong>Original</strong><br>Removed or replaced text receives red markup.</td>
|
|
118
|
+
<td width="50%"><strong>Revised</strong><br>Added or replaced text receives green markup.</td>
|
|
119
|
+
</tr>
|
|
120
|
+
<tr>
|
|
121
|
+
<td><code>original_highlighted.pdf</code></td>
|
|
122
|
+
<td><code>revised_highlighted.pdf</code></td>
|
|
123
|
+
</tr>
|
|
124
|
+
</table>
|
|
125
|
+
|
|
126
|
+
Use real PDF annotations by default, or boxes where highlights are not desired:
|
|
127
|
+
|
|
128
|
+
```python
|
|
129
|
+
from smart_pdf_compare import highlight_differences
|
|
130
|
+
|
|
131
|
+
highlight_differences(
|
|
132
|
+
"original.pdf", "revised.pdf", "diff-output",
|
|
133
|
+
style="box", # "highlight" is the default
|
|
134
|
+
include_report=True, # creates difference_report.pdf
|
|
135
|
+
)
|
|
136
|
+
```
|
|
137
|
+
|
|
138
|
+
Source PDFs are never overwritten. The side-by-side report contains only pages with differences, displays page labels, and leaves a clearly labeled blank side for an inserted or removed page.
|
|
139
|
+
|
|
140
|
+
## Alignment choices
|
|
141
|
+
|
|
142
|
+
```python
|
|
143
|
+
from smart_pdf_compare import ComparisonOptions, compare_pdfs
|
|
144
|
+
|
|
145
|
+
result = compare_pdfs(
|
|
146
|
+
"original.pdf", "revised.pdf",
|
|
147
|
+
options=ComparisonOptions(
|
|
148
|
+
alignment="content", # default
|
|
149
|
+
similarity_threshold=0.60,
|
|
150
|
+
lookahead=5,
|
|
151
|
+
max_differences=1_000,
|
|
152
|
+
),
|
|
153
|
+
)
|
|
154
|
+
```
|
|
155
|
+
|
|
156
|
+
| Strategy | Best for | Trade-off |
|
|
157
|
+
| --- | --- | --- |
|
|
158
|
+
| `content` | Documents with inserted, deleted, or moved pages | Greedy matching; not globally optimal for highly repetitive pages. |
|
|
159
|
+
| `sequential` | Mostly ordered revisions | Resynchronizes only inside `lookahead`. |
|
|
160
|
+
| `index` | Stable page-for-page documents | An insertion can make later pages appear modified. |
|
|
161
|
+
|
|
162
|
+
Normalization is deliberately conservative: it uses NFKC, case folding, ligature compatibility, non-breaking-space cleanup, and dash/minus unification. Punctuation remains significant by default, so decimal values, negative numbers, negation, and contractions do not silently disappear.
|
|
163
|
+
|
|
164
|
+
## Command line
|
|
165
|
+
|
|
166
|
+
```bash
|
|
167
|
+
smart-pdf-compare original.pdf revised.pdf \
|
|
168
|
+
--alignment content \
|
|
169
|
+
--output-dir diff-output \
|
|
170
|
+
--style highlight \
|
|
171
|
+
--report \
|
|
172
|
+
--json diff-output/comparison.json
|
|
173
|
+
```
|
|
174
|
+
|
|
175
|
+
| Exit code | Meaning |
|
|
176
|
+
| --- | --- |
|
|
177
|
+
| `0` | Textually identical under the chosen policy. |
|
|
178
|
+
| `1` | Changes were found. |
|
|
179
|
+
| `2` | Input, extraction, or configuration error. |
|
|
180
|
+
|
|
181
|
+
## Limitations
|
|
182
|
+
|
|
183
|
+
SmartPDFCompare compares the PDF **text layer**, not rendered pixels. It does not perform OCR, visual-diff analysis, or image comparison. If either document has no extractable text, it raises `MissingTextLayerError` instead of falsely claiming that two scanned PDFs are identical. Complex tables and multi-column PDFs can also expose an unreliable reading order from the source PDF.
|
|
184
|
+
|
|
185
|
+
PyMuPDF is available under [AGPL or commercial terms](https://pymupdf.readthedocs.io/en/latest/about.html#license-and-copyright). Confirm that its license is appropriate for your use case.
|
|
186
|
+
|
|
187
|
+
## Development
|
|
188
|
+
|
|
189
|
+
```bash
|
|
190
|
+
python -m pip install -e ".[dev]"
|
|
191
|
+
python -m pytest
|
|
192
|
+
python -m build
|
|
193
|
+
python -m twine check dist/*
|
|
194
|
+
```
|
|
195
|
+
|
|
196
|
+
Read the [contribution guide](CONTRIBUTING.md), [public API](docs/api.md), [architecture notes](docs/architecture.md), and [changelog](CHANGELOG.md). Before publishing, verify availability of `smart-pdf-compare` on [PyPI](https://pypi.org/), upload to TestPyPI, install the artifact in a clean environment, and use [PyPI Trusted Publishing](https://docs.pypi.org/trusted-publishers/) for the production release.
|
|
197
|
+
|
|
198
|
+
## License
|
|
199
|
+
|
|
200
|
+
SmartPDFCompare’s own source code is licensed under the [MIT License](LICENSE). Its PyMuPDF dependency has separate licensing, described above.
|
|
201
|
+
|
|
202
|
+
---
|
|
203
|
+
|
|
204
|
+
<div align="center"><sub>Built for clear, reviewable PDF changes.</sub></div>
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
smart_pdf_compare/__init__.py,sha256=B2IySKWiBJB55BCqkZurBHv-6SJ848AJAy5QO8dHe5o,790
|
|
2
|
+
smart_pdf_compare/__main__.py,sha256=k1ocEWawweo1qCJWNFAAvyxz3tcY13dzvCenHszij30,48
|
|
3
|
+
smart_pdf_compare/alignment.py,sha256=4IHmwCG8NZuqOjRWnfFTFr8-Vs_65feFQZquXdrUWSY,5834
|
|
4
|
+
smart_pdf_compare/api.py,sha256=jNoxi3Bjg-lzmY65tbYxhRqD2NKKTQ8RUCCPQCKha9Q,4743
|
|
5
|
+
smart_pdf_compare/cli.py,sha256=a1ABlEivm7xmIJsEHVwsEqp--JnlRZ0B3Yc8gG0Un9c,2595
|
|
6
|
+
smart_pdf_compare/diff.py,sha256=QNTMS6R6KMrnR98hZzHxUtaQDkm13yRvQOlCl5y9dKg,2258
|
|
7
|
+
smart_pdf_compare/exceptions.py,sha256=adP1IPAcEsBWGoPBgt4VZ75-s1tfUhc8ilWN_xTQGG8,496
|
|
8
|
+
smart_pdf_compare/extraction.py,sha256=sU8BhgxGyjR2NzZG-mqus7YW-ty2qQF94e01t5fZPd8,1908
|
|
9
|
+
smart_pdf_compare/highlighting.py,sha256=ODLO2uksbbTb2HOFkuRBQzjPmOkWASzFrtGISOTrw3M,5703
|
|
10
|
+
smart_pdf_compare/normalization.py,sha256=hh5NaQxAdcw8x9S_gAmopaj2g6vaXjV_SC9mL6sSQqw,1329
|
|
11
|
+
smart_pdf_compare/results.py,sha256=Kg3PoeLxDn8tnuBEBOc7M_hVMJ6O2lipXaBCbInKk3o,2079
|
|
12
|
+
smart_pdf_compare-0.1.0.dist-info/METADATA,sha256=QS356Vho3M_Ppw3tqmPDZgWGH1YMVPuHVFM_3mCERhE,8891
|
|
13
|
+
smart_pdf_compare-0.1.0.dist-info/WHEEL,sha256=2iZrJkDIkeDh0k9SikKMqFuRP-ua6mb4Ir1AsER4BEw,87
|
|
14
|
+
smart_pdf_compare-0.1.0.dist-info/entry_points.txt,sha256=OETfOM0vKdOPKoBEDr7J0hYVmp5olIDdDB8QxBReEkM,65
|
|
15
|
+
smart_pdf_compare-0.1.0.dist-info/licenses/LICENSE,sha256=Hki__28Lzr-1-YjVCQANQbiNidDlqN3nCS-JrmoYYuY,1085
|
|
16
|
+
smart_pdf_compare-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 SmartPDFCompare contributors
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|