Symptomet dök upp i ett sidkopieringsverktyg byggt ovanpå HotPDF Component: att be om sida 1 i ett tresidigt dokument producerade konsekvent sida 2. En kontroll av indexeringslogiken hittade inget fel. Anropet använde ett 0-baserat logiskt index, aritmetiken var korrekt, gränsvillkoren var okej. Ändå kom fel sida ut varje gång
Buggen fanns inte alls i kopieringskoden. Den låg i hur HotPDF byggde sin interna sidarray när filen laddades

Två ordningar, en källa till förvirring
En PDF-fil är en samling indirekta objekt, vart och ett identifierat av ett objektnummer. Filstrukturen ålägger inga krav på att dessa nummer ska återspegla läsordningen. Objekt 1 kan innehålla sida 2; objekt 20 kan innehålla sida 1. Det som faktiskt definierar läsordningen är sidträdet: en hierarki av /Pages-ordlistor (dictionaries) vars /Kids-arrayer listar sidreferenser i den ordningsföljd som en visare bör visa dem (ISO 32000-1 §7.7.3)
Dokumentet som utlöste buggen hade denna sidträdsstruktur:
{ Pages-trädets rot, objekt 16 }
16 0 obj
<<
/Type /Pages
/Count 3
/Kids [20 0 R { logisk sida 1 }
1 0 R { logisk sida 2 }
4 0 R] { logisk sida 3 }
>>
endobj
Filen råkade lista objekt 1 och objekt 4 före objekt 20 i byteströmmen. Vilken tolkare (parser) som helst som itererade genom indirekta objekt i filordning och stämplade in dem i en PageArr allteftersom den hittade sid-ordlistor, skulle sluta med objekt 1 på index 0, objekt 4 på index 1 och objekt 20 på index 2. Logisk sida 1 sitter på PageArr[2]. Att be om sidindex 0 hämtar därmed logisk sida 2 istället
Det är exakt vad båda HotPDF:s interna tolkningsvägar gjorde. Den traditionella vägen, som används för PDF 1.3/1.4-filer, och den moderna vägen, som används för objektströmsdokument (PDF 1.5+), byggde båda PageArr genom att stega igenom indirekta objekt i fysisk filordning snarare än att följa /Kids-kedjan
Att bekräfta hypotesen
Innan man rörde någon fix behövde diskrepansen bevisas istället för att antas. Kommandoradsverktyget qpdf gör detta rakt på sak:
{ skal }
qpdf --show-pages input.pdf
{ Utdata avslöjar Kids-ordningen: 20 0 R, sedan 1 0 R, sedan 4 0 R }
qpdf --show-object="16 0 R" input.pdf
{ Visar Pages-ordlistan med /Kids i läsordning }
Att extrahera varje sida individuellt och kontrollera filstorlekar bekräftade mappningen: vad PageArr[0] producerade var innehållet som tillhörde logisk sida 2, och PageArr[2] höll logisk sida 1. Den cirkulära förskjutningen var det avgörande beviset (the smoking gun). Detta förklarade också varför problemet dök upp över flera olika källdokument: vilken PDF som helst där sidobjekt råkade ha lägre objektnummer än en tidigare logisk sida skulle utlösa det
Det finns en enkel anledning till att PDF-filer hamnar i detta tillstånd. Inkrementella sparningar lägger till (append) uppdaterade objekt med nya objektnummer, och lämnar de gamla platserna i korsreferenstabellen pekandes ingenstans. Redigerare som lägger till en försättssida infogar den med ett högt objektnummer oavsett dess position i Kids-arrayen. Vissa generatorer skriver helt enkelt sidor i en ordning som är bekväm för innehållsströmning snarare än logisk sidsekvens. PDF-formatet kräver inte att de gör på annat sätt
Fixen: följ Kids-arrayen
Det korrekta tillvägagångssättet är att bygga PageArr genom att följa /Kids-kedjan från katalogroten, inte genom att skanna indirekta objekt. Efter att båda tolkningsvägarna slutfört sin initiala genomgång (pass), löser ett efterbehandlingssteg den logiska ordningen:
procedure THotPDF.ReorderPageArrByPagesTree;
var
PagesObj : THPDFDictionaryObject;
KidsArray : THPDFArrayObject;
NewPageArr: array of THPDFDictArrItem;
I, J, PageIndex, KidsIndex: Integer;
RefObj : THPDFLink;
PageObjNum: Integer;
Found : Boolean;
begin
{ Hitta rotens /Pages-ordlista 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;
{ Kids som inte är sidor (mellanliggande /Pages-noder) ger ingen matchning; hoppa över }
end;
if PageIndex > 0 then
begin
SetLength(PageArr, PageIndex);
for I := 0 to PageIndex - 1 do
PageArr[I] := NewPageArr[I];
end;
end;
Anropet går in i slutet av varje tolkningsväg, efter att alla objekt har katalogiserats men innan någon sidoperation betjänas:
{ Traditionell väg }
ListExtDictionary(THPDFDictionaryObject(IndirectObjects.Items[I]), FPageslink);
ReorderPageArrByPagesTree;
Break;
{ Modern väg (objektströmmar) }
if TryParseModernPDF then
begin
Result := ModernPageCount;
ReorderPageArrByPagesTree;
Exit;
end;
Omordningssteget är O(n * m) där n är antalet Kids och m är den nuvarande PageArr-längden, men för vilket dokument som helst med ett platt sidträd (alla löv på djup 1, vilket täcker den överväldigande majoriteten av verkliga PDF-filer) är båda samma värde och kostnaden är försumbar. Djupt nästlade sidträd kräver en rekursiv vandring snarare än envåningsmetoden (single-level approach) som visas här; produktionsimplementeringen hanterar det fallet separat
Att använda CopyPageFromDocument efter fixen
Med ReorderPageArrByPagesTree på plats fungerar logiska sidindex som förväntat. Den på högre nivå liggande CopyPageFromDocument tar ett 0-baserat logiskt index och kopierar rätt sida till destinationsdokumentet:
var
Source, Dest: THotPDF;
begin
Source := THotPDF.Create(nil);
Dest := THotPDF.Create(nil);
try
Source.LoadFromFile('source.pdf');
Dest.FileName := 'extracted.pdf';
Dest.BeginDoc;
{ Kopiera logisk sida 0 (första sidan som användaren ser) }
Dest.CopyPageFromDocument(Source, 0, 0);
Dest.EndDoc;
finally
Source.Free;
Dest.Free;
end;
end;
CopyPageFromDocument frågar internt efter sidträdets ordning snarare än att förlita sig på det råa PageArr-indexet, så det beter sig korrekt även mot dokument där fysisk och logisk ordning avviker. För batchoperationer accepterar InsertPagesFromDocument en array av logiska index och kopierar dem i ett pass
Vad detta avslöjar om PDF-tolkning
PDF-specifikationen är uttrycklig: logisk sidordning definieras av /Kids-arrayen i sidträdet, inte av objektnummer eller byte-förskjutningar (ISO 32000-1 §7.7.3.2). Vilken tolkare som helst som använder en annan ordning som en genväg kommer att producera korrekta resultat på majoriteten av de dokument den ser, eftersom de flesta generatorer skriver sidor i den naturliga ordningen och tilldelar sekventiella objektnummer. Buggen gömmer sig tills någon laddar en PDF som redigerats inkrementellt, omorganiserats av ett annat verktyg, eller genererats av programvara som valt en annan layout
Att bara testa mot självgenererade PDF-filer missar denna klass av problem helt och hållet. Fixen för en sidordningsregression behöver därför ett korpus av dokument från varierande källor: inkrementella sparningar, skannade dokument med infogade försättssidor, PDF-filer producerade av verktyg som linjäriserar eller optimerar objektgrafen annorlunda. Ett dokument som utlöste den ursprungliga buggen bör stanna i regressionssviten permanent
Sidan för HotPDF Component täcker det fullständiga API:et för sidoperationer, inklusive CopyPageFromDocument, InsertPagesFromDocument och MovePage