Il sintomo si è presentato in un'utilità di copia delle pagine costruita su HotPDF Component: la richiesta della pagina 1 di un documento di tre pagine produceva costantemente la pagina 2. Il controllo della logica di indicizzazione non ha rilevato nulla di sbagliato. La chiamata utilizzava un indice logico basato su 0, l'aritmetica era corretta, le condizioni al contorno andavano bene. Eppure la pagina sbagliata veniva fuori ogni volta
Il bug non era affatto nel codice di copia. Era in come HotPDF stava costruendo il suo array di pagine interno durante il caricamento del file

Due ordinamenti, una fonte di confusione
Un file PDF è una raccolta di oggetti indiretti, ciascuno identificato da un numero di oggetto. La struttura del file non impone alcun obbligo a quei numeri di riflettere l'ordine di lettura. L'oggetto 1 può contenere la pagina 2; l'oggetto 20 può contenere la pagina 1. Ciò che definisce effettivamente l'ordine di lettura è l'albero delle pagine: una gerarchia di dizionari /Pages i cui array /Kids elencano i riferimenti di pagina nella sequenza in cui un visualizzatore dovrebbe visualizzarli (ISO 32000-1 §7.7.3)
Il documento che ha innescato il bug aveva questa struttura ad albero delle pagine:
{ Radice dell'albero Pages, oggetto 16 }
16 0 obj
<<
/Type /Pages
/Count 3
/Kids [20 0 R { pagina logica 1 }
1 0 R { pagina logica 2 }
4 0 R] { pagina logica 3 }
>>
endobj
Nel file è capitato di elencare l'oggetto 1 e l'oggetto 4 prima dell'oggetto 20 nel flusso di byte (byte stream). Qualsiasi parser che avesse iterato attraverso gli oggetti indiretti nell'ordine del file e li avesse stampigliati in un PageArr man mano che trovava i dizionari di tipo pagina, si sarebbe ritrovato con l'oggetto 1 all'indice 0, l'oggetto 4 all'indice 1 e l'oggetto 20 all'indice 2. La pagina logica 1 si trova in PageArr[2]. Chiedere l'indice della pagina 0 recupera invece la pagina logica 2
Questo è esattamente ciò che facevano entrambi i percorsi di parsing interni di HotPDF. Il percorso tradizionale, utilizzato per i file PDF 1.3/1.4, e il percorso moderno, utilizzato per i documenti object-stream (PDF 1.5+), creavano ciascuno PageArr esaminando gli oggetti indiretti nell'ordine fisico del file anziché seguire la catena /Kids
Confermare l'ipotesi
Prima di toccare qualsiasi correzione (fix), la mancata corrispondenza doveva essere provata anziché ipotizzata. Lo strumento a riga di comando qpdf rende questo semplice:
{ shell }
qpdf --show-pages input.pdf
{ L'output rivela l'ordine dei Kids: 20 0 R, poi 1 0 R, poi 4 0 R }
qpdf --show-object="16 0 R" input.pdf
{ Mostra il dizionario Pages con i /Kids in ordine di lettura }
L'estrazione singola di ogni pagina e il controllo delle dimensioni del file ha confermato la mappatura: ciò che PageArr[0] produceva era il contenuto appartenente alla pagina logica 2, e PageArr[2] conteneva la pagina logica 1. Lo spostamento circolare era la prova schiacciante. Questo spiegava anche perché il problema appariva in più documenti di origine diversi: qualsiasi PDF in cui gli oggetti della pagina presentavano casualmente numeri di oggetto inferiori rispetto a una pagina logica precedente lo avrebbe innescato
C'è una ragione semplice per cui i PDF finiscono in questo stato. I salvataggi incrementali accodano oggetti aggiornati con nuovi numeri di oggetto, lasciando i vecchi slot nella tabella dei riferimenti incrociati (cross-reference table) che non puntano a nulla. Gli editor che aggiungono una pagina di copertina la inseriscono con un numero di oggetto alto indipendentemente dalla sua posizione nell'array Kids. Alcuni generatori si limitano a scrivere le pagine in un ordine conveniente per il content streaming piuttosto che per la sequenza logica delle pagine. Il formato PDF non richiede loro di fare diversamente
La correzione: seguire l'array Kids
L'approccio corretto consiste nel costruire PageArr percorrendo la catena /Kids dalla radice del catalogo, non scansionando gli oggetti indiretti. Dopo che entrambi i percorsi di parsing hanno completato il loro passaggio iniziale, un passaggio di post-elaborazione risolve l'ordine logico:
procedure THotPDF.ReorderPageArrByPagesTree;
var
PagesObj : THPDFDictionaryObject;
KidsArray : THPDFArrayObject;
NewPageArr: array of THPDFDictArrItem;
I, J, PageIndex, KidsIndex: Integer;
RefObj : THPDFLink;
PageObjNum: Integer;
Found : Boolean;
begin
{ Individua il dizionario /Pages radice tramite 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;
{ I Kids che non sono pagine (nodi /Pages intermedi) non producono alcuna corrispondenza; salta }
end;
if PageIndex > 0 then
begin
SetLength(PageArr, PageIndex);
for I := 0 to PageIndex - 1 do
PageArr[I] := NewPageArr[I];
end;
end;
La chiamata viene inserita alla fine di ogni percorso di parsing, dopo che tutti gli oggetti sono stati catalogati ma prima che qualsiasi operazione sulla pagina venga servita:
{ Percorso tradizionale }
ListExtDictionary(THPDFDictionaryObject(IndirectObjects.Items[I]), FPageslink);
ReorderPageArrByPagesTree;
Break;
{ Percorso moderno (stream di oggetti) }
if TryParseModernPDF then
begin
Result := ModernPageCount;
ReorderPageArrByPagesTree;
Exit;
end;
Il passaggio di riordino è O(n * m) dove n è il conteggio dei Kids e m è l'attuale lunghezza di PageArr, ma per qualsiasi documento con un albero delle pagine piatto (tutte le foglie a profondità 1, che copre la stragrande maggioranza dei PDF del mondo reale) entrambi hanno lo stesso valore e il costo è trascurabile. Gli alberi delle pagine profondamente nidificati richiedono un cammino ricorsivo piuttosto che l'approccio a livello singolo mostrato qui; l'implementazione in produzione gestisce quel caso separatamente
Usare CopyPageFromDocument dopo la correzione
Con ReorderPageArrByPagesTree in posizione, gli indici logici delle pagine funzionano come previsto. La funzione di livello superiore CopyPageFromDocument accetta un indice logico basato su 0 e copia la pagina corretta nel documento di destinazione:
var
Source, Dest: THotPDF;
begin
Source := THotPDF.Create(nil);
Dest := THotPDF.Create(nil);
try
Source.LoadFromFile('source.pdf');
Dest.FileName := 'extracted.pdf';
Dest.BeginDoc;
{ Copia la pagina logica 0 (la prima pagina che l'utente vede) }
Dest.CopyPageFromDocument(Source, 0, 0);
Dest.EndDoc;
finally
Source.Free;
Dest.Free;
end;
end;
CopyPageFromDocument interroga internamente l'ordine dell'albero delle pagine piuttosto che fare affidamento sull'indice raw PageArr, quindi si comporta correttamente anche nei confronti di documenti in cui l'ordine fisico e quello logico divergono. Per le operazioni batch, InsertPagesFromDocument accetta un array di indici logici e li copia in un unico passaggio
Cosa ci rivela tutto questo sull'analisi dei PDF
Le specifiche del PDF sono esplicite: l'ordine logico delle pagine è definito dall'array /Kids dell'albero delle pagine, non dai numeri di oggetto o dagli offset di byte (ISO 32000-1 §7.7.3.2). Qualsiasi parser che utilizza un ordinamento diverso come scorciatoia produrrà risultati corretti sulla maggior parte dei documenti che vede, perché la maggior parte dei generatori scrive le pagine nell'ordine naturale e assegna numeri di oggetto sequenziali. Il bug si nasconde finché qualcuno non carica un PDF che è stato modificato in modo incrementale, riorganizzato da un altro strumento o generato da un software che ha scelto un layout diverso
Testare solo con PDF generati autonomamente ignora completamente questa classe di problemi. La correzione per una regressione dell'ordinamento delle pagine richiede quindi un corpus di documenti provenienti da fonti diverse: salvataggi incrementali, documenti scansionati con pagine di copertina inserite, PDF prodotti da strumenti che linearizzano o ottimizzano il grafo degli oggetti in modo diverso. Un documento che ha innescato il bug originale dovrebbe rimanere permanentemente nella suite di regressione
La pagina di HotPDF Component illustra l'API completa per le operazioni sulle pagine, tra cui CopyPageFromDocument, InsertPagesFromDocument e MovePage