Technisch artikel

Het logische PDF-objectmodel: Typen, verwijzingen en structuur

Een PDF-bestand is, in de kern, een verzameling objecten die naar elkaar wijzen. Verwijder de compressie, de boekhouding van de kruisverwijzingen (cross-reference bookkeeping) en de byte-offsets, en wat overblijft is een graaf: een kleine set van getypeerde waarden, met elkaar verbonden door verwijzingen, geworteld in één enkel object dat de lezer weet te vinden. Alles wat een PDF kan uitdrukken, van een alinea tekst tot een ingesloten lettertype (embedded font) of een digitale handtekening, is opgebouwd uit acht primitieve objecttypen en de regel die het ene object in staat stelt naar het andere te verwijzen. Leer die, en de rest van het formaat leest als een compositie in plaats van een mysterie

Dit is de logische laag van PDF, gedefinieerd in ISO 32000-1 clausule 7.3, en deze bevindt zich één niveau boven de fysieke bestandsindeling (de header, body, cross-reference table en trailer, wat een op zichzelf staand onderwerp is in het technische overzicht van de PDF-bestandsstructuur). Het logische model is wat die bytes betekenen zodra ze zijn geparseerd. Een viewer leest het bestand achterstevoren om de trailer te vinden, volgt het naar de root, en van daaruit ontvouwt het document zich als objecten die naar objecten verwijzen. Dit is het deel waarover je nadenkt wanneer je een foutieve pagina (malformed page) debugt, een parser schrijft of erop vertrouwt dat een bibliotheek een document samenstelt

Acht objecttypen, en verder niets

PDF definieert precies acht basisobjecttypen. Elke waarde in een document is er één van, en dat is wat het formaat beheersbaar houdt ondanks het bereik ervan

Booleans zijn de sleutelwoorden true en false. Ze schakelen vlaggen (flags) in en uit, zoals of een annotatie wordt afgedrukt

Numbers komen in twee smaken die de specificatie als één type behandelt: gehele getallen (integers) zoals 42 en reële getallen (reals) zoals 3.14 of -0.002. PDF heeft geen exponentnotatie, dus je zult nooit 1e6 in een conform bestand zien. Coördinaten, lettergroottes en rotatiehoeken zijn allemaal getallen

Strings bevatten sequenties van bytes, geschreven tussen haakjes, (Hello), of in punthaken (angle brackets) als hexadecimaal, <48656C6C6F>. Beide notaties coderen identieke inhoud; hex is het ontsnappingsluik (escape hatch) voor bytes die lastig zijn tussen haakjes. Strings dragen tekst, maar ze zijn in de eerste plaats bytes, wat uitmaakt op het moment dat je iets buiten ASCII verwerkt

Names zijn atomaire tokens geïntroduceerd door een schuine streep (slash): /Type, /Pages, /MediaBox. Een naam is geen string; het is een identifier (identificatiecode), gebruikt als een dictionary-sleutel of een geïnumereerde waarde, en twee namen zijn alleen gelijk als ze byte voor byte overeenkomen. De slash is syntaxis, geen deel van de naam. Dit is een valkuil voor nieuwkomers die /Times-Roman en de string (Times-Roman) als inwisselbaar behandelen; het formaat doet dat niet

Arrays zijn geordende, heterogene lijsten tussen vierkante haken: [0 0 612 792] is een paginarechthoek, en een array kan typen vrij mengen, inclusief verwijzingen naar andere objecten. Dictionaries zijn het werkpaard. Geschreven tussen << en >>, wijst een dictionary naamsleutels (name keys) toe aan waarden van een willekeurig type, en bijna elke betekenisvolle structuur in PDF, of het nu een pagina, catalogus, lettertype of annotatie is, is een dictionary met een /Type-sleutel die aangeeft wat het is

Streams zijn dictionaries met een staart van onbewerkte bytes (raw bytes) tussen de trefwoorden stream en endstream. De dictionary beschrijft de bytes (hun lengte en eventuele filters zoals FlateDecode die ze comprimeren), en de bytes dragen de omvangrijke payload: pagina-inhoudinstructies, ingesloten lettertypeprogramma's, afbeeldingen. Een stream is waar PDF alles neerzet dat te groot of te binair is om inline te staan

Het achtste type is het null-object, het sleutelwoord null. Het is een echte waarde, te onderscheiden van een sleutel die afwezig is. Een dictionary-vermelding (entry) ingesteld op null wordt behandeld alsof deze niet aanwezig is, en een verwijzing die naar een niet-bestaand object leidt, levert ook null op in plaats van een fout. Dat vergevingsgezinde gedrag is opzettelijk: het laat een beschadigd bestand degraderen in plaats van te weigeren open te gaan. Er is geen negende type; alles wat PDF uitdrukt komt voort uit hoe deze acht zich combineren

Directe waarden, indirecte objecten en verwijzingen

Elk van die acht typen kan op twee manieren verschijnen. Een direct object wordt ter plaatse geschreven, zoals de 612 in een MediaBox-array. Een indirect object krijgt een identiteit zodat andere objecten ernaar kunnen wijzen: twee gehele getallen (integers), een objectnummer en een generatienummer, waarbij de definitie wordt verpakt in obj en endobj:

12 0 obj
<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>
endobj

Dit is object 12, generatie 0, een lettertype-dictionary. Overal elders in het bestand verwijst een ander object ernaar met een indirecte verwijzing (indirect reference): dezelfde twee nummers gevolgd door het sleutelwoord R, 12 0 R. De verwijzing is een pointer. Wanneer de resource-dictionary van een pagina /Font << /F1 12 0 R >> zegt, benoemt het object 12 als het lettertype achter de resource-naam /F1, zonder de definitie van het lettertype naar de pagina te kopiëren

Het generatienummer bestaat voor verwijderingen en hergebruik. Wanneer een object wordt vrijgegeven (freed) en de sleuf (slot) ervan opnieuw wordt gebruikt, wordt de generatie met één verhoogd, zodat een verouderde 12 0 R niet kan verwijzen naar de nieuwe huurder van slot 12. Vers geschreven bestanden zijn bijna allemaal generatie 0, maar een zwaar bewerkt bestand kan hogere nummers dragen, en een parser die de generatie negeert, zal uiteindelijk het verkeerde object lezen

Indirectie is wat PDF efficiënt en bewerkbaar maakt. Eén lettertype, afbeelding of kleurruimte (color space) kan één keer worden gedefinieerd en vanaf honderd pagina's worden gerefereerd. Een kleine verandering kan worden toegevoegd als een nieuwe revisie die een enkel object vervangt in plaats van het hele bestand te herschrijven. De kruisverwijzingstabel is de index die een objectnummer omzet in een byte-offset, zodat de lezer rechtstreeks naar 12 0 obj springt zonder te scannen, maar dat is een fysieke optimalisatie. Logisch gezien hoef je alleen maar te weten dat 12 0 R "het object geïdentificeerd als 12 0" betekent

De catalogus: waar elk document begint

Het oplossen van verwijzingen moet ergens beginnen, en dat ergens is de /Root-vermelding van de trailer, die wijst naar de documentcatalogus (document catalog): de root van de objectgraaf, een dictionary met /Type /Catalog. De lezer bereikt dit als eerste omdat de trailer als eerste wordt gevonden, en van daaruit is elk ander deel van het document bereikbaar door verwijzingen te volgen

De catalogus bevat slechts twee strikt vereiste vermeldingen: het /Type en /Pages, een indirecte verwijzing naar de root van de paginaboom (page tree). De rest is optioneel en beschrijft documentbreed gedrag in plaats van inhoud: /Outlines wijst naar de bladwijzerboom (bookmark tree), /Names bevat naambomen (name trees) gecodeerd (keyed) door strings, /Metadata verwijst naar een XMP metadata-stream, en /PageMode en /PageLayout suggereren hoe een viewer het document zou moeten openen. Geen daarvan is nodig om een pagina weer te geven; ze configureren de ervaring rondom de pagina's. De bladwijzer-, metadata- en annotatiestructuren die aan de catalogus hangen, worden besproken in het artikel over PDF-metadata, bladwijzers en annotaties

Het onderstaande diagram laat zien waar de object-body zich in het omringende bestand bevindt. De catalogus en paginaboom leven in die body als gewone indirecte objecten; de header, kruisverwijzingstabel en trailer eromheen vormen de fysieke steigers (scaffolding) waarmee een lezer ze kan lokaliseren

Diagram van de vier fysieke secties van een PDF-bestand: een versie-header, een body met de documentobjecten (inclusief catalogus en paginaboom), een kruisverwijzingstabel van object-offsets, en een trailer die naar de root wijst

De paginaboom: een gebalanceerde hiërarchie van pagina's

Vanuit /Pages vertakt het document zich in de paginaboom, waar PDF's keuze voor een graaf boven een platte lijst (flat list) zijn vruchten afwerpt. Pagina's worden niet opgeslagen als een eenvoudige reeks; ze hangen aan een boom waarvan de binnenste nodes (interior nodes) paginaboom-nodes (/Type /Pages) zijn en waarvan de bladeren pagina-objecten (/Type /Page). Een binnenste node somt zijn kinderen op in een /Kids-array en registreert, in /Count, hoeveel bladpagina's (leaf pages) eronder leven. Elke node behalve de root draagt een /Parent-verwijzing terug omhoog, zodat de boom in beide richtingen bewandeld kan worden

2 0 obj                                  % root van de paginaboom
<< /Type /Pages /Kids [3 0 R 4 0 R] /Count 3 >>
endobj

3 0 obj                                  % een bladpagina (leaf page)
<< /Type /Page /Parent 2 0 R
   /MediaBox [0 0 612 792]
   /Resources << /Font << /F1 12 0 R >> >>
   /Contents 5 0 R >>
endobj

4 0 obj                                  % een interior node die nog twee pagina's groepeert
<< /Type /Pages /Parent 2 0 R /Kids [6 0 R 7 0 R] /Count 2 >>
endobj

Hier is object 2 de root, met drie pagina's eronder: de bladpagina 3, plus nog twee bereikbaar via interior node 4. De /Count van 3 van de root moet gelijk zijn aan het totale aantal bladeren eronder, en een telling die niet overeenkomt met de werkelijke structuur is een veelvoorkomende manier waarop een handmatig bewerkt bestand misgaat. Het nut van de boom is de lokaliteit van toegang (locality of access). Een lezer die pagina 900 van een document van duizend pagina's opent, doorloopt geen 900 objecten; het daalt af via een handvol nodes, omdat een goedgevormde boom ondiep en gebalanceerd blijft. Een dergelijke boom met de hand bouwen is gepriegel genoeg om het de moeite waard te maken dit van begin tot eind te zien, wat de walkthrough over het helemaal vanaf nul opbouwen van een PDF-document doet

De boom verdient zijn tweede functie door middel van overerving (inheritance). Een handvol pagina-attributen, /Resources, /MediaBox, /CropBox en /Rotate, kan op een interior node worden ingesteld en worden weggelaten op de individuele pagina's, die vervolgens de waarde van de dichtstbijzijnde voorouder (ancestor) erven. Stel /MediaBox één keer in op de root en elk blad krijgt dezelfde paginagrootte zonder deze te herhalen; een pagina die moet afwijken, declareert zijn eigen grootte. Dit is de enige plaats in het objectmodel waar de betekenis van een waarde afhangt van de positie van een object in de boom, niet alleen van de eigen inhoud

Wat een bladpagina daadwerkelijk bevat

Een pagina-object is het verbindingspunt tussen het structurele model en de zichtbare inhoud. Zijn /Contents-vermelding verwijst naar een of meer content streams, de tekenoperatoren die tekst en afbeeldingen op de pagina schilderen. Zijn /Resources-dictionary benoemt de lettertypen, afbeeldingen en kleurruimten (color spaces) waarop die operatoren vertrouwen, waarbij elke vermelding een indirecte verwijzing is naar een object dat over pagina's wordt gedeeld. De /MediaBox geeft de paginarechthoek (page rectangle) in punten (1/72 inch), en vermeldingen zoals /Rotate en /CropBox passen aan hoe deze wordt gepresenteerd

Die arbeidsverdeling is het hele model in het klein. De pagina-dictionary is structuur: getypeerde vermeldingen en verwijzingen die vertellen wat de pagina is en waarmee deze tekent. De content stream is instructies: een afzonderlijke, comprimeerbare blob die vertelt hoe er getekend moet worden. Het lettertype achter /F1 is een gedeelde resource, één keer gedefinieerd en aangeduid waar het ook wordt gebruikt. Dictionary, stream en verwijzing werken samen om één pagina weer te geven, en dezelfde patronen schalen naar het hele document. De operatoren in de content stream in die blob worden apart behandeld voor tekst en lettertypen en voor graphics en visuele elementen

Waarom het de moeite waard is dit model te kennen

De meeste ontwikkelaars komen pas met het objectmodel in aanraking als er iets kapot gaat: een pagina wordt blanco weergegeven omdat de /Contents-verwijzing zweeft (dangles), tekst komt eruit als vakjes omdat een font-resource nooit is ingesloten, een tool rapporteert een /Count die niet overeenkomt met de pagina's die het kan vinden. Elk van deze is een uitspraak over de graaf, en de graaf direct lezen is beter dan gissen. De acht typen en de verwijzingsregel zijn een klein genoeg vocabulaire om in je hoofd te houden, en zodra je een PDF ziet als objecten die naar objecten wijzen, houden foutieve bestanden (malformed files) op met ondoorzichtig te zijn

Dat gezegd hebbende, is het handmatig schrijven van het model zelden de juiste keuze buiten leerdoeleinden. Het consistent houden van kruisverwijzing-offsets (cross-reference offsets), generatienummers, paginaboomtellingen en streamlengtes bij bewerkingen is het soort boekhouding dat een bibliotheek geacht wordt af te handelen. In productie beheert een volwassen PDF-ontwikkelbibliotheek de objectgraaf terwijl het jou laat denken in pagina's en inhoud. Het model kennen loont nog steeds: je begrijpt wat de bibliotheek daaronder opbouwt en waarom