Table of Contents

Command line

The pdiffy tool does outside of tests what the library does inside them: compare files, print snapshots, accept changes and summarize a run.

Install it as a local tool, at the same version as the PDiffy package your tests use, so pdiffy snapshot reads documents exactly as your tests do. Check in the manifest, and dotnet tool restore installs that version anywhere. Run it as dotnet pdiffy. A global install (dotnet tool install --global PDiffy.Cli) works too, but can drift from the package.

dotnet new tool-manifest
dotnet tool install PDiffy.Cli --version <the PDiffy package version>

The tool carries its own copy of PdfPig, the library PDiffy reads PDFs with. A test project that gets a newer PdfPig through another package can read some PDFs differently from the tool, so when the tool and a test disagree about a document, check which PdfPig the test project uses.

pdiffy --version prints the version and the snapshot formats it writes, such as pdiffy 1.0.0 (pdfua-snapshot v1, docx-snapshot v1), and pdiffy --help (or -h) prints the commands. Both exit with 0.

Command Does
pdiffy <before> <after> Compares two PDFs, two DOCX files, a DOCX file and its PDF, or two snapshot files
pdiffy compare <before> <after> Compares two folders of documents, file by file
pdiffy snapshot <file> Prints or saves a PDF's or DOCX's snapshot
pdiffy check <file.pdf\|file.docx> Runs every built-in check on a PDF or Word document, and can save what it finds as a page or JSON
pdiffy read <file.pdf\|file.docx> Prints what a screen reader announces, in reading order
pdiffy accept [targets] Accepts received snapshots as verified
pdiffy review [targets] Steps through received snapshots, accepting or skipping each one or opening its report
pdiffy report [targets] Summarizes received snapshots, for review or a CI job page
pdiffy coverage [targets] Counts what the verified snapshots compare and leave out
pdiffy findings [targets] Counts the check findings the verified snapshots hold
pdiffy changes [targets] --since <commit> Lists what changed in the verified snapshots since a commit, for a pull request
pdiffy unused [folders] --run-log <folder> Lists or deletes snapshots no test used in a run
pdiffy --version Prints the version and the snapshot formats it writes
pdiffy --help, -h Prints the commands

Exit codes

Code Meaning
0 Nothing changed (comparing files or folders), the content didn't change (--remediation), nothing was found (check), nothing is waiting to be accepted (report), no received snapshot is left (review), every snapshot was used or --delete removed the rest (unused), or the command succeeded
1 Something changed (comparing files or folders), the content changed (--remediation), something was found (check), received snapshots are waiting (report), received snapshots are left after reviewing (review), some snapshots weren't used (unused), a change can affect accessibility with --fail-on accessibility (changes), or a note is past its review date with --fail-on overdue (findings)
2 The arguments don't fit a command, or a file or folder is missing or can't be accessed. The problem is written to standard error.
3 A document can't be read: it isn't a PDF or Word document, or it's encrypted or too damaged to open. The problem is written to standard error. When comparing folders, a document that can't be read is listed as unreadable and the command exits with 3 once it has listed every file.

Compare

pdiffy <before> <after> [--html <report.html>] [--json [<changes.json>]] [--profile <profile>] [--checks]
       [--remediation]

Prints what changed between two files, the same summary a failing test shows, and exits with 0 when nothing did and 1 when something did.

Option Does
--html <file> Also writes the side-by-side HTML report
--json Prints only the changes as JSON, instead of the summary, so a script can read them from the pipe
--json <file> Saves the JSON to a file, and prints the summary as usual
--profile <profile> Leaves out what a profile leaves out: content, accessibility, layout or strict (the default). A Word document uses the Word profile of the same name, or strict when there's none. A snapshot file is compared as it is
--checks Also runs the latest checks on both PDFs or Word documents and compares what they find, so the summary lists each problem the new version introduces (+ new problem DocumentTitle) or fixes. Snapshot files are compared as they are
--remediation Compares a PDF with its remediated version, as RemediationDiff does: it prints the changes to the content, which a remediation isn't expected to make, then every change, and exits with 1 only when the content changed. --json gives both lists (contentChanges and allChanges), and --html report.html saves a report of the content changes to report.html and one of every change to report.all.html. With --checks, the problems the remediation introduces or fixes appear only in the list of every change, so they don't affect the exit code. Both inputs have to be PDFs

Each input can be:

  • A PDF, recognized by its %PDF- header, whatever its extension.
  • A Word document, recognized by its .docx extension or by its contents: a zip whose main part is a Word document's body, as in a .docm, .dotx or .dotm file.
  • A snapshot file, or any other text, compared line by line when it isn't a PDiffy snapshot.

Any other file, such as an image, can't be read, so pdiffy exits with 3.

A DOCX file and a PDF are compared the way CompareExport compares them, by what a screen reader announces in each (what pdiffy read prints): the title and language, the text in reading order, heading levels, alt text, tables, lists and links. --checks doesn't apply to them, since Word and PDF have different rules.

pdiffy guide-v1.pdf guide-v2.pdf
pdiffy guide-v1.pdf guide-v2.pdf --html report.html
pdiffy guide-v1.pdf guide-v2.pdf --json > changes.json
pdiffy TrailMap.verified.pdfua.txt TrailMap.received.pdfua.txt
pdiffy guide-v1.pdf guide-v2.pdf --profile content
pdiffy guide-v1.pdf guide-v2.pdf --checks
pdiffy brochure.pdf brochure-remediated.pdf --remediation
pdiffy guide.docx guide.pdf

Compare folders

pdiffy compare <before> <after> [--html <index.html>] [--json [<changes.json>]] [--profile <profile>] [--checks]

Compares two folders, such as the documents two releases generate, file by file. Files are paired by their path inside each folder, including subfolders. PDFs and Word documents (.docx, .docm, .dotx and .dotm) are compared as documents, and .txt and .json files as snapshots; other files are counted but not compared.

4 file(s) differ: 1 changed, 1 added, 1 removed, 1 unreadable; 12 unchanged, 3 not compared.
changed maps/ridgeline.pdf: 2: 2 text; by section: structure 1, layout 1
added guides/welcome.docx
removed guides/old.pdf
unreadable broken.pdf: <why it couldn't be read>

Files that differ are listed changed first, then added, removed and unreadable. Unchanged files are counted, not listed. A document that can't be read is listed as unreadable, so one damaged document doesn't stop the rest from being compared, and the command exits with 3. Any other problem with a file, such as one another program has locked, stops the command with 2.

Option Does
--html <file> Saves a page with one row per file that differs, and next to it each changed file's report, at the file's path with .diff.html added
--json Prints only the result as JSON, instead of the summary
--json <file> Saves the JSON to a file, and prints the summary as usual
--profile <profile> Leaves out what the profile leaves out, as when comparing two files
--checks Also compares what the checks find in each pair of PDFs or Word documents, as when comparing two files

The JSON has one entry per file that differs, each with its path, its status and either its changes, in the same shape as comparing two files, or the problem that kept it from being read:

{
  "version": 1,
  "unchanged": 12,
  "notCompared": 3,
  "files": [
    { "path": "maps/ridgeline.pdf", "status": "changed", "changes": [ ... ] },
    { "path": "guides/welcome.docx", "status": "added" }
  ]
}
pdiffy compare release-1.4/out release-1.5/out
pdiffy compare baseline current --profile content --html compare/index.html

Snapshot

pdiffy snapshot <file.pdf|file.docx> [<out>] [--profile <profile>] [--checks]

Prints the file's snapshot, or saves it to <out>. It won't save over a PDF or Word document, so <out> can't end in .pdf or .docx. Useful for seeing what PDiffy reads from a document.

Option Does
--profile <profile> Leaves out what the profile leaves out: content, accessibility, layout or strict (the default). A Word document takes the profile of the same name, or strict when there's none
--checks Adds a checks section with what the latest built-in checks find, so findings can count them
pdiffy snapshot guide.pdf
pdiffy snapshot guide.pdf guide.pdfua.txt --checks

Check

pdiffy check <file.pdf|file.docx> [--html <report.html>] [--json [<findings.json>]]

Runs every built-in check on a PDF, or every Word check on a DOCX file, prints each finding with the clause its rule comes from, and exits with 0 when there are none and 1 when there are, so a pipeline can gate on a generated document without writing a test. The house rules, NoActions and NoEmbeddedFiles, have no clause.

3 finding(s):
DocumentTitle (ISO 14289-1 7.1): The document has no title (metadata)
FigureAltText (ISO 14289-1 7.3): Figure on p2 has no alt text (structure > Document > Figure[2])
FontsEmbedded (ISO 14289-1 7.21.4.1): Font Helvetica isn't embedded (fonts > Helvetica)
Option Does
--html <file> Also saves a page with one row per finding, showing its rule (linked to the rule's help), severity, clause, page, where it is and what's wrong
--json Prints only the findings as JSON, instead of the list
--json <file> Saves the JSON to a file, and prints the list as usual

The JSON has one entry per finding. clause is null for a house rule, and page is null for a Word document or a finding about the whole document:

{
  "version": 1,
  "document": "guide.pdf",
  "findings": [
    {
      "rule": "FigureAltText",
      "category": "AltText",
      "severity": "Error",
      "clause": "ISO 14289-1 7.3",
      "page": 2,
      "path": "structure > Document > Figure[2]",
      "message": "Figure on p2 has no alt text",
      "helpUrl": "https://pdiffy.dev/reference/checks.html#figurealttext"
    }
  ]
}
pdiffy check guide.pdf
pdiffy check guide.pdf --html check.html --json check.json

Read

pdiffy read <file.pdf|file.docx>

Prints a PDF's structure tree or a Word document's body as the lines a screen reader announces, in reading order. They're the lines PdfUaDocument.ToTranscript() and DocxDocument.ToTranscript() write:

document: Trail Guide, language en-US
heading level 1: Trail Guide
figure: Park logo
table, 2 row(s), 2 column(s)
  row 1: column header Trail; column header Length
  row 2: Ridge loop; 5 km
list, 2 item(s)
  Bring water
  Stay on the path

It exits with 0, with 2 when the file is missing, or with 3 when it isn't a PDF or DOCX or is encrypted or too damaged to read.

Accept

pdiffy accept [<files or folders>] [--all] [--no-accessibility] [--only-sections <section,...>] [--group <n>]

Replaces each received snapshot's verified file with it, keeping its extension (Guide.received.json becomes Guide.verified.json), and deletes its report and any kept document, as a test does in accept mode.

Argument Does
none Accepts the received snapshots in the current folder
<folder> Accepts the received snapshots in that folder
<file> Accepts that received file. Shells expand *.received.* into many files, and each is accepted once; the reports and kept documents among them are skipped
--all Includes every folder under each folder

With a rule, it accepts only the snapshots whose changes all pass it, and leaves the rest received, printing why for each (left Notes.received.pdfua.txt: changes structure). A new snapshot has no changes to check, so a rule leaves it received too. Rules can be combined, and a snapshot must pass all of them.

Rule Accepts a snapshot when
--no-accessibility None of its changes can affect accessibility. A text or JSON snapshot's changes aren't checked for that, so it stays received
--only-sections <section,...> All of its changes are in these sections, such as layout,fonts. A name that isn't a PDF or Word section stops the command before anything is accepted
--group <n> It's in group n of the identical changes pdiffy report lists. The group's changes are printed first. Accepting a group renumbers the rest, so run pdiffy report again before the next one
pdiffy accept tests/Snapshots
pdiffy accept --all
pdiffy accept --all --group 1
pdiffy accept --all --no-accessibility --only-sections layout,fonts
Tip

Run pdiffy report --all first to see what you're about to accept, or pdiffy review to look at each one as you go.

Review

pdiffy review [<files or folders>] [--all]

Shows each received snapshot in turn with its changes, those that can affect accessibility first, and asks what to do with it. A snapshot with no verified file shows "New snapshot." instead, with what it holds by section. Type a letter and press Enter:

Answer Does
a Accepts it, as pdiffy accept does
s Skips it, leaving it received
o Opens its report in the browser, writing it first if the test run didn't, and asks again. A new snapshot opens its received file instead. Without a browser, it says so
q Stops, leaving the rest received. The end of the input stops it too

It takes the same targets and --all as pdiffy accept, and exits with 0 when no received snapshot is left and 1 when some are.

pdiffy review --all

Report

pdiffy report [<files or folders>] [--all] [--html <summary.html>] [--markdown <summary.md>]

Lists every received snapshot, new or changed, with its changes by kind and by section and how many can affect accessibility. Exits with 0 when there are none and 1 when there are, so a pipeline step can use it. Finds files the same way as accept.

Option Does
--html <file> Saves a page with one row per snapshot, linking each one's report
--markdown <file> Adds the table to the end of the file, such as GitHub's $GITHUB_STEP_SUMMARY
pdiffy report --all
pdiffy report --all --markdown "$GITHUB_STEP_SUMMARY" --html snapshot-summary.html

The printed summary looks like this:

2 received snapshot(s): 1 changed, 1 new
changed tests/Snapshots/TrailMap.received.pdfua.txt: 3: 1 moved, 1 attribute, 1 text; by section: structure 3; 2 can affect accessibility
new tests/Snapshots/Guide.received.docx.txt

When two or more snapshots have the same changes, as when a template's footer changes in every document, the summary also lists them as a group. A change repeated on several pages counts once, and pages are ignored, so documents with different page counts still group. Groups are numbered from 1, largest first, for pdiffy accept --group:

Identical changes (pdiffy accept --group <n> accepts a group):
  group 1: 143 snapshots
    artifacts text "[-v1.2-] {+v1.3+}"
    layout text "[-v1.2-] {+v1.3+}"

See Continuous integration for complete pipeline examples.

Coverage

pdiffy coverage [<files or folders>] [--all] [--markdown <coverage.md>]

Every snapshot's header says what it leaves out. coverage adds those headers up across the verified snapshots, so a suite that has drifted into ignoring layout everywhere, or fonts in half its tests, shows it. It reads only the headers, so it's fast and needs no documents.

Option Does
<files or folders> Which verified snapshots to count. The current folder by default. A file named here is also read when it's a PDF or Word snapshot under another name, such as one pdiffy snapshot saved, but not when it's a received snapshot
--all Includes every folder under each folder
--markdown <file> Adds the counts, as tables, to the end of the file, such as GitHub's $GITHUB_STEP_SUMMARY
42 PDF snapshot(s)
  document     42 of 42
  ...
  layout        6 of 42, ignored in 36
  fields ignored: positions in 30, colors in 12
  limited to some pages: 3
  with tolerances: 2
5 Word snapshot(s)
  ...

Each section shows how many snapshots compare it, and the fields line lists every field some snapshot leaves out, most often first. Text and JSON snapshots have no sections, so they're only counted. coverage exits with 0, or with 2 when a target is missing or is a file it doesn't read.

Findings

pdiffy findings [<files or folders>] [--all] [--since <commit>] [--html <findings.html>] [--markdown <findings.md>]
                [--notes <notes.txt>] [--fail-on overdue]

A snapshot that records checks holds its document's findings, so findings can count the problems a suite's documents have without regenerating any of them. PDF and Word snapshots without a checks section, or that ignore it, are counted separately; text and JSON snapshots are left out. A Word rule is counted under its help page's name, such as Word: TableHeaders, since some share a name with a PDF rule.

Option Does
<files or folders> Which verified snapshots to count. The current folder by default. A file named here is also read when it's a PDF or Word snapshot under another name, such as one pdiffy snapshot saved, but not when it's a received snapshot
--all Includes every folder under each folder
--since <commit> Also counts the same snapshots at a commit, branch or tag, read with git, and says how the count changed
--html <file> Saves a page with the counts by rule, each linked to its help, and by snapshot
--markdown <file> Adds the same tables to the end of the file, such as GitHub's $GITHUB_STEP_SUMMARY
--notes <file> Reads notes on known findings and lists the findings without one and the overdue notes
--fail-on overdue Exits with 1 when a note that still matches a finding is past its review date. Needs --notes
120 problem(s) in 34 snapshot(s) (2 snapshot(s) don't record checks)
by rule: UntaggedContent 61, FigureAltText 30, TableHeaders 18, HeadingLevels 11

Rules are listed most found first, and the tables list the snapshots with the most problems first. It's a summary, not a gate: findings exits with 0, or with 1 under --fail-on overdue when a note is overdue, or with 2 when a target is missing or is a file it doesn't read, or when a note can't be read. When no snapshot records checks, it says how to record them, and that pdiffy check is the command for a single document.

With --since, a line under the first says whether things are getting better, which suits a pull request's job summary:

120 problem(s) in 34 snapshot(s)
15 fewer than main (18 fixed, 3 new)

A finding is fixed when the snapshot had it at that commit and doesn't now, and new the other way around, so the findings of a renamed snapshot count as both. When git isn't on the path or can't read the commit, the line says so and the counts are printed as usual. In a CI checkout, fetch the branch first: for example, run git fetch origin main --depth 1 and pass --since origin/main.

Notes on known findings

A team that approves findings it can't fix yet, such as those its source documents bring with them, can say why in a notes file it keeps beside the snapshots. Each line is a note: the snapshot, the finding, the date to review it by and why it's kept, separated by |. Lines starting with # are comments.

# snapshot | finding | review by | why it's kept
Guide.verified.pdfua.txt | FigureAltText | 2027-03-31 | Logos from the shared template, TICKET-42
Guide.verified.pdfua.txt | TableHeaders p2 structure > TH | 2027-01-31 | Waiting on the data team
* | DocumentTitle | 2027-06-30 | Titles come from the upstream template

The snapshot path is relative to the notes file's folder, and * means every snapshot. The finding is a whole line from the checks section, or its start up to a space: the rule alone covers every finding of that rule in the snapshot, and FigureAltText p2 covers those on page 2. With --notes, findings adds how many findings have a note, then each overdue note, each note that no longer matches a finding (such as when its problem was fixed or its snapshot renamed), and each finding without a note:

notes: 3 of 4 problem(s) have one, 1 overdue, 1 unmatched
review due 2027-01-31 (line 3): Guide.verified.pdfua.txt | TableHeaders p2 structure > TH | Waiting on the data team
matches no problem (line 4): * | DocumentTitle | Titles come from the upstream template
no note: Guide.verified.pdfua.txt | HeadingLevels p3 structure > H3

--markdown and --html add tables of the overdue notes and the findings without a note. A note records a decision and when to revisit it. It doesn't make a document conform, and the finding still counts as a problem.

Changes

pdiffy changes [<files or folders>] [--all] --since <commit> [--html <changes.html>] [--markdown <changes.md>]
               [--fail-on accessibility]

After pdiffy accept, a pull request shows the .verified.* files as a plain line diff, which hides moves, page shifts and what changes for a screen reader. changes uses git to read the verified snapshots as they were at a commit, branch or tag, and compares each with the file on disk the way a failing test does:

3 verified snapshot(s) changed since main: 1 changed, 1 added, 1 removed; 1 change(s) can affect accessibility
[accessibility] tests/Guide.verified.pdfua.txt structure > P[1]: type: H1 -> P
changed tests/Guide.verified.pdfua.txt: 1: 1 changed; by section: structure 1; 1 can affect accessibility
added tests/Map.verified.pdfua.txt
removed tests/Old.verified.pdfua.txt

The changes that can affect accessibility come first, one line each, then a line per snapshot. --markdown adds the same to the end of a file, such as a CI job summary (GitHub's $GITHUB_STEP_SUMMARY), and --html saves a page with each changed snapshot's report beside it. changes exits with 0, so it can run on every pull request; with --fail-on accessibility it exits with 1 when a change can affect accessibility, which a required check or a label rule can key on. It exits with 2 when --since is missing, git can't read the commit, or a target is missing or is a file it doesn't read. Like findings, it also reads a file named as a target when it's a PDF or Word snapshot under another name, but not when it's a received snapshot. In a CI checkout, fetch the branch first, as for findings.

See snapshot text in git diff

To have git diff show a changed PDF or Word document as the change to its snapshot, set dotnet pdiffy snapshot as their text converter (or pdiffy snapshot with a global install):

# .gitattributes
*.pdf diff=pdiffy
*.docx diff=pdiffy
git config diff.pdiffy.textconv "dotnet pdiffy snapshot"

Unused

pdiffy unused [<folders>] --run-log <folder> [--all] [--delete]

A renamed or deleted test leaves its .verified.* file behind, and nothing reads it anymore. To find such files, set PDIFFY_RUN_LOG (Snapshot.RunLogVariable) to an empty folder and run every test: each snapshot call writes down the verified file it uses, in one log per test process. unused then lists the verified and received snapshots that no call used, along with their reports and kept documents (.received.diff.html, .received.pdf, .received.docx). Give the folder as a full path, since each test process resolves a relative one from its own folder.

PDIFFY_RUN_LOG="$PWD/artifacts/run-log" dotnet test
pdiffy unused tests --all --run-log artifacts/run-log
Option Does
<folders> Which folders to look in. The current folder by default
--all Includes every folder under each folder
--run-log <folder> The folder PDIFFY_RUN_LOG named for the test run
--delete Deletes the files it lists

A test that didn't run, or failed before its snapshot call, looks unused, so run the whole suite, without a filter and with every test passing, before deleting anything, and start each run with an empty log folder. Every .verified. and .received. file in the folders counts, so give the snapshot folders rather than a folder that also holds other tools' files or saved reports. unused exits with 0 when every snapshot was used or --delete removed the rest, with 1 when it lists some, and with 2 when the run log folder has no logs.