Skip to main content

Comparing Documents

paperjam can compare two PDF documents at both the text level and the visual (pixel) level. Text diff is fast and works without any optional dependencies. Visual diff requires the render feature (pdfium).

Text diff

diff() is available as both a module-level function and a Document method:

import paperjam

doc_a = paperjam.open("v1.pdf")
doc_b = paperjam.open("v2.pdf")

# Both forms are equivalent
result = paperjam.diff(doc_a, doc_b)
result = doc_a.diff(doc_b)

Reading the summary

s = result.summary
print(f"Pages changed: {s.pages_changed}")
print(f"Pages added: {s.pages_added}")
print(f"Pages removed: {s.pages_removed}")
print(f"Total additions:{s.total_additions}")
print(f"Total removals: {s.total_removals}")
print(f"Total changes: {s.total_changes}")

DiffSummary attributes:

AttributeTypeDescription
pages_changedintPages present in both docs but with different text
pages_addedintPages in doc_b but not doc_a
pages_removedintPages in doc_a but not doc_b
total_additionsintLines added across all pages
total_removalsintLines removed across all pages
total_changesintLines changed across all pages

Per-page diff operations

for page_diff in result.page_diffs:
if not page_diff.ops:
continue
print(f"\n--- Page {page_diff.page} ---")
for op in page_diff.ops:
if op.kind == "added":
print(f" + {op.text_b!r}")
elif op.kind == "removed":
print(f" - {op.text_a!r}")
elif op.kind == "changed":
print(f" ~ {op.text_a!r}{op.text_b!r}")

DiffOp attributes:

AttributeTypeDescription
kindstr"added", "removed", or "changed"
pageint1-based page number
text_astr | NoneText in doc_a (None for "added")
text_bstr | NoneText in doc_b (None for "removed")
bbox_atuple | NoneBounding box in doc_a
bbox_btuple | NoneBounding box in doc_b
line_index_aint | None0-based line index in doc_a
line_index_bint | None0-based line index in doc_b

Visual diff

Visual diff renders both documents and computes a pixel-level comparison. It requires the render feature:

result = doc_a.visual_diff(
doc_b,
dpi=150, # render resolution
highlight_color=(255, 0, 0, 128), # RGBA, semi-transparent red
mode="both", # "pixel_diff", "bbox_overlay", or "both"
threshold=10, # per-channel difference to count as changed
)

Modes

ModeDescription
"pixel_diff"Highlights every changed pixel
"bbox_overlay"Draws bounding boxes around changed regions
"both"Combines pixel diff and bounding boxes

Reading visual diff results

print(f"Overall similarity: {result.overall_similarity:.1%}")

for vp in result.pages:
print(f"\nPage {vp.page}:")
print(f" Similarity: {vp.similarity:.1%}")
print(f" Changed pixels: {vp.changed_pixel_count}")

# Save the side-by-side diff image
with open(f"diff_page_{vp.page}.png", "wb") as f:
f.write(vp.diff_image)

# Save the individual rendered pages
with open(f"a_page_{vp.page}.png", "wb") as f:
f.write(vp.image_a)
with open(f"b_page_{vp.page}.png", "wb") as f:
f.write(vp.image_b)

VisualDiffResult attributes:

AttributeTypeDescription
pagestuple[VisualDiffPage, ...]Per-page results
overall_similarityfloatAverage similarity across all pages (0.0–1.0)
text_diff_summaryDiffSummaryText-level diff summary

VisualDiffPage attributes:

AttributeTypeDescription
pageint1-based page number
image_abytesRendered page from doc_a (PNG)
image_bbytesRendered page from doc_b (PNG)
diff_imagebytesDiff visualization (PNG)
similarityfloatPixel similarity ratio for this page
changed_pixel_countintNumber of pixels that differ

Async visual diff

result = await doc_a.adiff(doc_b) # text diff