Articolo tecnico

Caricare PDF incorporati per byte range in Delphi con PDFium

Con il controllo di overflow spento, 2000000000 + 2000000000 vale -294.967.296 quando entrambi gli operandi sono Integer Delphi. Quel numero smette di essere curiosità nel momento in cui un parser di container legge un offset e una lunghezza dall'header di un record e li somma per vedere se un PDF incorporato sta dentro il buffer. La somma va in wrap verso il negativo, il confronto con Length(Data) passa, e la riga successiva prende l'indirizzo di un byte circa due gigabyte oltre la fine dell'array. Caricare un PDF dal mezzo di un buffer più grande richiede un range check che non somma mai, e una risposta chiara su chi possiede i byte una volta che il documento è aperto

Questo articolo copre i documenti che sono già in RAM, seduti a un offset noto dentro qualcos'altro. I documenti troppo grandi da tenere in memoria sono un problema diverso, gestito tirando blocchi da disco a richiesta come descritto in streaming di PDF grandi on demand con PDFium VCL. L'ultima sezione traccia la linea

Un PDF incorporato a un offset noto

Uno store di posta tiene corpi dei messaggi e allegati in un unico record. Un contenitore di archivio concatena un manifest, qualche immagine e un PDF. Un protocollo di rete custom incornicia un documento dietro a un header con prefisso di lunghezza. Supponiamo che il record sia da 40 MB, il PDF parta al byte 1.182.336 e corra per 312 KB. Il codice abituale chiama Copy(Data, Index, Count) per tagliare una fetta, poi consegna quella fetta a un loader che la copia di nuovo nel proprio buffer. Contando il contenitore, gli stessi 312 KB ora esistono tre volte, e una scansione della casella di posta lo ripete per ogni messaggio

PDFium Component indirizza la finestra direttamente. TPdf dichiara LoadDocument(const Data: TBytes; Index, Count: Integer; Buffered: Boolean = True), dove Index è una posizione di byte a base zero e Count una lunghezza in byte. Il metodo valida entrambi i numeri, calcola un singolo puntatore, e inoltra all'overload a puntatore LoadDocument(Data: Pointer; Size: NativeUInt; Buffered: Boolean), che apre il documento tramite FPDF_LoadMemDocument64. La forma a intero array LoadDocument(const Data: TBytes; Buffered: Boolean) è una chiamata di una riga alla forma per byte range con 0 e Length(Data), quindi ogni caricamento di TBytes passa per la stessa validazione

Diagramma di confronto che mostra il percorso doppia copia slice-then-load per un PDF incorporato dentro un contenitore in Delphi contro l'overload byte range LoadDocument di PDFium Component che indirizza la finestra sul posto
Tagliare con Copy e poi caricare copia due volte la stessa finestra di 312 KB; la chiamata per byte range la indirizza sul posto e Buffered decide se qualcosa viene copiato del tutto

In codice, la fetta semplicemente sparisce. LocateEmbeddedPdf sta per il vostro parser di container

uses
  System.SysUtils, System.IOUtils, PDFium;

procedure PreviewEmbedded(const RecordFile: string);
var
  Frame: TBytes;           // intero record contenitore, decine di megabyte
  Offset, Size: Integer;
  Pdf: TPdf;
begin
  Frame := TFile.ReadAllBytes(RecordFile);
  LocateEmbeddedPdf(Frame, Offset, Size);   // il vostro parser, non fa parte del componente

  Pdf := TPdf.Create(nil);
  try
    // Niente Copy(Frame, Offset, Size): la finestra è indirizzata sul posto
    Pdf.LoadDocument(Frame, Offset, Size);  // Buffered vale True per default
    Frame := nil;  // va bene qui: è stata copiata solo la finestra del PDF
    Writeln('Pages: ', Pdf.PageCount);
  finally
    Pdf.Free;      // chiude il documento
  end;
end;

Quando il globale EnableV8Engine è ancora False e la libreria PDFium non è stata ancora caricata, i loader da memoria scandagliano i byte in ingresso in cerca di marker XFA e accendono EnableV8Engine se ne trovano, così viene caricata la DLL con V8. Con la chiamata per byte range quella scansione vede solo la finestra. Un manifest o un secondo documento altrove nello stesso record non può attivarla

Perché Index più Count va in overflow nel controllo dei limiti?

Perché Index e Count sono entrambi Integer a 32 bit con segno, e sommare due grandi positivi non produce in modo affidabile un grande positivo. Dove offset e lunghezza vengono da un header di file, un attaccante o un record corrotto controlla entrambi. La stessa trappola aspetta nel vostro parser di container, che calcola quei numeri per primo

// Un helper per il vostro parser di container (suggerito, non fa parte del componente)
function IsValidWindow(const Data: TBytes; Index, Count: Integer): Boolean;
begin
  // SBAGLIATO: Index + Count viene valutato come Integer e può andare in wrap negativo
  //   Result := (Index >= 0) and (Index + Count <= Length(Data));

  // GIUSTO: rifiuta i segni, vincola Index da solo, poi confronta Count
  // con il resto; l'unica aritmetica è una sottrazione che non può andare in wrap
  Result := (Index >= 0) and (Count >= 0) and
            (Index <= Length(Data)) and
            (Count <= Length(Data) - Index);
end;

Ripercorrete il caso che fallisce. Con Index = 2000000000 e Count = 2000000000, la somma vera è quattro miliardi, ma l'aritmetica con segno a 32 bit la memorizza come -294.967.296. Quel valore sta comodamente sotto qualunque Length(Data), quindi il controllo sbagliato passa e @Data[Index] punta ben fuori dall'array, con una lunghezza di due gigabyte appiccicata. Un crash è l'esito buono. Quello cattivo è PDFium che parsa qualunque memoria di processo capiti a trovarsi lì

L'ordine corretto non somma mai. I valori negativi vengono rifiutati prima che qualsiasi cosa venga indicizzata. Index viene poi vincolato contro Length(Data) da solo, il che garantisce che Length(Data) - Index sia un Integer non negativo, e solo allora Count viene confrontato con quel resto. Delphi valuta and con logica short-circuit per default ({$B-}), quindi ogni termine successivo gira solo quando i precedenti tengono. Non appoggiatevi a {$Q+} come rete di sicurezza: le build di rilascio navigano di regola con il controllo di overflow spento, e con esso acceso avete solo scambiato un buco di memory safety per un EIntOverflow che sfuga dal mezzo di una routine di validazione

Diagramma di flusso che contrappone il controllo dei limiti in overflow di Index più Count con l'ordine di validazione a sola sottrazione che tiene il caricamento byte range PDFium memory-safe in Delphi
Sommare prima va in wrap oltre MaxInt e sconfigge la guardia; rifiutare i segni e vincolare i termini prima di una sola sottrazione senza wrap tiene ogni passaggio intermedio rappresentabile

Il LoadDocument per byte range esegue gli stessi quattro test nello stesso ordine e solleva EPdfError con un messaggio che nomina la regola fallita: "PDF byte range index cannot be negative", "PDF byte range count cannot be negative", "PDF byte range index exceeds data length" e "PDF byte range exceeds data length". Potete riprodurre ognuno con un array di quattro byte. Index = 5, Count = 0 fa scattare il terzo messaggio, Index = 2, Count = 3 il quarto, e dopo ogni rifiuto Pdf.Active resta False. Lo stesso trattamento delle lunghezze non fidate attraverso il confine nativo è trattato più in generale in hardening dell'ABI PDFium VCL e memory safety in Delphi

Una finestra a lunghezza zero resta una chiamata legale

Una finestra vuota passa un puntatore nil, perché @Data[Index] non è un'espressione legale per ogni Index che la validazione accetta. Index = Length(Data) con Count = 0 è una finestra vuota ben formata alla coda del buffer, e un TBytes vuoto dà Index = 0 su un array che non ha nessun elemento zero. Prendere l'indirizzo in entrambi i casi legge oltre la fine o dereferenzia un array dinamico nil, e col range checking acceso Delphi solleva ERangeError prima ancora che il loader giri

Quindi l'overload per byte range si ramifica: Count = 0 produce nil, qualunque altro conteggio produce @Data[Index]. L'overload a puntatore accetta nil solo insieme a una dimensione di zero, PDFium poi declina di aprire un buffer vuoto, e la chiamata finisce in un ordinario EPdfError che porta il testo last-error di PDFium, o "Cannot load PDF document" quando PDFium non ne riporta alcuno. Un header che dichiara un allegato di zero byte riceve la stessa eccezione catturabile di qualsiasi altro input cattivo. Rifiutarlo nel vostro parser, col numero di record nel messaggio, resta comunque più leggibile in un log di supporto

Buffered decide chi possiede i byte

Buffered è l'unico parametro qui le cui conseguenze sopravvivono alla chiamata. Con Buffered = True, il componente copia la finestra selezionata, e solo la finestra, nel proprio buffer prima di caricare. Nell'esempio da 40 MB vengono copiati 312 KB e il contenitore resta in pace. Una volta che LoadDocument è tornato, potete liberare, riusare o sovrascrivere il contenitore subito

Con Buffered = False, @Data[Index] va dritto a FPDF_LoadMemDocument64, e PDFium continua a leggere attraverso quel puntatore finché il documento è aperto. L'intero TBytes di appoggio diventa preso in prestito. Deve restare vivo e non modificato finché non impostate Active := False o non liberate il TPdf. Conta l'intero array, non solo la finestra: un array dinamico Delphi è reference counted come un blocco unico, quindi far cadere l'ultimo riferimento dove sia nel vostro codice libera la memoria che PDFium sta ancora leggendo. SetLength è altrettanto fatale. Una riallocazione può spostare il blocco

Due dettagli Delphi rendono l'errore più facile di quanto sembri. Gli array dinamici non hanno copy-on-write, a differenza delle stringhe, quindi Other := FFrame; Other[0] := 0; scrive proprio nei byte che PDFium sta parsando. E un buffer di ricezione che il vostro codice di rete riusa per il frame successivo viene sovrascritto sotto al documento aperto. Passare dritto il risultato di una funzione, come in Pdf.LoadDocument(ReadRecord(Id), Offset, Size, False), lascia l'array tenuto solo da un temporaneo nascosto che muore con la routine chiamante, mentre il documento continua a vivere

Diagramma di sequenza dei due modi Buffered quando si carica un PDF incorporato da un byte range in Delphi: copiare solo la finestra del PDF e liberare subito il contenitore contro prendere in prestito l'intero TBytes di appoggio finché il documento è chiuso
Buffered = True copia solo la finestra così il contenitore è usa e getta, mentre Buffered = False lascia l'intero array di appoggio preso in prestito finché il documento non è chiuso

Tenete Buffered = True per qualsiasi cosa attraversi un thread, stia dietro a un buffer riutilizzabile, o sopravviva alla routine che l'ha caricato. Il costo extra è una copia della dimensione del PDF, e compra la libertà di buttare un record da 40 MB nel momento in cui la chiamata ritorna. Il prestito si guadagna il posto solo quando il contenitore è già un campo di lunga vita che tenete comunque per tutta la sessione, come un viewer che tiene aperto un record di archivio mentre l'utente sfoglia l'allegato. In quel caso, rendete la proprietà esplicita in una sola classe

type
  TFrameSession = class
  private
    FFrame: TBytes;   // possiede lo storage di appoggio finché FPdf ha un documento aperto
    FPdf: TPdf;
  public
    constructor Create(const AFrame: TBytes);
    destructor Destroy; override;
    procedure OpenEmbedded(Offset, Size: Integer);
  end;

constructor TFrameSession.Create(const AFrame: TBytes);
begin
  inherited Create;
  FFrame := AFrame;   // condivide l'array; nessun altro può scriverci ora
  FPdf := TPdf.Create(nil);
end;

procedure TFrameSession.OpenEmbedded(Offset, Size: Integer);
begin
  FPdf.Active := False;                            // chiudi prima ogni finestra precedente
  FPdf.LoadDocument(FFrame, Offset, Size, False);  // FFrame deve sopravvivere a questo documento
end;

destructor TFrameSession.Destroy;
begin
  FPdf.Active := False;  // 1. rilascia il prestito
  FPdf.Free;
  FFrame := nil;         // 2. solo ora può andarsene lo storage
  inherited;
end;

Il costruttore condivide l'array del chiamante invece di copiarlo, quindi il chiamante deve smettere di scriverci. Se non potete prometterlo, passate Copy(AFrame) al costruttore e tornate a una copia, fatta una volta per sessione invece che una volta per caricamento

Controllare che la finestra parta da %PDF-

Una finestra sbagliata di qualche byte è il fallimento sottile, perché il documento può comunque aprirsi. ISO 32000-1 §7.5.4 definisce ogni voce della cross-reference come un offset di byte dall'inizio del file, e §7.5.2 mette l'header %PDF- all'inizio. Quando il vostro parser conta male un prefisso e la finestra comincia otto byte prima, quegli offset sono sbagliati di otto byte. PDFium cerca l'header entro i primi 1024 byte e compensa il junk iniziale; una finestra che parte tardi o finisce presto può spingerlo a ricostruire la cross-reference table dai marker di oggetto che trova. In entrambi i casi il conteggio pagine sembra giusto. L'offset nel vostro parser è comunque sbagliato, e il prossimo lettore di quel record potrebbe essere meno indulgente

L'overload LoadDocument(const Data: TBytes; Index, Count: Integer; const Options: TPdfLoadOptions; out Report: TPdfLoadReport; Buffered: Boolean = True) rende ciò visibile. Report.HeaderOffset è dove %PDF- è stato trovato entro i primi 1024 byte della finestra, o -1 quando non è stato trovato. Report.UsedRecovery è True quando PDFium non ha accettato la cross-reference table come valida. Tutti gli offset nel report contano dal primo byte della finestra, non dal contenitore. Un Count troppo corto taglia via il %%EOF finale, e TPdfLoadOptions.Default(plmStrict) accende RequireFinalEndOfFileMarker, quindi la modalità rigorosa rifiuta quella finestra in blocco

var
  Options: TPdfLoadOptions;
  Report: TPdfLoadReport;
begin
  Options := TPdfLoadOptions.Default;   // plmCompatible
  Pdf.LoadDocument(Frame, Offset, Size, Options, Report, True);

  // LogWarning è la vostra routine di logging
  if Report.HeaderOffset > 0 then
    LogWarning(Format('Window starts %d bytes before %%PDF-, check Offset',
      [Report.HeaderOffset]))
  else if Report.HeaderOffset < 0 then
    LogWarning('No %PDF- header in the first 1024 bytes of the window');

  if Report.UsedRecovery then
    LogWarning('Cross-reference table was rebuilt, check Offset and Size');
end;

L'audit dietro quei campi ha un costo che conta per i caricamenti presi in prestito. Quando la finestra sta entro Options.AuditByteLimit (256 MB per default), l'audit legge una copia temporanea della finestra, quindi Buffered = False combinato con un report non è più copy-free durante il caricamento. Impostare AuditByteLimit a 0 salta l'audit e la copia, e lascia HeaderOffset a -1. Usate la forma con audit mentre sviluppate il parser di container e contro nuove sorgenti di record; passate all'overload semplice una volta che gli offset sono fidati. Il resto del report è trattato in catturare i fallimenti di caricamento PDF con un load report PDFium

Quando un byte range è lo strumento sbagliato

L'overload per byte range presuppone che l'intero contenitore sia già in memoria, e poiché Count è un Integer, una finestra non può superare MaxInt byte. Un archivio da 6 GB su disco, o un socket che non potete riavvolgere, non entra in quella forma, e leggere tutto in TBytes solo per indirizzare una finestra dentro sconfigge lo scopo. Per quei casi LoadCustomDocument(AStream: TStream; AOwnsStream: Boolean) lascia che PDFium tiri blocchi da uno stream seekable a richiesta. Un piccolo discendente TStream vostro che aggiunge l'offset di partenza del PDF a ogni Seek presenta il documento incorporato come se iniziasse alla posizione zero, come mostra l'articolo sullo streaming on demand

L'altro disallineamento è contenuto che non è ancora un PDF semplice. Se i byte incorporati sono compressi, cifrati dal contenitore, o avvolti in un'altra codifica, una copia trasformata è inevitabile, e caricare quel nuovo array con il default Buffered = True è la risposta diretta. Il byte range ripaga in una sola forma: byte PDF contigui e non modificati, già residenti, a un offset che il vostro parser ha validato senza sommare

La chiamata per byte range siede accanto al caricamento da file, stream, puntatore grezzo e on demand nello stesso componente TPdf. La pagina del prodotto PDFium Component elenca le versioni Delphi e C++Builder supportate, le opzioni di licenza e il resto dell'API di caricamento