Un PDF qui arrive à une limite de production — une file d'attente d'impression (print queue), une archive, un portail de téléchargement client — doit être audité avant que quoi que ce soit ne le rende. Le fichier peut comporter une action de lancement (Launch action) câblée pour démarrer un programme externe, des images trop grossières pour survivre à l'impression, un dictionnaire de cryptage qui interdit le travail d'impression même pour lequel il a été soumis, ou une étiquette PDF/A à laquelle il ne répond pas. L'inspection d'un document par rapport à des règles telles que celles-ci avant qu'il n'entre dans un flux de travail (workflow) est appelée contrôle en amont (preflighting), et l'API C de PDFium donne à Delphi tout ce qui est nécessaire pour implémenter les vérifications directement, sans rendre une seule page
Cet article crée les vérifications elles-mêmes : quatre classes d'audit, chacune étant une petite routine qui ajoute des résultats (findings) à une liste de résultats partagée. Les éléments interactifs, les métriques de ressources, l'état de sécurité et les marqueurs de normes obtiennent tous un code de travail, y compris l'arithmétique. Si ce dont vous avez besoin est la machinerie autour des vérifications — boucles de dossiers par lots (batch folder loops), fichiers de rapport JSON et HTML, isolation par fichier — le Composant PDFium est livré avec un moteur de contrôle en amont (preflight engine) prêt à l'emploi, et l'article sur la CLI de contrôle en amont par lots (batch preflight) couvre cette plomberie. Les deux partagent délibérément un vocabulaire de code de sortie (exit-code), de sorte qu'un auditeur écrit ici s'insère directement sous ce pilote de lot
L'enregistrement des résultats (finding record) et le contrat de code de sortie (exit-code)
Chaque vérification écrit dans un type d'enregistrement plat (flat record), car l'alternative, chaque vérification imprimant sa propre prose, ne peut pas être comptée, filtrée ou seuillée par la suite. Quatre champs suffisent
uses
System.SysUtils, System.Math, System.IOUtils,
System.Generics.Collections, pdfium_lib;
type
TFindingSeverity = (fsInfo, fsWarning, fsError);
TPreflightFinding = record
Severity: TFindingSeverity;
Code: string; // stable machine key, e.g. 'ACT-LAUNCH'
Page: Integer; // 1-based; 0 means document level
Message: string; // for humans; free to reword between releases
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;
L'outillage en aval (downstream tooling) se base sur le Code, jamais sur le texte du Message, qui est libre de changer. Le code de sortie du processus (process exit code) suit le même contrat à trois valeurs que l'article sur les lots (batch) : 0 signifie que le fichier n'a produit aucun résultat, 1 signifie que des résultats existent et 2 signifie que l'audit lui-même n'a pas pu s'exécuter car le fichier n'a pas pu être analysé (failed to parse) ou exige un mot de passe. Il est important de séparer le code 2. Un dossier de numérisations corrompues est un scanner cassé en amont, et non un effondrement soudain de la conformité, et plier (folding) les deux ensemble envoie quelqu'un chasser le mauvais problème
Éléments interactifs : scripts, cibles de lancement (launch targets), liens externes
PDFium classe chaque action qu'il trouve par un type entier, et les constantes de fpdf_doc.h valent la peine d'être fixées avec précision, car des valeurs mal copiées rendent un scanner silencieusement aveugle. L'énumération réelle est PDFACTION_UNSUPPORTED = 0, PDFACTION_GOTO = 1, PDFACTION_REMOTEGOTO = 2, PDFACTION_URI = 3, PDFACTION_LAUNCH = 4 et PDFACTION_EMBEDDEDGOTO = 5. Notez ce qui est absent : il n'y a pas de membre JavaScript. Les scripts au niveau du document ne sont pas des actions de lien et n'apparaissent jamais via FPDFAction_GetType ; ils sont énumérés par une famille d'appels distincte. Un auditeur qui teste les types d'actions par rapport à une constante JavaScript imaginée se compile, s'exécute et ne trouve rien, pour toujours
const
PDFACTION_GOTO = 1; // in-document jump: harmless
PDFACTION_REMOTEGOTO = 2; // jump into another local file
PDFACTION_URI = 3; // opens an external URL
PDFACTION_LAUNCH = 4; // starts an external program
PDFACTION_EMBEDDEDGOTO = 5; // jump into an embedded file
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; // destination-only link, nothing to flag
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 stays silent by design
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 division de gravité encode la stratégie (policy). Une action de lancement (Launch action) est une erreur car le démarrage d'un programme arbitraire est la chose la plus dangereuse qu'un clic dans un PDF puisse faire, et aucune facture n'en a besoin. Les URI externes sont des avertissements : courants dans les documents légitimes, mais un réviseur doit voir la cible sans cliquer, car le texte du lien visible et la destination réelle n'ont pas besoin d'être d'accord. Les sauts GoTo dans le document sont de la structure, pas du comportement, et restent entièrement hors du rapport — un contrôle en amont (preflight) qui crie au loup sur chaque entrée de la table des matières entraîne les gens à l'ignorer. Pour lire les corps de script derrière le nombre de JavaScript, et pour les niveaux MDP de signature et la détection XFA, l'article sur l'audit des risques de sécurité parcourt la même surface via l'enveloppeur d'objet (object wrapper) du composant
Métriques de ressources : DPI effectif de l'image
Une image à l'intérieur d'un PDF n'a pas de DPI propre. Elle a des pixels, et la page place ces pixels dans un rectangle mesuré en points, où 72 points font un pouce. La résolution n'existe que comme le rapport des deux, c'est pourquoi la même photo 600 par 400 est très nette en tant que miniature (thumbnail) et un gâchis flou en tant que héros pleine page. L'audit a donc besoin des deux nombres pour chaque image : les dimensions de pixels source à partir des métadonnées de l'image, et le rectangle placé à partir des limites de l'objet (object bounds)
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; // placed size on the page, in points
HeightPt := T - B;
if (WidthPt <= 0) or (HeightPt <= 0) or
(Meta.Width = 0) or (Meta.Height = 0) then
Continue;
// 72 points = 1 inch, so placed inches = points / 72, and
// effective DPI = source pixels / placed inches.
DpiX := Meta.Width / (WidthPt / 72.0);
DpiY := Meta.Height / (HeightPt / 72.0);
EffDpi := Min(DpiX, DpiY); // the worse axis decides print quality
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;
Les seuils sont une question de stratégie, pas de physique : 150 DPI est un plancher en dessous duquel l'impression de bureau se pixellise visiblement, 300 est la cible commerciale habituelle, et tout ce qui dépasse 600 n'achète aucune qualité visible tout en gonflant la taille du fichier, c'est pourquoi il est signalé comme un ballonnement informationnel (informational bloat) plutôt que comme un défaut. Une mise en garde honnête : FPDFPageObj_GetBounds renvoie la boîte alignée sur l'axe (axis-aligned box), de sorte que pour une image placée avec rotation, le chiffre calculé sous-estime la véritable densité. La structure FPDF_IMAGEOBJ_METADATA comporte également les champs horizontal_dpi et vertical_dpi que PDFium dérive de la matrice de transformation complète, et la comparaison des deux résultats est un moyen peu coûteux de repérer les placements pivotés. La même arithmétique points-pixels (points-to-pixels) pilote le rendu dans la direction opposée, abordée dans l'article sur l'exportation JPEG
État de sécurité : cryptage et bits d'autorisation (permission bits)
Le cryptage PDF définit deux mots de passe avec des tâches différentes. Le mot de passe utilisateur bloque le déchiffrement : sans lui, le fichier ne s'ouvrira pas du tout, et FPDF_LoadDocument renvoie nil avec FPDF_GetLastError signalant FPDF_ERR_PASSWORD. Le mot de passe propriétaire bloque les autorisations : un fichier protégé uniquement par un mot de passe propriétaire s'ouvre sans informations d'identification mais comporte des bits de restriction qu'un lecteur conforme doit honorer. La tentative de chargement elle-même est donc la première sonde de sécurité, et la distinction décide du code de sortie — un fichier à mot de passe utilisateur n'est pas auditable (code 2), tandis qu'un fichier à mot de passe propriétaire est audité normalement et accumule simplement des résultats
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 means the file is not encrypted
begin
// Opened with an empty password yet encrypted: owner-password-only.
// Anyone may read it, but the permission bits restrict what a
// conforming reader lets them do. Unencrypted files report all
// bits set, which is why the revision gate comes first.
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: print
Add(Findings, fsWarning, 'SEC-NOPRINT', 0,
'printing is not permitted');
if (Perms and 16) = 0 then // bit 5: copy / extract content
Add(Findings, fsInfo, 'SEC-NOCOPY', 0,
'content extraction is not permitted');
if (Perms and 2048) = 0 then // bit 12: high-resolution print
Add(Findings, fsWarning, 'SEC-LOWPRINT', 0,
'only low-resolution printing is permitted');
end;
end;
Les masques proviennent du tableau 22 de l'ISO 32000-1, qui numérote les bits à partir de 1 : le bit 3 de la valeur /P est le masque 4, le bit 5 est 16, le bit 12 est 2048. Qu'une découverte (finding) donnée ait de l'importance est une décision de routage. Un bureau d'impression doit rejeter un fichier SEC-NOPRINT à la réception, où l'expéditeur reçoit un message clair, plutôt qu'au niveau du RIP trois heures avant une échéance. Une archive doit traiter SEC-ENC lui-même comme un bloqueur, car le cryptage et la préservation à long terme ne font pas bon ménage — un point que la vérification des normes est sur le point de faire formellement
Marqueurs de normes : lecture d'une réclamation PDF/A (claim)
Un fichier déclare la conformité PDF/A dans son paquet de métadonnées XMP, via la propriété pdfaid:part (1 à 4) et pdfaid:conformance (la lettre de niveau, telle que b pour la fidélité visuelle ou a pour un balisage structurel complet). L'API C de PDFium n'offre aucun accesseur XMP ; FPDF_GetMetaText ne lit que le dictionnaire Info, ce qui n'est pas là que se trouve l'identification. La trappe de secours (escape hatch) est une règle de la norme elle-même : l'ISO 19005 exige que le flux de métadonnées XMP soit stocké non compressé, précisément pour que les outils puissent le trouver sans un analyseur (parser) PDF complet. Une analyse d'octets bruts (raw byte scan) est donc un détecteur de réclamation légitime — et un fichier dont la réclamation se cache dans un flux compressé a déjà violé la norme qu'il revendique
function PdfAClaim(const FileName: string): string;
var
Bytes: TBytes;
S: RawByteString;
P, Limit: Integer;
begin
Result := ''; // empty = no PDF/A claim present
Bytes := TFile.ReadAllBytes(FileName);
if Length(Bytes) = 0 then
Exit;
SetString(S, PAnsiChar(@Bytes[0]), Length(Bytes));
P := Pos('pdfaid:part', S); // XMP identification schema
if P = 0 then
Exit;
// Handles both <pdfaid:part>2</pdfaid:part> and pdfaid:part="2":
// take the first digit after the property name.
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;
Le résultat (finding) que cela produit est délibérément informatif, car une réclamation est une déclaration, pas une propriété du fichier. L'entrée XMP est une ligne de XML que n'importe quel producteur peut écrire, y compris un mauvais ; la conformité est le fichier satisfaisant réellement des centaines de règles sur les polices intégrées, les couleurs indépendantes du périphérique et les fonctionnalités interdites. La détection de la réclamation vous indique quels fichiers acheminer vers une véritable validation, et rien de plus. Le moteur de contrôle en amont (preflight engine) intégré du composant effectue cette validation sur les profils PDF/A, PDF/UA et PDF/X, et l'article sur la CLI de lot montre comment le câbler dans un pipeline avec des rapports qu'un auditeur peut ouvrir ultérieurement
Une exécution par rapport à un fichier à problème
Le pilote enchaîne les vérifications : la sécurité d'abord, car elle décide si l'audit s'exécute, puis les comportements au niveau du document et la réclamation de normes, puis une boucle de page pour les actions et les images
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); // audit failure, not a verdict
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;
Face à une brochure revenue d'une agence externe, le résultat (output) ressemble à ceci
> 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
Chaque ligne est exploitable en soi, mais la combinaison est le véritable verdict. Ce fichier revendique PDF/A-2 tout en portant un dictionnaire de cryptage et du JavaScript en direct, et PDF/A interdit catégoriquement les deux — la réclamation est donc manifestement fausse avant même qu'un validateur profond ne s'exécute. C'est le genre de contradiction qu'une liste de résultats plate (flat findings list) fait surface et qu'un passe/échec (pass/fail) booléen cache
Ce que cet audit ne peut pas vous dire
L'honnêteté quant à la portée est ce qui maintient la confiance en un outil de contrôle en amont (preflight). Tout ce qui précède lit ce que le fichier déclare sur lui-même : PDFium analyse (parses) la structure, et cet audit la répertorie. Il n'effectue pas de validation PDF/A — pas de vérification de couverture de glyphes par rapport aux polices intégrées, pas d'analyse de l'espace colorimétrique par rapport aux intentions de sortie (output intents), aucune des règles au niveau de la clause qui séparent une réclamation de la conformité ; pour cela, vous avez besoin d'un validateur dédié tel que le moteur de contrôle en amont du composant ou veraPDF. Les bits d'autorisation sont des déclarations que les lecteurs conformes honorent, et non des murs cryptographiques, de sorte que SEC-NOPRINT décrit l'intention plutôt que l'application (enforcement). L'analyse d'action couvre les annotations de lien et les scripts au niveau du document ; les scripts enfouis dans les dictionnaires d'événements de champs de formulaire (form-field event dictionaries) nécessitent les API de formulaire (form APIs) en plus. Et une vérification de signature, si vous étendez l'audit avec une, rapporte l'intention déclarée, et non la cryptographie vérifiée — la validation de la chaîne de certificats est un travail distinct. Un audit de contrôle en amont est l'entretien d'admission (intake interview), pas le procès (trial) : son travail consiste à rendre la décision de routage éclairée, rapide et reproductible
Remarque : Les API d'objet de document, de page, d'annotation et d'image utilisées tout au long de cet audit, associées à un enveloppeur Delphi (Delphi wrapper) de haut niveau et à un moteur de contrôle en amont de validation des normes (standards-validation preflight engine) complet, sont fournies avec le Composant PDFium