Articolo tecnico

Costruire un workbench di intake PDF in Delphi con PDFium Component

Una pipeline di sinistri assicurativi su cui ho lavorato perse mezza giornata per un solo file in ingresso. Il "contratto firmato" caricato da un broker era un PDF cifrato con owner password che incapsulava un modulo XFA: l'estrattore di testo a valle restituì stringhe vuote, l'indicizzatore archiviò il sinistro come documento bianco, e nessuno se ne accorse finché il contraente chiamò. Il guasto non era nell'estrattore. Era che nessun codice aveva davvero guardato il file prima di instradarlo. Ogni team che accetta PDF dal mondo esterno finisce per costruire la stessa cosa: un workbench di intake che ispeziona ogni documento e decide dove può andare. PDFium Component, libreria e viewer documento VCL/LCL con sorgente per Delphi, C++Builder e Lazarus, fornisce le chiamate di introspezione per costruire quel workbench; il resto dell'articolo spiega quali chiamate rispondono a quali domande, e dove possono trarre in inganno.

Cinque domande da rispondere prima di instradare un file

Togli la griglia e la striscia di miniature, e il triage di intake si riduce a cinque domande:

  • Il file può essere aperto, e con quale password?
  • Cosa dichiara di essere: titolo, autore, data di creazione?
  • Trasporta contenuto attivo o rischioso: JavaScript, un modulo XFA, file incorporati?
  • Esiste testo estraibile, oppure è una scansione diretta all'OCR?
  • Dato tutto questo, quale coda lo riceve: elaborazione diretta, revisione manuale o quarantena?

Ogni domanda corrisponde a una o due chiamate di PDFium Component. Due di queste corrispondenze hanno spigoli vivi e spiegano la maggior parte dei file instradati male che ho debuggiato in produzione: metadati documento che vivono in due posti diversi, e cifratura che non impedisce l'apertura del documento.

Apri a basso costo: form fill spento, zero pagine renderizzate

Il triage dovrebbe essere l'apertura più economica possibile. Impostare FormFill := False prima di Active := True dice al componente di saltare del tutto l'ambiente form-fill, riducendo il tempo di load e, cosa altrettanto importante per file di origine ignota, impedendo l'inizializzazione di eventuale JavaScript a livello documento. Nessuna delle proprietà di ispezione usate sotto richiede il rendering di una pagina, quindi un passaggio di triage non deve produrre nemmeno un bitmap.

procedure InspectIncoming(const IncomingPath: string; var Rec: TIntakeRecord);
var
  Pdf: TPdf;
begin
  Pdf := TPdf.Create(nil);
  try
    Pdf.FileName := IncomingPath;
    Pdf.FormFill := False;     // no form environment, no JavaScript init
    Pdf.Active := True;        // failure is silent: Active simply stays False

    if not Pdf.Active then
    begin
      Rec.OpenFailed := True;  // damaged file or user-password lock
      Exit;                    // the finally block still runs
    end;

    Rec.PageCount := Pdf.PageCount;
    CollectIdentity(Pdf, IncomingPath, Rec);
    CollectRiskSignals(Pdf, Rec);
  finally
    Pdf.Active := False;
    Pdf.Free;                  // never leak the instance on a malformed file
  end;
end;

Il controllo dopo l'assegnazione non è facoltativo, ed è un controllo invece di un handler di eccezioni per un motivo: quando il motore non riesce a caricare il file, il componente inghiotte l'EPdfError interna e lascia Active a False invece di propagarla. Codice che aspetta un'eccezione leggerà serenamente PageCount da un documento mai aperto. Se il workflow di rifiuto ha bisogno del testo errore effettivo del motore, leggi il file in un array di byte e chiama l'overload LoadDocument che prende TBytes: quel percorso solleva EPdfError con il messaggio, incluso il caso password. Il try..finally resta meritato: i servizi di intake girano incustoditi per settimane, e nessuna eccezione successiva deve perdere l'istanza TPdf o tenere un lock su cui inciamperà il retry.

Il throughput raramente diventa il collo di bottiglia. Con form fill disabilitato e senza rendering, un'apertura di triage è dominata dall'I/O, e un solo worker ispeziona comodamente diversi file al secondo da disco locale. Se il volume di intake superasse mai un worker, partiziona il lavoro per file invece che per controllo: le cinque domande condividono una sola apertura, e dividerle tra processi moltiplicherebbe lo step più costoso invece di ammortizzarlo.

I metadati vivono in due posti, e non concordano

ISO 32000-1 definisce due sedi per i metadati del documento: il document information dictionary, clausola 14.3.3, e un pacchetto XMP collegato al catalogo, clausola 14.3.2. Le proprietà Title, Author, Subject e CreationDate leggono l'Info dictionary, con MetaText[] per ogni altra chiave e DecodeDate per analizzare la stringa data D:YYYYMMDD.... Il punto critico è che i produttori moderni scrivono sempre più spesso solo XMP, una direzione che ISO 32000-2 rende ufficiale deprecando la maggior parte delle chiavi dell'Info dictionary in PDF 2.0. In uno strumento di intake il sintomo è concreto: il workbench mostra un titolo vuoto mentre Adobe Acrobat ne mostra uno, perché Acrobat ha fatto fallback a dc:title nel pacchetto XMP, che le proprietà dell'Info dictionary non toccano mai.

procedure CollectIdentity(Pdf: TPdf; const FilePath: string;
  var Rec: TIntakeRecord);
begin
  Rec.Title := Pdf.Title;             // Info dictionary value
  Rec.Author := Pdf.Author;
  Rec.CreatedAt := Pdf.CreationDate;  // raw PDF date string ("D:2026...")

  // An empty Info title does not mean the document is untitled. The
  // component does not expose the XMP packet, so probe the raw file
  // bytes for the dc:title element before trusting the blank.
  if (Rec.Title = '') and FileContainsText(FilePath, 'dc:title') then
    Include(Rec.Flags, ifTitleInXmpOnly);
end;

Anche la sonda grezza per sottostringa sopra si guadagna il posto: "metadati presenti, ma non dove guardano gli strumenti legacy" è un fatto rilevante per l'instradamento in qualunque pipeline di archivio che indicizza su titolo o autore. Se l'indice a valle legge solo l'Info dictionary, i file marcati così diventeranno silenziosamente non ricercabili.

File cifrati che si aprono comunque

Un documento cifrato non fallisce necessariamente l'apertura. Il security handler standard, ISO 32000-1 clausola 7.6.3, distingue una user password, richiesta per aprire il documento, da una owner password che regola solo permessi come stampa e copia. Una larga quota di documenti business "protetti" è cifrata con una owner password e una user password vuota: si aprono senza prompt, si decifrano completamente e si affidano alla collaborazione volontaria dei viewer nel rispettare i flag di permesso. È policy, non protezione, e gli stati di intake dovrebbero riflettere la differenza.

Rilevare la cifratura dopo un'apertura riuscita richiede una chiamata al motore più un fallback: FPDF_GetSecurityHandlerRevision(Pdf.Document) restituisce -1 per file non protetti e la revisione dell'handler altrimenti, mentre Pdf.Permissions che restituisce qualunque cosa diversa dalla maschera all-bits-set $FFFFFFFF è il segnale di conferma. Per file davvero bloccati da user password, assegna Password prima di impostare Active := True; se l'apertura fallisce ancora, instrada il file a uno stato bloccato che richiede credenziali al mittente tramite un canale sicuro invece di ritentare alla cieca. E resisti alla tentazione di trattare "cifrato" come quarantena automatica: nella maggior parte dei settori ad alta intensità documentale, i file cifrati ma apribili sono la norma, non il sospetto.

Contenuto attivo: JavaScript, XFA e file incorporati

Tre risultati devono sempre arrivare alla decisione di routing. Primo, JavaScript: l'evento OnUnsupportedFeature segnala funzionalità strutturali come XFA o contenuto 3D quando il motore le incontra, ma non rileva JavaScript; controlla invece JavaScriptActionCount e tratta un risultato non zero come contenuto attivo. Secondo, XFA: quando FormType restituisce ftXfaFull, le pagine visibili spesso sono poco più di un rendering del template XFA, e l'estrazione testo convenzionale vedrà boilerplate invece dei valori compilati. Terzo, allegati: un PDF è un formato contenitore, e AttachmentCount ti dice se questo file trasporta passeggeri.

procedure CollectRiskSignals(Pdf: TPdf; var Rec: TIntakeRecord);
var
  i, PageNo: Integer;
  Ext: string;
begin
  Rec.IsEncrypted := Assigned(FPDF_GetSecurityHandlerRevision) and
    (FPDF_GetSecurityHandlerRevision(Pdf.Document) <> -1);
  Rec.HasForms := Pdf.FormType <> ftNone;
  Rec.IsXfa := Pdf.FormType = ftXfaFull;
  Rec.HasJavaScript := Pdf.JavaScriptActionCount > 0;

  // AnnotationCount is a per-page property; walk the pages to total
  // it. Loading a page object renders nothing, so this stays cheap.
  Rec.Annotations := 0;
  for PageNo := 1 to Pdf.PageCount do
  begin
    Pdf.PageNumber := PageNo;
    Inc(Rec.Annotations, Pdf.AnnotationCount);
  end;

  Rec.Attachments := Pdf.AttachmentCount;

  for i := 0 to Rec.Attachments - 1 do
  begin
    Ext := LowerCase(ExtractFileExt(string(Pdf.AttachmentName[i])));
    if (Ext = '.exe') or (Ext = '.js') or (Ext = '.vbs') or (Ext = '.dll') then
      Include(Rec.Flags, ifDangerousAttachment);
  end;
end;

Due dettagli in quel ciclo meritano attenzione. Il nome dell'allegato viene dall'interno del documento, quindi non riusarlo mai come percorso di output senza sanitizzarlo: un nome incorporato come ..\..\start.exe è una path traversal pronta a colpire una chiamata save imprudente. E una blocklist di estensioni è un tripwire, non una garanzia; il suo compito è forzare una decisione umana, non certificare che il file sia pulito.

Trasformare segnali in stati di routing

Un modello di stati praticabile richiede meno stati di quanto la maggior parte dei team si aspetti: ready, nessun blocco e testo presente; review, apertura riuscita ma qualcosa richiede occhi, come modulo XFA, JavaScript, layer testo vuoto o titolo solo in XMP; blocked, user password richiesta; e damaged, apertura fallita. Registra le prove insieme allo stato, hash del file, numero pagine, flag esatti, messaggio errore del motore per file danneggiati, perché chi metterà in discussione una decisione di routing lo farà settimane dopo, contro un file che potrebbe essere stato sostituito o modificato.

Quando un operatore deve davvero guardare un file in quarantena, non passarlo al viewer shell predefinito. Renderizzalo dentro un pannello hardened con scripting e gestione link disabilitati, l'approccio descritto in costruire una superficie di preview PDF sicura in Delphi. E se il tuo intake alimenta un archivio con requisiti di conformità, il passaggio di triage è il punto naturale in cui pianificare un controllo più profondo; la validazione preflight batch contro profili PDF/A e PDF/UA riprende esattamente dove questa ispezione si ferma.

Domande frequenti

Come controllo se un PDF è protetto da password in Delphi?

Aprilo con PDFium Component e interroga il security handler: FPDF_GetSecurityHandlerRevision(Pdf.Document) restituisce -1 per file non protetti. Se Active resta False senza password, il file usa molto probabilmente una user password: assegna Password e riprova. Se si apre bene ma è presente un security handler, il file porta solo protezione owner-password: è pienamente leggibile, e i flag in Permissions sono consultivi.

Perché la proprietà Title restituisce una stringa vuota quando Acrobat mostra un titolo?

Il titolo è memorizzato solo nel pacchetto metadati XMP, non nel document information dictionary letto da Title. Il componente non espone il pacchetto XMP, quindi sonda i byte grezzi del file per dc:title e segnala il file alle pipeline che indicizzano sui metadati dell'Info dictionary.

PDFium Component può rilevare JavaScript dentro un PDF?

Sì: controlla JavaScriptActionCount oppure enumera le azioni a livello documento tramite JavaScriptActions. Non affidarti all'evento OnUnsupportedFeature per questo; segnala funzionalità come XFA e 3D, ma non lo scripting.

La pagina prodotto del componente copre licenza, API completa di ispezione e demo incluse, compreso un inspector documentale in stile intake: PDFium Component.