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