Skip to content

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.7
1 0 obj
<< /Type /Catalog /Pages 2 0 R >>
endobj
2 0 obj
<< /Type /Pages /Kids [3 0 R] /Count 1 >>
endobj
3 0 obj
<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] /Resources << /Font << /F1 4 0 R >> >> /Contents 5 0 R >>
endobj
4 0 obj
<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>
endobj
5 0 obj
<< /Length 44 >>
stream
BT
/F1 24 Tf
72 700 Td
(Hello, world) Tj
ET
endstream
endobj
xref
0 6
0000000000 65535 f
0000000009 00000 n
0000000058 00000 n
0000000115 00000 n
0000000241 00000 n
0000000311 00000 n
trailer
<< /Size 6 /Root 1 0 R >>
startxref
404
%%EOF

Part 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.

#ChunkOffsetBytesRole
1Header09Version marker
2Object 1949Catalog β€” document root
3Object 25857Page tree node
4Object 3115126The page itself
5Object 424170Font resource
6Object 531193Content stream β€” the drawing
7xref404129Offset index
8trailer53334Entry point + object count
9startxref / %%EOF56720Pointer to the index

Syntax rules worth internalising

  • << >> delimits a dictionary (key/value map). [ ] is an array.
  • N 0 R is 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 … endobj wraps 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:

OperatorMeaning
BT / ETBegin / end a text object
/F1 24 TfSelect font /F1 from /Resources at 24pt
72 700 TdMove cursor to x=72, y=700 (1in from left, 700pt up from bottom)
(Hello, world) TjPaint 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

ChunkStep
9Find %%EOF, read startxref β†’ 404
7Seek to byte 404, parse xref into an offset map
8Read trailer, find /Root β†’ object 1
2Object 1 at byte 9 β€” Catalog β†’ /Pages is object 2
3Object 2 at byte 58 β€” /Kids gives object 3
4Object 3 at byte 115 β€” page size, resources, contents
5, 6Objects 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 >>
>>
endobj
2 0 obj
<< /Type /Pages /Kids [3 0 R] /Count 1 >>
endobj
3 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
>>
endobj
4 0 obj
<< /Type /Font
/Subtype /Type1
/BaseFont /Helvetica
/Encoding /WinAnsiEncoding
/ToUnicode 9 0 R
>>
endobj
5 0 obj
<< /Length 70 >>
stream
/H1 << /MCID 0 >> BDC
BT
/F1 24 Tf
72 700 Td
(Hello, world) Tj
ET
EMC
endstream
endobj
6 0 obj
<< /Type /StructTreeRoot
/K [7 0 R]
/ParentTree 10 0 R
/ParentTreeNextKey 1
>>
endobj
7 0 obj
<< /Type /StructElem
/S /Document
/P 6 0 R
/K [8 0 R]
>>
endobj
8 0 obj
<< /Type /StructElem
/S /H1
/P 7 0 R
/Pg 3 0 R
/K [0]
>>
endobj
9 0 obj
<< /Length 355 >>
stream
/CIDInit /ProcSet findresource begin
12 dict begin
begincmap
/CMapName /Adobe-Identity-UCS def
/CMapType 2 def
1 begincodespacerange
<00> <FF>
endcodespacerange
9 beginbfchar
<20> <0020>
<2C> <002C>
<48> <0048>
<64> <0064>
<65> <0065>
<6C> <006C>
<6F> <006F>
<72> <0072>
<77> <0077>
endbfchar
endcmap
CMapName currentdict /CMap defineresource pop
end
end
endstream
endobj
10 0 obj
<< /Nums [0 [8 0 R]] >>
endobj
11 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"?>
endstream
endobj
12 0 obj
<< /Title (Hello, world) >>
endobj
xref
0 13
0000000000 65535 f
0000000015 00000 n
0000000191 00000 n
0000000248 00000 n
0000000418 00000 n
0000000544 00000 n
0000000663 00000 n
0000000766 00000 n
0000000847 00000 n
0000000931 00000 n
0000001336 00000 n
0000001376 00000 n
0000002036 00000 n
trailer
<< /Size 13 /Root 1 0 R /Info 12 0 R
/ID [<48656C6C6F20776F726C6420504446> <48656C6C6F20776F726C6420504446>] >>
startxref
2080
%%EOF

Part 4 β€” What accessibility actually added

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:

  1. Content stream wraps the drawing in marked content: /H1 << /MCID 0 >> BDC … EMC. MCID 0 is a page-local identifier.
  2. Struct element (obj 8) declares β€œI am an /H1, I live on page 3 0 R, my kid is MCID 0.” Structure β†’ content.
  3. ParentTree (obj 10) runs the link in reverse: page /StructParents 0 indexes 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

AddedObjectWhy it is required
/Lang (en-US)1AT cannot pick a pronunciation rules set without it
/MarkInfo << /Marked true >>1Declares the file claims to be tagged
/StructTreeRoot1 β†’ 6Entry point to the structure tree
/ViewerPreferences /DisplayDocTitle true1Forces title, not filename, in the window/announcement
/StructParents 03Page’s key into the ParentTree
/Tabs /S3Tab order follows structure, not annotation creation order
/Encoding /WinAnsiEncoding4Makes byte→glyph mapping explicit rather than default
/ToUnicode4 → 9Makes glyph→character mapping explicit
BDC / EMC5Binds painted marks to MCID 0
Structure tree6, 7, 8Document β†’ H1 β†’ MCID 0
ParentTree10Reverse lookup, page β†’ structure
XMP metadata11PDF/UA-1 requires pdfuaid:part and dc:title
/Info /Title12Title 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 /Prev points 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.