Article technique

Preflight de version PDF en Delphi : règles Measure RL/GEO

Un plan d'étage exporté par un paquet CAO d'architecture porte typiquement un tableau /VP sur sa première page avec deux viewports, et chaque viewport porte une entrée /Measure pour qu'un lecteur puisse lire des mètres directement sur le dessin. Une feuille de carte GeoPDF a le même tableau /VP et la même clé /Measure. Le plan d'étage est du PDF 1.6 ordinaire. La feuille de carte demande du PDF 1.7 plus Adobe Extension Level 3. Un contrôle de version qui s'arrête au nom de la clé exigera l'extension pour les deux, et le plan d'étage se fait refuser à la sauvegarde avec un message au sujet d'un contenu géospatial qu'il n'a jamais contenu

Une clé /Measure, deux dictionnaires différents

Le nom de la clé vous dit qu'un viewport est mesurable ; seul le dictionnaire derrière lui vous dit comment. Les dictionnaires Measure sont arrivés en PDF 1.6, et ISO 32000-1 §12.9 définit le genre rectiligne : une chaîne de rapport d'échelle dans /R, des tableaux de format de nombre dans /X et /Y, et d'autres tableaux pour la distance (/D) et la surface (/A). La mesure géospatiale est venue plus tard, dans Adobe Extension Level 3 au-dessus du PDF 1.7. Elle se marque par /Subtype /GEO et porte un système de coordonnées géographiques dans /GCS, un système de coordonnées d'affichage optionnel dans /DCS, des tableaux de points appariés dans /GPTS et /LPTS, et des unités d'affichage préférées dans /PDU — les structures parcourues dans la lecture des viewports GeoPDF et des tableaux GPTS et LPTS en Delphi

Le détail qui piège les gens, c'est le défaut. §12.9 donne à /Subtype une valeur par défaut RL, et pas mal d'exporteurs CAO en profitent pour omettre l'entrée. Un contrôleur qui lit un sous-type manquant comme "inconnu, prendre le pire" transforme chacun de ces dessins en document géospatial. Un contrôleur qui apparie sur la clé /Measure n'atteint même jamais la question du sous-type, car le dictionnaire de viewport qui porte la clé est un niveau au-dessus du dictionnaire de mesure qui y répond

Une clé /Measure, deux dictionnaires dans PDFlibPas : la mesure rectiligne, avec /RL ou sous-type omis, ne demande que du PDF 1.6, tandis qu'un dictionnaire géospatial demande 1.7 ExtensionLevel 3, donc le preflight décide au contenu du dictionnaire de mesure là où une règle au niveau de la clé ne peut pas les distinguer
Un preflight qui bloque un fichier valide est pire qu'un preflight lent, parce que l'appelant reçoit un diagnostic autoritaire sur une fonctionnalité que le document n'a jamais contenue, et c'est pourquoi le contrôle discriminant se trouve un niveau plus bas

Comment le preflight de version à la sauvegarde de PDF Library for Delphi rend son verdict

PDF Library for Delphi (PDFlibPas) fait tourner un preflight de version à l'intérieur de SaveToFile avant que toute sortie ne soit écrite. Il visite chaque objet indirect, en descendant dans les dictionnaires et tableaux imbriqués, et teste chaque nœud contre une table de règles de fonctionnalités. Chaque règle nomme une version PDF minimale et, pour les fonctionnalités d'extension, un niveau d'extension Adobe ; une règle d'extension ne peut être satisfaite que par du PDF 1.7 avec une entrée /Extensions /ADBE assortie dans le catalogue. La cible contre laquelle il vérifie est la version du document, ou la version que vous avez épinglée avec LockSaveVersion, plus le niveau d'extension que le catalogue déclare. La première règle qui correspond à la fois et dépasse cette cible met fin à la sauvegarde : SaveToFile renvoie 0 et LastErrorCode devient 602, la valeur de PDFLIB_ERROR_VERSION_COMPLIANCE

Preflight de version à la sauvegarde dans PDFlibPas : SaveToFile vérifie chaque objet contre une table fixe de règles de fonctionnalités, chacune portant une version PDF minimale et, pour les fonctionnalités d'extension, un niveau d'extension Adobe que seul PDF 1.7 avec une entrée /Extensions assortie peut satisfaire, et la première correspondance que la cible ne peut pas satisfaire arrête la sauvegarde avec l'erreur 602
Les clés 311, 312 et 313 de GetInformation transforment un refus en diagnostic, rapportant la version exigée, la fonctionnalité qui l'a déclenchée et la cible verrouillée, pour que l'appelant corrige le fichier ou la règle au lieu de deviner

Un code d'erreur seul ne dit pas quoi corriger. Trois clés de GetInformation portent le reste : 311 est la version que la fonctionnalité en cause demande (par exemple 1.7 ExtensionLevel 3), 312 est son nom, et 313 est la version à laquelle la cible de sauvegarde est verrouillée, vide quand rien n'est verrouillé

var
  Pdf: TPDFlib;
begin
  Pdf := TPDFlib.Create;
  try
    if Pdf.LoadFromFile('floor-plan.pdf', '') <> 1 then
      raise Exception.Create('load failed');
    if Pdf.SaveToFile('floor-plan-out.pdf') <> 1 then
      if Pdf.LastErrorCode = PDFLIB_ERROR_VERSION_COMPLIANCE then
        // 311 : version exigée, 312 : fonctionnalité qui l'a déclenchée,
        // 313 : version à laquelle la cible de sauvegarde est verrouillée ('' si déverrouillée)
        Writeln('Needs ', Pdf.GetInformation(311),
          ' for ', Pdf.GetInformation(312),
          ', locked at [', Pdf.GetInformation(313), ']');
  finally
    Pdf.Free;
  end;
end;

Pour le contenu de mesure la table porte trois règles et délibérément aucune règle sur la clé /Measure nue. Une page avec /VP ou /UserUnit demande du PDF 1.6. Un dictionnaire /PtData demande le niveau d'extension 3. Un dictionnaire de mesure demande le niveau d'extension 3 quand il dit /Subtype /GEO ou contient l'un de /GCS, /DCS, /GPTS, /LPTS ou /PDU. Ainsi un viewport dont le dictionnaire de mesure omet /Subtype et un qui épelle /RL passent tous deux à 1.6, tandis que la même page à 1.5 est refusée à cause de /VP. Ajoutez un tableau /GPTS et la réponse devient 1.7 ExtensionLevel 3

Le test de contenu est conservateur à dessein. Un dictionnaire rectiligne qui porte en plus un /GCS ou un /PDU égaré est traité comme géospatial. Nous préférons refuser ce fichier plutôt que le laisser passer : ces clés ne signifient rien dans le modèle rectiligne, si bien que qui les a écrites est soit un outil géospatial soit quelque chose qui a endommagé le fichier, et un lecteur conscient du GEO pourrait bien tenter de les interpréter. Le chargeur est tout aussi prudent dans l'autre sens. Son scan de fonctionnalités peut porter le numéro de version du document à 1.7, mais il n'écrit jamais de déclaration /Extensions au nom d'un fichier d'entrée — déclarer une extension est une affirmation sur le contenu, et seul votre code ou les appels d'écriture devraient la faire

Examiner les viewports d'un dessin avant de sauvegarder

Quand 312 nomme une fonctionnalité géospatiale et que vous êtes sûr que le fichier est un simple dessin, regardez vous-même les dictionnaires de mesure. Les appels publics de viewport suffisent. GetMeasureDictCoordinateSystem lit /Subtype et renvoie 1 pour RL, 2 pour GEO, et 0 quand l'entrée est absente — ce qui, au sens du §12.9, signifie aussi rectiligne. Les comptages de tableaux de points vous disent si des données de géoréférencement sont présentes

// Rapporte chaque dictionnaire de mesure du document chargé.
// Tous les appels ici ne font que lire ; aucun n'ajoute de clé au fichier.
procedure ReportMeasureDicts(Pdf: TPDFlib);
var
  Page, VP, VPID, MD, Kind: Integer;
begin
  for Page := 1 to Pdf.PageCount do
  begin
    Pdf.SelectPage(Page);
    for VP := 1 to Pdf.GetPageViewPortCount do
    begin
      VPID := Pdf.GetPageViewPortID(VP);
      MD := Pdf.GetViewPortMeasureDict(VPID);
      if MD = 0 then
        Continue;
      Kind := Pdf.GetMeasureDictCoordinateSystem(MD); // 0 = omis (RL), 1 = RL, 2 = GEO
      Writeln(Format('page %d viewport "%s": subtype %d, GPTS %d, LPTS %d',
        [Page, string(Pdf.GetViewPortName(VPID)), Kind,
         Pdf.GetMeasureDictGPTSCount(MD), Pdf.GetMeasureDictLPTSCount(MD)]));
    end;
  end;
end;

Deux appels manquent à cette boucle à dessein : GetMeasureDictGCSDict et GetMeasureDictDCSDict. Ces deux-là sont des accesseurs obtenir-ou-créer bâtis pour l'écriture. Sur un dictionnaire de mesure sans entrée /GCS, GetMeasureDictGCSDict en ajoute une vide et déclare le niveau d'extension 3 pour le document. Appelez-le comme sonde sur un plan d'étage rectiligne et le dictionnaire contient désormais réellement /GCS ; une sauvegarde suivante verrouillée à 1.6 est refusée, correctement, pour du contenu que votre diagnostic vient d'écrire. Si vous devez savoir si un système de coordonnées existe, le sous-type et les comptages de tableaux de points y répondent sans effets de bord

Verrouiller la sortie à PDF 1.6

Une version épinglée garde la sortie honnête quand vos consommateurs ont un plafond. TPDFlib.LockSaveVersion accepte les chaînes '1.0' à '1.7' et renvoie 0 pour toute autre chose. Il fixe la version du document et empêche les appels d'écriture de la monter d'eux-mêmes, mais le preflight de sauvegarde tourne quand même contre la valeur verrouillée. Un dessin CAO verrouillé à 1.6 se sauvegarde proprement. Un vrai GeoPDF verrouillé à 1.6 reçoit 602, et c'est la réponse que vous voulez, car l'alternative est un fichier dont l'en-tête promet moins que ce que son contenu exige

if Pdf.LockSaveVersion('1.6') <> 1 then
  raise Exception.Create('unsupported version string');
try
  if Pdf.SaveToFile('floor-plan-16.pdf') <> 1 then
    if Pdf.LastErrorCode = PDFLIB_ERROR_VERSION_COMPLIANCE then
      // Contenu réel au-dessus de 1.6, par exemple un dictionnaire de mesure GEO
      raise Exception.CreateFmt('Locked at 1.6 but %s needs %s',
        [string(Pdf.GetInformation(312)), string(Pdf.GetInformation(311))]);
finally
  Pdf.UnlockSaveVersion;
end;

Lisez 311 et 312 avant de toucher à la version. Si la fonctionnalité est un vrai contenu géospatial, vous avez deux choix honnêtes : laisser le document déverrouillé à 1.7 avec l'extension déclarée, ou retirer les données géospatiales de la copie que vous envoyez à un consommateur limité au 1.6. Monter le verrou juste pour faire partir l'erreur est l'option à éviter. Elle cache la vraie question — le lecteur en aval sait-il traiter ce que vous expédiez ? Quand vous bâtissez du contenu géospatial via l'API, les setters SetMeasureDict* tels que SetMeasureDictCoordinateSystem et SetMeasureDictGPTSCount déclarent eux-mêmes le niveau d'extension 3, si bien que les fichiers écrits arrivent au preflight déjà étiquetés. La même habitude de contrôles bornés et adossés à des preuves se retrouve dans le preflight en mode auteur PDF/E-1 pour les documents d'ingénierie, où les dessins CAO rencontrent une norme de conformité plutôt qu'un numéro de version

Pourquoi le balayage de règles lit la table sur place

Le coût du preflight vaut à peu près nœuds d'objets fois règles, et le compte de nœuds inclut chaque nombre et chaque nom à l'intérieur de chaque tableau. Cela rend le surcoût par règle plus important qu'il n'y paraît. Chaque règle de la table est un enregistrement avec deux champs AnsiString, la chaîne d'appariement et le nom de fonctionnalité. Copier un tel enregistrement dans une variable locale fait que Delphi ou FPC incrémente deux compteurs de références et libère ce que la locale détenait avant. Fait une fois par règle et par nœud, cela signifie payer la comptabilité de chaînes même pour les règles qui ne s'appliquent pas à la version cible et sont jetées à la ligne suivante

Balayage de règles dans PDFlibPas : copier un enregistrement de règle qui porte des champs de chaînes ajuste les compteurs de références à chaque visite, tandis que passer l'entrée de table en lecture seule comme paramètre const la lit sur place sans toucher aux chaînes
Lire la table en lecture seule sur place retire le travail de comptage de références du chemin d'appariement des règles, tandis qu'une sauvegarde complète paie toujours la détection de fonctionnalités, le décodage d'objets et la sérialisation

La table de règles est remplie une fois à l'initialisation de l'unité et jamais modifiée ensuite, si bien que le balayage remet les entrées de table à son code d'appariement via des paramètres d'enregistrement const et les lit là où elles vivent. Un paramètre d'enregistrement const est passé par référence, si bien que tester une règle ne touche aucun champ de chaîne et les compteurs de références restent où ils étaient. L'effet se concentre sur le chemin d'appariement des règles. Une vraie sauvegarde décode aussi des objets, fait tourner la détection de fonctionnalités différée et sérialise la sortie, et ces étapes ne dépendent pas de la façon dont les règles sont passées. L'ordre des règles, les seuils et le diagnostic de premier échec en 311 et 312 n'en dépendent pas non plus, et aucun résultat de règle n'est mis en cache entre les sauvegardes, si bien qu'un document que vous éditez et sauvegardez deux fois est vérifié en entier les deux fois

Le même schéma mérite un contrôle dans votre propre code Delphi partout où une boucle parcourt une table d'enregistrements qui contiennent des chaînes, des interfaces ou des tableaux dynamiques :

type
  TMyRule = record
    Name: string;      // champ managé : les copies coûtent du travail de comptage de références
    Threshold: Integer;
  end;

// Code du lecteur, montré comme un schéma
function Applies(const Rule: TMyRule; Value: Integer): Boolean;
begin
  Result := Value >= Rule.Threshold;  // const : pas de copie, pas de trafic de compteurs de références
end;

// Dans la boucle chaude, passez l'entrée de table directement au lieu de
// l'affecter d'abord à un enregistrement local
for I := 0 to High(Rules) do
  if Applies(Rules[I], V) then
    Inc(Hits);

Viewports de mesure, dictionnaires géospatiaux, verrouillage de version et contrôle de conformité à la sauvegarde voyagent tous dans PDF Library for Delphi, disponible pour les projets Delphi, C++Builder et Lazarus