Articolo tecnico

Preflight PDF automatico e audit dei rischi con PDFium

Un PDF che arriva a un confine di produzione — una coda di stampa, un archivio, un portale di caricamento per i clienti — andrebbe verificato prima che qualcosa lo renda. Il file potrebbe portare un'azione Launch collegata all'avvio di un programma esterno, immagini troppo grossolane per sopravvivere alla stampa, un dizionario di cifratura che vieta proprio il lavoro di stampa per cui è stato inviato, oppure un'etichetta PDF/A che non rispetta. Ispezionare un documento rispetto a regole come queste prima che entri in un flusso di lavoro si chiama preflight, e la API C di PDFium dà a Delphi tutto il necessario per implementare i controlli direttamente, senza rendere una sola pagina

Questo articolo costruisce i controlli veri e propri: quattro classi di audit, ciascuna una piccola routine che accoda i risultati a un elenco condiviso. Elementi interattivi, metriche delle risorse, stato di sicurezza e marcatori di conformità ottengono tutti codice funzionante, aritmetica inclusa. Se quel che vi serve è la meccanica attorno ai controlli — cicli su cartelle in batch, file di report JSON e HTML, isolamento per file — il PDFium Component distribuisce un motore di preflight già pronto, e l'articolo sulla CLI di preflight in batch tratta quell'impianto. I due condividono deliberatamente un unico vocabolario di codici di uscita, così un auditor scritto qui si incastra direttamente sotto quel driver batch

Diagramma della pipeline PDF: un PDF in ingresso si dirama attraverso quattro classi di controllo i cui risultati confluiscono in un unico record TPreflightFinding che si mappa su un codice di uscita basato su soglie
L'audit fa passare un file non affidabile attraverso quattro classi di controllo — elementi interattivi, metriche delle risorse, stato di sicurezza e marcatori di conformità — raccoglie ogni risultato in un unico record contabile e lo trasforma in un solo codice di uscita

Il record dei risultati e il contratto sui codici di uscita

Ogni controllo scrive in un unico tipo di record piatto, perché l'alternativa, ogni controllo che stampa la propria prosa, non si può contare, filtrare né confrontare con una soglia in un secondo momento. Bastano quattro campi

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, ad es. 'ACT-LAUNCH'
    Page: Integer;      // in base 1; 0 significa livello documento
    Message: string;    // per le persone; riformulabile fra 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 si basano su Code, mai sul testo di 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, 1 che esistono risultati e 2 che l'audit stesso non è potuto partire perché il file non si è analizzato o richiede una password. Tenere il codice 2 separato conta. Una cartella di scansioni corrotte è uno scanner rotto a monte, non un improvviso crollo della conformità, e mescolare le due cose manda qualcuno a inseguire il problema sbagliato

Elementi interattivi: script, destinazioni Launch, collegamenti esterni

PDFium classifica ogni azione che trova con un tipo intero, e vale la pena fissare con precisione le costanti di fpdf_doc.h, perché valori copiati male rendono uno scanner cieco in silenzio. L'enumerazione reale è PDFACTION_UNSUPPORTED = 0, PDFACTION_GOTO = 1, PDFACTION_REMOTEGOTO = 2, PDFACTION_URI = 3, PDFACTION_LAUNCH = 4 e PDFACTION_EMBEDDEDGOTO = 5. Notate cosa manca: non c'è un membro JavaScript. Gli script a livello di documento non sono azioni di collegamento e non compaiono mai attraverso FPDFAction_GetType; vengono enumerati da una famiglia di chiamate separata. Un auditor che confronti i tipi di azione con una costante JavaScript immaginaria compila, gira e non trova nulla, per sempre

const
  PDFACTION_GOTO         = 1;   // salto interno al documento: innocuo
  PDFACTION_REMOTEGOTO   = 2;   // salto dentro un altro file locale
  PDFACTION_URI          = 3;   // apre un URL esterno
  PDFACTION_LAUNCH       = 4;   // avvia un programma esterno
  PDFACTION_EMBEDDEDGOTO = 5;   // salto dentro 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 con sola destinazione, nulla da segnalare
    AType := FPDFAction_GetType(Action);
    case AType of
      PDFACTION_LAUNCH:
        Add(Findings, fsError, 'ACT-LAUNCH', PageNo,
          'Launch action targets "' + ActionTarget(Doc, Action, AType) + '"');
      PDFACTION_URI:
        Add(Findings, fsWarning, 'ACT-URI', PageNo,
          'link opens ' + ActionTarget(Doc, Action, AType));
      PDFACTION_REMOTEGOTO, PDFACTION_EMBEDDEDGOTO:
        Add(Findings, fsWarning, 'ACT-XFILE', PageNo,
          'cross-file destination "' + ActionTarget(Doc, Action, AType) + '"');
    end;                        // PDFACTION_GOTO resta silenzioso per scelta
  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 document-level JavaScript action(s) run on open', [N]));
  N := FPDFDoc_GetAttachmentCount(Doc);
  if N > 0 then
    Add(Findings, fsWarning, 'ATT-EMB', 0,
      Format('%d embedded file attachment(s)', [N]));
end;

La ripartizione delle gravità codifica una politica. Un'azione Launch è un errore perché avviare un programma arbitrario è la cosa più pericolosa che un clic dentro un PDF possa fare, e nessuna fattura ne ha bisogno. Gli URI esterni sono avvisi: comuni nei documenti legittimi, ma un revisore dovrebbe vedere la destinazione senza fare clic, dato che il testo visibile del collegamento e la destinazione reale non devono per forza coincidere. I salti GoTo interni al documento sono struttura, non comportamento, e restano del tutto fuori dal report — un preflight che grida al lupo su ogni voce di un indice insegna alle persone a ignorarlo. Per leggere i corpi degli script dietro il conteggio JavaScript, e per i livelli MDP delle firme e il rilevamento XFA, l'articolo sull'audit dei rischi di sicurezza percorre la stessa superficie attraverso il wrapper a oggetti del componente

Metriche delle risorse: DPI effettivi delle immagini

Un'immagine dentro un PDF non ha DPI propri. Ha pixel, e la pagina colloca quei pixel in un rettangolo misurato in punti, dove 72 punti fanno un pollice. La risoluzione esiste solo come rapporto fra i due, ed è per questo che la stessa foto da 600 per 400 è nitidissima come miniatura e un pasticcio sfocato come immagine a piena pagina. L'audit ha quindi bisogno di entrambi i numeri per ogni immagine: le dimensioni in pixel della sorgente dai metadati dell'immagine e il rettangolo di collocazione dai confini 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 collocata 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 i pollici collocati = punti / 72, e
    // i DPI effettivi = pixel sorgente / pollici collocati.
    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('image %dx%d px placed at %.1fx%.1f pt = %.0f DPI effective',
          [Meta.Width, Meta.Height, WidthPt, HeightPt, EffDpi]))
    else if EffDpi > 600.0 then
      Add(Findings, fsInfo, 'IMG-BLOAT', PageNo,
        Format('image is %.0f DPI at placed size; resampling would ' +
          'shrink the file with no visible loss', [EffDpi]));
  end;
end;

Le soglie sono politica, non fisica: 150 DPI è un pavimento sotto il quale la stampa da ufficio si sgrana visibilmente, 300 è il consueto obiettivo commerciale, e qualsiasi cosa sopra i 600 non compra qualità visibile mentre gonfia la dimensione del file, ed è per questo che viene riportata come sovrappeso informativo anziché come difetto. Un avvertimento onesto: FPDFPageObj_GetBounds restituisce il riquadro allineato agli assi, quindi per un'immagine collocata con una rotazione la cifra calcolata sottostima la densità reale. La struttura FPDF_IMAGEOBJ_METADATA porta anche i campi horizontal_dpi e vertical_dpi che PDFium deriva dalla matrice di trasformazione completa, e confrontare i due risultati è un modo economico per individuare le collocazioni ruotate. La stessa aritmetica fra punti e pixel guida il rendering nella direzione opposta, trattata nell'articolo sull'esportazione JPEG

Stato di sicurezza: cifratura e bit di permesso

La cifratura PDF definisce due password con compiti diversi. La password utente presidia la decifratura: senza di essa il file non si apre affatto, e FPDF_LoadDocument restituisce nil con FPDF_GetLastError che riporta FPDF_ERR_PASSWORD. La password proprietario presidia i permessi: un file protetto solo da una password proprietario si apre senza credenziali ma porta bit di restrizione che un lettore conforme deve onorare. Il tentativo di caricamento è quindi il primo sondaggio di sicurezza, e la distinzione decide il codice di uscita — un file con password utente non è verificabile (codice 2), mentre un file con password proprietario si verifica normalmente e si limita ad accumulare 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,
        'user (open) password required; audit cannot proceed')
    else
      Add(Findings, fsError, 'DOC-BROKEN', 0, 'file failed to parse');
    Exit;
  end;

  Revision := FPDF_GetSecurityHandlerRevision(Result);
  if Revision >= 0 then       // -1 significa che il file non è cifrato
  begin
    // Aperto con password vuota eppure cifrato: solo password proprietario.
    // Chiunque può leggerlo, ma i bit di permesso limitano ciò che un
    // lettore conforme gli lascia fare. I file non cifrati riportano tutti
    // i bit impostati, ed è per questo che il controllo sulla revisione viene prima.
    Perms := FPDF_GetDocPermissions(Result);
    Add(Findings, fsInfo, 'SEC-ENC', 0,
      Format('encrypted, security handler revision %d', [Revision]));
    if (Perms and 4) = 0 then      // bit 3: stampa
      Add(Findings, fsWarning, 'SEC-NOPRINT', 0,
        'printing is not permitted');
    if (Perms and 16) = 0 then     // bit 5: copia / estrazione del contenuto
      Add(Findings, fsInfo, 'SEC-NOCOPY', 0,
        'content extraction is not permitted');
    if (Perms and 2048) = 0 then   // bit 12: stampa ad alta risoluzione
      Add(Findings, fsWarning, 'SEC-LOWPRINT', 0,
        'only low-resolution printing is permitted');
  end;
end;

Le maschere vengono dalla Tabella 22 di ISO 32000-1, che numera i bit a partire da 1: il bit 3 del valore /P è la maschera 4, il bit 5 è 16, il bit 12 è 2048. Se un dato risultato conti oppure no è una decisione di instradamento. Un centro stampa dovrebbe respingere un file SEC-NOPRINT alla ricezione, dove chi lo invia riceve un messaggio chiaro, anziché al RIP tre ore prima di una scadenza. Un archivio dovrebbe trattare SEC-ENC stesso come un blocco, dato che cifratura e conservazione a lungo termine non vanno d'accordo — un punto che il controllo sulla conformità sta per rendere formale

Marcatori di conformità: leggere un claim PDF/A

Un file dichiara la conformità PDF/A nel proprio pacchetto di metadati XMP, tramite la proprietà pdfaid:part (da 1 a 4) e pdfaid:conformance (la lettera del livello, come b per la fedeltà visiva o a per la marcatura strutturale completa). La API C di PDFium non offre alcun accessore XMP; FPDF_GetMetaText legge solo il dizionario Info, che non è dove risiede l'identificazione. La via d'uscita è una regola dello standard stesso: ISO 19005 richiede che il flusso di metadati XMP sia memorizzato non compresso, proprio perché gli strumenti possano trovarlo senza un parser PDF completo. Una scansione grezza dei byte è quindi un rilevatore di claim legittimo — e un file il cui claim si nasconde dentro 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 = nessun claim 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 ne esce è deliberatamente informativo, perché un claim è una dichiarazione, non una proprietà del file. La voce XMP è una riga di XML che qualsiasi produttore può scrivere, anche uno difettoso; la conformità è il file che soddisfa davvero centinaia di regole su font incorporati, colore indipendente dal dispositivo e funzionalità vietate. Rilevare il claim vi dice quali file instradare a una validazione vera, e nulla di più. Il motore di preflight incorporato nel componente esegue quella validazione sui profili PDF/A, PDF/UA e PDF/X, e l'articolo sulla CLI in batch mostra come inserirlo in una pipeline con report che un revisore possa aprire in seguito

Una esecuzione su un file problematico

Il driver mette in fila i controlli: prima la sicurezza, perché decide se l'audit possa girare, poi i comportamenti a livello di documento e il claim di conformità, poi un ciclo sulle pagine per azioni e 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 + ' conformance claimed (declaration only, not validated)');
    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, 'page failed to parse');
        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;

Su una brochure tornata da un'agenzia esterna, il risultato 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 per conto suo, ma è la combinazione il verdetto vero. Questo file dichiara PDF/A-2 pur portando un dizionario di cifratura e JavaScript attivo, e PDF/A vieta entrambi in modo netto — quindi il claim è dimostrabilmente falso prima ancora che giri un validatore approfondito. È il tipo di contraddizione che un elenco piatto di risultati fa emergere e che un esito booleano di superato o non superato nasconde

Cosa questo audit non può dirvi

L'onestà sull'ambito è ciò che mantiene affidabile uno strumento di preflight. Tutto quanto sopra legge ciò che il file dichiara di sé: PDFium ne analizza la struttura, e questo audit la inventaria. Non esegue la validazione PDF/A — nessun controllo di copertura dei glifi contro i font incorporati, nessuna analisi degli spazi colore contro gli output intent, nessuna delle regole a livello di clausola che separano un claim dalla conformità; per quello vi serve un validatore dedicato come il motore di preflight del componente oppure veraPDF. I bit di permesso sono dichiarazioni che i lettori conformi onorano, non muri crittografici, quindi SEC-NOPRINT descrive un'intenzione anziché un'imposizione. La scansione delle azioni copre le annotazioni di collegamento e gli script a livello di documento; gli script sepolti nei dizionari di evento dei campi modulo richiedono in più le API dei moduli. E un controllo sulle firme, se estendete l'audit con uno, riporta l'intenzione dichiarata, non la crittografia verificata — la validazione della catena di certificati è un lavoro a parte. Un audit di preflight è il colloquio di ammissione, non il processo: il suo compito è rendere la decisione di instradamento informata, rapida e ripetibile

Nota: le API per documento, pagina, annotazioni e oggetti immagine 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, sono distribuite con PDFium Component