forked from AgoraDMV/DeltaTrack
-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathdiff_pdf.py
More file actions
executable file
·608 lines (507 loc) · 25.2 KB
/
Copy pathdiff_pdf.py
File metadata and controls
executable file
·608 lines (507 loc) · 25.2 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
#!/usr/bin/env python3
"""Block-level diff for PDF bill versions, parallel to diff_bill.py for XML.
A bill is grouped into anchor-delimited blocks (TITLE / SEC. / account heading)
on each side, then aligned section-by-section. Lines before the first anchor
form a preamble block. The block is the natural unit of comparison — it mirrors
how `diff_bill.match_nodes` operates on BillTree nodes for XML and avoids the
SequenceMatcher line-level fragmentation that produced over-counted hunks and
missed added/moved sections.
Within matched blocks, the renderer applies word-level diff against the joined
block text. The classifier produces:
- `added` — block present only in v2
- `removed` — block present only in v1
- `moved` — block bodies similar but anchors differ (renumbered SEC.)
- `modified` — paired blocks with different bodies
Reuses amount extraction (`extract_amounts`, `match_amounts`) and text
similarity (`_text_similarity`) from diff_bill.py.
"""
from __future__ import annotations
import argparse
import difflib
import re
import sys
from collections import Counter
from dataclasses import dataclass
from pathlib import Path
from typing import Literal
from diff_bill import _move_candidates, _text_similarity_at_least, extract_amounts, match_amounts
from parsers.pdf_anchors import Anchor, _is_uppercase_heading, extract_anchors
from parsers.pdf_text import Page
from shared.version_stems import label_from_stem
ChangeType = Literal["added", "removed", "modified", "moved"]
PageLineRange = tuple[int, int, int, int] # (start_page, start_line, end_page, end_line)
_AMENDMENT_RE_DETAIL = re.compile(r"\((increased|reduced|decreased) by\s+\$([\d,]+)\)")
# Body similarity needed to call a block-pair "moved" rather than "modified",
# and to reconcile a removed+added pair as moved. Matches diff_bill's threshold.
_MOVE_SIMILARITY_THRESHOLD = 0.6
# Below this similarity, two blocks paired by alignment aren't really a
# modified pair — they're an unrelated removal + addition that happen to
# share an anchor (e.g. v1 SEC. 413 = H-2A waiver, v2 SEC. 413 = Asylum
# Fee renumbered from SEC. 414). Split them so reconcile_moves can pair
# v1 SEC. 414 with v2 SEC. 413 by body similarity. Matches diff_bill's
# _SIMILARITY_THRESHOLD.
_PAIR_BODY_THRESHOLD = 0.4
# Label and breadcrumb for the synthesized front-matter anchor (issue #33) — the
# boilerplate before the first real anchor (calendar number, designator, long
# title, enacting clause).
_FRONT_MATTER_LABEL = "Front Matter"
@dataclass(frozen=True)
class PdfHunk:
change_type: ChangeType
v1_anchor: Anchor | None
v2_anchor: Anchor | None
v1_range: PageLineRange | None
v2_range: PageLineRange | None
v1_text: str
v2_text: str
amount_pairs: tuple[tuple[int | None, int | None], ...] = ()
has_amendment_annotations: bool = False # mirrors FinancialChange field for XML parity
@dataclass(frozen=True)
class PdfDiff:
hunks: tuple[PdfHunk, ...]
v1_anchors: tuple[Anchor, ...] = ()
v2_anchors: tuple[Anchor, ...] = ()
@property
def summary(self) -> dict[str, int]:
return dict(Counter(h.change_type for h in self.hunks))
# ---- Internal helpers --------------------------------------------------------
@dataclass(frozen=True)
class _IndexedLine:
text: str
page_number: int
line_number: int | None # None when source PDF didn't number this line
@dataclass(frozen=True)
class _Block:
"""An anchor-delimited group of lines.
`anchor` is None only for the preamble (lines before the first anchor on
either side, e.g. cover page, enacting clause). The `indexed_lines` start
with the anchor's own line and run until the next anchor.
"""
anchor: Anchor | None
indexed_lines: tuple[_IndexedLine, ...]
@property
def text(self) -> str:
return "\n".join(ln.text for ln in self.indexed_lines)
@property
def page_range(self) -> PageLineRange | None:
if not self.indexed_lines:
return None
first, last = self.indexed_lines[0], self.indexed_lines[-1]
return (
first.page_number,
first.line_number if first.line_number is not None else -1,
last.page_number,
last.line_number if last.line_number is not None else -1,
)
def _rejoin_cross_page_hyphens(lines: list[_IndexedLine]) -> list[_IndexedLine]:
"""Stitch a soft-hyphenated word split across a page boundary back together.
Per-page cleanup (`pdf_text._merge_print_lines`) rejoins soft hyphens within
a page, but a word broken across a page seam survives as a trailing `WORD-`
on one page's last line and its lowercase continuation on the next page's
first line. Merge the continuation into the trailing-hyphen line, dropping
its now-empty record. The merged line keeps the first line's page/line
coordinates; the continuation was only a word fragment.
The guard mirrors `_merge_print_lines` (alphanumeric before the hyphen,
lowercase continuation) so real compounds like `Child-Rescue`, which
continue uppercase, are preserved. Anchors never start lowercase, so a
TITLE/SEC heading opening a page is never absorbed.
"""
merged: list[_IndexedLine] = []
i = 0
while i < len(lines):
current = lines[i]
nxt = i + 1
while (
nxt < len(lines)
and current.text.endswith("-")
and len(current.text) >= 2
and current.text[-2].isalnum()
and lines[nxt].text[:1].islower()
):
current = _IndexedLine(current.text[:-1] + lines[nxt].text, current.page_number, current.line_number)
nxt += 1
merged.append(current)
i = nxt
return merged
def _flatten(pages: list[Page]) -> list[_IndexedLine]:
"""Flatten pages into a single ordered list of (text, page, line) records.
Cross-page soft hyphens are rejoined on the flattened stream so the diff
compares whole words; otherwise a word split at a page seam in one version
(`includ-`/`ing`) but whole in the other (`including`) reads as a spurious
change (issue #31).
"""
flat: list[_IndexedLine] = []
for page in pages:
for line in page.lines:
flat.append(_IndexedLine(line.text, page.page_number, line.line_number))
return _rejoin_cross_page_hyphens(flat)
def _front_matter_anchor(lines: tuple[_IndexedLine, ...]) -> Anchor:
"""Synthesize a top-level anchor for the bill's front matter — the preamble
preceding the first real anchor (Union Calendar number, Congress/session,
`A BILL`, the enacting clause).
Every GPO bill carries this boilerplate before TITLE I, so without an anchor
its hunks resolved nothing on either side and rendered as a degraded "anchor
unresolved" card — making every PDF report open on what looks like a parser
failure (issue #33). A synthesized "Front Matter" anchor gives those hunks a
clean, navigable breadcrumb instead. Coordinates are the block's first line
(line number coerced to 1 when that line is unnumbered, e.g. a cover page).
"""
first = lines[0]
return Anchor(first.page_number, first.line_number or 1, "preamble", _FRONT_MATTER_LABEL)
def _with_front_matter(blocks: list[_Block], anchors: list[Anchor]) -> list[Anchor]:
"""Prepend the front-matter anchor to `anchors` when the first block carries
one (issue #33). `_group_into_blocks` already synthesized it for hunk
attribution; lifting that same object into the anchor list keeps the section
TOC complete without re-deriving it. Returns `anchors` unchanged when there
is no front matter (no real anchors, or the document opens on one)."""
if blocks and blocks[0].anchor is not None and blocks[0].anchor.kind == "preamble":
return [blocks[0].anchor, *anchors]
return list(anchors)
def _is_strippable_heading_line(text: str) -> bool:
"""A leading/trailing line safe to drop from a block body as heading chrome
(issue #56). True for a blank line (so the body lands on a numbered prose
line, keeping the full-text span resolvable) or a pure uppercase heading
that carries no dollar amount. Uppercase-ness — not glyph size — identifies
account/agency headings; the amount guard keeps an all-caps "TOTAL, ..., $X"
recap line so a money change is never silently dropped. _is_uppercase_heading
rejects SEC./TITLE lines, so their inline-body anchor lines survive (the bare
title line is dropped separately, by anchor kind)."""
if not text.strip():
return True
return _is_uppercase_heading(text) and not extract_amounts(text)
def _strip_heading_lines(lines: tuple[_IndexedLine, ...], anchor: Anchor | None) -> tuple[_IndexedLine, ...]:
"""Trim heading chrome that bleeds into an anchor-delimited block body (#56).
Drops leading and trailing runs of blank / uppercase-heading lines: the
block's own account heading (start-bleed) and the next section's uncaptured
heading swept into the tail (end-bleed). A title block opens with its own
bare "TITLE I—..." line, which carries no inline body (unlike a SEC. line)
but is rejected by _is_uppercase_heading, so it is dropped here by kind. If
trimming would empty the block, the lines are returned unchanged so a
heading-only block keeps its page/line coordinates.
The (anchor, pos) pairing that guards issue #16 is resolved against the full
indexed_lines before this runs, so trimming the slice never disturbs it;
page_range then bounds the prose body instead of the heading.
"""
start, end = 0, len(lines)
# A title block's own bare heading line carries no inline body — drop it so
# the body doesn't repeat the breadcrumb. Only the leading line, only for a
# title anchor: SEC. headings carry inline body and must be preserved.
if anchor is not None and anchor.kind == "title" and start < end:
start += 1
while start < end and _is_strippable_heading_line(lines[start].text):
start += 1
while end > start and _is_strippable_heading_line(lines[end - 1].text):
end -= 1
if start >= end:
return lines
return lines[start:end]
def _group_into_blocks(indexed_lines: list[_IndexedLine], anchors: list[Anchor]) -> list[_Block]:
"""Group lines into anchor-delimited blocks.
Lines preceding the first real anchor become a front-matter block, tagged
with a synthesized "preamble" anchor so the bill's boilerplate resolves to a
"Front Matter" breadcrumb rather than degrading (issue #33). A document with
no real anchors at all stays a single anchor=None block — it's genuinely
unstructured, not front matter. Each subsequent anchor starts a new block
that runs until the next anchor.
"""
if not indexed_lines:
return []
# Build a (page, line) → first-occurrence-index map so anchor lookup is O(1).
# `line.index(...)` would be O(n) per anchor, making this loop O(anchors × lines).
line_index: dict[tuple[int, int | None], int] = {}
for i, ln in enumerate(indexed_lines):
key = (ln.page_number, ln.line_number)
if key not in line_index:
line_index[key] = i
# Keep each surviving anchor paired with its resolved position. Collecting
# positions alone and indexing `anchors[j]` would misalign once any anchor
# is skipped, labeling every later block with the wrong heading (issue #16).
anchor_at: list[tuple[Anchor, int]] = []
for a in anchors:
pos = line_index.get((a.page_number, a.line_number))
if pos is None:
# Anchor's line was rejoined into a previous line during cleanup;
# skip — its text is already part of an earlier line and will end
# up in the previous block.
continue
anchor_at.append((a, pos))
blocks: list[_Block] = []
if not anchor_at:
# No anchors at all — entire document is preamble.
return [_Block(None, tuple(indexed_lines))]
first_pos = anchor_at[0][1]
if first_pos > 0:
preamble_lines = tuple(indexed_lines[:first_pos])
blocks.append(_Block(_front_matter_anchor(preamble_lines), preamble_lines))
for j, (anchor, pos) in enumerate(anchor_at):
end = anchor_at[j + 1][1] if j + 1 < len(anchor_at) else len(indexed_lines)
blocks.append(_Block(anchor, _strip_heading_lines(tuple(indexed_lines[pos:end]), anchor)))
# Drop empty blocks. A block is empty only when its slice `[pos:end]` is empty,
# i.e. the next anchor resolves to `end <= pos` — in practice the SEC-inline run-in
# subsection collision (DeltaTrack#96), where the section anchor and subsection share
# a (page, line) so the section gets `indexed_lines[pos:pos]` and the subsection
# (later in doc order) owns the text; a non-monotonic within-page line ordering could
# in principle also yield `end < pos`, and dropping that is likewise correct (an empty
# slice carries no diff content either way). `_strip_heading_lines` never empties a
# block and rejoined-line anchors are skipped above, so the filter only removes these
# zero-line artifacts — surgically killing the phantom empty-text hunk a
# renumbered/removed colliding section would emit (contradictory move citation), while
# the section anchor stays in the anchor lists for TOC/breadcrumbs. The renumber then
# surfaces as a text diff inside the subsection's hunk instead.
return [b for b in blocks if b.indexed_lines]
def _block_key(block: _Block) -> str:
"""Alignment key for SequenceMatcher.
Combines anchor text (e.g. "SEC. 101", "OPERATIONS AND SUPPORT") with the
first ~80 chars of the block's body to disambiguate non-unique account
headings while staying stable to amendment annotations appearing later
in the body.
"""
anchor_text = block.anchor.text if block.anchor else "(preamble)"
body_preview = block.text[:80].strip()
return f"{anchor_text}::{body_preview}"
def _extract_amount_pairs(v1_text: str, v2_text: str) -> tuple[tuple[int | None, int | None], ...]:
"""All amount pairs from match_amounts as a tuple, including unchanged pairs.
The full pair list (changed / added / removed / unchanged) is carried on the
hunk. The canonical producer categorizes it into `amount_entries` — the export's
only money field since v2.0 (#274) — dropping unchanged (`old == new`) pairs
there (`formatters/canonical.py:_amount_entries`). Preserving the unchanged
pairs here keeps this function a lossless view of match_amounts.
"""
return tuple(match_amounts(v1_text, v2_text))
def _has_amendment_annotations(v1_text: str, v2_text: str) -> bool:
"""True if either side carries a floor amendment annotation.
Mirrors `FinancialChange.has_amendment_annotations` in diff_bill.py.
"""
return bool(_AMENDMENT_RE_DETAIL.search(v1_text) or _AMENDMENT_RE_DETAIL.search(v2_text))
def _hunk_for_paired_blocks(v1_block: _Block, v2_block: _Block, similarity: float) -> PdfHunk:
"""Emit a hunk for two blocks paired by alignment.
Classifies as `moved` when anchors differ and bodies are highly similar
(renumbered SEC.), else `modified`. Caller has already confirmed v1 and v2
block texts differ AND has computed `similarity` (the
`_text_similarity` between the two block texts) to decide split-vs-pair.
"""
v1_text = v1_block.text
v2_text = v2_block.text
v1_anchor = v1_block.anchor
v2_anchor = v2_block.anchor
if v1_anchor and v2_anchor and v1_anchor.text != v2_anchor.text and similarity >= _MOVE_SIMILARITY_THRESHOLD:
change_type: ChangeType = "moved"
else:
change_type = "modified"
return PdfHunk(
change_type=change_type,
v1_anchor=v1_anchor,
v2_anchor=v2_anchor,
v1_range=v1_block.page_range,
v2_range=v2_block.page_range,
v1_text=v1_text,
v2_text=v2_text,
amount_pairs=_extract_amount_pairs(v1_text, v2_text),
has_amendment_annotations=_has_amendment_annotations(v1_text, v2_text),
)
def _hunk_for_added(v2_block: _Block) -> PdfHunk:
# A whole account added on the PDF side carries real dollars; match against the
# empty other side so they surface as `added` amount entries (#86). Previously
# hardcoded to (), leaving PDF added/removed hunks silent on the money axis.
return PdfHunk(
change_type="added",
v1_anchor=None,
v2_anchor=v2_block.anchor,
v1_range=None,
v2_range=v2_block.page_range,
v1_text="",
v2_text=v2_block.text,
amount_pairs=tuple(match_amounts("", v2_block.text)),
has_amendment_annotations=_has_amendment_annotations("", v2_block.text),
)
def _hunk_for_removed(v1_block: _Block) -> PdfHunk:
# Mirror of _hunk_for_added: a whole account removed surfaces its dollars as
# `removed` entries (#86).
return PdfHunk(
change_type="removed",
v1_anchor=v1_block.anchor,
v2_anchor=None,
v1_range=v1_block.page_range,
v2_range=None,
v1_text=v1_block.text,
v2_text="",
amount_pairs=tuple(match_amounts(v1_block.text, "")),
has_amendment_annotations=_has_amendment_annotations(v1_block.text, ""),
)
def _reconcile_moves(hunks: list[PdfHunk], threshold: float = _MOVE_SIMILARITY_THRESHOLD) -> list[PdfHunk]:
"""Pair `removed`+`added` hunks whose bodies are highly similar into `moved` hunks.
Catches renumbered sections (e.g. SEC. 414 in v1 → SEC. 413 in v2) when block
keys diverge enough that SequenceMatcher emitted them as separate insert
and delete rather than aligning them. Mirrors `diff_bill.reconcile_moves`.
"""
removed_idx = [i for i, h in enumerate(hunks) if h.change_type == "removed"]
added_idx = [i for i, h in enumerate(hunks) if h.change_type == "added"]
if not removed_idx or not added_idx:
return hunks
# Gated + matcher-reused pairwise similarity; _move_candidates returns local
# indices, so map them back to absolute hunk indices. Identical result to the
# naive removed×added loop (same tuples; the sort below is what orders them).
local = _move_candidates(
[hunks[ri].v1_text for ri in removed_idx],
[hunks[ai].v2_text for ai in added_idx],
threshold,
)
candidates = [(sim, removed_idx[r], added_idx[a]) for sim, r, a in local]
if not candidates:
return hunks
candidates.sort(reverse=True)
claimed_r: set[int] = set()
claimed_a: set[int] = set()
moved_pairs: list[tuple[int, int]] = []
for _, ri, ai in candidates:
if ri in claimed_r or ai in claimed_a:
continue
claimed_r.add(ri)
claimed_a.add(ai)
moved_pairs.append((ri, ai))
consumed = claimed_r | claimed_a
moved_lookup = {ri: ai for ri, ai in moved_pairs}
result: list[PdfHunk] = []
for i, h in enumerate(hunks):
if i in moved_lookup:
removed = h
added = hunks[moved_lookup[i]]
result.append(
PdfHunk(
change_type="moved",
v1_anchor=removed.v1_anchor,
v2_anchor=added.v2_anchor,
v1_range=removed.v1_range,
v2_range=added.v2_range,
v1_text=removed.v1_text,
v2_text=added.v2_text,
amount_pairs=_extract_amount_pairs(removed.v1_text, added.v2_text),
has_amendment_annotations=_has_amendment_annotations(removed.v1_text, added.v2_text),
)
)
elif i in consumed:
continue
else:
result.append(h)
return result
# ---- Public entry point ------------------------------------------------------
def _emit_pair(v1_b: _Block, v2_b: _Block, sink: list[PdfHunk]) -> None:
"""Emit a paired-block hunk into `sink`, or split into removed+added.
When v1/v2 block texts are very dissimilar, treat the pair as an unrelated
removal and addition that happen to share alignment — emit two hunks so
`_reconcile_moves` can later pair them with the right counterparts.
"""
if v1_b.text == v2_b.text:
# Stripping headings from the body (#56) can equalize two blocks that
# differ only by a renamed anchor (an account renamed with otherwise
# identical prose). With the heading gone from the body, that rename
# would vanish; surface it as a moved/renamed hunk instead.
if v1_b.anchor and v2_b.anchor and v1_b.anchor.text != v2_b.anchor.text:
sink.append(_hunk_for_paired_blocks(v1_b, v2_b, similarity=1.0))
return
# Gate at the lower (split) threshold: when sim >= 0.4 the exact ratio is
# needed downstream for the 0.6 moved/modified split in _hunk_for_paired_blocks.
sim = _text_similarity_at_least(v1_b.text, v2_b.text, _PAIR_BODY_THRESHOLD)
if sim < _PAIR_BODY_THRESHOLD:
sink.append(_hunk_for_removed(v1_b))
sink.append(_hunk_for_added(v2_b))
else:
sink.append(_hunk_for_paired_blocks(v1_b, v2_b, similarity=sim))
def diff_pdfs(v1_pages: list[Page], v2_pages: list[Page]) -> PdfDiff:
"""Block-level diff of two extracted PDF page sequences."""
v1_indexed = _flatten(v1_pages)
v2_indexed = _flatten(v2_pages)
v1_anchors = extract_anchors(v1_pages)
v2_anchors = extract_anchors(v2_pages)
v1_blocks = _group_into_blocks(v1_indexed, v1_anchors)
v2_blocks = _group_into_blocks(v2_indexed, v2_anchors)
# Surface the front-matter anchor synthesized per-block (issue #33) into the
# anchor lists so the full-bill section TOC links to it like any other anchor.
v1_anchors = _with_front_matter(v1_blocks, v1_anchors)
v2_anchors = _with_front_matter(v2_blocks, v2_anchors)
matcher = difflib.SequenceMatcher(
a=[_block_key(b) for b in v1_blocks],
b=[_block_key(b) for b in v2_blocks],
autojunk=False,
)
hunks: list[PdfHunk] = []
for op, i1, i2, j1, j2 in matcher.get_opcodes():
if op == "equal":
# Block keys match. Bodies might still differ (e.g. amendment
# annotations appearing past the 80-char preview).
for v1_b, v2_b in zip(v1_blocks[i1:i2], v2_blocks[j1:j2]):
_emit_pair(v1_b, v2_b, hunks)
elif op == "delete":
for v1_b in v1_blocks[i1:i2]:
hunks.append(_hunk_for_removed(v1_b))
elif op == "insert":
for v2_b in v2_blocks[j1:j2]:
hunks.append(_hunk_for_added(v2_b))
else: # replace
v1_slice = v1_blocks[i1:i2]
v2_slice = v2_blocks[j1:j2]
# Pair positionally; surplus on either side becomes added/removed.
for k in range(max(len(v1_slice), len(v2_slice))):
v1_b = v1_slice[k] if k < len(v1_slice) else None
v2_b = v2_slice[k] if k < len(v2_slice) else None
if v1_b is not None and v2_b is not None:
_emit_pair(v1_b, v2_b, hunks)
elif v1_b is not None:
hunks.append(_hunk_for_removed(v1_b))
else:
assert v2_b is not None
hunks.append(_hunk_for_added(v2_b))
return PdfDiff(
hunks=tuple(_reconcile_moves(hunks)),
v1_anchors=tuple(v1_anchors),
v2_anchors=tuple(v2_anchors),
)
# ---- CLI ---------------------------------------------------------------------
def render_pdf_diff_html(
v1_pdf: Path,
v2_pdf: Path,
*,
v1_label: str | None = None,
v2_label: str | None = None,
) -> str:
"""Render an HTML diff page for two PDF paths.
Delegates to ``server.pdf_compare.compare_pdfs_html`` — the same pipeline
the web app uses — so the report carries the full-bill text view, in-page
search, section TOC, and embedded export. Title and Congress are derived
from the PDF front matter; labels default to the (de-prefixed) filename
stems. Imported lazily to avoid a circular import (pdf_compare imports
diff_pdf).
"""
from server.pdf_compare import compare_pdfs_html
return compare_pdfs_html(
v1_pdf.read_bytes(),
v2_pdf.read_bytes(),
start_label=v1_label if v1_label is not None else label_from_stem(v1_pdf.stem),
end_label=v2_label if v2_label is not None else label_from_stem(v2_pdf.stem),
)
def build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(
description="Diff two PDF bill versions and produce an HTML diff page "
"(full-bill view, search, and export included).",
)
parser.add_argument("v1_pdf", type=Path, help="Path to the older PDF")
parser.add_argument("v2_pdf", type=Path, help="Path to the newer PDF")
parser.add_argument("-o", "--output", type=Path, help="Output HTML file (default: stdout)")
parser.add_argument("--v1-label", help="Label for the older version (default: filename stem)")
parser.add_argument("--v2-label", help="Label for the newer version (default: filename stem)")
return parser
def main(argv: list[str] | None = None) -> None:
args = build_parser().parse_args(argv)
html = render_pdf_diff_html(
args.v1_pdf,
args.v2_pdf,
v1_label=args.v1_label,
v2_label=args.v2_label,
)
if args.output:
args.output.write_text(html)
print(f"Wrote {args.output}", file=sys.stderr)
else:
print(html)
if __name__ == "__main__":
main()