Technisch artikel

PDF-bestandsstructuur: Header, Body, Xref en Trailer

Een PDF-reader begint niet aan het begin van het bestand. Hij begint aan het einde. De laatste paar bytes bevatten het adres van al het andere. Een parser die die volgorde niet begrijpt, zal het formaat vanaf de eerste regel verkeerd interpreteren. De nuttigste manier om PDF op schijf te leren kennen, is dus om het te leren op de manier zoals een reader het doet: staart eerst, dan achteruit springen naar de kaart, en vervolgens de objecten oplossen waar de kaart naar wijst

De bytes zelf zijn eenvoudig genoeg om te lezen in een teksteditor wanneer er niets gecomprimeerd is. Een minimaal document van één pagina dat "Hello, World!" tekent, past in minder dan vijfhonderd bytes, en elk structureel element van het formaat is erin zichtbaar. Hier is het hele bestand, waarbij de vier delen zijn gemarkeerd:

%PDF-1.0                          % Header
%âãÏÓ

1 0 obj                           % Body: the object sequence
<<
/Kids [2 0 R]
/Count 1
/Type /Pages
>>
endobj

2 0 obj
<<
/Rotate 0
/Parent 1 0 R
/Resources 3 0 R
/MediaBox [0 0 612 792]
/Contents [4 0 R]
/Type /Page
>>
endobj

3 0 obj
<< /Font << /F0 << /BaseFont /Times-Italic /Subtype /Type1 /Type /Font >> >> >>
endobj

4 0 obj
<< /Length 65 >>
stream
1. 0. 0. 1. 50. 700. cm BT
  /F0 36. Tf
  (Hello, World!) Tj
ET
endstream
endobj

5 0 obj
<< /Pages 1 0 R /Type /Catalog >>
endobj

xref                              % Cross-reference table
0 6
0000000000 65535 f
0000000015 00000 n
0000000074 00000 n
0000000192 00000 n
0000000291 00000 n
0000000409 00000 n

trailer                           % Trailer
<<
/Root 5 0 R
/Size 6
>>
startxref
459
%%EOF

Vier delen, altijd in deze volgorde in het bestand: een header, een body van objecten, een kruisverwijzingstabel (cross-reference table) en een trailer. De clou is dat u ze in bijna omgekeerde volgorde leest. ISO 32000-2 §7.5.1 beschrijft dezelfde vierdelige anatomie. De reden voor de toegang van achter naar voren is puur praktisch: een reader die rechtstreeks naar het benodigde object springt, is veel sneller dan een reader die elke byte vanaf de bovenkant scant. Die willekeurige toegang (random access) is precies waarom de trailer en de kruisverwijzingstabel bestaan

De header bestaat uit twee regels en de tweede is belangrijk

De eerste regel is %PDF-1.0. Het procentteken maakt het een commentaar wat betreft de syntaxis, maar readers behandelen het als de bestandssignatuur en halen het versienummer eruit. Versiebeheer is in de praktijk losjes. Een reader gebouwd voor PDF 2.0 zal zonder problemen een bestand openen dat 1.0 claimt, en de meeste readers proberen een bestand waarvan de gedeclareerde versie onjuist is of waarvan de versieregel een stukje verder in het bestand is weggestopt in plaats van bij byte nul. Het nummer is een hint over welke functies verwacht kunnen worden, geen harde eis (poortwachter)

De tweede regel is degene die mensen per ongeluk verwijderen, om vervolgens een middag lang te debuggen. Het is ook een commentaar, maar de inhoud bestaat uit vier bytes boven ASCII 127. Ze bestaan zodat alles dat het bestand in "tekstmodus" verplaatst, het als binair herkent en stopt met het herschrijven van regeleinden. Een PDF bevat gecomprimeerde streams waarvan de bytes toevallig overeen kunnen komen met een carriage return of line feed. Als een overdrachtstool deze herschrijft, komt de stroomlengte die in het woordenboek is geregistreerd niet langer overeen met de bytes op de schijf en is het bestand beschadigd. Het high-byte-commentaar is een veertig jaar oude verdediging tegen FTP in ASCII-modus en staat nog steeds in elk bestand dat een serieuze tool schrijft, omdat de fout die het voorkomt onzichtbaar en desastreus is

De body bevat de objecten, elk voorzien van een nummer

Alles wat het document vormt, leeft in de body als een vlakke reeks indirecte objecten. Elk object opent met twee gehele getallen (integers) en het obj trefwoord, bevat zijn inhoud, en sluit met endobj. Object 1 in het bovenstaande voorbeeld is het page-tree-knooppunt: 1 0 obj, dan een woordenboek, dan endobj. Het eerste gehele getal is het objectnummer, de tweede is het generatienummer. Generatie is bijna altijd nul in een nieuw geschreven bestand. Het loopt alleen op wanneer een objectnummer opnieuw wordt gebruikt bij bewerkingen, wat zeldzaam genoeg is dat u een generatie groter dan nul kunt beschouwen als een teken dat het bestand incrementele updates heeft ondergaan. De inhoud tussen de trefwoorden is hier een woordenboek, geschreven tussen << en >>, maar het zou net zo goed een getal, een string, een array of een stream kunnen zijn

Wat dit een graaf (graph) in plaats van een lijst maakt, is de referentie-token 2 0 R. Dat betekent "object 2, generatie 0, waar het zich dan ook in het bestand bevindt". Het page-tree-knooppunt hierboven bevat niet zijn pagina; het wijst naar object 2, dat via hetzelfde mechanisme naar zijn resources en content stream wijst. De body wordt opgemaakt in een volgorde die de schrijver handig vond, en de referenties rijgen het samen tot een boomstructuur die is geworteld in de catalogus (catalog). De positie in het bestand heeft geen betekenis. Identiteit komt van het objectnummer, en de locatie komt uit de kruisverwijzingstabel

De kruisverwijzingstabel is een index van byte-offsets

De xref-tabel maakt van objectnummers bestandsposities. Het is de reden dat een reader een document van duizend pagina's kan openen en pagina 850 kan weergeven zonder de 849 pagina's ervoor te ontleden. Elk item (entry) registreert precies waar het bijbehorende object begint, geteld in bytes vanaf het begin van het bestand:

xref
0 6                  % 6 entries, starting at object 0
0000000000 65535 f   % entry 0: head of the free list
0000000015 00000 n   % object 1 begins at byte 15
0000000074 00000 n   % object 2 begins at byte 74
0000000192 00000 n   % object 3 begins at byte 192
0000000291 00000 n   % object 4 begins at byte 291
0000000409 00000 n   % object 5 begins at byte 409

De vaste breedte is opzettelijk. Elke vermelding is exact twintig bytes: een offset van tien cijfers, een spatie, een generatie van vijf cijfers, een spatie, een type van één teken, en een einde-regel (end-of-line) van twee bytes. Omdat de rijen uniform zijn, kan een reader via een berekening rechtstreeks naar de vermelding voor object n indexeren in plaats van te hoeven scannen. Hierdoor is de tabel die willekeurige toegang (random access) tot de body biedt, zelf willekeurig toegankelijk. De regel 0 6 is een subsectie-header: deze geeft aan dat de volgende items zes objecten beschrijven, beginnend bij nummer 0

Object 0 is speciaal en altijd aanwezig. Het type is f voor vrij (free), de generatie is 65535, en het is het begin van de gekoppelde lijst van vrije objectnummers. In een bestand dat nog nooit is bewerkt, is de vrije lijst slechts deze ene vermelding, een formaliteit. Hij bewijst zijn waarde tijdens incrementele updates, waarbij het verwijderen van een object het nummer toevoegt aan die lijst zodat een latere bewerking het kan terugeisen. De andere vermeldingen zijn type n voor in-gebruik (in-use), en hun tiencijferige nummer is de offset waarnaar u zou zoeken om de definitie van dat object te lezen

De trailer is het startpunt en bevindt zich aan het einde

De trailer is het eerste wat een reader daadwerkelijk consumeert, ook al is deze als laatste weggeschreven. Een parser opent het bestand, zoekt naar het einde en loopt achteruit op zoek naar %%EOF. Net daarboven zit startxref gevolgd door een enkel getal, en dat getal is de byte-offset van het xref-trefwoord. Hiermee springt de reader rechtstreeks naar de kruisverwijzingstabel zonder ook maar één object te hebben gescand:

trailer
<<
/Root 5 0 R          % the document catalog
/Size 6              % one more than the highest object number
>>
startxref
459                  % byte offset of the xref table
%%EOF

Het trailer-woordenboek bevat de twee waarden die een reader nodig heeft voordat hij iets anders kan doen. /Root wijst naar de documentcatalogus (object 5 hier), wat de top van de objectengraaf (object graph) is en de route naar de paginaboom (page tree). /Size is het aantal vermeldingen dat de kruisverwijzingstabel zou moeten bevatten, wat één meer is dan het hoogste objectnummer vanwege de vrije vermelding op slot nul. Vanaf %%EOF volgt de hele leesvolgorde: vind de markering, lees startxref om de tabel te lokaliseren, laad de tabel om te leren waar elk object leeft, lees /Root om de catalogus te vinden en los de objecten van daaruit op aanvraag op. De header, bovenaan, wordt pas laat geraadpleegd. De kaart onderaan is wat de reader als eerste nodig heeft

Incrementele update voegt een tweede kaart toe in plaats van te herschrijven

Dat ontwerp, dat aan de staart begint, werpt zijn vruchten af wanneer een bestand verandert. Een PDF kan worden bewerkt zonder dat er bytes op schijf herschreven hoeven te worden. Nieuwe en gewijzigde objecten worden aan het einde toegevoegd, gevolgd door een nieuwe kruisverwijzingssectie en een nieuwe trailer. Het oorspronkelijke onderliggende bestand blijft onaangetast. Het enige nieuwe stukje boekhouding is een /Prev vermelding in de nieuwe trailer, die de byte-offset van de vorige kruisverwijzingstabel bevat:

% ... original file, unchanged, ends here ...

6 0 obj                          % an object added by this edit
<< /Type /Annot /Subtype /Text /Rect [100 700 120 720] >>
endobj

xref                             % a second xref section, for the new object only
6 1
0000000612 00000 n

trailer
<<
/Root 5 0 R
/Size 7
/Prev 459                        % byte offset of the earlier xref table
>>
startxref
680                              % offset of this new xref section
%%EOF

Een reader begint nog steeds bij de laatste %%EOF en volgt nog steeds startxref naar de meest recente tabel, maar volgt nu de /Prev-keten terug naar oudere tabellen. Ze worden samengevoegd, zodat de nieuwste vermelding voor een objectnummer altijd wint. De kruisverwijzingssecties vormen een gekoppelde lijst (linked list) doorheen het bestand, waarbij elke sectie de voorgaande overschrijft voor de objecten die het raakt. Een object dat bij een bewerking werd vervangen, bestaat nog steeds fysiek op zijn oude offset; het is simpelweg niet meer bereikbaar omdat een latere xref-vermelding naar een nieuwere locatie wijst

Dit is het mechanisme dat getekende PDF's verifieerbaar maakt. Een digitale handtekening dekt een bytebereik van het bestand, en omdat een incrementele update alleen maar toevoegt, verplaatsen de ondertekende bytes zich nooit. De handtekening wordt nog steeds gevalideerd tegen het oorspronkelijke bereik, terwijl latere herzieningen ernaast staan, elk met een eigen xref en trailer. Het is ook de reden waarom een PDF herstelbare historie (recoverable history) kan bevatten: elk vervangen object staat nog op de schijf onder een eerdere kruisverwijzingssectie. Dit is handig voor versiebeheer, maar een risico voor wie dacht dat "verwijderen" (delete) betekende dat de bytes daadwerkelijk weg waren

De prijs is groei. Elke bewerking voegt inhoud toe; er wordt lokaal niets teruggevorderd, waardoor een vaak herzien bestand dode objecten en een lange reeks xref-secties opstapelt. De remedie is een volledige herschrijving: laad het document en sla het als nieuw op. Hierbij worden de overlevende objecten gehernummerd, de onbereikbare objecten weggelaten en één schone kruisverwijzingstabel gegenereerd. De twee strategieën (toevoegen en herschrijven) wegen rechtstreeks tegen elkaar op. Toevoegen is snel en behoudt handtekeningen en historie; herschrijven is trager en vernietigt beide, in ruil voor een compacter bestand

De vier delen in de praktijk lezen

Als u de lay-out kent, is dat voldoende om de meeste "dit bestand wil niet openen"-problemen handmatig op te sporen en te debuggen. Als een reader een PDF weigert, zitten de daders meestal aan beide uiteinden en niet in het midden. Een afgebroken download verliest de trailer, waardoor startxref of %%EOF ontbreekt en de reader geen startpunt heeft. Tolerante readers vallen terug op het scannen van het hele bestand om de xref opnieuw op te bouwen, wat precies het langzame pad is dat de tabel moest voorkomen. Een mislukte tekstmodus-overdracht beschadigt streambytes of zorgt ervoor dat offsets niet langer overeenkomen met de werkelijkheid. Objecten laden dan op de verkeerde positie. Wanneer de offsets in de tabel niet langer naar echte obj-trefwoorden wijzen, is het bestand structureel stuk, zelfs als elk object afzonderlijk in orde is

Voor nieuwe code is de les van de lay-out om de byte-boekhouding over te laten aan een bibliotheek. De offsets in de kruisverwijzingstabel moeten tot op de byte nauwkeurig overeenkomen met de werkelijke posities van elk object, de trailer moet naar de juiste tabel wijzen en incrementele updates moeten correct via /Prev worden gekoppeld. Een native component zoals de HotPDF-component voor Delphi en C++Builder handelt dat allemaal af bij het wegschrijven van een bestand. Dit is inclusief de keuze tussen het toevoegen van een incrementele herziening en het herschrijven naar een compact bestand. Als u wilt zien hoe dezelfde structuur uit het niets wordt opgebouwd in plaats van ontleed, doorloopt het begeleidende stuk over het vanaf nul opbouwen van een PDF-document het genereren van de header, objecten, xref en trailer in de juiste volgorde