PDF में track changes नहीं होता। जब कोई कॉन्ट्रैक्ट "कुछ छोटे बदलावों के साथ" PDF में वापस आता है, तो क्या बदला, इसका इकलौता रिकॉर्ड दोनों फ़ाइलों के बीच का फ़र्क होता है। यह फ़र्क निकालने के तीन सीधे तरीके हैं: रेंडर किए गए पेजों को पिक्सल-दर-पिक्सल compare करना, निकाले गए टेक्स्ट को लाइन-दर-लाइन compare करना, या उसे शब्द-दर-शब्द compare करना। एक ही फ़ाइल-जोड़ी पर ये तीनों बहुत अलग जवाब देते हैं, और इनमें से सिर्फ़ एक तब भी टिकता है जब दूसरी तरफ़ का word processor टेक्स्ट को दोबारा re-flow कर देता है।
नीचे पहले एक असली PDF जोड़ी पर तीनों तरीकों के माप हैं, फिर करीब 120 लाइनों का Python (PyMuPDF और standard library का difflib) जो शब्द के स्तर पर तुलना करता है, हर बदले हुए शब्द के box निकालता है, और बदलावों को comments के रूप में वापस PDF में लिख देता है। osFoundry डेस्कटॉप ऐप का PDF Editor भी इसी तरह काम करता है: उसके Compare Files dialog में लिखा है "Text is compared word by word; the differences are shown on the new file", और इस जोड़ी पर वह वही पाँच बदलाव दिखाता है जो नीचे की script दिखाती है।
टेस्ट जोड़ी
फ़ाइलें एक काल्पनिक एक-पेज design services agreement (Example) हैं, जो PyMuPDF से बनाई गई हैं ताकि text layer असली हो। वापस आए वर्ज़न में पाँच बदलाव हैं:
| क्लॉज़ | भेजा गया | वापस आया |
|---|---|---|
| 3. Payment | within 30 days. Late payments add a 2% fee each month. | within 60 days. |
| 4. Revisions | two rounds of revisions | unlimited rounds of revisions |
| 5. Ownership | the final logo once the fee is paid in full. | the final logo and all drafts once work begins. |
दूसरी तरफ़ के software के टेक्स्ट को दोबारा सेट करने की नकल करने के लिए, एक तीसरी फ़ाइल में वापस आए वर्ज़न के बिल्कुल वही शब्द हैं, लेकिन 11.5 pt और 468 pt कॉलम की जगह 12 pt और 300 pt कॉलम में दोबारा टाइपसेट किए गए। टेक्स्ट वही, line break अलग।
पिक्सल, लाइनें या शब्द
पिक्सल diff दोनों पहले पेजों को 72 dpi पर greyscale में रेंडर करता है और उन rows को गिनता है जिनमें कोई भी पिक्सल 40 level से ज़्यादा अलग है; ऐसी लगातार rows का एक सिलसिला एक "band" है। लाइन diff, newline पर split किए गए page.get_text() पर difflib.SequenceMatcher चलाता है। शब्द diff नीचे वाली script है।
| नई फ़ाइल | पिक्सल diff | लाइन diff | शब्द diff |
|---|---|---|---|
| जैसी वापस आई | 792 में से 30 rows, 3 bands | 3 hunks, 3 लाइनें | 5 बदलाव |
| वही शब्द, दोबारा टाइपसेट | 792 में से 284 rows, 20 bands | 6 hunks, 9 लाइनें | 5 बदलाव |
जैसी वापस आई उस फ़ाइल पर पिक्सल diff तीन हिस्से ढूँढता है और लाइन diff तीन लाइनें। दोनों सही हैं, पर कोई यह नहीं बताता कि बदला क्या: लाइन diff क्लॉज़ 3 को एक बदली हुई लाइन बताता है, और "30" बनाम "60" आपको खुद ढूँढना पड़ता है। दोबारा टाइपसेट की गई कॉपी पर पिक्सल diff लगभग पूरे टेक्स्ट में 20 अलग-अलग bands जला देता है, और लाइन diff दोगुना होकर 9 लाइनें दिखाता है क्योंकि wrapping खिसक गई। शब्द diff दोनों फ़ाइलों पर वही पाँच बदलाव देता है, क्योंकि whitespace और line break उस तक पहुँचते ही नहीं।
कैरेक्टर उनके box के साथ निकालना
बदलाव ढूँढने के लिए सादा get_text() string काफ़ी है, लेकिन उन्हें पेज पर वापस लिखने के लिए coordinates चाहिए। get_text("rawdict") हर कैरेक्टर को उसके bounding box के साथ देता है। नीचे का function इसे पढ़ने के क्रम में एक ही list में बदल देता है:
import difflib, re, sys, unicodedata import pymupdf def char_stream(path): """All characters of a PDF in reading order, NFKC-normalised, each with its page and box.""" chars = [] for pno, page in enumerate(pymupdf.open(path)): for block in page.get_text('rawdict', sort=True)['blocks']: for line in block.get('lines', []): for span in line['spans']: for ch in span['chars']: for c in unicodedata.normalize('NFKC', ch['c']): chars.append((c, pno, pymupdf.Rect(ch['bbox']))) chars.append(('\n', pno, None)) # a line break is only whitespace return chars दो बातें मायने रखती हैं। sort=True blocks को ऊपर से नीचे, बाएँ से दाएँ क्रम में रखता है, जो एक-कॉलम कॉन्ट्रैक्ट के लिए चाहिए (दो-कॉलम layout को अपना अलग क्रम चाहिए)। NFKC एक जैसे दिखने वाले कैरेक्टर को एक कर देता है, इसलिए एक तरफ़ टाइप किया गया full-width "60" और दूसरी तरफ़ का "60" बदलाव के रूप में नहीं दिखते। हर normalise किया गया कैरेक्टर उसी कैरेक्टर का box रखता है जिससे वह बना, इसलिए list पेज के साथ aligned रहती है।
Tokens: शब्द, और जहाँ spaces नहीं हैं वहाँ एक-एक कैरेक्टर
Diff कैरेक्टर पर नहीं, tokens पर चलता है। यहाँ token का मतलब है Latin अक्षरों और अंकों का एक सिलसिला, या कोई भी दूसरा अकेला दिखने वाला कैरेक्टर:
TOKEN = re.compile(r"[0-9A-Za-z][0-9A-Za-z.,%$'’-]*(?<![.,])|\S") def tokens(chars): text = ''.join(c for c, _, _ in chars) return text, [(m.start(), m.end(), m.group()) for m in TOKEN.finditer(text)] अंग्रेज़ी के लिए इससे शब्द मिलते हैं, और आख़िरी full stop अलग हो जाता है ताकि "days." और "days" फिर भी match करें। नंबर पूरे रहते हैं, इसलिए बदलाव "3" → "6" की जगह "30" → "60" के रूप में दिखता है।
"कोई भी दूसरा अकेला कैरेक्टर" वाला हिस्सा उन भाषाओं के लिए है जिनमें शब्दों के बीच space नहीं होता। उसी कॉन्ट्रैक्ट के जापानी वर्ज़न को whitespace पर split करने से, जो PyMuPDF का get_text("words") करता है, हर वाक्य एक "शब्द" बन जाता है, और diff तीन पूरे वाक्यों को बदला हुआ बताता है। हर कैरेक्टर को एक token मानने पर diff उनके अंदर के बदलाव ढूँढ लेता है, लेकिन 8 टुकड़ों में, क्योंकि の जैसे दोनों तरफ़ मौजूद कैरेक्टर एक बदलाव को कई हिस्सों में तोड़ देते हैं ("2" हटाया गया, "まで" → "数", "制限なく" जोड़ा गया)। जिन बदलावों के बीच ज़्यादा से ज़्यादा एक बिना बदला कैरेक्टर हो, उन्हें merge करने पर यह 5 हो जाता है, यानी अंग्रेज़ी फ़ाइल वाले वही पाँच बदलाव। अंग्रेज़ी में यह merge कभी लागू नहीं होता, क्योंकि दो बदलावों के बीच सबसे छोटा gap ("once") चार कैरेक्टर का है।
Diff
PUNCT = set('.。.!?') def hunks(old_toks, new_toks, gap_chars=1): a = [t for _, _, t in old_toks] b = [t for _, _, t in new_toks] out = [] for tag, i1, i2, j1, j2 in difflib.SequenceMatcher(None, a, b, autojunk=False).get_opcodes(): if tag == 'equal': continue if tag in ('delete', 'insert'): seq, lo, hi = (a, i1, i2) if tag == 'delete' else (b, j1, j2) k = 0 while hi + k < len(seq) and seq[lo + k] == seq[hi + k]: k += 1 if seq[hi + k - 1] in PUNCT and seq[lo + k] not in PUNCT: i1, i2, j1, j2 = (i1 + k, i2 + k, j1 + k, j2 + k) break if out: pi1, pi2, pj1, pj2 = out[-1] if sum(len(x) for x in a[pi2:i1]) <= gap_chars and sum(len(x) for x in b[pj2:j1]) <= gap_chars: out[-1] = [pi1, i2, pj1, j2] continue out.append([i1, i2, j1, j2]) return out Sliding वाला loop एक ऐसी अजीब बात संभालता है जो हर diff algorithm में होती है। हटाया गया वाक्य "days" और "4. Revisions" के बीच है, और उसके आसपास का टेक्स्ट एक तरफ़ ". Late payments add a 2% fee each month." है और दूसरी तरफ़ "."। ". Late … month" हटाना और "Late … month." हटाना, दोनों बराबर minimal हैं, और SequenceMatcher ने पहला चुना। किसी pure deletion या insertion को दोहराए गए tokens के साथ तब तक खिसकाने से जब तक वह punctuation पर ख़त्म न हो, वही वर्ज़न मिलता है जिसे कोई इंसान mark करता।
autojunk=False जानबूझकर है; इस पर आगे बात करेंगे। दोनों फ़ाइलों पर चलाने पर पूरी script (contract_diff.py) यह print करती है:
5 changes 1. replaced '30' -> '60' 2. deleted 'Late payments add a 2% fee each month.' -> '' 3. replaced 'two' -> 'unlimited' 4. inserted '' -> 'and all drafts' 5. replaced 'the fee is paid in full' -> 'work begins' और दोबारा टाइपसेट की गई कॉपी के लिए बिल्कुल यही पाँच लाइनें।
बदलावों को comments के रूप में वापस लिखना
हर token अपने कैरेक्टरों के box साथ रखता है, इसलिए नई फ़ाइल पर बदलाव mark करना बस उन boxes पर एक highlight annotation लगाना है, लाइन के हिसाब से merge करके, और पुराना व नया टेक्स्ट उसके comment में। Deletion में highlight करने को कुछ बचता ही नहीं, इसलिए उसे उससे पहले वाले टेक्स्ट के ठीक बाद एक note icon मिलता है:
def mark(new_pdf, changes, out_pdf): doc = pymupdf.open(new_pdf) for ch in changes: by_page = {} for pno, r in ch['boxes']: rects = by_page.setdefault(pno, []) if rects and abs(rects[-1].y0 - r.y0) < 2: # same line: one box, not one per character rects[-1] |= r else: rects.append(pymupdf.Rect(r)) note = {'replaced': f'Replaced: "{ch["old"]}" with "{ch["new"]}"', 'deleted': f'Deleted: "{ch["old"]}"', 'inserted': f'Inserted: "{ch["new"]}"'}[ch['kind']] for pno, rects in by_page.items(): page = doc[pno] if ch['kind'] == 'deleted': annot = page.add_text_annot(rects[-1].tr + (2, -4), note, icon='Comment') else: annot = page.add_highlight_annot(rects) annot.set_info(content=note) annot.set_info(title='compare') annot.update() doc.save(out_pdf) ये standard PDF annotations हैं, इसलिए आम PDF viewers इन्हें दिखाते हैं, और जिसने बदलाव भेजे थे उसे ठीक-ठीक दिखता है कि आप किस बारे में पूछ रहे हैं।
लंबे कॉन्ट्रैक्ट: autojunk और क्लॉज़-दर-क्लॉज़
SequenceMatcher में autojunk नाम का एक heuristic है: जब दूसरे sequence में 200 या उससे ज़्यादा elements हों, तो जो भी element उसका 1% से ज़्यादा हिस्सा बनाता है, उसे matching anchor के तौर पर ignore कर दिया जाता है। यह source code की खाली लाइनों जैसी चीज़ों के लिए बना है। कॉन्ट्रैक्ट के टेक्स्ट में बार-बार आने वाले tokens "the" जैसे शब्द होते हैं या, जापानी में, の और 甲 (कॉन्ट्रैक्ट में एक पक्ष का नाम)। एक-पेज की जापानी फ़ाइल पर (नए वर्ज़न में 238 tokens) इसने 18 अलग-अलग कैरेक्टर किनारे कर दिए, जिनमें 甲, 乙, の और 。 शामिल हैं, और नतीजा 8 hunks से बदलकर 6 हो गया। उस केस में बदतर नहीं, पर अलग, और जिस दस्तावेज़ पर आप साइन करने वाले हैं उसमें यह चीज़ किसी heuristic पर नहीं छोड़नी चाहिए।
इसे बंद करने की एक कीमत है। Worst case में diff लगभग quadratic है, और 40 पेज का कॉन्ट्रैक्ट एक पेज से कहीं लंबा होता है। इसे मापने के लिए जापानी कॉन्ट्रैक्ट को 10 और 100 बार दोहराया गया, सिर्फ़ आख़िरी कॉपी में बदलाव के साथ, M1 Mac पर Python 3.12 के साथ:
| Tokens | autojunk=False | autojunk=True | क्लॉज़-दर-क्लॉज़ |
|---|---|---|---|
| 2,550 | 12 ms | 3 ms | 1 ms |
| 25,500 | 1,142–1,564 ms | 163–209 ms | 7–10 ms |
25,500 tokens वाले आँकड़े तीन runs की range हैं; उसी समय दूसरे काम भी CPU इस्तेमाल कर रहे थे, और ज़्यादा load में किए गए दो पहले के runs में autojunk के बिना 4.2 सेकंड तक लगे।
आख़िरी कॉलम ही हल है। कॉन्ट्रैक्ट पहले से नंबर वाले क्लॉज़ में बँटे होते हैं, तो दोनों टेक्स्ट को क्लॉज़ headings पर split करें, क्लॉज़ों की list पर एक SequenceMatcher चलाकर क्लॉज़ों को आपस में मिलाएँ (एक जैसे क्लॉज़ पूरे item के रूप में सस्ते में match हो जाते हैं), और token diff सिर्फ़ उन जोड़ियों के अंदर चलाएँ जो अलग हैं। इससे autojunk=False बना रहता है और फिर भी यह तीनों में सबसे तेज़ है। measure.py में जापानी टेक्स्ट के लिए split, 第N条 पर एक regex है; अंग्रेज़ी के लिए "3. Payment" जैसी लाइनों पर एक pattern यही काम कर देगा।
यह कहाँ काम नहीं करता
ऊपर सब कुछ text layer पढ़ता है। स्कैन किए गए कॉन्ट्रैक्ट में text layer नहीं होती, उसे पहले OCR चाहिए, और उसके बाद OCR की गलतियाँ बदलाव के रूप में दिखती हैं। गलत reading order में निकाली गई tables से noisy diff आते हैं। और जो क्लॉज़ edit नहीं बल्कि दूसरी जगह move किया गया हो, वह एक deletion और एक insertion के रूप में आता है; move पकड़ने के लिए एक दूसरा pass चाहिए जो हटाए गए और जोड़े गए blocks को आपस में match करे।
Scripts (contract_diff.py, measure.py और टेस्ट फ़ाइलों के generators) uvx --with pymupdf python से चलती हैं। यही compare, comments के रूप में mark करने और report वाले steps के साथ PDF Editor मुफ़्त डेस्कटॉप ऐप का हिस्सा है, जो osFoundry के download पेज पर है, और यह osFoundry के local-first प्लेटफ़ॉर्म के बाकी हिस्सों की तरह आपकी अपनी मशीन पर चलता है।

