Articolo tecnico

Indice lazy sparse di oggetti PDF in Delphi con PDFiumPas

Vuoi un dizionario da un PDF da 2 GB e lo strumento prima espande lintera tabella di riferimenti incrociati in un array dimensionato dal trailer /Size. PDFiumPas sostituisce quel passo con un indice lazy sparse di oggetti: tiene solo i descrittori di sezione xref, risolve un singolo numero di oggetto a richiesta attraverso finestre limitate, e mette in cache solo le voci che hai effettivamente toccato

La vecchia forma di questo codice in FPdfCompress era onesta ma costosa. ApplyDefaultOpenAction leggeva il file completo in un solo TBytes, poi allocava un array denso TPdfActiveXrefEntries con uno slot per numero di oggetto fino a /Size. Due cose andavano male alla scala. Il costo di lettura cresceva linearmente con la dimensione del documento anche quando il chiamante voleva quattro dizionari, e larray denso urtava contro il budget del parser: TPdfParserResourceBudget.Default imposta MaxObjects a 4.000.000, quindi un file perfettamente valido il cui numero di oggetto più alto sta sopra quel tetto veniva rifiutato per un motivo di memoria invece che di correttezza

Lindice lazy sparse di oggetti di PDFiumPas in Delphi confrontato con un array denso di riferimenti incrociati: il percorso denso legge lintero file e alloca uno slot per numero di oggetto fino alla dimensione del trailer, mentre il percorso sparse tiene solo i descrittori di sezione
Solo i descrittori restano in memoria, le voci restano nel file, e ogni lettura passa per una finestra limitata di un mebibyte

Perché la API pubblica di PDFium non risponde a questa domanda?

Perché linformazione esiste dentro PDFium ma non attraversa mai il confine C. CPDF_Parser mantiene internamente la tabella di riferimenti incrociati, lappartenenza agli object stream e la precedenza delle revisioni, eppure gli header pubblicati non espongono nessun punto di ingresso che prende un numero di oggetto e restituisce il suo offset grezzo, la sua generazione, quale revisione ha vinto, o in quale ObjStm vive. Il lato salvataggio è altrettanto chiuso: FPDF_SaveAsCopy e FPDF_SaveWithVersion ti consegnano solo un callback di scrittura sequenziale. Qualsiasi patch a livello di byte a un catalogue dopo un salvataggio nativo quindi va costruita nel layer Pascal, ed è per questo che PDFiumPas analizza queste strutture da sé invece di riutilizzare la DLL

Cosa tiene davvero in memoria lindice sparse?

Descrittori, non voci. Per una tabella classica (ISO 32000-1 §7.5.4) una TPdfSparseXrefSubsection memorizza il primo numero di oggetto, il numero di oggetti, loffset in byte dove iniziano le righe delle voci e la larghezza misurata delle voci. Le voci in sé restano nel file. La larghezza è misurata dalla prima riga invece che assunta di 20 byte, perché i produttori non concordano sulle terminazioni di riga; PDFiumPas accetta da 18 a 64 e rifiuta qualsiasi cosa fuori da quella banda, insieme a qualsiasi sottosezione il cui conteggio dichiarato andrebbe oltre la fine dello stream. Per uno stream di riferimenti incrociati (§7.5.8) la sezione tiene le tre larghezze dei campi /W, ciascuna limitata da 0 a 8, le coppie /Index appiattite, e i byte delle voci decodificati, la cui lunghezza attesa è calcolata da /W e /Index prima che un solo byte venga gonfiato

Lintero indice è costruito da Initialize da una finestra finale di al massimo 1 MiB, che è dove si trova startxref, e ogni successiva lettura di oggetto usa una finestra oggetto di 1 MiB. Il tetto dello stream grezzo è 64 MiB e una singola riga xref non può superare 1024 byte. Se hai letto la nostra nota su validare object stream e stream di riferimenti incrociati con PDFiumPas, la stessa disciplina sulle larghezze dei campi vale qui, solo che ora serve ad indirizzare una voce invece di revisionare unintera tabella

uses
  FPdfCompress;

var
  Source: TFileStream;
  Revision: TPdfSparseRevisionInfo;
begin
  Source := TFileStream.Create(FileName, fmOpenRead or fmShareDenyWrite);
  try
    { percorre solo startxref, la catena /Prev e il catalogue }
    if ReadPdfSparseRevisionInfo(Source, Revision) then
    begin
      Writeln('root      ', Revision.RootObjectNumber, ' ',
        Revision.RootGeneration);
      Writeln('max obj   ', Revision.MaximumObjectNumber);
      Writeln('xref str  ', Revision.UsesXrefStream);
      Writeln('encrypted ', Revision.HasEncrypt);
      Writeln(string(Revision.CatalogDictionary));
    end;
  finally
    Source.Free;
  end;
end;

Come arriva una lookup a un oggetto?

Per aritmetica, in entrambi i layout. Una sottosezione classica ha righe a larghezza fissa, quindi lindirizzo di una voce è linizio della sottosezione più loffset delloggetto per la larghezza misurata; PDFiumPas poi legge quella riga, analizza loffset a dieci cifre e la generazione a cinque cifre, controlla la generazione contro il tetto 65535 del §7.5.4, e classifica la parola chiave finale come axkDirect o axkFree. Uno stream di riferimenti incrociati richiede un passo in più perché le sottosezioni /Index sono concatenate nella sequenza di byte decodificata, così lindice accumula i conteggi delle sottosezioni precedenti prima di moltiplicare per la larghezza /W sommata. Il tipo 1 restituisce uno offset, il tipo 2 restituisce un numero di object stream e un indice di membro, e qualsiasi altra cosa diventa axkUnknown invece di tirare a indovinare

{ tabella classica, ISO 32000-1 sezione 7.5.4 }
EntryOffset := Subsection.EntryOffset +
  Int64(ObjectNumber - Subsection.FirstObject) * Subsection.EntryWidth;

{ stream di riferimenti incrociati, ISO 32000-1 sezione 7.5.8 }
EntryWidth := Section.Widths[0] + Section.Widths[1] + Section.Widths[2];
EntryPosition := Integer((PriorCount + ObjectNumber -
  Section.IndexValues[I]) * EntryWidth);

Nulla in nessuno dei due percorsi è proporzionale a /Size. Questo è il senso della riscrittura: il valore di dimensione del trailer viene portato avanti come metadato e usato quando si scrive la revisione incrementale, ma non guida mai unallocazione. La suite di regressione lo blocca con una fixture il cui albero di pagine vive agli oggetti 1.000.000.000 e 1.000.000.001 sotto un trailer che dichiara /Size 1000000002. La vecchia implementazione densa rifiutava quel file; lindice sparse risolve entrambi i riferimenti e preserva la dimensione dichiarata nel trailer di output

Come PDFiumPas risolve un numero di oggetto in Delphi: una tabella di riferimenti incrociati classica moltiplica la larghezza di riga misurata, mentre uno stream di riferimenti incrociati accumula i conteggi delle sottosezioni precedenti prima di moltiplicare le larghezze dei campi sommate dallarray /W
Entrambe le lookup sono aritmetica pura, quindi nessuna delle due è proporzionale al numero di oggetti dichiarato nel trailer

Revisioni ibride, catene /Prev e le protezioni attorno

La precedenza delle revisioni è dove un indice lazy ingenuo va storto. PDFiumPas percorre la catena da startxref in ordine dal più nuovo e ferma una lookup alla prima sezione che risponde, il che riproduce la regola di precedenza senza materializzare una tabella fusa. I file hybrid-reference (§7.5.8.4) sono gestiti dentro il ramo classico: quando il trailer porta un /XRefStm, la sezione stream supplementare viene registrata prima della sezione classica che lha referenziato, così gli oggetti compressi invisibili alla tabella semplice vengono comunque trovati mentre le voci classiche tengono il loro posto. Le revisioni più vecchie vengono poi seguite attraverso /Prev

Due protezioni limitano quel percorso, ed entrambe contano sui file danneggiati. Ogni offset visitato viene registrato, così un /Prev che punta indietro nella catena termina invece di girare in tondo, e la profondità di attraversamento è limitata da MaxRecursionDepth, che è 1024 per impostazione predefinita. Il flag di cifratura viene accumulato attraverso lintera catena invece che letto solo dal trailer più nuovo, perché un documento il cui ultimo trailer omette /Encrypt può essere comunque cifrato più indietro; i chiamanti che aggiungono revisioni contano su quel flag per rifiutare di scrivere oggetti in chiaro in un file cifrato

Come PDFiumPas percorre una catena di revisioni PDF ibride in Delphi: le sezioni vengono registrate dal più nuovo partendo da startxref, una sezione XRefStm supplementare va davanti alla tabella classica che lha nominata, e il percorso /Prev è limitato dagli offset visitati e da un tetto di profondità
Una lookup si ferma alla prima sezione che risponde, il che riproduce la precedenza delle revisioni senza mai materializzare una tabella fusa

Voci di tipo 2: perché lo object stream aspetta

Una voce di tipo 2 nomina uno object stream, e PDFiumPas non tocca quello stream finché un chiamante non chiede un membro di esso. Quando finalmente lo fa, /Type /ObjStm viene verificato, /N viene controllato contro il budget di oggetti e /First contro il tetto dei byte decodificati, e /N viene controllato di sanità contro /First dato che ogni coppia di header richiede almeno quattro byte. Solo allora lo stream viene gonfiato, e la scansione dellheader si ferma al membro richiesto e al suo successore invece di costruire una tabella completa dei membri. Uno object stream decodificato viene trattenuto alla volta, che è il compromesso giusto quando un ramo dellalbero di pagine si raggruppa in un solo ObjStm; la nostra trattazione su decodifica di object stream e predictor in Delphi copre cosa succede dentro quel passo di inflate (§7.5.7)

var
  Reader: TPdfSparseDictionaryReader;
  Generation: Integer;
  Dict: AnsiString;
begin
  { un indice trattenuto, tante letture consapevoli della generazione }
  Reader := TPdfSparseDictionaryReader.Create(Source);
  try
    if Reader.Valid and
       Reader.ReadLatestDictionary(PageObjectNumber, Generation, Dict) then
      HandlePage(PageObjectNumber, Generation, Dict);
  finally
    Reader.Free;  { Source resta tua }
  end;
end;

Dove la cache smette di fare promesse

Lindice è uno snapshot, e vale la pena esser diretti su questo. Le sezioni vengono analizzate una volta in Initialize; se lo stream sottostante viene modificato dopo, ogni voce in cache è stantia e la classe non se ne accorgerà. TPdfSparseDictionaryReader tiene lindice per la durata della sorgente posseduta dal chiamante, che è esattamente ciò che vuole un attraversamento ricorsivo di un albero di pagine ed è esattamente ciò che non devi fare attraverso una riscrittura. La cache delle voci è un array piatto cercato linearmente e memorizza anche i risultati negativi, così qualche centinaio di lookup è economico e qualche centinaio di migliaia no. ReadDictionary esige una corrispondenza esatta di generazione mentre ReadLatestDictionary risolve quella attiva, e la differenza è deliberata: la risoluzione dei riferimenti serve la prima, linspectazione del catalogue serve la seconda. Dove questi limiti non possono essere onorati, le unità circostanti ricadono sul parser legacy dellintero file invece di restringere linsieme dei file che funzionano ancora, uno schema che usiamo anche per lo streaming on-demand di PDF grandi

Le regressioni cross-compiler coprono lo stesso comportamento su tutte e tre le toolchain, incluso un assertion che una sorgente da 2 MiB non vede mai una singola lettura più grande di 1 MiB. Se mantieni codice Delphi, C++Builder o Lazarus che tocca direttamente la struttura PDF e sei stanco di pagare costi di analisi dellintero file per quattro dizionari, lindice sparse e la sutura pubblica attorno a lui sono nella pagina del componente PDFium PDF per Delphi PDFiumPas