Accessibility

A searchable PDF and an accessible PDF are different features

Selectable text passes the copy-paste test and still fails a Section 508 audit. Structure — headings, list semantics, table header cells, reading order — is a separate thing you have to write, and the decorative parts have to be excluded on purpose.

10 min read

There is a test people run on an exported PDF: open it, try to select a sentence, try Ctrl+F. If the text highlights and the search finds it, the PDF is judged accessible.

It is not. That test distinguishes a text PDF from a scanned image, which is a real and worthwhile distinction — but it is the first of two, and the second is the one an accessibility audit is actually about.

What a screen reader is missing

Text in a PDF content stream is a sequence of positioned strings. There is no notion of a heading, a list, a table cell, or an order to read them in. A reader can extract the characters; it cannot tell you anything about them. Concretely, in an untagged PDF:

Section 508 in the US and EN 301 549 in the EU both require the structure, not just the text. So does PDF/UA. A searchable PDF fails these; a tagged one passes.

Marked content: labelling the drawing operations

Tagging has two halves. The first labels the content stream, wrapping each piece of drawing in a marked-content sequence with an id:

/P << /MCID 0 >> BDC
  BT /F0 11 Tf 36 700 Td (Ordinary paragraph.) Tj ET
EMC

The second half is a tree of structure elements, elsewhere in the file, that points back at those ids. The content says “this drawing is marked-content 0”; the tree says “marked-content 0 on page 1 is a paragraph, and it is the third child of the document”.

<< /Type /StructElem /S /H1 /P 5 0 R
   /K [<< /Type /MCR /Pg 9 0 R /MCID 0 >>] >>

Keeping them separate looks like indirection for its own sake until you hit the case that requires it: a paragraph split across a page break. It is one logical element with drawing on two pages, and the /K array holds a reference per page. If you build the tree from your block list rather than from the marks you actually emitted, you have nowhere to put the second one.

Which suggests the right order of operations. Render first, recording (owner, page, mcid) as you go; build the tree afterwards from those records. The tree then describes what was drawn, rather than what you intended to draw.

The ParentTree, and why it exists

There is a third structure that is easy to skip and produces a file that looks tagged and behaves as though it is not. The tree points down to marked content; assistive technology also needs to go up — from a piece of content on the page to the element describing it. That is the /ParentTree, a number tree keyed by each page’s /StructParents value:

% on each page object
/StructParents 0

% in the struct tree root
/ParentTree << /Nums [
  0 [12 0 R 12 0 R 15 0 R]     % struct element per MCID, in order
  1 [18 0 R 19 0 R]
] >>

Omit it and the tags are unreachable from the content. Some checkers report the file as tagged anyway, which is the worst outcome: a document that passes a superficial audit and helps nobody.

Artifacts: what to deliberately exclude

The part that is easy to miss is that tagging is not only about adding structure. Decoration has to be marked as decoration, or it enters the reading order:

/Artifact BMC
  0.8 0.8 0.8 RG 0.5 w 36 480 m 576 480 l S    % a horizontal rule
EMC

Everything in this category should be an artifact:

A useful check on a finished file: search the extracted tag tree for the text of your running header. If it appears, it is in the reading order and should not be.

Roles that carry real meaning

Most roles are a direct translation of the HTML you started from — H1H6, P, BlockQuote, L/LI/Lbl/LBody, Table/TR/TH/TD, Figure. Two are worth singling out because they change what a user hears:

TH versus TD. A header cell tagged TH with /Scope /Columnis what lets a screen reader say “Revenue, 1,240” when the user moves into that cell. Tagged TD, the same cell reads “1,240” and the user has to remember which column they are in — across a table that may span pages.

Figure and /Alt. An image with no alt text is an accessibility failure. An image with /Alt () — empty — is a worse one, because empty alt is the standard way of saying this is decorative, skip it. Writing that when nobody made that decision tells the user there is nothing here, confidently and incorrectly. If you have no alt text, say something neutral and true, or surface it as a warning; do not assert decorativeness on the author’s behalf.

Two catalogue-level declarations round it out:

/MarkInfo << /Marked true >>
/ViewerPreferences << /DisplayDocTitle true >>

The second is small and worth doing. It makes the viewer announce the document’s title rather than its filename — “Quarterly Report” instead of “Export-260802-1431.pdf”.

Verify by reading the tree back

Assertions against the raw bytes will tell you that you emitted /StructTreeRoot. They will not tell you the tree is well-formed, correctly parented, or in document order. Read it the way a consumer does — pdf.js exposes it per page, which is exactly the interface assistive technology works from:

const tree = await (await doc.getPage(1)).getStructTree();

const roles = [];
(function walk(n) { if (n.role) roles.push(n.role); (n.children || []).forEach(walk); })(tree);

assert(roles.includes("H1"));
assert(roles.filter(r => r === "TH").length === 2);   // headers are TH, not TD
assert(roles.indexOf("H2") < roles.indexOf("L"));     // reading order is document order
assert(!JSON.stringify(tree).includes("Confidential")); // header is an artifact

That last assertion is the one we would keep if we could only keep one. It is the automated form of “is the decoration excluded?”, and it fails the moment someone adds a new drawing operation without thinking about which half of the document it belongs to.

When we first ran this suite it reported Table, TR, TH and TD entirely absent, with six stray P elements instead. Table cells were being rendered through a shared code path with a synthetic block object, and the tagging keyed on that object rather than on the cell — so every cell became a top-level paragraph and the table lost its structure completely. It looked correct on the page. It would have failed an audit while appearing to pass a screenshot.

Which is the whole argument for testing the tree rather than the appearance. Structure is invisible by definition: the only way to know it is right is to read it the way the people who depend on it will.


RichTextEditor is a perpetual-licence JavaScript editor — one purchase, self-hosted, no metered editor loads. Download the evaluation or see how it compares.