Articolo tecnico

Preflight e Audit dei Rischi PDF Automatizzato con PDFium

Un PDF che arriva a un confine di produzione (una coda di stampa, un archivio, un portale di caricamento per i clienti) dovrebbe essere sottoposto ad audit prima che qualsiasi cosa ne effettui il rendering. Il file potrebbe contenere un'azione Launch (Avvio) predisposta per avviare un programma esterno, immagini troppo sgranate per sopravvivere alla stampa, un dizionario di crittografia che vieta proprio il lavoro di stampa per cui è stato inviato, o un'etichetta PDF/A di cui non è all'altezza. Ispezionare un documento in base a regole come queste prima che entri in un flusso di lavoro si chiama preflight (verifica preliminare) e l'API C di PDFium offre a Delphi tutto il necessario per implementare i controlli direttamente, senza eseguire il rendering di una singola pagina

Questo articolo costruisce i controlli stessi: quattro classi di audit, ognuna una piccola routine che aggiunge i risultati (findings) a una lista di risultati condivisa. Gli elementi interattivi, le metriche delle risorse, lo stato della sicurezza e i marcatori degli standard ottengono tutti un codice funzionante, inclusa l'aritmetica. Se ciò di cui hai bisogno è il meccanismo attorno ai controlli (cicli batch sulle cartelle, file di report JSON e HTML, isolamento per singolo file), il Componente PDFium include un motore di preflight pronto all'uso, e l'articolo sulla CLI per preflight batch copre quell'infrastruttura. I due condividono deliberatamente un unico vocabolario per i codici di uscita (exit-code), quindi un auditor scritto qui si inserisce direttamente sotto quel driver batch

Il record del risultato e il contratto del codice di uscita

Ogni controllo scrive in un singolo tipo di record piatto, perché l'alternativa, in cui ogni controllo stampa la propria prosa, non può essere contata, filtrata o soggetta a limiti di soglia in un secondo momento. Quattro campi sono sufficienti

uses
  System.SysUtils, System.Math, System.IOUtils,
  System.Generics.Collections, pdfium_lib;

type
  TFindingSeverity = (fsInfo, fsWarning, fsError);

  TPreflightFinding = record
    Severity: TFindingSeverity;
    Code: string;       // chiave macchina stabile, es. 'ACT-LAUNCH'
    Page: Integer;      // basata su 1; 0 significa livello documento
    Message: string;    // per esseri umani; libera da riformulare tra le release
  end;

  TFindings = TList<TPreflightFinding>;

procedure Add(Findings: TFindings; Severity: TFindingSeverity;
  const Code: string; Page: Integer; const Msg: string);
var
  F: TPreflightFinding;
begin
  F.Severity := Severity;
  F.Code := Code;
  F.Page := Page;
  F.Message := Msg;
  Findings.Add(F);
end;

Gli strumenti a valle (downstream) si basano su Code, mai sul testo Message, che è libero di cambiare. Il codice di uscita del processo segue lo stesso contratto a tre valori dell'articolo sul batch: 0 significa che il file non ha prodotto risultati (nessuna anomalia), 1 significa che esistono risultati, e 2 significa che l'audit stesso non è potuto essere eseguito perché il file non è stato elaborato correttamente (failed to parse) o richiede una password. Mantenere separato il codice 2 è importante. Una cartella di scansioni corrotte rappresenta uno scanner rotto a monte, non un improvviso crollo della conformità (compliance), e unire i due elementi spinge qualcuno a inseguire il problema sbagliato

Elementi interattivi: script, destinazioni di avvio (launch), link esterni

PDFium classifica ogni azione che trova in base a un tipo intero, e vale la pena fissare con precisione le costanti di fpdf_doc.h, perché i valori copiati in modo errato rendono uno scanner silenziosamente cieco. La vera enumerazione è PDFACTION_UNSUPPORTED = 0, PDFACTION_GOTO = 1, PDFACTION_REMOTEGOTO = 2, PDFACTION_URI = 3, PDFACTION_LAUNCH = 4 e PDFACTION_EMBEDDEDGOTO = 5. Nota cosa è assente: non esiste alcun membro JavaScript. Gli script a livello di documento non sono azioni di collegamento e non compaiono mai tramite FPDFAction_GetType; vengono enumerati da una famiglia separata di chiamate. Un auditor che testa i tipi di azione con un'immaginaria costante JavaScript compila, viene eseguito e non trova nulla, per sempre

const
  PDFACTION_GOTO         = 1;   // salto all'interno del documento: innocuo
  PDFACTION_REMOTEGOTO   = 2;   // salto in un altro file locale
  PDFACTION_URI          = 3;   // apre un URL esterno
  PDFACTION_LAUNCH       = 4;   // avvia un programma esterno
  PDFACTION_EMBEDDEDGOTO = 5;   // salto in un file incorporato

function ActionTarget(Doc: FPDF_DOCUMENT; Action: FPDF_ACTION;
  AType: ULONG): string;
var
  Buf: array[0..2047] of AnsiChar;
begin
  FillChar(Buf, SizeOf(Buf), 0);
  if AType = PDFACTION_URI then
    FPDFAction_GetURIPath(Doc, Action, @Buf, SizeOf(Buf))
  else
    FPDFAction_GetFilePath(Action, @Buf, SizeOf(Buf));
  Result := string(UTF8String(PAnsiChar(@Buf)));
end;

procedure AuditPageActions(Doc: FPDF_DOCUMENT; Page: FPDF_PAGE;
  PageNo: Integer; Findings: TFindings);
var
  StartPos: Integer;
  Link: FPDF_LINK;
  Action: FPDF_ACTION;
  AType: ULONG;
begin
  StartPos := 0;
  while FPDFLink_Enumerate(Page, @StartPos, @Link) <> 0 do
  begin
    Action := FPDFLink_GetAction(Link);
    if Action = nil then
      Continue;                 // collegamento di sola destinazione, nulla da segnalare
    AType := FPDFAction_GetType(Action);
    case AType of
      PDFACTION_LAUNCH:
        Add(Findings, fsError, 'ACT-LAUNCH', PageNo,
          'L''azione di avvio punta a "' + ActionTarget(Doc, Action, AType) + '"');
      PDFACTION_URI:
        Add(Findings, fsWarning, 'ACT-URI', PageNo,
          'il collegamento apre ' + ActionTarget(Doc, Action, AType));
      PDFACTION_REMOTEGOTO, PDFACTION_EMBEDDEDGOTO:
        Add(Findings, fsWarning, 'ACT-XFILE', PageNo,
          'destinazione tra file "' + ActionTarget(Doc, Action, AType) + '"');
    end;                        // PDFACTION_GOTO resta silenzioso per progettazione
  end;
end;

procedure AuditDocumentBehaviors(Doc: FPDF_DOCUMENT; Findings: TFindings);
var
  N: Integer;
begin
  N := FPDFDoc_GetJavaScriptActionCount(Doc);
  if N > 0 then
    Add(Findings, fsError, 'JS-DOC', 0,
      Format('%d azione/i JavaScript a livello di documento eseguita/e all''apertura', [N]));
  N := FPDFDoc_GetAttachmentCount(Doc);
  if N > 0 then
    Add(Findings, fsWarning, 'ATT-EMB', 0,
      Format('%d allegato/i di file incorporato/i', [N]));
end;

La suddivisione della gravità codifica la politica. Un'azione Launch è un errore perché l'avvio di un programma arbitrario è la cosa più pericolosa che un clic in un PDF possa fare, e a nessuna fattura serve. Gli URI esterni sono avvisi (warning): comuni nei documenti legittimi, ma un revisore dovrebbe poter vedere la destinazione senza fare clic, poiché il testo del link visibile e la destinazione effettiva non devono per forza coincidere. I salti GoTo (Vai a) all'interno del documento sono struttura, non comportamento, e rimangono completamente fuori dal report — un preflight che grida 'al lupo, al lupo' su ogni voce del sommario addestra le persone a ignorarlo. Per leggere i corpi degli script dietro il conteggio dei JavaScript e per i livelli MDP della firma e il rilevamento XFA, l'articolo sull'audit dei rischi per la sicurezza percorre la stessa superficie tramite il wrapper di oggetti del componente

Metriche delle risorse: DPI effettivi dell'immagine

Un'immagine all'interno di un PDF non possiede dei DPI propri. Ha dei pixel e la pagina posiziona quei pixel in un rettangolo misurato in punti, in cui 72 punti formano un pollice (inch). La risoluzione esiste solo come il rapporto tra i due, motivo per cui la stessa foto 600 per 400 è nitidissima come miniatura e un disastro sfocato come immagine a tutta pagina (hero). L'audit necessita pertanto di entrambi i numeri per ogni immagine: le dimensioni in pixel di origine provenienti dai metadati dell'immagine e il rettangolo posizionato in base ai limiti dell'oggetto

procedure AuditPageImages(Page: FPDF_PAGE; PageNo: Integer;
  Findings: TFindings);
var
  I, ObjCount: Integer;
  Obj: FPDF_PAGEOBJECT;
  Meta: FPDF_IMAGEOBJ_METADATA;
  L, B, R, T: Single;
  WidthPt, HeightPt, DpiX, DpiY, EffDpi: Double;
begin
  ObjCount := FPDFPage_CountObjects(Page);
  for I := 0 to ObjCount - 1 do
  begin
    Obj := FPDFPage_GetObject(Page, I);
    if FPDFPageObj_GetType(Obj) <> FPDF_PAGEOBJ_IMAGE then
      Continue;
    if FPDFImageObj_GetImageMetadata(Obj, Page, @Meta) = 0 then
      Continue;
    if FPDFPageObj_GetBounds(Obj, @L, @B, @R, @T) = 0 then
      Continue;

    WidthPt  := R - L;              // dimensione posizionata sulla pagina, in punti
    HeightPt := T - B;
    if (WidthPt <= 0) or (HeightPt <= 0) or
       (Meta.Width = 0) or (Meta.Height = 0) then
      Continue;

    // 72 punti = 1 pollice, quindi pollici posizionati = punti / 72, e
    // DPI effettivi = pixel di origine / pollici posizionati.
    DpiX := Meta.Width  / (WidthPt  / 72.0);
    DpiY := Meta.Height / (HeightPt / 72.0);
    EffDpi := Min(DpiX, DpiY);      // l'asse peggiore decide la qualità di stampa

    if EffDpi < 150.0 then
      Add(Findings, fsWarning, 'IMG-LOWRES', PageNo,
        Format('immagine %dx%d px posizionata a %.1fx%.1f pt = %.0f DPI effettivi',
          [Meta.Width, Meta.Height, WidthPt, HeightPt, EffDpi]))
    else if EffDpi > 600.0 then
      Add(Findings, fsInfo, 'IMG-BLOAT', PageNo,
        Format('l''immagine è %.0f DPI alla dimensione posizionata; un ricampionamento ' +
          'ridurrebbe il file senza perdita visibile', [EffDpi]));
  end;
end;

Le soglie sono una questione di politica, non di fisica: 150 DPI sono la base al di sotto della quale la stampa per ufficio si pixelizza visibilmente, 300 è il consueto obiettivo commerciale, e qualsiasi valore al di sopra dei 600 non apporta alcuna qualità visibile pur ingrandendo la dimensione del file, motivo per cui viene segnalato come un 'gonfiore' (bloat) informativo piuttosto che un difetto. Un'onesta avvertenza: FPDFPageObj_GetBounds restituisce un riquadro allineato agli assi (axis-aligned box), quindi per un'immagine posizionata con rotazione la cifra calcolata sottostima la vera densità. La struct FPDF_IMAGEOBJ_METADATA contiene anche i campi horizontal_dpi e vertical_dpi che PDFium deriva dall'intera matrice di trasformazione, e confrontare i due risultati è un modo economico per individuare posizionamenti ruotati. La stessa aritmetica da punti a pixel guida il rendering nella direzione opposta, argomento trattato nell'articolo sull'esportazione JPEG

Stato della sicurezza: crittografia e bit di autorizzazione

La crittografia PDF definisce due password con ruoli diversi. La password utente funge da cancello per la decrittografia: senza di essa il file non si aprirà affatto e FPDF_LoadDocument restituirà nil con FPDF_GetLastError che riporterà FPDF_ERR_PASSWORD. La password proprietario gestisce le autorizzazioni: un file protetto solo da una password proprietario si apre senza credenziali, ma porta con sé bit di restrizione che un lettore conforme (conforming reader) deve rispettare. Il tentativo di caricamento in sé è quindi la prima sonda di sicurezza, e la distinzione decide il codice di uscita — un file con password utente non può essere verificato (codice 2), mentre un file con password proprietario viene sottoposto normalmente ad audit e si limita ad accumulare i risultati

const
  FPDF_ERR_PASSWORD = 4;

function AuditSecurity(const FileName: string;
  Findings: TFindings): FPDF_DOCUMENT;
var
  Perms: ULONG;
  Revision: Integer;
begin
  Result := FPDF_LoadDocument(PAnsiChar(AnsiString(FileName)), nil);
  if Result = nil then
  begin
    if FPDF_GetLastError() = FPDF_ERR_PASSWORD then
      Add(Findings, fsError, 'SEC-USERPW', 0,
        'richiesta password utente (apertura); l''audit non può procedere')
    else
      Add(Findings, fsError, 'DOC-BROKEN', 0, 'elaborazione del file non riuscita');
    Exit;
  end;

  Revision := FPDF_GetSecurityHandlerRevision(Result);
  if Revision >= 0 then       // -1 significa che il file non è crittografato
  begin
    // Aperto con una password vuota eppure crittografato: solo-password-proprietario.
    // Chiunque può leggerlo, ma i bit di autorizzazione limitano cosa
    // un lettore conforme consente loro di fare. I file non crittografati riportano
    // tutti i bit impostati, motivo per cui il controllo della revisione (gate) viene per primo.
    Perms := FPDF_GetDocPermissions(Result);
    Add(Findings, fsInfo, 'SEC-ENC', 0,
      Format('crittografato, revisione del gestore di sicurezza %d', [Revision]));
    if (Perms and 4) = 0 then      // bit 3: stampa
      Add(Findings, fsWarning, 'SEC-NOPRINT', 0,
        'la stampa non è consentita');
    if (Perms and 16) = 0 then     // bit 5: copia / estrazione contenuto
      Add(Findings, fsInfo, 'SEC-NOCOPY', 0,
        'l''estrazione di contenuto non è consentita');
    if (Perms and 2048) = 0 then   // bit 12: stampa in alta risoluzione
      Add(Findings, fsWarning, 'SEC-LOWPRINT', 0,
        'è consentita solo la stampa a bassa risoluzione');
  end;
end;

Le maschere provengono dalla Tabella 22 di ISO 32000-1, che numera i bit da 1: il bit 3 del valore /P è la maschera 4, il bit 5 è 16, il bit 12 è 2048. Sapere se un determinato risultato abbia importanza o meno è una decisione di instradamento (routing). Un'agenzia di stampa (print bureau) dovrebbe respingere un file SEC-NOPRINT in fase di accettazione (intake), fornendo al mittente un messaggio chiaro, piuttosto che al RIP tre ore prima della scadenza. Un archivio dovrebbe trattare lo stesso SEC-ENC come elemento bloccante, dal momento che crittografia e conservazione a lungo termine non vanno d'accordo — un punto che il controllo degli standard sta per formalizzare

Marcatori degli standard: leggere una dichiarazione PDF/A

Un file dichiara la conformità PDF/A nel suo pacchetto di metadati XMP, attraverso la proprietà pdfaid:part (da 1 a 4) e pdfaid:conformance (la lettera del livello, come b per la fedeltà visiva o a per l'etichettatura strutturale completa). L'API C di PDFium non offre alcun metodo di accesso per XMP; FPDF_GetMetaText legge solo il dizionario Info, che non è il luogo in cui risiede l'identificazione. La scappatoia è una regola nello standard stesso: l'ISO 19005 richiede che il flusso dei metadati XMP sia memorizzato non compresso, proprio affinché gli strumenti possano trovarlo senza un parser PDF completo. Una scansione di byte raw è pertanto un rilevatore di dichiarazioni legittimo — e un file la cui dichiarazione si nasconde all'interno di un flusso compresso ha già violato lo standard che dichiara

function PdfAClaim(const FileName: string): string;
var
  Bytes: TBytes;
  S: RawByteString;
  P, Limit: Integer;
begin
  Result := '';                     // vuoto = nessuna dichiarazione PDF/A presente
  Bytes := TFile.ReadAllBytes(FileName);
  if Length(Bytes) = 0 then
    Exit;
  SetString(S, PAnsiChar(@Bytes[0]), Length(Bytes));
  P := Pos('pdfaid:part', S);       // schema di identificazione XMP
  if P = 0 then
    Exit;
  // Gestisce sia <pdfaid:part>2</pdfaid:part> sia pdfaid:part="2":
  // prende la prima cifra dopo il nome della proprietà.
  Limit := Min(P + 32, Length(S));
  Inc(P, Length('pdfaid:part'));
  while (P <= Limit) and not (S[P] in ['1'..'4']) do
    Inc(P);
  if P <= Limit then
    Result := 'PDF/A-' + Char(S[P]);
end;

Il risultato che questo produce è volutamente informativo, poiché una dichiarazione (claim) è un annuncio, non una proprietà del file. La voce XMP è una singola riga di XML che qualsiasi produttore può scrivere, incluso uno danneggiato; la conformità sta nel fatto che il file soddisfi effettivamente centinaia di regole sui font incorporati, sui colori indipendenti dal dispositivo (device-independent) e sulle funzionalità proibite. Rilevare la dichiarazione ti dice quali file instradare alla vera validazione, e niente di più. Il motore di preflight integrato nel componente esegue tale validazione sui profili PDF/A, PDF/UA e PDF/X, e l'articolo sulla CLI in batch mostra come collegarlo a una pipeline con report che un auditor può aprire in seguito

Un'esecuzione su un file problematico

Il driver concatena i controlli insieme: per prima la sicurezza, perché decide se l'audit debba essere o meno avviato, poi i comportamenti a livello di documento e la dichiarazione dello standard, e infine un ciclo di pagine per le azioni e le immagini

function AuditFile(const FileName: string; Findings: TFindings): Integer;
var
  Doc: FPDF_DOCUMENT;
  Page: FPDF_PAGE;
  I: Integer;
  Claim: string;
begin
  Doc := AuditSecurity(FileName, Findings);
  if Doc = nil then
    Exit(2);                        // fallimento dell'audit, non un verdetto
  try
    AuditDocumentBehaviors(Doc, Findings);
    Claim := PdfAClaim(FileName);
    if Claim <> '' then
      Add(Findings, fsInfo, 'STD-PDFA', 0,
        Claim + ' conformità dichiarata (solo dichiarazione, non validata)');
    for I := 0 to FPDF_GetPageCount(Doc) - 1 do
    begin
      Page := FPDF_LoadPage(Doc, I);
      if Page = nil then
      begin
        Add(Findings, fsError, 'PAGE-BROKEN', I + 1, 'elaborazione della pagina non riuscita');
        Continue;
      end;
      try
        AuditPageActions(Doc, Page, I + 1, Findings);
        AuditPageImages(Page, I + 1, Findings);
      finally
        FPDF_ClosePage(Page);
      end;
    end;
  finally
    FPDF_CloseDocument(Doc);
  end;
  if Findings.Count > 0 then
    Result := 1
  else
    Result := 0;
end;

Eseguito su una brochure restituita da un'agenzia esterna, l'output si presenta così

> preflight_audit brochure_final.pdf
brochure_final.pdf: 5 finding(s)
  [ERROR]   ACT-LAUNCH   page 3   Launch action targets "..\tools\setup.exe"
  [ERROR]   JS-DOC       doc      2 document-level JavaScript action(s) run on open
  [WARNING] IMG-LOWRES   page 7   image 412x287 px placed at 396.0x275.8 pt = 75 DPI effective
  [WARNING] SEC-NOPRINT  doc      printing is not permitted
  [INFO]    STD-PDFA     doc      PDF/A-2 conformance claimed (declaration only, not validated)
exit code 1

Ogni riga è azionabile (actionable) di per sé, ma è la combinazione a costituire il vero verdetto. Questo file dichiara la conformità al PDF/A-2 pur portando un dizionario di crittografia e JavaScript attivo, ed il PDF/A li vieta categoricamente entrambi — quindi la dichiarazione è dimostrabilmente falsa ancor prima che venga eseguito alcun validatore approfondito. Questo è il tipo di contraddizione che una lista di risultati piatta fa emergere e che un booleano passa/fallisce nasconde

Cosa questo audit non può dirti

L'onestà sullo scopo è ciò che mantiene affidabile uno strumento di preflight. Tutto quanto detto sopra legge ciò che il file dichiara su se stesso: PDFium parsa la struttura, e questo audit ne fa l'inventario. Non esegue la validazione del PDF/A — nessun controllo della copertura dei glifi sui font incorporati, nessuna analisi dello spazio colore sugli intenti di output, nessuna delle regole a livello di clausola (clause-level rules) che separano una dichiarazione dalla conformità; per questo serve un validatore dedicato, come il motore di preflight del componente o veraPDF. I bit di autorizzazione sono dichiarazioni che i lettori conformi rispettano, non muri crittografici, quindi SEC-NOPRINT descrive un'intenzione piuttosto che un'applicazione forzata (enforcement). La scansione delle azioni copre le annotazioni di collegamento e gli script a livello di documento; gli script sepolti nei dizionari degli eventi dei campi del modulo necessitano in aggiunta delle API dei moduli (form APIs). Inoltre, un controllo della firma, se estendi l'audit includendone uno, riporta l'intento dichiarato, non la crittografia verificata — la validazione della catena dei certificati (certificate chain validation) è un lavoro a sé stante. Un audit di preflight è il colloquio di accettazione, non il processo: il suo compito è rendere la decisione di instradamento informata, rapida e ripetibile

Nota: Le API degli oggetti del documento, della pagina, delle annotazioni e delle immagini usate in tutto questo audit, insieme a un wrapper Delphi di alto livello e a un motore di preflight completo per la validazione degli standard, vengono fornite con il Componente PDFium