PDF File Format Primer β Hello World, Untagged vs Tagged
Two complete PDF files, byte for byte. Both render one line of 24pt Helvetica reading βHello, worldβ at the same coordinates. They are visually identical. One is inaccessible; one is tagged.
The point of this doc is to make the delta concrete. Everything our remediation pipeline adds to a file is visible here in about 1,900 bytes of plain text.
- Untagged: 587 bytes, 5 objects
- Tagged: 2,494 bytes, 12 objects
- Delta: 4.2Γ bytes, 2.4Γ objects, zero visual difference
Part 1 β The minimal file (587 bytes)
%PDF-1.71 0 obj<< /Type /Catalog /Pages 2 0 R >>endobj2 0 obj<< /Type /Pages /Kids [3 0 R] /Count 1 >>endobj3 0 obj<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] /Resources << /Font << /F1 4 0 R >> >> /Contents 5 0 R >>endobj4 0 obj<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>endobj5 0 obj<< /Length 44 >>streamBT/F1 24 Tf72 700 Td(Hello, world) TjETendstreamendobjxref0 60000000000 65535 f0000000009 00000 n0000000058 00000 n0000000115 00000 n0000000241 00000 n0000000311 00000 ntrailer<< /Size 6 /Root 1 0 R >>startxref404%%EOFPart 2 β How the format works
A PDF is not a markup language. It is a small object database with a lookup table bolted to the end. Nothing is read top to bottom: a reader opens the file at the back, finds the index, and seeks to byte offsets. That single fact explains most of the formatβs strangeness, including why a PDF cannot be hand-edited safely.
| # | Chunk | Offset | Bytes | Role |
|---|---|---|---|---|
| 1 | Header | 0 | 9 | Version marker |
| 2 | Object 1 | 9 | 49 | Catalog β document root |
| 3 | Object 2 | 58 | 57 | Page tree node |
| 4 | Object 3 | 115 | 126 | The page itself |
| 5 | Object 4 | 241 | 70 | Font resource |
| 6 | Object 5 | 311 | 93 | Content stream β the drawing |
| 7 | xref | 404 | 129 | Offset index |
| 8 | trailer | 533 | 34 | Entry point + object count |
| 9 | startxref / %%EOF | 567 | 20 | Pointer to the index |
Syntax rules worth internalising
<< >>delimits a dictionary (key/value map).[ ]is an array.N 0 Ris an indirect reference: βlook up object N, generation 0.β Generation is almost always 0; it increments only when an object slot is reused in an incrementally-updated file.N 0 obj β¦ endobjwraps an object definition.- Names begin with
/. Strings are(parenthesised)or<hex>. %starts a comment. The whole syntax is inherited from PostScript.
The objects
Catalog (obj 1) β the document root. Exactly one per file; the trailer
points at it. In a real file this is also where document-level accessibility
lives: /Lang, /MarkInfo, /StructTreeRoot, /ViewerPreferences.
Page tree (obj 2) β pages are a tree, not a list, so a 1,000-page document
does not need a 1,000-entry array. /Count is the number of leaf pages
beneath the node, not the number of /Kids.
Page (obj 3) β holds no content, only pointers and a rectangle.
/MediaBox [left bottom right top] is in points at 72/inch, origin at the
bottom-left, y increasing upward β inverted from screen coordinates.
/Resources declares what the content stream is permitted to name: the stream
says /F1, and this dictionary is what binds that nickname to object 4. Fonts,
images and colour spaces all work this way.
Font (obj 4) β Helvetica is one of the standard 14 fonts every conforming
reader must have built in, so nothing is embedded. That is why the whole file
fits in 587 bytes. Embedding a real font adds a /FontDescriptor, a /Widths
array, an encoding and a compressed binary font program β typically tens of KB.
Content stream (obj 5) β the only chunk that draws anything. The body is a stack language, operands before operator:
| Operator | Meaning |
|---|---|
BT / ET | Begin / end a text object |
/F1 24 Tf | Select font /F1 from /Resources at 24pt |
72 700 Td | Move cursor to x=72, y=700 (1in from left, 700pt up from bottom) |
(Hello, world) Tj | Paint the string |
/Length 44 must equal the exact byte count between stream and endstream.
Wrong value, and readers truncate or reject. In production files this stream is
Flate-compressed, which is why a normal PDF looks like binary noise in an editor.
xref (chunk 7) β the index. 0 6 means βthis subsection starts at object 0
and holds 6 entries.β Every entry is exactly 20 bytes: 10-digit offset,
space, 5-digit generation, space, one flag letter, two-byte line ending. The
trailing space is padding, not decoration β readers seek into the table by
multiplying object number by 20. Entry 0 is always the free-list head
(65535 f). PDF 1.5+ usually replaces this with a compressed xref stream.
trailer (chunk 8) β /Size is the total xref entry count including the
free entry, so 6 not 5. /Root points at the Catalog; that is the single thread
from which the whole object graph unwinds.
startxref / %%EOF (chunk 9) β the number is the byte offset of the xref
keyword. Readers scan the last kilobyte for %%EOF, read backwards to
startxref, and begin there.
Reading order is nearly backwards
| Chunk | Step |
|---|---|
| 9 | Find %%EOF, read startxref β 404 |
| 7 | Seek to byte 404, parse xref into an offset map |
| 8 | Read trailer, find /Root β object 1 |
| 2 | Object 1 at byte 9 β Catalog β /Pages is object 2 |
| 3 | Object 2 at byte 58 β /Kids gives object 3 |
| 4 | Object 3 at byte 115 β page size, resources, contents |
| 5, 6 | Objects 4 and 5 β font and drawing instructions |
| β | Execute the content stream, paint the page |
Eight of the nine chunks exist to answer βwhere is everything?β One draws.
Why offsets make PDFs fragile
Change one character anywhere above the xref table and every subsequent offset shifts, silently invalidating the index. This is the root cause of most βcorrupt PDFβ reports from tools that do naive string replacement on a file. Any mutation must rewrite the xref, or append an incremental update.
Part 3 β The tagged file (2,494 bytes)
%PDF-1.7%Γ’Γ£ΓΓ1 0 obj<< /Type /Catalog /Pages 2 0 R /Lang (en-US) /MarkInfo << /Marked true >> /StructTreeRoot 6 0 R /ViewerPreferences << /DisplayDocTitle true >>>>endobj2 0 obj<< /Type /Pages /Kids [3 0 R] /Count 1 >>endobj3 0 obj<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] /Resources << /Font << /F1 4 0 R >> >> /Contents 5 0 R /StructParents 0 /Tabs /S>>endobj4 0 obj<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica /Encoding /WinAnsiEncoding /ToUnicode 9 0 R>>endobj5 0 obj<< /Length 70 >>stream/H1 << /MCID 0 >> BDCBT/F1 24 Tf72 700 Td(Hello, world) TjETEMCendstreamendobj6 0 obj<< /Type /StructTreeRoot /K [7 0 R] /ParentTree 10 0 R /ParentTreeNextKey 1>>endobj7 0 obj<< /Type /StructElem /S /Document /P 6 0 R /K [8 0 R]>>endobj8 0 obj<< /Type /StructElem /S /H1 /P 7 0 R /Pg 3 0 R /K [0]>>endobj9 0 obj<< /Length 355 >>stream/CIDInit /ProcSet findresource begin12 dict beginbegincmap/CMapName /Adobe-Identity-UCS def/CMapType 2 def1 begincodespacerange<00> <FF>endcodespacerange9 beginbfchar<20> <0020><2C> <002C><48> <0048><64> <0064><65> <0065><6C> <006C><6F> <006F><72> <0072><77> <0077>endbfcharendcmapCMapName currentdict /CMap defineresource popendendendstreamendobj10 0 obj<< /Nums [0 [8 0 R]] >>endobj11 0 obj<< /Type /Metadata /Subtype /XML /Length 579 >>stream<?xpacket begin="" id="W5M0MpCehiHzreSzNTczkc9d"?><x:xmpmeta xmlns:x="adobe:ns:meta/"> <rdf:RDF xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"> <rdf:Description rdf:about="" xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:pdf="http://ns.adobe.com/pdf/1.3/" xmlns:pdfuaid="http://www.aiim.org/pdfua/ns/id/"> <dc:title><rdf:Alt><rdf:li xml:lang="x-default">Hello, world</rdf:li></rdf:Alt></dc:title> <pdf:Producer>hand-written</pdf:Producer> <pdfuaid:part>1</pdfuaid:part> </rdf:Description> </rdf:RDF></x:xmpmeta><?xpacket end="w"?>endstreamendobj12 0 obj<< /Title (Hello, world) >>endobjxref0 130000000000 65535 f0000000015 00000 n0000000191 00000 n0000000248 00000 n0000000418 00000 n0000000544 00000 n0000000663 00000 n0000000766 00000 n0000000847 00000 n0000000931 00000 n0000001336 00000 n0000001376 00000 n0000002036 00000 ntrailer<< /Size 13 /Root 1 0 R /Info 12 0 R /ID [<48656C6C6F20776F726C6420504446> <48656C6C6F20776F726C6420504446>] >>startxref2080%%EOFPart 4 β What accessibility actually added
The three-way link (the core mechanism)
Tagging is not an annotation on the content β it is a bidirectional binding between painted marks and a semantic tree, and it takes three cooperating pieces:
- Content stream wraps the drawing in marked content:
/H1 << /MCID 0 >> BDC β¦ EMC.MCID 0is a page-local identifier. - Struct element (obj 8) declares βI am an
/H1, I live on page3 0 R, my kid is MCID 0.β Structure β content. - ParentTree (obj 10) runs the link in reverse: page
/StructParents 0indexes into/Nums, yielding[8 0 R]. Content β structure.
Break any one and the tag is orphaned. The page still renders identically, which is exactly why untagged and mistagged files pass visual review. This is the failure mode our validators exist to catch.
Change-by-change
| Added | Object | Why it is required |
|---|---|---|
/Lang (en-US) | 1 | AT cannot pick a pronunciation rules set without it |
/MarkInfo << /Marked true >> | 1 | Declares the file claims to be tagged |
/StructTreeRoot | 1 β 6 | Entry point to the structure tree |
/ViewerPreferences /DisplayDocTitle true | 1 | Forces title, not filename, in the window/announcement |
/StructParents 0 | 3 | Pageβs key into the ParentTree |
/Tabs /S | 3 | Tab order follows structure, not annotation creation order |
/Encoding /WinAnsiEncoding | 4 | Makes byteβglyph mapping explicit rather than default |
/ToUnicode | 4 β 9 | Makes glyphβcharacter mapping explicit |
BDC / EMC | 5 | Binds painted marks to MCID 0 |
| Structure tree | 6, 7, 8 | Document β H1 β MCID 0 |
| ParentTree | 10 | Reverse lookup, page β structure |
| XMP metadata | 11 | PDF/UA-1 requires pdfuaid:part and dc:title |
/Info /Title | 12 | Title in the trailerβs document info dictionary |
/ToUnicode deserves its own note
In the untagged file, text extraction works by accident β Helveticaβs
standard encoding happens to line up with ASCII. The CMap in object 9 makes it
explicit: <48> <0048> means βthe glyph I call byte 0x48 is really U+0048, H.β
With a subset-embedded font and no /ToUnicode, byte codes are arbitrary β a
subsetter may map the glyph for βHβ to code 0x01. The page looks perfect;
copy-paste and screen readers produce garbage. This is one of the most common
real-world failures we see in supplied source files, and it is invisible to
anyone reviewing the rendered page.
The semantic gap
Note what the untagged content stream does not know: that βHello, worldβ is a heading, a paragraph, or even a word. It is ink at a coordinate. Structure is not derived from the drawing β it must be asserted separately. That is the entire premise of tagged PDF, and the entire reason remediation is not a formatting problem.
Part 5 β Caveats on the tagged example
This file is not veraPDF-clean. ISO 14289-1 requires all fonts to be
embedded. Helvetica is a standard-14 font, so nothing is embedded, and a strict
run fails on that clause. Fixing it requires a /FontDescriptor, a /Widths
array and a subset /FontFile2 β a binary blob that would push the file past
30 KB and make it unreadable as source. The example is optimised for legibility,
not for passing validation. Treat it as a teaching artifact, not a conformance
reference.
The tagged file above was verified structurally (tree resolves StructTreeRoot β Document β H1 β MCID 0, ParentTree round-trips, text extracts correctly), not validated against veraPDF.
Also absent, and required for real documents: /Widths on the font, a
/RoleMap where non-standard structure types are used, /Alt on figures,
/Scope on table headers, and heading levels that descend without skipping.
Part 6 β Why this matters for our pipeline
- Byte cost is not the objection. 4.2Γ on a trivial file sounds dramatic; on a real document the structure tree is a rounding error next to embedded fonts and images. The actual cost of tagging is authoring effort, not size.
- Visual review cannot detect any of this. Every failure described above renders correctly. Conformance has to be machine-checked.
- Append-only updates leave history. Incremental updates work by appending
new objects, a new xref section, and a trailer whose
/Prevpoints at the previous one. Original bytes are never touched. This is how signatures stay verifiable β and why content sometimes remains recoverable in a file that appears redacted. Worth knowing when handling client source documents.
Regenerating these files
The untagged listing is byte-exact: save it verbatim and it opens.
The tagged listing is not copy-pasteable as-is. Two chunks contain bytes that do not survive display:
- Line 2 is the binary comment
%followed by four high bytes (E2 E3 CF D3), rendered above as%Γ’Γ£ΓΓ. - The XMP packet header opens with a UTF-8 BOM inside
begin="β¦", which is counted in/Length 579.
Strip or mangle either and the /Length values and every xref offset below
them go stale. Regenerate the file from a script that computes offsets after
assembling the object bodies β never patch offsets by hand. That constraint
applies to any PDF mutation, which is why our tooling rewrites the xref rather
than doing string replacement in place.