Technical Article

Visual PDF Diff in Delphi: Pixel Compare and Page Pairing

PDFlibPas visual comparison renders two PDFs at the same DPI and diffs what a reader actually sees. TPDFlib.CompareRenderedPagesWith compares one page pair and returns changed pixels, regions, a mask and an overlay. TPDFlib.CompareDocumentVisualsWith and TPDFlib.CompareFileVisuals pair whole documents page by page, detect inserted and removed pages, and report everything as JSON

The use case is visual regression testing. Someone rewrites the invoice template, bumps a font package, or swaps the rendering engine, and the question is not whether the bytes changed (they always do) but whether any customer will see a difference. A structural diff answers a different question. Two files can differ in every content stream and still paint identical pages, and a single shifted operand can move a total by three points without touching anything a text extractor would flag. Pixels are the only layer where "looks the same" is testable

What does a visual PDF diff actually compare?

PDFlibPas compares the visible result on a white page, not raw buffer contents. Every page is rendered through RenderPageToString as an uncompressed BMP, decoded into a top-down bitmap, and any pixel that carries alpha is composited over white before the channels are compared. An RGB bitmap and an RGBA bitmap that look identical on screen therefore compare as identical, which is exactly what you want when the two sides came from different render paths

The BMP decoder has one deliberate quirk worth knowing about. A 32-bit BI_RGB bitmap carries a fourth byte per pixel, but the format leaves that byte undefined: some writers fill it with 0, some with 255, some with leftovers. PLDecodeVisualBMP ignores it and keeps the colour. Treating that byte as opacity would let a renderer that writes zeros erase every pixel on the page, and the diff would happily report two blank, identical documents. If you feed your own bitmaps into the lower-level API, alpha is honoured only when you declare the format as vpfRGBA or vpfBGRA yourself

A pixel counts as changed when any of its red, green or blue channels differs by more than ColorTolerance. Tolerance 0 means bit-exact. In practice a value in the single digits absorbs anti-aliasing jitter between renderer builds without hiding a changed glyph, because a real text change moves channels by far more than that. Pick the number from a known-good baseline run, not by guessing

Comparing a single page pair with CompareRenderedPagesWith

TPDFlib.CompareRenderedPagesWith is the tool when you already know which pages belong together and want detail: a bounding box, connected change regions, an optional per-pixel mask and an optional red-tinted overlay. The options record and result types live in the PDFlibVisualCompare unit, and PLDefaultVisualCompareOptions gives you tolerance 0, 32-pixel tiles, no ignore regions and no mask or overlay

uses
  PDFlibrary, PDFlibVisualCompare;

var
  Baseline, Candidate: TPDFlib;
  Options: TPDFVisualCompareOptions;
  Comparison: TPDFVisualCompareResult;
  ErrorText: WideString;
begin
  Baseline := TPDFlib.Create;
  Candidate := TPDFlib.Create;
  try
    if (Baseline.LoadFromFile('invoice-v1.pdf', '') = 1) and
       (Candidate.LoadFromFile('invoice-v2.pdf', '') = 1) then
    begin
      Options := PLDefaultVisualCompareOptions;
      Options.ColorTolerance := 8;
      Options.IncludeOverlay := True;
      // Mask out the print timestamp; pixel units at the DPI below
      SetLength(Options.IgnoreRects, 1);
      Options.IgnoreRects[0].Left := 600;
      Options.IgnoreRects[0].Top := 20;
      Options.IgnoreRects[0].Right := 790;
      Options.IgnoreRects[0].Bottom := 50;

      if Baseline.CompareRenderedPagesWith(Candidate, 1, 1, 96, Options,
        Comparison, ErrorText) then
      begin
        if Comparison.ChangedPixels > 0 then
          Writeln(PLVisualCompareResultToJSON(Comparison));
      end
      else
        Writeln('Compare failed: ', ErrorText);
    end;
  finally
    Candidate.Free;
    Baseline.Free;
  end;
end;

Three details bite people here. Ignore rectangles are in rendered pixels with a top-left origin and half-open intervals, so Right and Bottom are exclusive and the same rectangle covers a different part of the page if you change the DPI. Overlapping ignore rectangles are counted once, and ignored pixels show up in IgnoredPixels rather than vanishing, which keeps ChangedRatio honest because it divides by compared pixels only. Finally, the single-page call requires both renders to have the same dimensions; a page that changed size fails with an error instead of being stretched to fit

The regions come from a tile pass: changed pixels are bucketed into TileSize squares and adjacent tiles are merged into one region with its own bounds and pixel count. Bigger tiles give fewer, coarser regions, which is usually what a test report wants. The overlay is an RGB copy of the second page with changed pixels tinted red, returned as raw bytes in Comparison.Overlay; writing it to a PNG for your CI artefacts is your code, not the library's

How does document-level page pairing handle inserted pages?

TPDFlib.CompareDocumentVisualsWith pairs pages with a longest common subsequence over exact pixel fingerprints, so one inserted page does not turn every following page into a false change. A naive page 1 to page 1, page 2 to page 2 loop is useless the moment a template gains a cover sheet: every page after the insertion is offset by one and the report lights up red from top to bottom

Each page is rendered and reduced to a key made of its pixel width, height and a SHA-256 digest of the visible pixels. The size matters more than it looks: an all-white portrait page and an all-white landscape page of the same area have byte-identical pixel buffers, and without the dimensions in the key they would anchor to each other. Identical keys on both sides become anchors, the LCS picks the longest ordered chain of them, and the pages between two consecutive anchors are paired in order. When one gap holds more pages on one side, the surplus is reported as added or removed

var
  Options: TPDFVisualCompareOptions;
  Report: WideString;
begin
  // Baseline and Candidate loaded as before, with matching render settings
  Options := PLDefaultVisualCompareOptions;
  Options.ColorTolerance := 8;
  Options.TileSize := 64;
  Report := Baseline.CompareDocumentVisualsWith(Candidate, 96, Options);
  if Baseline.LastErrorCode <> 0 then
    Writeln('Visual diff failed: ', Report)
  else
    Writeln(Report);
end;

The anchors are bit-exact, and that is a design choice rather than an oversight. ColorTolerance applies only to the comparison of paired pages, never to the fingerprint. A page with nothing but anti-aliasing noise will not anchor, but it still lands in a gap, gets paired in order with its counterpart, and then compares as unchanged under tolerance. Fuzzy anchors would be more forgiving and far less predictable, because near-duplicate pages (blank separators, repeated terms pages) would start matching each other

Be clear about what this alignment is. The JSON names it exact-raster-lcs-gap-order on purpose: it is a raster alignment, not semantic page recognition. If a gap contains a rewritten page and an inserted page, in-order pairing may compare the rewrite against the insertion and report the original as removed. Documents full of identical pages are similarly ambiguous. When pairing matters for a verdict, look at the pairs, not just the counters

Reading the JSON report

The document report has a fixed shape you can assert on in a test runner. On success it carries "status":"complete", "complete":true, the alignment name, firstPageCount, secondPageCount, and the counters changedPages, addedPages, removedPages and unchangedPages. The pairs array then lists every row with firstPage, secondPage (0 for the missing side) and a kind of unchanged, changed, size_changed, added or removed

Rows of kind changed and unchanged embed a comparison object with width, height, comparedPixels, changedPixels, ignoredPixels, changedRatio, boundingBox (null when nothing changed) and a regions list of bounds plus changedPixels. A size_changed row has no comparison at all; it reports firstWidth, firstHeight, secondWidth and secondHeight and counts toward changedPages. Stretching a resized page to compare it would produce a wall of meaningless changed pixels, so the library reports the geometry change and stops there

The document report never contains masks or overlays. CompareDocumentVisualsWith forces IncludeMask and IncludeOverlay off even if your options ask for them, so memory stays flat as the page count grows instead of holding a full-page preview for every pair. Use the report to find the interesting pages, then call CompareRenderedPagesWith on just those pages to get the overlay. Failures come back as "status":"error", "complete":false with an error string, and LastErrorCode is set to 401

Comparing against a file with CompareFileVisuals

TPDFlib.CompareFileVisuals is the one-call form for CI: it loads the other PDF, copies the current renderer settings onto it, and returns the same document JSON. The signature is CompareFileVisuals(OtherFileName, Password, DPI, ColorTolerance), where tolerance must be 0 to 255 and DPI must be finite and positive

var
  Lib: TPDFlib;
  Report: WideString;
begin
  Lib := TPDFlib.Create;
  try
    if Lib.LoadFromFile('baseline\statement.pdf', '') = 1 then
    begin
      Report := Lib.CompareFileVisuals('build\statement.pdf', '', 96, 8);
      Writeln(Report);
    end;
  finally
    Lib.Free;
  end;
end;

The renderer inheritance is the reason to prefer this call over loading the second file yourself. The loaded document takes the current instance render scale, crop type and graphics settings, plus the Cairo or PDFium engine choice when those are compiled in. If you build two TPDFlib instances by hand for CompareDocumentVisualsWith, configuring both identically is on you, and a mismatch shows up as every page changed. It also cannot take ignore regions; reach for the typed API when you need those

Limits, cost and where structural diff fits

PDFlibPas bounds the work explicitly so a hostile or huge input fails with an error instead of exhausting memory. A rendered page may not exceed 64 Mi pixels, the tile grid is capped at 4 Mi tiles, and page alignment refuses inputs where the two page counts sum past 8192 or the LCS matrix would exceed 4 Mi cells. Document comparison also renders every page twice, once for the fingerprint and again for the paired compare, so budget for that at 150 DPI and above. For most regression suites 72 to 96 DPI catches layout and text changes at a fraction of the time

Visual diff and structural diff answer different questions, and a serious pipeline usually wants both. The pixel compare tells you whether a reader would notice anything. It cannot tell you whether a signed document gained an annotation that renders off-page, or whether an incremental update quietly replaced a form field value with the same visible text. For that layer, classifying what changed in a PDF after it was signed walks the revision-level diff. And because renderer choice drives the pixels, multi-engine PDF rendering in Delphi is worth reading before you pin a baseline to one engine

FAQ

Does PDFlibPas visual comparison stretch pages that changed size?

No. CompareRenderedPagesWith returns an error for pages of different pixel size, and the document report marks such pairs size_changed with both dimensions instead of comparing them

Why are near-identical pages not used as anchors?

Anchors use an exact SHA-256 fingerprint of the visible pixels plus page size, so alignment stays predictable. Tolerance is applied afterward, when paired pages are compared, so noise-only pages still end up unchanged

Can I get an overlay image for every page of a document?

Not from the document call, which deliberately omits masks and overlays to keep memory flat. Use the JSON to find changed pages, then call CompareRenderedPagesWith with IncludeOverlay on those pages

Visual comparison, page pairing and the rest of the rendering and document APIs ship with the PDFlibPas PDF library for Delphi