Table of Contents

Check documents directly

You don't need a snapshot to use PDiffy. PdfUaDocument.Load reads the whole PDF once into a model you can assert on, with no PDF knowledge needed: enums and constant classes list every option.

Built-in checks

Check applies the rules generated documents most often break and lists what it finds. Each PdfCheckFinding has the check, the page, a path and a message, so a failed test says which figure on which page. The path names the snapshot section and the item; for a structure element, it's the path a snapshot diff shows:

IReadOnlyList<PdfCheckFinding> findings = document.Check(PdfCheck.DocumentLanguage, PdfCheck.FigureAltText);

// On failure: "FigureAltText (ISO 14289-1 7.3): Figure on p2 has no alt text (structure > Document > Figure[2])".
Assert.Empty(findings);

Check() with no arguments applies every check; pass the ones a test guards to apply only those. Each finding also has:

  • A Severity: Error for a rule PDF/UA requires, or Warning for a house rule or a practice that another standard, such as WCAG, asks for and PDF/UA doesn't check.
  • A Category: the family its rule belongs to (HouseRules for policies that aren't accessibility rules).
  • A Reference: the PDF/UA-1 (ISO 14289-1) or WCAG 2.2 clause the rule comes from, listed under From below. It says where a rule comes from, not that the document conforms.
PdfCheck Finds Category From
DocumentLanguage A document with no language Language 7.2
DocumentTitle A document with no title, or one whose viewer shows the file name instead Document 7.1
FigureAltText A figure with no alt text or replacement text AltText 7.3
UntaggedContent Text, images, paths and shadings that are neither tagged nor artifacts, page by page Content 7.1
LinkDescriptions A link with no description in its contents (/Contents), even when its element has text or alt text Annotations 7.18.5
FormFieldNames A form field with no accessible name: no tooltip, and not every widget's Form element has alt text Forms 7.18.1
TableHeaders A header with no scope that no cell names, and a cell that names a header that doesn't exist Tables 7.5
HeadingLevels A heading that skips a level, or one that comes before any H1 Headings 7.4.2
HeadingTypes Unnumbered (H) and numbered (H1 to H6) headings in one document, and an element with more than one H Headings 7.4.4
NoteIds A note with no ID, and a note with the ID of an earlier one Structure 7.9
TableStructure A row, cell or row group in the wrong place, an element holding something it can't, a second THead, TFoot or Caption, a THead or TFoot with no TBody, and a caption that isn't first or last Tables 7.2
TableGrid Cells that overlap, a cell that spans past the last row, and a row with a different number of columns from the first, counting spans Tables 7.2
AnnotationTags A link not tagged with Link, a widget not tagged with Form, another annotation not tagged with Annot (or not tagged at all), a Form with no field role that holds anything but one widget, a TrapNet annotation, and a tagged printer mark Annotations 7.18
AnnotationDescriptions An annotation other than a link or form field with no contents and no alt text on the element that tags it Annotations 7.18.1
TabOrder A page with annotations whose tab order isn't the structure order (S) Annotations 7.18.3
LanguageTags A document, element or marked-content language that isn't a well-formed language tag, such as english or en_US Language 7.2
TextLanguage In a document with no language that sets some on its elements or marked content: text, alt text, annotation contents, form field names, bookmark titles and default-language XMP text with no language to inherit Language 7.2
FormulaAltText A formula with no alt text or replacement text AltText 7.7
MediaClips A media clip a Screen annotation plays with no content type or no alternate text AltText 7.18.6.2
ArtifactNesting An artifact inside tagged content, or an artifact holding tagged content, page by page Content 7.1
LayerConfigurations An optional-content configuration with no name, or one that turns layers on or off by itself (AS) Document 7.10
EmbeddedFileNames An embedded file, in the document's list or attached through a comment, not named both as F and as UF Document 7.11
NoDynamicXfa A dynamic XFA form Forms 7.15
AccessibilityPermission Encryption that doesn't let assistive technology extract the text Document 7.16
NoReferenceXObjects A reference XObject, which shows a page of another file, in the pages' resources or annotation appearances Content 7.20
MeaningfulAltText A warning: alt text that's a file name, such as IMG_2041.jpg, or a placeholder such as image or graphic 3 AltText WCAG 1.1.1
LinkText A warning: a link that says only something vague, such as "click here", "read more" or "link", with no other text saying where it goes Annotations WCAG 2.4.4
ReadingOrder A warning: a paragraph, heading, list item or table cell read after one below it in the same column of the same page, such as a paragraph read before the heading above it Structure WCAG 1.3.2
FontsEmbedded A font that isn't embedded Fonts 7.21.4.1
FontsMapToUnicode A font whose text uses codes that don't map to Unicode, or map to U+0000, U+FEFF or U+FFFE Fonts 7.21.7
NoMissingGlyphs A font whose text shows the missing-glyph box (.notdef), according to its encoding, CID-to-glyph map or embedded TrueType, Type 1 or CFF program Fonts 7.21.4.1, 7.21.8
FontDictionaries A font whose dictionaries break a font rule: a CMap and CIDSystemInfo that disagree, a missing CIDToGIDMap, a CMap that isn't built in or embedded, or a TrueType encoding PDF/UA doesn't allow Fonts 7.21.3, 7.21.6
FontPrograms An embedded TrueType, Type 1 or CFF program that disagrees with its font: glyph widths that differ, a CIDSet or CharSet that leaves out glyphs, cmaps that don't suit the font, or a program that can't be read Fonts 7.21.4, 7.21.5, 7.21.6
NoActions JavaScript, actions run on events such as closing or printing, and an open action that does more than go to a page HouseRules House rule
NoEmbeddedFiles Each embedded file HouseRules House rule
RoleMap A custom structure type that doesn't map to a standard one, a mapping that loops back on itself, and a standard type that's remapped Structure 7.1
XmpMetadata No XMP metadata, or XMP that doesn't say the document is PDF/UA-1 with the pdfuaid prefix, or whose dc:title is missing or a plain value Document 5, 7.1
TaggedPdf A document that isn't marked as tagged, says its tags may be unreliable (Suspects), has no structure tree, has elements with no parent entry or one that names the wrong element, or has elements that list a child the structure tree already lists elsewhere Structure 6.2, 7.1
ParentTree No parent tree when elements own content, a parent tree whose keys repeat, are out of order or reach ParentTreeNextKey, or elements whose marked content or annotation the parent tree gives to another element or to none Structure 32000-1 14.7.4.4
PdfVersion A file header or catalog version past PDF 1.7 Document 6.1
ListStructure A list item outside a list or a list body outside an item, a list holding something other than lists, items and a caption, an item holding something other than a label and a body, and a list caption that isn't first Lists 7.2
TocStructure A table of contents item outside a table of contents, one holding something other than tables of contents, items and a caption, and a caption that isn't first Lists 7.2

Check() applies every rule PDiffy has, so an upgrade that adds rules can fail a test that passed. To choose when that happens, pass a PdfCheckSet: a release's own set, such as PdfCheckSet.Version1_0, keeps the rules that release shipped whatever later releases add, while PdfCheckSet.Latest grows with each release, as .NET analyzers do with AnalysisLevel. Only and Except narrow a set:

document.Check(PdfCheckSet.Version1_0);                          // only the rules 1.0 shipped
document.Check(PdfCheckSet.Latest.Only(PdfCheckCategory.Tables));
document.Check(PdfCheckSet.Latest.Except(PdfCheck.HeadingLevels));

Approve findings with the snapshot

A document generated from real data often starts with problems no one can fix at once. Instead of asserting that a check finds nothing, record what it finds in the snapshot with Check, and approve the findings along with everything else:

await Snapshot.VerifyPdfAsync(pdf, configure: options => options.Check(PdfCheckSet.Version1_0));
# pdfua-snapshot v1
# checks: 1.0, 42 rules
...
checks
  FigureAltText p2 structure > Document > Figure[2]
  TableHeaders p3 structure > Document > Table > TR[1] > TH[2] > scope

A new problem fails the test, like any other change. A fixed one fails it too until the snapshot is accepted, so the problem can't come back unnoticed. Each line names the rule, the page and the path, with no message text, so rewording a message never changes a snapshot. The checks run on the whole document. OnlyPages narrows the findings they record, but Ignore doesn't change them, since it only controls what's compared. To leave a rule out, leave it out of the set. Scrubbers apply to the paths too, so a form field or attachment name gets the same token as in its own section. Without the option, no snapshot prints a checks section.

Check rules explains each rule: why it matters to someone using assistive technology and how to fix it. A finding's HelpUrl links to its rule's entry.

Since the approved snapshots hold the findings, pdiffy findings --all counts them across a suite, by rule and by snapshot, without regenerating anything: a quick measure of how many problems a team's documents have. For a finding a team can't fix yet, a note records why it's kept and when to review it.

These are fast regression checks for the mistakes generated documents make, not a PDF/UA validator: for conformance, use veraPDF or PAC. On the command line, pdiffy check runs them all. For a rule of your own, assert on the model, as the rest of this page shows.

Accessibility

[Fact]
public async Task TrailMap_ShouldBeReadableByScreenReaders_WhenGenerated()
{
    using Stream pdf = await ReportGenerator.CreateTrailMapAsync();
    PdfUaDocument document = PdfUaDocument.Load(pdf);

    // The document is tagged and declares a language, so a screen reader knows how to pronounce it.
    Assert.True(document.Document.Marked);
    Assert.Equal("en-US", document.Document.Language);

    // Every figure (logo, chart, signature) has alt text describing it.
    // Elements also finds custom tag types that the PDF maps to Figure.
    Assert.All(document.Elements(PdfStructureTypes.Figure), figure => Assert.False(string.IsNullOrWhiteSpace(figure.AltText)));

    // Every piece of text is either tagged or marked as decoration. Untagged text is invisible to a screen reader.
    Assert.DoesNotContain(document.TextRuns, run => run.Role == PdfContentRole.Untagged);

    // Every link has a description a screen reader can announce.
    Assert.All(document.Annotations.Where(annotation => annotation.Subtype == PdfAnnotationTypes.Link),
        link => Assert.False(string.IsNullOrWhiteSpace(link.Contents)));
}

Hear what a screen reader announces

ToTranscript() writes the structure tree as the lines a screen reader announces, in reading order. Headings include their level, figures and formulas their alt text, and tables their size and each row's cells. Lists include their items, with a numbered item's label but no bullet. Links are announced by name, and form fields by type and name (check box: Agree). Grouping elements such as Sect and Div announce nothing of their own, and a language that differs from the one around it shows in brackets ([fr-FR] Bonjour).

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

Snapshot it as text, so a test fails when what a screen reader user hears changes, but not when only the layout does:

await Snapshot.VerifyTextAsync(PdfUaDocument.Load(pdf).ToTranscript());

pdiffy read guide.pdf prints the same at the command line. A Word document has a transcript too: see Word documents.

The HTML report shows the transcript when it has the documents: before and after side by side for DocumentDiff.Compare on two documents or pdiffy before.pdf after.pdf, and the received one alone for a failing VerifyPdf or VerifyDocx, since the verified file is only a snapshot. It covers only the pages the snapshot keeps, runs the snapshot's scrubbers on its text, and is left out when the snapshot leaves out the structure (the body, for a Word document). Because it's what a screen reader hears, it still shows a title or alt text the snapshot ignores with PdfDetail.Title or PdfDetail.AltText.

Walk the structure tree

The document's Elements(type) throws for a type that's neither standard, nor in the role map, nor used in the document, with a hint such as "Did you mean 'Figure'?", so a typo can't make an Assert.All over no elements pass. Each element has:

  • Text, the text of its own marked content, and AllText, its text with its descendants', such as a table cell's paragraph or a paragraph's link, joined by position as the snapshot joins text.
  • TextRuns, the text runs its own marked content draws, with each one's font, size and color, such as for checking that every H1 is 18 pt.
  • Descendants(), and Elements(type) for one type. An element's Elements doesn't check the type, so use the PdfStructureTypes constants.
  • Path, the path a check finding shows, such as structure > Document > Table > TR[2].

Headings() lists every heading with its level, role-mapped types included.

PdfUaDocument document = PdfUaDocument.Load("trail-guide.pdf");

// Headings go down one level at a time, so a screen reader's outline has no gaps.
var levels = document.Headings().Select(heading => heading.Level ?? 1).ToList();
Assert.All(levels.Zip(levels.Skip(1)), pair => Assert.True(pair.Second <= pair.First + 1));

// Every table cell says something.
PdfStructureElement table = document.Elements(PdfStructureTypes.Table).Single();
Assert.All(table.Elements(PdfStructureTypes.TableDataCell), cell => Assert.False(string.IsNullOrWhiteSpace(cell.AllText)));

To check one part of the structure without approving the whole snapshot or writing a query, MatchStructure matches a template written like the snapshot's structure section. A line matches an element of its type, or role-mapped type, that has everything else the line gives: words such as p1, keys such as scope=Column, and quoted text. Keys left out match anything, and /regex/ in place of the text or a key's value matches part of it (write \s for a space). A line's children match in order, with others allowed between them, and a top-level line can match anywhere in the tree. Indent each level by two spaces.

StructureMatch match = document.MatchStructure("""
    Table
      TR
        TH scope=Column "Trail"
        TH scope=Column /Length/
    """);
Assert.True(match.IsMatch, match.Message);

When it doesn't match, Message shows the closest part of the tree and the first template line it doesn't match. With the headers in the other order (TH "Length", then TH "Trail"), it says:

No part of the structure matches the template line: Table
The closest is:
Table
  TR
    TH p1 scope=Column "Trail"
    TH p1 scope=Column "Length"
  TR
    TD p1 "Ridgeline"
    TD p1 "2 km"
It has nothing, in order, for the template line: TH "Trail"

Forms

Each form field has its full name, type, accessible name, value and flags.

[Fact]
public async Task EnrollmentForm_ShouldLabelAndRequireTheRightFields_WhenGenerated()
{
    using Stream pdf = await ReportGenerator.CreateEnrollmentFormAsync();
    PdfUaDocument document = PdfUaDocument.Load(pdf);

    // Every field has a name a screen reader announces (the "tooltip" in most PDF editors).
    Assert.All(document.FormFields, field => Assert.False(string.IsNullOrWhiteSpace(field.AccessibleName)));

    // Find the required fields. Names include their parents, joined with dots.
    var required = document.FormFields.Where(field => field.Flags.HasFlag(PdfFormFieldFlags.Required)).Select(field => field.Name);
    Assert.Equal(["hiker.name", "hiker.dateOfBirth"], required);

    // The state picker is a drop-down with all 50 states.
    PdfFormField state = document.FormFields.Single(field => field.Name == "hiker.address.state");
    Assert.Equal(PdfFormFieldType.ComboBox, state.Type);
    Assert.Equal(50, state.Options.Count);
}

Document settings

Settings covers how the file opens and what it allows. It's where a library upgrade or a configuration change tends to show up first.

[Fact]
public async Task ExportedReport_ShouldOpenSafely_WhenGenerated()
{
    using Stream pdf = await ReportGenerator.CreateReportAsync();
    PdfSettings settings = PdfUaDocument.Load(pdf).Settings;

    // No scripts and no attached files.
    Assert.False(settings.HasJavaScript);
    Assert.Empty(settings.EmbeddedFiles);

    // If the file is ever encrypted, it must still let screen readers extract the text.
    // Permissions is null for an unencrypted file.
    Assert.True(settings.Permissions is null || settings.Permissions.Value.HasFlag(PdfPermissions.ExtractForAccessibility));

    // Open with bookmarks showing, one page at a time in a continuous column.
    Assert.Equal(PdfPageModes.UseOutlines, settings.PageMode);
    Assert.Equal(PdfPageLayouts.OneColumn, settings.PageLayout);
}

How text is drawn

The layout lists every text run, image and path with its position and style. It's useful for rules that are hard to see by eye, such as hidden text or off-brand colors.

[Fact]
public async Task Brochure_ShouldUseOnlyBrandFontsAndColors_WhenGenerated()
{
    using Stream pdf = await ReportGenerator.CreateBrochureAsync();
    PdfUaDocument document = PdfUaDocument.Load(pdf);

    // No invisible text. It usually means an OCR layer or text hidden behind an image.
    Assert.DoesNotContain(document.TextRuns, run => run.RenderMode == PdfTextRenderMode.Invisible);

    // Only the two brand fonts. Subset prefixes such as "ABCDEF+" are already removed.
    Assert.All(document.TextRuns, run => Assert.Contains(run.Font, new[] { "OpenSans", "OpenSans-Bold" }));

    // Body text is black or brand navy. Colors are written as gray:0 (black), #rrggbb or cmyk:c,m,y,k.
    Assert.All(document.TextRuns.Where(run => run.Size <= 12), run => Assert.Contains(run.Color, new[] { "gray:0", "#1f3a5f" }));
}

Fonts that can't be read aloud

A font whose character codes don't map to Unicode produces text that can't be copied, searched or read aloud:

Assert.All(document.Fonts, font => Assert.Empty(font.UnmappedCodes));

Word documents

DocxDocument works the same way for DOCX files. See Word documents. Its Check applies the Word rules, and DocxSnapshotOptions.Check records their findings in a checks section, as above.

See also