Technisch artikel

Bouw handmatig een minimale PDF: De vijf objecten die je nodig hebt

Een PDF is in de kern een plain-text container. Open de meeste bestanden in een hex-editor en de bovenkant is leesbaar: een versie-opmerking, dan een reeks genummerde objecten, vervolgens een kleine index en helemaal onderaan een aanwijzer (pointer) die een lezer vertelt waar te beginnen. Verwijder de compressie en het formaat is toegankelijk genoeg dat je een werkend document in een teksteditor kunt typen en het door een viewer kunt laten openen. Als je dat één keer doet, leer je meer over hoe een PDF in elkaar steekt dan welke hoeveelheid specificaties je ook leest, omdat je de objecten handmatig met elkaar moet verbinden (wiren) en het bestand weigert te openen totdat je de bedrading goed hebt

Deze walkthrough bouwt de kleinste PDF die daadwerkelijk iets weergeeft: één pagina, de woorden "Hello, World!" in een ingebouwd lettertype, op US Letter-papier. Het voltooide bestand heeft precies vijf objecten nodig en een paar regels boekhouding eromheen. We zullen eerst de objecten schrijven en vervolgens de header, kruisverwijzingstabel (cross-reference table) en trailer samenstellen die ze binden tot een bestand dat een lezer zal accepteren

De vijf objecten waarop een viewer staat

Een lezer scant een PDF niet van boven naar beneden op zoek naar inhoud. Het begint bij de trailer, volgt een verwijzing naar de documentencatalogus (document catalog) en doorloopt vanaf daar een keten van objecten. Elk object in die keten moet bestaan, anders mislukt het openen. Voor een document van één pagina is de keten kort en elke schakel heeft één enkele taak:

  • Catalog is de root. Het is het object waar de trailer naar wijst, en de enige vereiste invoer hier is een verwijzing naar de paginaboom (page tree)
  • Pages is de paginaboom-node (page tree node). Het somt de pagina's in het document op en meldt hoeveel het er zijn
  • Page beschrijft één fysieke pagina: de grootte, de resources waarmee het tekent en welke content stream het schildert
  • Content stream bevat de tekenoperatoren, de postfix-opdrachten die tekst en afbeeldingen op die pagina plaatsen
  • Font declareert het lettertype (typeface) waarnaar de content stream verwijst. Gebruik een van de 14 standaardlettertypen en je hoeft niets in te sluiten (embedden)

Elk object is genummerd en adresseerbaar. Een indirect object wordt geschreven als N 0 obj ... endobj, waarbij N het objectnummer is en de 0 het generatienummer (altijd 0 in een bestand dat je nieuw schrijft). Overal elders in het bestand wijs je naar dat object met een verwijzing: 5 0 R betekent "object 5". Die verwijzingen zijn de bedrading. De catalogus bevat 2 0 R in onze nummering om de paginaboom te bereiken, de paginaboom bevat een verwijzing terug naar de pagina, enzovoort. Als je een nummer verkeerd hebt, volgt de lezer een zwevende pointer (dangling pointer) in het niets

Namen, dictionaries en streams

Drie syntaxis-onderdelen dragen bijna alles. Een naam (name) begint met een schuine streep (slash): /Type, /Page, /F0. Namen zijn hoofdlettergevoelige identificatiecodes, geen strings, en PDF gebruikt ze voor dictionary-sleutels (keys) en om te markeren wat een object is. Een dictionary is een set sleutel-waardeparen (key-value pairs) verpakt in dubbele punthaken, waarbij elke sleutel een naam is: << /Type /Page /MediaBox [0 0 612 792] >>. Waarden kunnen getallen, namen, arrays in vierkante haken, verwijzingen of geneste dictionaries zijn. De meeste PDF-objecten zijn dictionaries

Een stream is een dictionary gevolgd door een blok bytes tussen de trefwoorden stream en endstream. Dat is waar de operatoren voor het tekenen van pagina's zich bevinden, en in echte bestanden waar ook gecomprimeerde afbeeldingen en ingesloten lettertypen leven. De stream-dictionary beschrijft de bytes; in een productiebestand moet het een /Length-vermelding bevatten die de exacte byte-telling geeft, en vaak een /Filter zoals /FlateDecode wanneer de data is gecomprimeerd. We gaan leunen op een tool om /Length in te vullen, omdat handmatig bytes tellen het deel van deze oefening is met nul educatief rendement en een grote kans op een off-by-one fout die het bestand breekt

De objecten schrijven

Hier zijn de vijf objecten op volgorde. Het coördinaatdetail om in gedachten te houden voordat je de content stream leest: PDF meet vanaf de linkerbenedenhoek van de pagina in punten (points), waarbij één punt 1/72 inch is, en Y groeit naar boven. Een US Letter-pagina is 612 bij 792 punten, dus 50 700 bevindt zich linksboven, niet onderaan

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 << /F0 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
/F0 36 Tf
50 700 Td
(Hello, World!) Tj
ET
endstream
endobj

Lees de verwijzingen en de structuur valt op zijn plek. Object 1, de catalogus, wijst met zijn /Pages-invoer naar object 2. Object 2, de paginaboom, vermeldt object 3 in /Kids en declareert /Count 1. Object 3, de pagina, wijst met /Parent terug omhoog naar object 2 (de boom en de pagina verwijzen naar elkaar, wat vereist is), bepaalt zijn eigen grootte met /MediaBox, stelt het lettertype bloot onder de lokale naam /F0 in zijn /Resources, en benoemt object 5 als zijn inhoud (content). Object 4 is het lettertype: /BaseFont /Helvetica kiest een van de 14 standaardlettertypen die elke conforme lezer al heeft, dus er is niets om in te sluiten. Object 5 is de content stream

Wat de content stream eigenlijk zegt

De stream body is een piepklein programma in de paginabeschrijvingstaal (page-description language) van PDF, die postfix is: operanden komen eerst, daarna de operator die ze consumeert. Vijf regels doen het werk. BT en ET openen en sluiten een tekstobject; alles wat tekst positioneert of toont, moet daartussen zitten. /F0 36 Tf stelt het huidige lettertype in op de resource met de naam /F0 op 36 punten (Tf is "set text font and size"). 50 700 Td verplaatst de tekstpositie naar (50, 700) in paginacoördinaten. (Hello, World!) Tj toont de string, die PDF als letterlijke tekst tussen haakjes schrijft, met behulp van Tj om het op de huidige positie te schilderen. Laat BT/ET weg en een strikte lezer verwerpt de tekstoperatoren; vergeet een lettertype in te stellen vóór Tj en er is geen huidig lettertype om mee te tekenen

De /Length 44 in de stream-dictionary is het aantal bytes tussen stream en endstream, en het moet exact zijn. Dit is de waarde die het waard is om over te dragen aan een tool in plaats van handmatig regeleinden (newlines) te tellen, vooral omdat het de totale telling verandert afhankelijk van of je editor regeleinden als LF of CRLF schrijft

Header, xref en trailer

De objecten vormen de inhoud. Drie structurele stukken maken er een bestand van. De eerste is de header, de allereerste regel, die het formaat en de versie benoemt:

%PDF-1.7

De % begint een opmerking in PDF-syntaxis, maar een lezer behandelt deze specifieke opmerking als de formaathandtekening (format signature) en leest de versie eruit. Een echte schrijver (writer) volgt dit onmiddellijk op met een tweede opmerkingsregel van high-bit bytes, een hint voor bestandsoverdrachtstools (file-transfer tools) dat het bestand binair is en niet als tekst mag worden verminkt

Aan het einde van het bestand bevindt zich de kruisverwijzingstabel (cross-reference table), de index die random access mogelijk maakt. Het registreert de byte-offset van elk object vanaf het begin van het bestand, zodat een lezer rechtstreeks naar object 3 kan zoeken zonder eerst objecten 1 en 2 te parseren. De tabel is rigide: vermeldingen hebben een vaste breedte, 20 bytes elk inclusief het regeleinde, geformatteerd als een 10-cijferige offset, een 5-cijferige generatie, een sleutelwoord (n voor in gebruik, f voor vrij) en een twee-byte terminator. Een correcte tabel voor onze zes vermeldingen (object 0 is altijd het begin van de free-list) ziet er als volgt uit:

xref
0 6
0000000000 65535 f
0000000009 00000 n
0000000058 00000 n
0000000115 00000 n
0000000235 00000 n
0000000308 00000 n
trailer
<< /Size 6
   /Root 1 0 R
>>
startxref
408
%%EOF

Die offsets zijn het kwetsbare deel van het handmatig schrijven van PDF. Elke offset is de exacte bytepositie waar het bijbehorende N 0 obj begint, en elke offset verschuift op het moment dat je ergens daarboven een teken toevoegt. De trailer is het toegangspunt dat een lezer als laatste en als eerste gebruikt: /Root 1 0 R benoemt de catalogus, /Size 6 vermeldt het aantal objecten en startxref 408 geeft de byte-offset van het woord xref zelf. Een lezer opent het bestand, springt naar het einde, leest startxref, zoekt naar de kruisverwijzingstabel en bereikt vanaf daar de catalogus en alles eronder. %%EOF markeert de laatste byte

Laat een tool de byte-tellingen repareren

De bovenstaande offsets zijn illustratief; in de praktijk zullen ze verkeerd zijn tegen de tijd dat je klaar bent met typen, omdat ze afhankelijk zijn van de exacte byte-layout van je bestand. In plaats van ze opnieuw te berekenen, schrijf je de structuur met tijdelijke waarden (placeholder values) en laat je een hulpprogramma (utility) de kruisverwijzingstabel en stream-lengtes opnieuw opbouwen. Het gratis, cross-platform programma pdftk doet dit in één keer:

pdftk hello-draft.pdf output hello.pdf

Het parseert je objecten, herberekent elke byte-offset, vult de juiste /Length-waarden in, schrijft een geldige xref-tabel en trailer, en geeft hello.pdf uit. Open dat in een viewer en je krijgt één pagina met "Hello, World!" in 36-punts Helvetica bovenaan. Qpdf doet hetzelfde werk, en veel viewers repareren ook direct een ietwat misvormd bestand (malformed file). Het doel van het leunen op een tool hier is niet luiheid; het is dat de offset-rekenkunde het enige deel van het formaat is met nul conceptuele inhoud en het hoogste foutpercentage, dus door het te automatiseren blijft de structuur hetgene wat je leert

Waarom dit schaalt naar echte documenten

Niets aan een rapport van honderd pagina's verandert de vorm die je zojuist hebt gebouwd. De catalogus zit nog steeds in de root, de paginaboom verzamelt nog steeds de pagina's, en elke pagina wijst nog steeds naar zijn resources en een content stream. Wat groeit is de breedte, niet de ruggengraat: de paginaboom vertakt zich zodat een lezer hele subtrees kan overslaan, content streams dragen honderden operatoren in plaats van vijf, lettertypen worden ingesloten als hun eigen stream-objecten met breedtetabellen en coderingen (encodings), en afbeeldingen komen binnen als streams met afbeeldingsspecifieke filters. Moderne bestanden hebben ook de neiging om veel objecten in gecomprimeerde object-streams te verpakken en de gewone xref-tabel te vervangen door een cross-reference stream, en dat is waarom het openen van een echte PDF in een teksteditor meestal een muur van binair materiaal laat zien. Het onderliggende model is identiek aan dat in je handgemaakte bestand. Voor de bredere object-graaf en hoe de catalogus, paginaboom en resource-dictionaries in een groter document aan elkaar gerelateerd zijn, gaat de diepgaande tour door de PDF-documentstructuur verder waar dit ophoudt, en het overzicht van de bestandsstructuur behandelt incrementele updates en hoe de trailer over revisies heen wordt gekoppeld

Van handmatig schrijven naar een bibliotheek

Handmatig objecten typen is een leeroefening, geen productietechniek. Op het moment dat je echte lettertypen, verpakte tekst (wrapped text), afbeeldingen of meer dan een triviale pagina nodig hebt, wordt de byte-boekhouding die pdftk voor je patchte de hele klus, en dan wil je een bibliotheek die daar de baas over is. Dezelfde vijf objecten worden nog steeds geschreven, maar een bibliotheek berekent elke offset, beheert de lettertype- en resource-dictionaries en comprimeert de content streams zonder dat jij ook maar één byte hoeft bij te houden. In Delphi en C++Builder reduceert de HotPDF Component dit hele bestand tot een handvol aanroepen: stel het document in, roep BeginDoc, SetFont en TextOut aan om dezelfde begroeting te plaatsen, en dan EndDoc om een correcte catalogus, paginaboom, xref en trailer te schrijven. Het begrijpen van de onderliggende objecten stelt je in staat om over de uitvoer na te denken wanneer een document niet wordt weergegeven zoals je had verwacht