DEV Community

sunshey
sunshey

Posted on

How to Compare Two PDFs Pixel by Pixel in the Browser with Vue 3 and Canvas

Comparing two PDFs visually — finding exactly which pages changed between an original and a revision — is a surprisingly practical need. Contract reviews, design proofing, invoice verification, and regulatory submissions all require knowing what changed. But doing this in the browser without a server introduces real engineering constraints: memory limits, render resolution, and the complexity of pixel-level comparisons across potentially large documents.

This post walks through building a browser-based PDF comparison tool with Vue 3, PDF.js for rendering, and Canvas API for pixel diffing.

Why visual comparison?

Text-based diff tools (like diff for plain text) work on character streams. But PDF is a presentation format — two PDFs can render identically while having completely different internal structures. A visual comparison catches everything: text changes, image shifts, margin adjustments, font substitutions, and layout differences. If it looks different, it gets flagged.

The stack

  • Vue 3 with Composition API
  • PDF.js (pdfjs-dist) for rendering PDF pages to canvas
  • Canvas API for pixel-level image comparison
  • Vite for bundling

The core approach

The comparison pipeline has four stages:

  1. Render each page of both PDFs to offscreen canvases
  2. Extract raw pixel data from both canvases
  3. Compare pixel by pixel with a configurable threshold
  4. Report per-page difference percentages and a summary

Here's the high-level flow:

async function comparePdfs(
  leftPdf: PDFDocumentProxy,
  rightPdf: PDFDocumentProxy,
  settings: CompareSettings
): Promise<CompareResult> {
  const pageCount = Math.min(leftPdf.numPages, rightPdf.numPages)
  const results: PageResult[] = []

  for (let i = 1; i <= pageCount; i++) {
    const [leftPage, rightPage] = await Promise.all([
      leftPdf.getPage(i),
      rightPdf.getPage(i),
    ])

    const viewport = leftPage.getViewport({ scale: getScale(settings.renderWidth) })

    // Render both pages to canvases at the same dimensions
    const leftCanvas = await renderPageToCanvas(leftPage, viewport)
    const rightCanvas = await renderPageToCanvas(rightPage, viewport)

    // Compare pixel data
    const diffPercent = compareCanvases(
      leftCanvas,
      rightCanvas,
      settings.threshold
    )

    results.push({
      page: i,
      diffPercent: Math.round(diffPercent * 100) / 100,
      verdict: diffPercent > 0 ? 'DIFF' : 'PASS',
    })
  }

  return summarize(results)
}
Enter fullscreen mode Exit fullscreen mode

Implementation details

1. Rendering PDF pages to canvas

PDF.js renders pages to a canvas context. We create offscreen canvases (they don't need to be in the DOM) to keep the comparison fast and memory-efficient:

async function renderPageToCanvas(
  page: PDFPageProxy,
  viewport: PageViewport
): Promise<HTMLCanvasElement> {
  const canvas = document.createElement('canvas')
  canvas.width = viewport.width
  canvas.height = viewport.height
  const ctx = canvas.getContext('2d', { willReadFrequently: true })!

  await page.render({ canvasContext: ctx, viewport }).promise
  return canvas
}
Enter fullscreen mode Exit fullscreen mode

The willReadFrequently: true hint is important — it tells the browser to optimize the canvas for getImageData() calls, which we'll need for pixel extraction.

2. Pixel-level comparison with threshold

The comparison extracts raw RGBA pixel data from both canvases and counts how many pixels differ:

function compareCanvases(
  a: HTMLCanvasElement,
  b: HTMLCanvasElement,
  threshold: number
): number {
  const aCtx = a.getContext('2d')!
  const bCtx = b.getContext('2d')!

  // Ensure both canvases are the same size
  const width = Math.min(a.width, b.width)
  const height = Math.min(a.height, b.height)

  const aData = aCtx.getImageData(0, 0, width, height).data
  const bData = bCtx.getImageData(0, 0, width, height).data

  let diffCount = 0
  const pixelCount = width * height

  for (let i = 0; i < aData.length; i += 4) {
    const rDiff = Math.abs(aData[i] - bData[i])
    const gDiff = Math.abs(aData[i + 1] - bData[i + 1])
    const bDiff = Math.abs(aData[i + 2] - bData[i + 2])
    const aDiff = Math.abs(aData[i + 3] - bData[i + 3])

    // A pixel is "different" if any channel exceeds the threshold
    if (rDiff > threshold || gDiff > threshold ||
        bDiff > threshold || aDiff > threshold) {
      diffCount++
    }
  }

  return (diffCount / pixelCount) * 100
}
Enter fullscreen mode Exit fullscreen mode

The threshold parameter is key. Set it low (e.g., 12) for high sensitivity — even tiny anti-aliasing differences are flagged. Set it higher (e.g., 40) to only catch substantial visual changes.

3. Handling different page dimensions

Two PDFs might have different page dimensions (A4 vs. Letter, or different aspect ratios). The comparison normalizes by rendering both pages at the same viewport scale. If the dimensions still differ after rendering, we compare only the overlapping pixel region:

const viewport = leftPage.getViewport({
  scale: settings.renderWidth / leftPage.getViewport({ scale: 1 }).width,
})

// Both pages rendered at the same physical pixel width
const leftCanvas = await renderPageToCanvas(leftPage, viewport)
const rightCanvas = await renderPageToCanvas(rightPage, viewport)
Enter fullscreen mode Exit fullscreen mode

This means a landscape page and a portrait page would have different canvas heights — but comparing the overlapping region still gives useful results.

4. Memory management

Rendering 100 pages at 820px width can consume significant memory. Key optimizations:

  • Process one page at a time, not all at once
  • Dispose canvases after comparison — let the GC clean up
  • Use willReadFrequently to avoid GPU-to-CPU readback penalties per frame
  • Limit max pages to a user-configurable cap (20/50/100)
for (let i = 1; i <= pageCount; i++) {
  // ... render, compare, collect result ...

  // Let canvases go out of scope for GC
  // No explicit disposal needed — JS engine handles it
}
Enter fullscreen mode Exit fullscreen mode

5. CSV report generation

The download is a simple CSV string built from results and triggered via a blob URL:

function generateCsv(results: PageResult[]): string {
  const header = 'Page,Diff %,Verdict'
  const rows = results.map(
    r => `${r.page},${r.diffPercent}%,${r.verdict}`
  )
  return [header, ...rows].join('\n')
}

function downloadCsv(csv: string) {
  const blob = new Blob([csv], { type: 'text/csv' })
  const url = URL.createObjectURL(blob)
  const a = document.createElement('a')
  a.href = url
  a.download = 'pdf-comparison-report.csv'
  a.click()
  URL.revokeObjectURL(url)
}
Enter fullscreen mode Exit fullscreen mode

Edge cases

  • Mismatched page counts: Compare only up to min(leftPages, rightPages). The report notes this so users understand why later pages weren't compared.
  • Scanned documents: Scanner noise means even "identical" scanned pages may show 0.5-3% difference. Use a lower sensitivity or higher threshold for scanned PDFs.
  • Single-page PDFs: These work fine — the comparison is just one page. The UX should still show the summary metrics.
  • Encrypted PDFs: PDF.js handles password-protected PDFs, but you need to prompt for the password before rendering.

Summary

Building a browser-based PDF comparison tool involves:

  1. Rendering PDF pages to offscreen canvases via PDF.js
  2. Extracting raw pixel data with getImageData()
  3. Comparing RGBA channels with a configurable threshold
  4. Generating a per-page difference report as CSV
  5. Managing memory by processing pages sequentially

The result is a completely private comparison tool — both files stay on the user's device. Try it at en.sotool.top/compare-pdf.

Top comments (0)