DEV Community

A text diff that reads .docx and .pdf in the browser, and the trap that makes every PDF look 100% changed

I maintain a small online text diff that
people mostly use for contracts, price lists and CVs, not code. Its search traffic
told me something I had not designed for: the queries were "compare two Word
documents"
, "compare 2 text files online", "compare two PDF documents".
Nobody arrives holding two strings. They arrive holding two files.

This post is the handful of decisions that turned a textbook LCS exercise into
something that survives real documents. None of it is clever; all of it was
learned by watching the tool be wrong.

Two levels, not one

A diff over whole lines is what git diff shows you and it is the right first
level: it keeps paragraphs aligned, and a moved clause shows up as one removal
and one addition instead of a cloud of red and green words.

But a contract edit is usually a single number inside a long line. A line-only
diff paints the whole line red and the whole replacement green, and the reader
still has to find the number by eye — which is the exact task the tool exists
to remove.

So the second level runs only on lines the first level paired as "modified":

// Level 1: LCS over lines -> ops of keep / remove / add
const ops = computeLCS(leftLines, rightLines);

// Adjacent removes and adds are paired positionally as "modified" lines,
// and each pair gets a Level 2 diff over its words.
const pairs = Math.min(removes.length, adds.length);
for (let p = 0; p < pairs; p++) {
  const [lw, rw] = wordDiff(leftLines[removes[p]], rightLines[adds[p]]);
  result.push({ type: 'modified', leftWords: lw, rightWords: rw, ... });
}
Enter fullscreen mode Exit fullscreen mode

The pairing rule is deliberately dumb: the k-th removed line in a block is
matched with the k-th added line. A smarter pairing (best similarity across
the block) sounds better and is worse in practice on prose, because a block of
three rewritten sentences is almost always three edits in order, and similarity
scoring will happily pair sentence 1 with sentence 3 because they share the
word "the".

Word tokenising keeps whitespace as its own tokens:

const tokens = text.match(/(\S+|\s+)/g) ?? [];
Enter fullscreen mode Exit fullscreen mode

That looks pointless until you render: if you drop the spaces, you cannot
reconstruct the line, and a diff you cannot reconstruct is a diff you cannot
copy out.

LCS, not Myers, and the number that actually matters

Every diff article eventually says "use Myers' O(ND) algorithm". For a tool that
runs on someone's phone against two 40-page contracts, I did not, and here is
the honest reasoning.

The plain dynamic-programming LCS is O(m·n) in time and memory. Two 2,000-line
documents are 4 million cells — fine. Two 5,000-line exports are 25 million
cells of a number[][], which on a mid-range Android phone is a tab crash, not
a slow result. Myers fixes the typical case but its worst case is the same
shape, and the failure mode is identical: the browser dies with no message.

So the thing that made the tool reliable was not the algorithm. It was a guard:

if (m * n > 5_000_000) {
  return simpleDiff(left, right);   // positional, O(max(m, n)), always finishes
}
Enter fullscreen mode Exit fullscreen mode

Above 5M cells it degrades to a line-by-line positional compare. That produces
a worse diff for large inputs with insertions near the top (everything after
the insertion is offset by one and reads as changed). But it produces one, in
milliseconds, and the visitor can narrow the selection and try again. A diff
tool that sometimes shows nothing is worth less than one that is sometimes
coarse. The same cap exists inside the word level at 500K cells, where the
fallback is "whole line removed, whole line added".

If your inputs are code and your users are on laptops, Myers is the right call.
If your inputs are documents and your users are on phones, put the guard in
first and pick the algorithm second.

Reading the files without a server

.docx and .xlsx are ZIP containers. .pdf is something every modern browser
already knows how to render. That means extraction can happen entirely
client-side, and for this particular tool that is not a nice-to-have: the
documents people most often need to compare are contracts, payslips, offers and
medical reports. A comparison that requires uploading both versions is not one
they can honestly run on those files.

Three things that bit me, in order of how much support mail they generated:

Scanned PDFs have no text. A scan is a picture of a page. The extraction
"succeeds" and returns an empty string, and a naive tool then reports the
document as identical to an empty box. Detect the empty result and say the word
OCR to the user, or they will assume the tool is broken.

Not everything is UTF-8. A .txt exported from an older Windows app arrives
in Windows-1252, and decoding it as UTF-8 turns every é and into a
replacement character — which then shows up as a difference between two files
that are byte-for-byte the same. Fall back to Windows-1252 when UTF-8 decoding
produces replacement characters, and tell the user you did, so they check the
accented characters themselves.

Size. I cap at 8 MB per side. Not because the ZIP or PDF parsers cannot
handle more, but because the LCS guard above is line-count based, and an
enormous file is the one input that will blow past every guard at once.

The trap: PDF text is wrapped

This is the one I would put at the top of the page if I could only keep one
sentence.

Text extracted from a PDF carries a line break wherever the layout broke the
line — mid-sentence, after every 80-odd characters. Text from the .docx the PDF
was exported from carries a line break only at paragraph ends. Compare the two,
and a line-based diff reports every paragraph as changed, because no line
on the left is equal to any line on the right, even though not a word differs.

You can partially rescue it by re-joining lines that do not end in sentence
punctuation before you compare — a preprocessing step, and one I have not
automated, because it guesses wrong on lists, addresses and table cells. The
"ignore whitespace" option does something narrower and safer: it collapses
runs of spaces and tabs within a line, which fixes the double-space and
tab-versus-spaces noise a PDF exporter adds, but cannot undo a line break.
There is no fully general fix, because a PDF also breaks lines at the ends of
table cells and around headers and footers, and reflowing those back into
prose is a layout-analysis problem, not a string problem. The honest answer
on the page is: compare paragraph by paragraph rather than pasting the whole
document at once
, and the output becomes far more useful.

What I would keep if I rewrote it

  • Two levels: lines first, words only inside modified pairs.
  • Positional pairing of removes with adds. Boring and right for prose.
  • A cell-count guard before any algorithm choice.
  • Client-side extraction, with explicit messages for the empty-scan and wrong-encoding cases.
  • One sentence about PDF line wrapping, above the fold.

The running tool is at
confileo.com/tools/text-diff if you
want to see how the modified-line rendering reads with real documents. It does
not store the text, which is the whole point when the documents are contracts.

Top comments (1)

Some comments may only be visible to logged-in visitors. Sign in to view all comments.