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
.docxextension or by its contents: a zip whose main part is a Word document's body, as in a.docm,.dotxor.dotmfile. - 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.