Technisch artikel

PDF-paginavolgorde: Hoe de paginaboom de paginavolgorde regelt

Objectnummer 1 is niet pagina 1. Dat ene feit doet meer PDF-verwerkingscode struikelen dan welk ander aspect van het formaat dan ook, en om te begrijpen waarom, moet u verder kijken dan wat een viewer u laat zien en kijken naar de objectengraaf (object graph) die de viewer daadwerkelijk leest.

Een PDF-bestand is een verzameling genummerde indirecte objecten. Elk object heeft een objectnummer en een generatienummer, en andere objecten wijzen ernaar met een referentie die wordt geschreven als N G R: 3 0 R betekent de huidige versie van object 3. Pagina's behoren tot die objecten, maar hun weergavevolgorde heeft niets te maken met waar ze zich in het bestand bevinden of welke nummers ze dragen. De weergavevolgorde wordt volledig bepaald door de /Pages-boom, een gekoppelde structuur die geworteld is in de documentcatalogus. Als u de boom negeert en objecten numeriek scant, verzamelt u de pagina's in de verkeerde volgorde voor een aanzienlijke fractie van de bestanden in de echte wereld.

De paginaboom: wat de volgorde daadwerkelijk bepaalt

Elke PDF begint met een documentcatalogus (ISO 32000-2 §7.7.2). De catalogus bevat een /Pages-item dat wijst naar de hoofdknoop (root node) van de paginaboom. Dat hoofdknooppunt is een woordenboek met /Type /Pages, een /Kids-array van indirecte referenties, en een /Count die het totale aantal bladpagina's (leaf-pages) daaronder aangeeft. De weergavevolgorde is de depth-first van-links-naar-rechts doorloop van die boom, punt uit.

Een minimaal bestand van drie pagina's maakt dit concreet:

%PDF-1.7

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

2 0 obj
<< /Type /Pages /Kids [20 0 R  4 0 R  9 0 R] /Count 3 >>
endobj

% Object 4 is stored third in the file but is page 2 in display order
4 0 obj
<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792]
   /Contents 5 0 R /Resources << /Font << /F1 6 0 R >> >> >>
endobj

% Object 9 is stored fourth but is page 3
9 0 obj
<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792]
   /Contents 10 0 R /Resources << /Font << /F1 6 0 R >> >> >>
endobj

% Object 20 is stored last but is page 1; Kids[0] decides, not object number
20 0 obj
<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792]
   /Contents 21 0 R /Resources << /Font << /F1 6 0 R >> >> >>
endobj

De /Kids-array leest [20 0 R 4 0 R 9 0 R], dus object 20 is pagina 1, object 4 is pagina 2 en object 9 is pagina 3. Objectnummering is niet relevant. Elke code die objecten in numerieke volgorde herhaalt (itereert) en de objecten verzamelt met /Type /Page, zal de verkeerde volgorde in dit bestand produceren.

Waarom produceren generatoren niet-sequentiële lay-outs? Om verschillende redenen. Een bibliotheek die vooraf objectnummers toewijst aan alle pagina's voordat de inhoud ervan wordt geschreven, nummert ze in de volgorde van aanmaak en schrijft vervolgens de daadwerkelijke bytes in de volgorde die bij de serializer past. Een samenvoegingstool (merge tool) die documenten aan elkaar hecht, hernummert objecten uit elk brondocument om botsingen te voorkomen; de gehernummerde pagina-objecten raken verspreid over de gecombineerde objecttabel, terwijl de nieuwe root /Kids-array de juiste weergavevolgorde bevat. Incrementele updates voegen nieuwe objecten aan het einde van het bestand toe met verse nummers, dus een pagina die als revisie wordt toegevoegd, leeft ergens aan het einde van de bytestream, zelfs als deze op positie 1 van de weergavevolgorde thuishoort.

Vlakke bomen en geneste subbomen

De specificatie staat twee vormen toe voor de paginaboom. Eenvoudige generatoren produceren een vlakke structuur: één root /Pages-knooppunt waarvan de /Kids-array niets anders bevat dan /Page-bladobjecten. Dat is eenvoudig te doorlopen: één niveau diep, één passage.

Grote documenten gebruiken daarentegen routinematig een gebalanceerde boom. De /Kids-array van de root /Pages-knoop bevat tussenliggende /Pages-knooppunten, die elk op hun beurt een eigen /Kids-array bevatten. De /Count op elk tussenliggend knooppunt rapporteert het totale aantal bladpagina's in de subboom ervan. Hierdoor kan een viewer volledige subbomen overslaan wanneer er op index naar een pagina wordt gesprongen, zonder elk object te hoeven parsen. Een document van 1.000 pagina's dat is gestructureerd als een gebalanceerde boom met 10 pagina's per bladknoop, kan pagina 750 lokaliseren via binair zoeken met drie of vier opzoekacties in woordenboeken in plaats van het scannen van 750 /Kids-vermeldingen.

De consequentie voor verwerkingscode: u kunt er niet van uitgaan dat het eerste niveau van /Kids /Page-objecten bevat. Elk kind moet gecontroleerd worden. Als zijn /Type /Pages is, ga dan recursief verder. Als zijn /Type /Page is, is het een blad. Stoppen op het eerste niveau laat stilletjes volledige subbomen weg bij elk document waar de generator ervoor koos om te nesten. Waarom schrijvers in de eerste plaats voor diepe bomen kiezen, wat afvlakkingstools opgeven, en hoe corruptie van /Count zich in de praktijk afspeelt, wordt behandeld in ons begeleidende artikel over de vorm van de paginaboom, fan-out en de integriteit van /Count.

Geërfde pagina-attributen

De paginaboom bevat ook een mechanisme voor het delen van bronnen. Bepaalde pagina-attributen: /MediaBox, /CropBox, /Resources en /Rotate zijn overerfbaar (inheritable, volgens ISO 32000-2 §7.7.3.4). Als een /Page-woordenboek een ervan weglaat, wandelt een reader de /Parent-keten op totdat deze het attribuut vindt of de root bereikt. Het plaatsen van een gedeeld lettertypewoordenboek in het root /Pages-knooppunt in plaats van het naar elke bladpagina te kopiëren, kan de bestandsgrootte aanzienlijk verkleinen voor documenten die overal dezelfde lettertypen gebruiken.

De overervingsregel creëert een subtiliteit voor code die pagina-eigenschappen leest. /MediaBox rechtstreeks uit een /Page-object lezen en een ontbrekende sleutel als een fout behandelen is onjuist; de sleutel kan eenvoudigweg zijn geërfd. Code die de paginageometrie correct oplost, moet de ouderketen volgen. Het heeft ook een cyclusbeveiliging nodig: een beschadigd bestand kan een /Parent-verwijzing hebben die terugwijst naar een reeds bezocht knooppunt. Dit zou een oneindige lus (loop) opleveren zonder een controle op bezochte objecten.

De xref-tabel en cross-reference streams

Het opzoeken van indirecte objecten gaat via de kruisverwijzingstabel (xref-tabel) (of de opvolger ervan, de cross-reference stream, geïntroduceerd in PDF 1.5). De xref koppelt elk objectnummer aan een byte-offset in het bestand. Een conforme reader gebruikt de xref om direct naar een willekeurig object te springen; het bestand wordt niet sequentieel gescand. Dat random-access-ontwerp is wat snel paginaspringen mogelijk maakt: de viewer leest de catalogus, lost de /Pages-verwijzing op via de xref, leest het root /Pages-knooppunt, lost een /Kids-vermelding op, enzovoort, waarbij het alleen de objecten raakt die nodig zijn.

Incrementele updates voegen een nieuwe xref-sectie toe aan het einde van het bestand, met een trailer die weer gekoppeld is aan de vorige. Een object dat in een revisie is bijgewerkt, krijgt een nieuwe vermelding in de toegevoegde xref-sectie. De oorspronkelijke bytes blijven op hun plek, maar worden vervangen (overruled). Dit is de manier waarop digitaal ondertekende PDF's verifieerbaar blijven, zelfs nadat er annotatie- of formulier-invulrevisies zijn toegevoegd: het ondertekende bytebereik wordt nooit aangeraakt en de nieuwe inhoud bevindt zich in de toegevoegde sectie. De paginaboom kan ook worden bijgewerkt, dus pagina-toevoegingen of -verwijderingen in een revisie produceren een nieuwe /Pages-root met een herziene /Kids-array, terwijl het oude root-object nog steeds zijn oorspronkelijke positie in het bestand inneemt. Gelineariseerde (voor het web geoptimaliseerde) bestanden voegen een draai toe aan de byte-indeling: de objecten voor pagina 1 worden fysiek naar het begin van het bestand verplaatst zodat een viewer de eerste pagina kan weergeven terwijl de rest nog aan het downloaden is. Toch blijft de paginaboom de enige autoriteit over de volgorde — alleen de offsets die in de xref zijn vastgelegd veranderen.

Wat er misgaat zonder de boom te doorlopen

De faalmodus voor object-scan-benaderingen is stil. Het uitvoerdocument ziet er plausibel uit: het heeft het juiste aantal pagina's en elke pagina bevat herkenbare inhoud. De volgorde is gewoon verkeerd, en op een manier die afhangt van de generator, het aantal herzieningen en of er pagina's zijn samengevoegd uit externe bronnen. Een testbestand van PDF's geproduceerd door een enkele tool slaagt mogelijk zonder fouten; bestanden van een andere tool of uit een samenvoegingsworkflow (merge workflow) zullen falen. Deze inconsistentie is de reden waarom heuristische oplossingen nooit standhouden. Voor een stap-voor-stap analyse van exact deze storing in een echt klantdocument — symptoom, verkeerde diagnose en de 'doorloop'-oplossing — raadpleegt u onze casestudy over het debuggen van paginavolgorde.

Bestanden met incrementele updates zijn hier bijzonder vatbaar voor. Pagina's die in latere revisies zijn toegevoegd of herschikt, dragen namelijk hoge objectnummers, terwijl de weergavevolgorde wordt beheerd door de bijgewerkte /Kids-array. Een scan die objecten in numerieke volgorde verwerkt, zal die laat-genummerde pagina's aan het einde plaatsen, ongeacht waar de boom zegt dat ze thuishoren.

De oplossing is niet ingewikkeld. Begin bij de catalogus, los de /Pages-referentie op, doorloop (walk) de /Kids-array recursief en genereer blaadjes (leaves) in de volgorde waarin u ze tegenkomt. Dat is per definitie de weergavevolgorde, ongeacht objectnummers, byte-offsets of bestandsstructuur. De meeste volwassen PDF-bibliotheken bieden een paginatelling en een geïndexeerde paginatoegang die dit al correct doen; het risico schuilt in code die het paginamodel van de bibliotheek omzeilt en de objectlaag direct raakt.

Eén structurele afwijking is de moeite waard om expliciet te behandelen: de /Count-waarde op een tussenliggend /Pages-knooppunt kan verkeerd zijn in slecht gevormde bestanden (malformed files). Vertrouwen op /Count voor controle van de grenzen en dan net voor een volledige doorloop stoppen, zal stilletjes pagina's weglaten wanneer de telling te laag is opgegeven. Het gebruiken van /Count enkel als prestatietip voor voorafgaande toewijzing van capaciteit of binair zoeken, en het afleiden van de werkelijke telling uit de doorloop, is het veiligere patroon voor belangrijke documenten.

 Volgend artikel