Articolo tecnico

Errori di controllo dell'intervallo nelle librerie PDF Delphi: cause profonde

Gli errori di controllo dell'intervallo nelle librerie PDF Delphi hanno la reputazione di essere difficili da individuare perché non seguono un pattern di input coerente. Lo stesso documento può generarli su una macchina e non su un'altra; lo stesso percorso di codice può sollevare l'eccezione su un file di 3 pagine ma eseguirsi correttamente su uno di 12 pagine. Questa incoerenza è quasi sempre riconducibile a un'unica causa profonda: gli oggetti pagina PDF non sono memorizzati nell'ordine del file. Se la libreria compila il proprio array di pagine interno eseguendo la scansione sequenziale degli oggetti invece di scorrere l'albero delle pagine dichiarato nel catalogo, costruisce un indice il cui intervallo valido non corrisponde a quello atteso dai chiamanti, e il controllo dell'intervallo rileva questa discrepanza nel peggior momento possibile.

Come funziona il controllo dell'intervallo in Delphi

Con la direttiva di compilazione {$R+} attiva (impostazione predefinita nella configurazione Debug), la RTL di Delphi convalida a runtime ogni indice di array, pedice di stringa e assegnazione di tipi enumerati. Un accesso fuori dai limiti solleva un'eccezione ERangeError invece di leggere silenziosamente la memoria adiacente. Questo comportamento è prezioso: fa emergere tempestivamente i bug latenti invece di consentire loro di corrompere una struttura dati che fallirebbe solo cento righe dopo. La parte frustrante è che l'eccezione viene sollevata nel punto di accesso, non nel punto in cui l'indice è stato calcolato in modo errato. Quando il call stack mostra un metodo profondamente annidato in un'unità PDF, il vero errore si trova solitamente diversi frame indietro.

Le condizioni booleane composte peggiorano le cose. Delphi valuta le espressioni and da sinistra a destra con semantica di cortocircuito, ma il cortocircuito salta la valutazione solo quando il lato sinistro è False. Un'espressione come:

if FDocStarted and (DestIndex < Length(PageArr)) and
   (PageArr[DestIndex].PageObj <> nil) then

sembra sicura, ma protegge da un indice fuori intervallo solo se FDocStarted è True e DestIndex non è negativo. Il controllo DestIndex < Length(PageArr) non ha effetto quando DestIndex è negativo, poiché il confronto di un intero negativo con una lunghezza non negativa restituisce True nell'aritmetica con segno, e il successivo accesso all'array solleva comunque l'errore di controllo dell'intervallo. Spostare il controllo dei limiti nella posizione più esterna è la correzione corretta:

if (DestIndex >= 0) and (DestIndex < Length(PageArr)) then
begin
  if FDocStarted and (PageArr[DestIndex].PageObj <> nil) then
    Result := PageArr[DestIndex].PageObj
  else
    Result := nil;
end
else
  raise ERangeError.CreateFmt(
    'Page index %d is out of range (0..%d)',
    [DestIndex, Length(PageArr) - 1]);

Questa è la correzione meccanica. Blocca il crash. Tuttavia, non spiega perché DestIndex abbia ricevuto in primo luogo un valore esterno all'intervallo valido.

La vera causa: ordine degli oggetti rispetto all'ordine delle pagine

Lo standard ISO 32000-1 §7.7.3 definisce l'albero delle pagine come un albero di nodi Pages i cui array Kids elencano gli oggetti pagina nell'ordine di visualizzazione. Il file memorizza tali oggetti a qualsiasi offset scelto dal programma di scrittura; l'oggetto numero 20 può fisicamente precedere l'oggetto numero 3 nel flusso di byte. Una libreria che compila il proprio elenco di pagine scorrendo la tabella dei riferimenti incrociati nell'ordine dei numeri di oggetto, anziché seguire la catena Kids, produrrà una sequenza diversa da quella prevista dall'utente. Nei documenti in cui il generatore ha scritto le pagine in ordine, tutto funziona correttamente. Nei documenti in cui ciò non è avvenuto, la discrepanza tra la numerazione delle pagine della libreria e quella del chiamante genera indici che non rientrano in PageArr.

L'approccio corretto consiste nel partire dal catalogo, risolvere il riferimento indiretto a /Pages e scorrere l'array Kids in modo ricorsivo. Per un documento semplice (flat) senza nodi Pages intermedi, lo scorrimento è semplice:

procedure BuildPageIndexFromTree(
  const KidsArray: THPDFArray;
  var PageArr: TPageObjArray);
var
  i, Idx: Integer;
  Child: THPDFObject;
  ChildType: string;
begin
  for i := 0 to KidsArray.Count - 1 do
  begin
    Child := KidsArray.GetIndirectObject(i);
    if Child = nil then
      Continue;
    ChildType := Child.GetNameValue('/Type');
    if ChildType = 'Page' then
    begin
      Idx := Length(PageArr);
      SetLength(PageArr, Idx + 1);
      PageArr[Idx].PageObj := Child;
    end
    else if ChildType = 'Pages' then
    begin
      // intermediate node: recurse into its Kids
      BuildPageIndexFromTree(Child.GetArray('/Kids'), PageArr);
    end;
  end;
end;

Dopo l'esecuzione di questa procedura, PageArr[0] è la prima pagina che verrebbe mostrata da un visualizzatore, indipendentemente da dove si trovi tale oggetto nel flusso di byte. Gli indici passati dai chiamanti che assumono l'ordine di visualizzazione ora vengono mappati correttamente e gli errori di controllo dell'intervallo cessano.

Le soluzioni temporanee hard-coded complicano il problema

Nelle basi di codice in cui la causa profonda non è mai stata identificata, è comune trovare patch euristiche: invertire la prima e l'ultima pagina se il conteggio totale è pari a 3, ruotare l'indice per i documenti provenienti da uno specifico generatore o applicare un offset quando il primo numero di oggetto supera una determinata soglia. Ciascuna di queste patch si adatta esattamente all'insieme di file di test disponibili al momento della sua stesura. Aggiungendo una sorgente PDF diversa, una delle patch si attiverà al momento sbagliato, producendo un indice doppiamente errato: errato perché calcolato da un array non in ordine, e nuovamente errato perché vi è stata applicata sopra una mappatura non applicabile. Il controllo dell'intervallo lo rileva in qualche punto a valle e lo stack trace non fornisce alcuna indicazione utile.

L'unica strada produttiva consiste nel rimuovere ogni mappatura euristica e sostituire la costruzione dell'array di pagine con un corretto scorrimento dell'albero. Una volta che gli indici sono corretti per costruzione, non sono più necessarie patch e il controllo dell'intervallo diventa una risorsa anziché un ostacolo.

Se state manutenendo una libreria che presenta questo pattern, abilitate temporaneamente il controllo dell'intervallo in una build di Release ed eseguite il test su un corpus variegato di PDF: documenti generati da Word, LaTeX, firmware di scanner e utility di divisione PDF-to-PDF. I file che sollevano eccezioni sono quelli in cui l'ordine degli oggetti pagina devia dall'ordine di scorrimento assunto dal vostro codice. Ognuno di essi rappresenta un elemento di analisi (data point), non un bug separato.

Per il nuovo codice che chiama una libreria PDF Delphi, il consiglio pratico è quello di considerare autorevole il conteggio delle pagine della libreria e non passare mai un indice derivato da calcoli aritmetici su dati esterni senza aver prima verificato che rientri nell'intervallo 0..PageCount - 1. Il componente HotPDF espone il conteggio delle pagine risolto tramite THotPDF.PageCount dopo BeginDoc o dopo aver caricato un documento; tale valore riflette sempre lo scorrimento dell'albero delle pagine ed è sicuro da utilizzare come limite superiore per qualsiasi calcolo aritmetico sugli indici.