DEV Community

Kevin Carne
Kevin Carne

Posted on

Diffing two PDFs in the browser: PDFs don't have lines, so you have to rebuild them

``A common request for a PDF tool: "they sent the contract back with a few small changes, what actually changed?" I wanted to answer that without uploading anyone's contract to a server, so the comparison runs entirely in the browser tab.

The diff part is easy: jsdiff does it in one call. The hard part is getting text you can diff out of a PDF, because a PDF has no idea what a "line" is.

What a PDF actually stores

A PDF page isn't a document with paragraphs. It's a list of drawing instructions: "move to (72, 700), draw these glyphs, move to (210, 700), draw these glyphs". Text can be drawn in any order, split anywhere, and positioned anywhere.

pdf.js gives you that as getTextContent(): a flat list of text items, each with a string and a transform matrix. The last two values of the matrix, transform[4] and transform[5], are the item's x and y position on the page.

So a single visual line like

`plaintext
Notice period: 30 days
`

might come back as three items ("Notice period:", "30", "days"), all with the same y.

Rebuilding lines

My first version groups items by their (rounded) y coordinate, then sorts the groups top to bottom. PDF coordinates start at the bottom-left, so "top to bottom" means sorting y descending:

`ts
export async function getPageLines(pdf: PDFDocumentProxy, pageNumber: number): Promise {
const page = await pdf.getPage(pageNumber);
const content = await page.getTextContent();

const lines = new Map();
for (const item of content.items) {
if (!("str" in item)) continue; // skip marked-content items
const y = Math.round(item.transform[5]);
const bucket = lines.get(y) ?? [];
bucket.push(item.str);
lines.set(y, bucket);
}

const sortedYs = Array.from(lines.keys()).sort((a, b) => b - a);
return sortedYs
.map((y) => lines.get(y)!.join(" ").trim())
.filter((line) => line.length > 0);
}
`

This same helper turned out to be useful well beyond comparing: PDF to Word, PDF to HTML, PDF to Excel and PDF to EPUB all start from "give me the lines of each page".

Diffing

With lines per page, the comparison itself is short. Flatten both documents into one string each and let jsdiff do a line diff:

`ts
import { diffLines, type Change } from "diff";

export async function comparePdfs(fileA: File, fileB: File): Promise {
const [pagesA, pagesB] = await Promise.all([getAllPageLines(fileA), getAllPageLines(fileB)]);
const textA = pagesA.flat().join("\n");
const textB = pagesB.flat().join("\n");
return diffLines(textA, textB);
}
`

diffLines returns an array of Change objects with added / removed flags, which map directly onto green and red blocks in the UI. Both PDFs are parsed in parallel, and nothing leaves the tab.

pdf.js itself has to be loaded lazily in Next.js (it touches DOMMatrix at import time, which breaks server prerendering), so getPdfjs() does a dynamic import("pdfjs-dist") the first time it's needed.

Where the naive version gets it wrong

This works well for the main use case, contracts and letters that went through a small edit. But "group by y" has sharp edges, and I'd rather list them than pretend they don't exist:

  1. Items on a line aren't sorted by x. I join them in the order the PDF drew them. Most generators draw left to right, but not all. Sorting each bucket by transform[4] is the obvious fix.
  2. Exact y matching is fragile. Superscripts, mixed fonts, or a baseline off by 0.6pt can split one visual line into two. Bucketing with a tolerance (say, a fraction of the font height) instead of Math.round would be more robust.
  3. Two-column layouts merge. Left and right columns at the same y become one "line". You'd need to detect columns by x gaps first.
  4. Re-wrapping looks like a total rewrite. If one word is added early in a paragraph and the text re-flows, every following line in the paragraph changes. A line diff reports the whole paragraph as changed. Running a word diff (diffWords) inside changed blocks would show the actual edit.
  5. Headers, footers and page numbers add noise, especially if pagination shifts by a page.
  6. Scanned PDFs have no text at all. They're images, so there's nothing to diff without OCR first.
  7. It's a text diff, not a visual diff. A moved logo or a changed font won't show up.

For "did they change the notice period?", the current version does the job. For a 200-page reflowed manual, you'd want 1, 2 and 4 at least.

Takeaways

  • A PDF stores positioned glyphs, not lines or paragraphs. Any text feature starts by reconstructing structure from coordinates.
  • PDF y coordinates grow upwards, so reading order is y descending.
  • The diff algorithm is the easy part; the quality of the comparison depends almost entirely on how you rebuild the text.

If you want to try it, the tool is at Handybin Compare PDFs. It runs in your browser, so you can open DevTools' Network tab and confirm the files aren't uploaded.

I'd love to hear how others have handled line reconstruction, especially multi-column layouts.

Top comments (0)