Technisch artikel

PDF paginavolgorde bugs in HotPDF: Fysieke vs. logische structuur

Het symptoom dook op in een hulpprogramma voor het kopiëren van pagina's dat bovenop HotPDF Component is gebouwd: vragen om pagina 1 van een document van drie pagina's produceerde steevast pagina 2. Controle van de indexeringslogica vond niets verkeerds. De aanroep gebruikte een logische index op basis van 0 (0-based logical index), de rekenkunde klopte, grensvoorwaarden waren in orde. Toch kwam elke keer de verkeerde pagina eruit.

De bug zat helemaal niet in de kopieercode. Het zat in de manier waarop HotPDF zijn interne paginamatrix (page array) opbouwde bij het laden van het bestand.

Concept van PDF paginavolgorde: verschil tussen fysieke volgorde en logische volgorde
PDF paginavolgorde: de /Kids-array in de Pages-boom definieert de logische reeks, onafhankelijk van hoe objecten zijn genummerd of opgeslagen in het bestand.

Twee volgordes, één bron van verwarring

Een PDF-bestand is een verzameling indirecte objecten, elk geïdentificeerd door een objectnummer. De bestandsstructuur legt geen verplichting op aan die nummers om de leesvolgorde te weerspiegelen. Object 1 kan pagina 2 bevatten; object 20 kan pagina 1 bevatten. Wat de leesvolgorde daadwerkelijk definieert, is de paginaboom (page tree): een hiërarchie van /Pages-dictionaries waarvan de /Kids-arrays paginaverwijzingen opsommen in de reeks waarin een viewer ze zou moeten weergeven (ISO 32000-1 §7.7.3).

Het document dat de bug veroorzaakte, had deze paginaboomstructuur:

{ Pages tree root, object 16 }
16 0 obj
<<
  /Type /Pages
  /Count 3
  /Kids [20 0 R   { logical page 1 }
         1 0 R    { logical page 2 }
         4 0 R]   { logical page 3 }
>>
endobj

Het bestand vermeldde toevallig object 1 en object 4 vóór object 20 in de bytestroom. Elke parser die de indirecte objecten in bestandsvolgorde doorliep (itereerde) en ze in een PageArr stampte naarmate het dictionaries van het pagina-type vond, zou eindigen met object 1 op index 0, object 4 op index 1 en object 20 op index 2. Logische pagina 1 bevindt zich op PageArr[2]. Vragen om pagina-index 0 haalt in plaats daarvan logische pagina 2 op.

Dat is precies wat beide interne parseerpaden van HotPDF deden. Het traditionele pad, gebruikt voor PDF 1.3/1.4-bestanden, en het moderne pad, gebruikt voor object-stream-documenten (PDF 1.5+), bouwden elk de PageArr op door indirecte objecten in fysieke bestandsvolgorde te doorlopen in plaats van de /Kids-keten te volgen.

De hypothese bevestigen

Voordat een oplossing kon worden aangeraakt, moest de mismatch worden bewezen in plaats van aangenomen. De qpdf command-line tool maakt dit eenvoudig:

{ shell }
qpdf --show-pages input.pdf
{ Output reveals Kids order: 20 0 R, then 1 0 R, then 4 0 R }

qpdf --show-object="16 0 R" input.pdf
{ Shows the Pages dictionary with /Kids in reading order }

Elke pagina afzonderlijk extraheren en de bestandsgroottes controleren bevestigde de mapping: wat PageArr[0] produceerde, was de inhoud die behoorde bij logische pagina 2, en PageArr[2] bevatte logische pagina 1. De circulaire verschuiving was de smoking gun. Dit verklaarde ook waarom het probleem verscheen bij meerdere verschillende brondocumenten: elke PDF waarbij pagina-objecten toevallig lagere objectnummers hadden dan een eerdere logische pagina, zou het veroorzaken.

Er is een eenvoudige reden waarom PDF's in deze staat belanden. Incrementele opslag (incremental saves) voegt bijgewerkte objecten toe met nieuwe objectnummers, waardoor de oude slots in de kruisverwijzingstabel nergens naar wijzen. Editors die een voorblad toevoegen, voegen dit in met een hoog objectnummer, ongeacht de positie ervan in de Kids-array. Sommige generatoren schrijven pagina's gewoon in een volgorde die handig is voor het streamen van inhoud (content streaming) in plaats van in de logische paginavolgorde. Het PDF-formaat vereist niet dat ze het anders doen.

De oplossing: volg de Kids-array

De juiste benadering is om PageArr op te bouwen door de /Kids-keten vanaf de root van de catalogus te doorlopen, en niet door indirecte objecten te scannen. Nadat beide parseerpaden hun initiële pass (doorgang) hebben voltooid, lost een nabewerkingsstap de logische volgorde op:

procedure THotPDF.ReorderPageArrByPagesTree;
var
  PagesObj  : THPDFDictionaryObject;
  KidsArray : THPDFArrayObject;
  NewPageArr: array of THPDFDictArrItem;
  I, J, PageIndex, KidsIndex: Integer;
  RefObj    : THPDFLink;
  PageObjNum: Integer;
  Found     : Boolean;
begin
  { Locate root /Pages dictionary via FRootIndex }
  PagesObj := FindPagesRootFromCatalog;
  if PagesObj = nil then Exit;

  KidsIndex := PagesObj.FindValue('Kids');
  if KidsIndex < 0 then Exit;
  KidsArray := THPDFArrayObject(PagesObj.GetIndexedItem(KidsIndex));

  SetLength(NewPageArr, KidsArray.Items.Count);
  PageIndex := 0;

  for I := 0 to KidsArray.Items.Count - 1 do
  begin
    RefObj     := THPDFLink(KidsArray.GetIndexedItem(I));
    PageObjNum := RefObj.Value.ObjectNumber;

    Found := False;
    for J := 0 to Length(PageArr) - 1 do
    begin
      if PageArr[J].PageLink.ObjectNumber = PageObjNum then
      begin
        NewPageArr[PageIndex] := PageArr[J];
        Inc(PageIndex);
        Found := True;
        Break;
      end;
    end;
    { Non-page Kids (intermediate /Pages nodes) produce no match; skip }
  end;

  if PageIndex > 0 then
  begin
    SetLength(PageArr, PageIndex);
    for I := 0 to PageIndex - 1 do
      PageArr[I] := NewPageArr[I];
  end;
end;

De aanroep gaat erin aan het einde van elk parseerpad, nadat alle objecten zijn gecatalogiseerd maar voordat een paginabewerking wordt afgehandeld:

{ Traditional path }
ListExtDictionary(THPDFDictionaryObject(IndirectObjects.Items[I]), FPageslink);
ReorderPageArrByPagesTree;
Break;

{ Modern path (object streams) }
if TryParseModernPDF then
begin
  Result := ModernPageCount;
  ReorderPageArrByPagesTree;
  Exit;
end;

De herordeningsstap is O(n * m) waarbij n het aantal Kids is en m de huidige lengte van PageArr, maar voor elk document met een platte paginaboom (alle bladeren (leaves) op diepte 1, wat de overgrote meerderheid van echte PDF's dekt) zijn beide dezelfde waarde en is de kostprijs verwaarloosbaar. Diep geneste paginabomen vereisen een recursieve wandeling (recursive walk) in plaats van de hier getoonde aanpak op één niveau; de productie-implementatie behandelt dat geval afzonderlijk.

CopyPageFromDocument gebruiken na de reparatie

Met ReorderPageArrByPagesTree op zijn plaats, werken logische pagina-indexen zoals verwacht. Het niveau hoger liggende CopyPageFromDocument neemt een 0-based logische index en kopieert de juiste pagina naar het bestemmingsdocument:

var
  Source, Dest: THotPDF;
begin
  Source := THotPDF.Create(nil);
  Dest   := THotPDF.Create(nil);
  try
    Source.LoadFromFile('source.pdf');

    Dest.FileName := 'extracted.pdf';
    Dest.BeginDoc;

    { Copy logical page 0 (first page the user sees) }
    Dest.CopyPageFromDocument(Source, 0, 0);

    Dest.EndDoc;
  finally
    Source.Free;
    Dest.Free;
  end;
end;

CopyPageFromDocument vraagt intern de volgorde van de paginaboom op in plaats van te vertrouwen op de ruwe PageArr-index, zodat het zich correct gedraagt, zelfs bij documenten waar fysieke en logische volgorde uiteenlopen. Voor batchbewerkingen accepteert InsertPagesFromDocument een array van logische indices en kopieert ze in één doorgang.

Wat dit onthult over het parseren van PDF's

De PDF-specificatie is expliciet: de logische paginavolgorde wordt gedefinieerd door de /Kids-array van de paginaboom, niet door objectnummers of byte-offsets (ISO 32000-1 §7.7.3.2). Elke parser die een andere volgorde als afkorting (shortcut) gebruikt, zal correcte resultaten produceren bij de meerderheid van de documenten die het ziet, omdat de meeste generatoren pagina's in de natuurlijke volgorde schrijven en sequentiële objectnummers toewijzen. De bug verbergt zich totdat iemand een PDF laadt die incrementeel is bewerkt, gereorganiseerd door een andere tool, of gegenereerd door software die een andere lay-out heeft gekozen.

Uitsluitend testen met zelfgegenereerde PDF's mist deze klasse van problemen volledig. De oplossing voor een regressie in paginavolgorde heeft daarom een corpus van documenten uit gevarieerde bronnen nodig: incrementele opslag (incremental saves), gescande documenten met ingevoegde voorbladen, PDF's geproduceerd door tools die de objectgraaf anders lineariseren of optimaliseren. Een document dat de oorspronkelijke bug veroorzaakte, moet permanent in de regressiesuite blijven.

De HotPDF Component pagina behandelt de volledige API voor paginabewerkingen, inclusief CopyPageFromDocument, InsertPagesFromDocument en MovePage.