Article technique

Rapports de contrôle en amont PDF par lots (Batch Preflight) dans Delphi avec le CLI du Composant PDFium

Un outil de contrôle en amont par lots (batch preflight) est un programme de console sans fenêtre, pointé vers un dossier de PDF, qui valide chacun d'eux par rapport aux normes de conformité que vous indiquez et laisse derrière lui une preuve lisible par machine de ce qu'il a trouvé. Personne ne s'assoit pour le regarder. Il s'exécute à deux heures du matin sous cron ou dans le planificateur de tâches Windows, ou comme point de passage dans un pipeline CI, et la prochaine personne à s'intéresser à sa sortie est soit un planificateur (scheduler) lisant un code de sortie (exit code), soit un auditeur ouvrant un rapport des semaines plus tard. Cela change la définition du mot "correct". Le moteur de contrôle en amont du Composant PDFium, une bibliothèque PDF en code source pour Delphi, C++Builder et Lazarus, rend les appels de validation eux-mêmes presque triviaux. Le travail qui décide si l'outil justifie son existence se situe autour de ces appels : quel profil vous avez vérifié, ce que le code de sortie a dit au planificateur, et si le rapport qui aurait détecté une erreur existe toujours lorsque quelqu'un part à sa recherche

Le contrat : ce qu'un planificateur peut réellement voir

Un exécuteur CI (CI runner) ou le planificateur de tâches Windows ne voit exactement que deux choses de votre outil : le code de sortie et les fichiers qu'il a laissés derrière lui. Lignes de journal (log lines), couleurs de console, affichage de progression : tout cela est destiné à un humain qui regarde en direct, et à deux heures du matin, il n'y en a aucun. Fixez donc le vocabulaire des codes de sortie avant de toucher à l'API, et restez simple :

  • 0 : chaque fichier était conforme à chaque profil demandé
  • 1 : au moins un fichier a produit des résultats de validation
  • 2 : l'outil lui-même a échoué sur au moins un fichier (entrée corrompue, verrouillage, plantage)

La distinction entre les codes 1 et 2 est celle que les équipes omettent et regrettent plus tard. Un PDF corrompu qui refuse de s'ouvrir n'est pas un échec de validation. Regroupez-le dans le code 1, et un chargement de numérisations endommagées apparaîtra dans vos tableaux de bord comme un effondrement soudain de la conformité, envoyant quelqu'un à la recherche d'une régression des normes qui n'a jamais eu lieu, alors que la véritable histoire est un scanner défectueux en amont

Deux autres éléments font partie du contrat. Le premier est un délai d'attente (timeout) par fichier. Un PDF pathologique, contenant des milliers de pages avec des structures d'objets profondément imbriquées, peut bloquer une seule passe de validation pendant des minutes, et une fenêtre d'exécution nocturne n'a pas la patience de l'attendre. Tuez le travail sur ce fichier à l'échéance, comptez-le comme un échec de l'outil et laissez le lot avancer. Le second est un répertoire de quarantaine : déplacez chaque entrée ayant dépassé le délai ou ne pouvant pas être ouverte au lieu de la laisser en place. En quelques mois, ce répertoire accumulera discrètement les pires documents que vos vrais clients envoient, et ce corpus vaut plus pour les tests de version (release testing) que n'importe quel échantillon synthétique que vous pourriez écrire à la main

Le choix des normes et pourquoi le niveau de conformité est important

L'énumération TPdfPreflightStandard couvre les familles qui se présentent en pratique : ppsPdfA pour la conformité d'archivage ISO 19005, ppsPdfUa pour l'accessibilité ISO 14289, ppsPdfX pour l'échange d'impressions, plus ppsPdfE, ppsPdfR et ppsPdfVT pour l'ingénierie, le rastérisage et le travail sur des données variables. Au sein d'une famille, le moteur lit le niveau de conformité revendiqué par le document et le signale par norme dans le ConformanceName du résultat. Il est rarement suffisant de nommer la famille, car c'est au niveau que réside la véritable différence. PDF/A-2b promet une reproductibilité visuelle et rien de plus. PDF/A-3a ajoute une exigence de balisage de la structure logique et autorise les fichiers sources intégrés, ce qui est une barre beaucoup plus difficile à franchir pour du matériel numérisé qui ne possède aucune arborescence de balises. Si vous vous trompez dans l'un ou l'autre sens, le lot vous mentira. Si votre politique de conservation exige réellement du PDF/A-2b mais que vous refusez les fichiers parce qu'ils manquent de balises de structure, le rapport se remplit de résultats que personne ne corrigera jamais. Acceptez n'importe quelle étiquette PDF/A sans vérifier le niveau et vous validez des documents qui respectent une norme plus faible que celle que vous aviez promise. Les mandats d'accessibilité des acheteurs gouvernementaux empilent de plus en plus PDF/UA par-dessus tout cela, ce qui n'ajoute aucun coût à l'exécution car BuildPdfPreflightReport (issu de l'unité FPdfPreflightReport) prend un ensemble de normes :

Report := BuildPdfPreflightReport(Pdf, [ppsPdfA, ppsPdfUa]);

Un seul appel évalue les deux normes et renvoie un enregistrement de rapport consolidé unique

Pourquoi une liste de résultats vide n'est pas un succès (pass)

Le rapport énumère les résultats par norme, et une liste de problèmes vide signifie uniquement "aucun problème trouvé dans les normes qui ont été réellement exécutées". C'est une affirmation plus restreinte que "le fichier est conforme à la norme qui vous intéresse", et c'est dans l'écart entre les deux que le contrôle en amont par lots pourrit discrètement. Une faute de frappe dans la configuration qui supprime ppsPdfA de l'ensemble produit exactement la même liste de problèmes vide qu'un fichier véritablement propre. Traitez donc le silence comme suspect. Parcourez Report.Results et vérifiez deux choses pour chaque norme que vous vouliez vérifier : qu'une entrée de résultat existe pour elle, et que son indicateur IsCompliant, soutenu par Status = pfsPass, est vrai. Une tâche nocturne qui assimile "aucun résultat" à "prêt pour l'archive" sans jamais confirmer quelles normes ont été évaluées est la façon classique pour un dossier de fichiers non conformes de passer inaperçu pendant des mois, jusqu'à ce qu'un auditeur externe en ouvre un avec veraPDF et que l'ensemble de l'archive soit remis en question

Un deuxième piège se cache dans ce qu'est réellement une conclusion (finding). Chaque TPdfPreflightIssue comporte un code (Code), une catégorie (Category), une description (Description) et une recommandation (Recommendation), et il nomme la règle qui a été violée, et non une page ou un objet. C'est un choix de conception qui a des conséquences sur la boucle de rétroaction (feedback loop). Le rapport indique à l'équipe de production quelle classe de défaut existe, une police non incorporée ou un identifiant XMP manquant, et trouver l'objet fautif spécifique est le travail de l'outil de remédiation en aval, pas celui du validateur. Construisez vos consommateurs de rapports sur les valeurs stables du Code, jamais sur le texte de description lisible par l'homme, qui peut être reformulé entre les versions sans avertissement

Des fichiers de rapport pour les machines et pour la personne d'astreinte

L'enregistrement du rapport écrit les mêmes conclusions dans cinq formats : SaveJsonToFile, SaveCsvToFile, SaveHtmlToFile, SaveTextToFile et SaveMarkdownToFile, chacun avec une fonction de style ToJson correspondante lorsque vous souhaitez que la chaîne soit en mémoire plutôt que sur le disque. Résistez à l'envie d'en choisir un seul. Écrivez le JSON pour le pipeline, afin que la CI puisse le joindre à l'enregistrement de la tâche et analyser les codes de problèmes et les statuts par norme sans extraire (scraping) de texte. Écrivez le HTML pour l'humain qui est appelé (paged), car il s'ouvre dans n'importe quel navigateur sans aucun outillage. L'association des deux coûte une ligne supplémentaire par fichier et épargne à votre ingénieur d'astreinte la pire des tâches du traitement par lots, qui consiste à faire de l'ingénierie inverse sur un bloc (blob) JSON brut à deux heures du matin pour savoir quel fichier a provoqué la panne. Une discipline importe plus que le choix du format : dérivez chaque nom de rapport du nom de fichier d'entrée, jamais d'un horodatage (timestamp), sinon deux exécutions parallèles entremêleront des rapports que vous ne pourrez plus associer à leurs entrées

Les seuils de gravité relèvent de la configuration plutôt que du code. Une annotation sans description alternative est un échec pur et simple (hard failure) pour un portail de soumission PDF/UA et une remarque ignorable pour une archive interne, pourtant c'est la conclusion identique dans les deux cas. Exposez un niveau de d'échec (fail-on level) par profil afin que la politique puisse changer sans nécessiter une recompilation, et estampillez le niveau qui était en vigueur dans le résumé de la tâche lui-même. Au trimestre prochain, personne ne se souviendra sous quel seuil le lot d'octobre dernier a été exécuté, et le résumé est le seul endroit où cette mémoire survit

Isoler les fichiers pour qu'un mauvais PDF ne puisse pas faire couler le lot

procedure RunPreflightBatch(const InputDir, ReportDir: string;
  out FilesWithFindings, ToolFailures: Integer);
var
  SR: TSearchRec;
  Pdf: TPdf;
  Report: TPdfPreflightReport;
begin
  FilesWithFindings := 0;
  ToolFailures := 0;
  if FindFirst(InputDir + '*.pdf', faAnyFile, SR) = 0 then
  try
    repeat
      Pdf := TPdf.Create(nil);   // instance nouvelle par fichier : pas de fuite d'état (state bleed)
      try
        try
          Pdf.FileName := InputDir + SR.Name;
          Pdf.Active := True;
          if not Pdf.Active then  // les échecs de chargement sont silencieux, non levés (raised)
            raise EPdfError.Create('Impossible d''ouvrir ' + SR.Name);
          Report := BuildPdfPreflightReport(Pdf, [ppsPdfA, ppsPdfUa]);
          Report.SaveJsonToFile(ReportDir + ChangeFileExt(SR.Name, '.json'));
          Report.SaveHtmlToFile(ReportDir + ChangeFileExt(SR.Name, '.html'));
          if Report.TotalIssueCount > 0 then
            Inc(FilesWithFindings);
        except
          on E: Exception do
          begin
            Inc(ToolFailures);   // territoire du code de sortie 2, pas un verdict de validation
            WriteLn(ErrOutput, SR.Name + ': ' + E.Message);
          end;
        end;
      finally
        Pdf.Free;
      end;
    until FindNext(SR) <> 0;
  finally
    FindClose(SR);
  end;
end;

Trois choix délibérés vivent dans cette boucle. Un nouveau TPdf par fichier garantit qu'un document qui corrompt l'état du moteur ne peut pas empoisonner les fichiers qui le suivent. La vérification explicite Active gagne sa place car Active := True engloutit les erreurs de chargement au lieu de les lever (raising) ; si vous baissez la garde, un fichier tronqué dérive dans l'appel de validation avant d'échouer quelque part en aval avec un message trompeur. Le try..except interne vit à dessein dans la portée par fichier (per-file scope), de sorte qu'une seule exception incrémente le compteur d'échecs et la boucle continue. Vous voulez des rapports propres pour les 4 999 bons fichiers, même si le fichier 5 000 est déchiqueté. Et les deux formats de rapport sont écrits sur le disque avant que le verdict ne soit comptabilisé, ce qui signifie que les preuves survivent même si un bogue plus loin dans la logique de résumé se trompe de compte

Le mappage du code de sortie se réduit ensuite à quelques lignes dans le fichier du projet :

begin
  RunPreflightBatch(ParamStr(1), ParamStr(2), Findings, Failures);
  if Failures > 0 then
    Halt(2)
  else if Findings > 0 then
    Halt(1);
  // si aucun des cas précédents n'est atteint, la sortie se fait avec 0 : chaque fichier est conforme
end.

Ce que le contrôle en amont ne fera pas pour vous

Le moteur détecte ; il ne répare pas. Une conclusion concernant une police non incorporée ou un espace colorimétrique dépendant du périphérique (device-dependent) est un bon de travail (work order) pour quiconque produit les fichiers, et le validateur n'a aucun moyen de la corriger sur place (patch it in place). Planifiez donc la boucle de rétroaction délibérément. Les rapports doivent atterrir là où l'équipe de production les lit réellement, sinon les mêmes conclusions réapparaissent chaque nuit jusqu'à ce que quelqu'un demande enfin pourquoi le taux de conformité ne s'améliore jamais. Il est également utile de vérifier de manière croisée (cross-check) un échantillon de verdicts avec un validateur indépendant, veraPDF pour PDF/A ou le contrôle en amont d'Acrobat pour PDF/X, avant qu'un auditeur externe ne les vérifie de manière croisée pour vous. Lorsque deux moteurs sont en désaccord sur un fichier client réel, ce document n'est pas une nuisance ; c'est exactement le cas de régression qui manquait à vos tests de publication. Conservez-le, nommez-le et exécutez-le à chaque compilation

Une dernière association mérite d'être connue. Le même moteur de validation pilote les vérifications interactives dans une interface utilisateur de révision, de sorte que cette CLI sans interface (headless) et un banc de travail de révision d'admission de PDF (PDF intake review workbench) destiné aux analystes peuvent partager un vocabulaire de validation unique au lieu de s'écarter au fil du temps. Et parce que [ppsPdfA, ppsPdfUa] évalue l'accessibilité dans la même passe, le côté PDF/UA du lot s'aligne proprement avec le travail côté visionneuse comme la construction d'un lecteur PDF accessible dans Delphi. Les profils, les formats de rapport et l'API complète de contrôle en amont sont documentés sur la page produit du Composant PDFium