Skip to content

ops: say what changed between two versions of a document - #85

Merged
tannevaled merged 1 commit into
mainfrom
feat/compare-two-documents
Nov 9, 2026
Merged

tannevaled merged 1 commit into
mainfrom
feat/compare-two-documents

Conversation

@tannevaled

Copy link
Copy Markdown
Contributor

ops.Compare and pdfops compare: BentoPDF's Compare PDFs, the half a
library can own.

pdfops compare draft1.pdf draft2.pdf
- -> 2    page added
2 -> 3    text removed       zeta
2 -> 3    text added         theta
2 of 3 page(s) changed

Its categories are text, image, header/footer, annotation, formatting and
background. What is here is the pairing and the three that can be read out
of the file — text, pictures, annotations — plus the two its pairing implies
and its own list does not name: a page added and a page removed. Side by side
and overlay are a viewer's job and belong in the workbench; formatting and
background are a rendering comparison, which is a different instrument and a
different cost.

⛔ The pages are paired by what they say, not by their numbers

A page inserted in the middle moves nothing after it: the pages that follow
are still paired with the pages they came from, and the insertion is one
PageAdded. Pairing by number would report that page as an edit of the next,
that one as an edit of the one after, and so on to the end — every page of the
document changed, which is both useless and false.

And a pair must share half its words, or it is one page removed and another
added. Without that floor the alignment pairs the least unalike pages it can
find, so a replaced chapter reads as a rewrite of the one before it.

The diff, and the bug the oracle caught

Myers, E. W., An O(ND) Difference Algorithm and Its Variations, Algorithmica
1 (1986) 251–266, over words, with the words that changed together reported
together: "quick brown fox" → "slow grey mole" is two changes, not six.

⛔⛔ The whole of V is kept each round. The first version stored a row of
2d+1 and filled it as the round went — so a round that finished early left the
diagonals after it as zeroes, and the walk back read those zeroes as real
furthest points. It reported words removed and added that both pages had, and
panicked on an index of −1 where the zeroes put it. The trace is the state, and
half the state is not the state.

The gate that found it is the equality oracle for any diff: the script must
rebuild both sides
. Every other assertion about a diff is a matter of taste —
which of several shortest scripts it chose — but a script whose kept-and-removed
words are not the earlier page is simply wrong. Swept over 400 pseudo-random
pairs with a fixed seed, because a hand-written case exercises one shape of edit
and the walk back has a branch for each.

⛔ Two unreachable tails were removed from the walk back rather than left
untested: undoing every difference lands on the furthest point a path of no
differences reached, which is the common prefix, so x == y by construction and
the loops that emitted "whatever is left of the longer page" could never run.

Bounds, because the input is two files

The alignment is a cell per pair of pages and gives up past four million of
them, pairing the pages where they stand; the diff gives up past two thousand
differences and reports both pages whole rather than a diff of the half that was
cheap. Both are vars, so a test lowers them and watches it happen.

⛔ A picture is told apart by a hash of its samples — two pictures of the
same size are not the same picture, and holding both documents' bitmaps to find
that out would turn a comparison into an exhausted machine.

⛔ A reference that points at itself is a file anybody can write and no
reader can follow. It stops the comparison with the page named, because a page
reported as carrying no annotations because nobody could read them is the
worst answer available.

⛔ compare exits 0 whether or not the documents differ, unlike diff: in
this command an exit of 1 means the work could not be done, and a document that
changed is the work being done.

The word diff is generic and lives here anyway: fleetfind diff answers with
rdiff and vcdiff, which are byte deltas for synchronising files, not Myers. It
moves to a library of its own the day a second caller wants it; one caller is
not a shared concern.

ok github.com/go-pdfkit/ops 14.9s coverage: 100.0% of statements,
covblocks: 0 blocks uncovered.

🤖 Generated with Claude Code

ops.Compare and `pdfops compare`, BentoPDF's Compare PDFs. What a library can
own of it: the PAIRING, and the categories that can be read out of the file --
text, pictures, annotations -- plus the two its pairing implies and its own
list does not name, a page added and a page removed. Side by side and overlay
are a viewer's job; "formatting" and "background" are a rendering comparison,
which is a different instrument and a different cost.

⛔⛔ THE PAGES ARE PAIRED BY WHAT THEY SAY, NOT BY THEIR NUMBERS. A page
inserted in the middle moves nothing after it: the pages that follow are still
paired with the pages they came from, and the insertion is one PageAdded.
Pairing by number would report that page as an edit of the next, that one as an
edit of the one after, and so on to the end -- every page changed, which is
both useless and false.

⛔ And a pair must share HALF ITS WORDS or it is one page removed and another
added. Without the floor the alignment pairs the least unalike pages it can
find, so a replaced chapter reads as a rewrite of the one before it.

Inside a pair: Myers, E. W., "An O(ND) Difference Algorithm and Its
Variations", Algorithmica 1 (1986) 251-266, over words, with the words that
changed together reported together -- "quick brown fox" to "slow grey mole" is
two changes and not six.

⛔⛔ THE WHOLE OF V IS KEPT EACH ROUND. The first version stored a row of 2d+1
and filled it as the round went, so a round that finished early left the
diagonals after it as ZEROES -- and the walk back read those zeroes as real
furthest points. It reported words removed and added that both pages had, and
panicked on an index of -1 where the zeroes put it. The trace is the state, and
half the state is not the state. The gate that found it is the equality oracle
for any diff: the script must REBUILD BOTH SIDES, swept over 400 pseudo-random
pairs with a fixed seed, because a hand-written case exercises one shape of
edit and the walk back has a branch for each.

⛔ Two unreachable tails were REMOVED from the walk back rather than left
untested: undoing every difference lands on the furthest point a path of no
differences reached, which is the common prefix, so x == y by construction and
the loops that emitted "whatever is left of the longer page" could never run.

Bounds, because the input is two files and a file can say anything: the
alignment is a cell per pair of pages and gives up past four million of them,
pairing where the pages stand; the diff gives up past two thousand differences
and reports both pages whole, rather than a diff of the half that was cheap.
Both are vars so a test can lower them and watch it happen.

⛔ A picture is told apart by a HASH OF ITS SAMPLES -- two pictures of the same
size are not the same picture, and holding both documents' bitmaps to find that
out would turn a comparison into an exhausted machine. A reference that points
at itself is a file anybody can write and no reader can follow; it stops the
comparison with the page named, because a page reported as carrying no
annotations because nobody could read them is the worst answer available.

⛔ `compare` exits 0 whether or not the documents differ, unlike `diff`: here an
exit of 1 means the work could not be done, and a document that changed is the
work being done.

The word diff is generic and lives here anyway: `fleetfind diff` answers with
rdiff and vcdiff, which are byte deltas for synchronising files, not Myers. It
moves to a library the day a second caller wants it.

ok github.com/go-pdfkit/ops 14.9s coverage: 100.0% of statements; covblocks: 0
blocks uncovered.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@tannevaled
tannevaled merged commit 7511f04 into main Nov 9, 2026
1 check passed
@tannevaled
tannevaled deleted the feat/compare-two-documents branch November 9, 2026 20:35
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant