Article technique

Garder /ParentTree et /StructParents en phase en Delphi

PDFium Component balise du contenu PDF existant depuis Delphi via EditPdfDocumentStructure dans FPdfDocumentStructure.pas. Les opérations psekAddMarkedContent et psekAddObjectReference font plus quajouter un MCR ou un OBJR au /K dun élément de structure : elles allouent aussi la clé /StructParents ou /StructParent et écrivent lentrée /ParentTree correspondante, lindex inversé quISO 32000-1 §14.7.4.4 exige

Cette seconde moitié est là où la pose rétroactive de balises tourne dordinaire mal. Ajouter une enveloppe BDC et un MCR est facile, et une visionneuse qui parcourt larbre de structure depuis le haut trouvera joyeusement le nouveau contenu. Les technologies dassistance vont souvent dans lautre sens, cependant : elles partent dun morceau de contenu sur la page et demandent quel élément de structure le possède. Si la page na pas de clé /StructParents, ou si le /ParentTree na pas dentrée pour le nouveau MCID, cette recherche échoue, et les vérificateurs le rapportent comme un arbre parent manquant ou incohérent. Le fichier a lair balisé et ne marche quand même pas

Que mappe réellement /ParentTree ?

Le /ParentTree est un arbre de nombres qui mappe des clés entières vers des éléments de structure, et les clés viennent du contenu, pas de larbre. ISO 32000-1 §14.7.4.4 donne à chaque page ou Form XObject contenant du contenu marqué un entier /StructParents, et la valeur darbre parent sous cette clé est un tableau indexé par MCID : lentrée 3 du tableau est lélément de structure qui possède <</MCID 3>>. Une annotation ou un XObject employé comme article de contenu entier reçoit une clé /StructParent unique à la place, et sa valeur darbre parent est lélément de structure lui-même, pas un tableau

Index inversé darbre parent PDF/UA écrit par PDFium Component en Delphi : une marque de contenu portant MCID 3 se résout au travers de la clé /StructParents 17 de la page vers larbre de nombres /ParentTree, dont lentrée 3 du tableau est lélément de structure qui possède le contenu marqué
Lordre de lecture compte pour le PDF balisé : la technologie dassistance marche depuis le MCID sur la page au travers de /StructParents vers le tableau /ParentTree, et une clé ou entrée manquante casse cette recherche alors même que larbre de structure lui-même semble bon

Le format conteneur est larbre de nombres générique de §7.9.7. Une feuille détient /Nums, un tableau plat de paires clé-valeur triées en ordre de clé croissant. Les nœuds intermédiaires et feuilles sous la racine portent /Limits avec la plus petite et la plus grande clé quils couvrent, tandis que la racine elle-même ne doit pas avoir de /Limits. Par-dessus, la racine de larbre de structure peut porter /ParentTreeNextKey (§14.7.2), la prochaine clé quun rédacteur devrait distribuer. Toute édition qui balise du nouveau contenu touche donc jusquà quatre objets qui doivent sentendre : le flux de contenu, le dictionnaire de page ou de formulaire, la feuille de larbre parent, et la racine de larbre de structure

Le sens direct a ses propres règles, posées dans §14.7.4.2 et §14.7.4.3. Une référence de contenu marqué (MCR) pour du contenu dans le flux de contenu propre de la page na besoin que de /Pg et /MCID. Quand le contenu marqué vit à lintérieur dun Form XObject, le MCR exige aussi un /Stm pour nommer ce flux, parce que les MCIDs sont numérotés par flux de contenu. Une référence dobjets (OBJR) nomme lannotation ou le XObject avec /Obj et sa page avec /Pg

Balisage dun flux de contenu avec EditPdfDocumentStructure

EditPdfDocumentStructure est un éditeur par lots gardé par photo. Vous appelez dabord ReadPdfDocumentStructure pour obtenir un TPdfDocumentStructureSnapshot, puis repassez son SourceSha256 avec un tableau denregistrements TPdfStructureEditOperation. Si les octets de la source ont changé entre-temps, lédition est refusée sans sortie. Les éléments existants sont adressés par leur numéro dobjets dans NodeID, et 0 dénote la racine de larbre de structure

uses
  System.SysUtils, System.Classes, System.IOUtils, FPdfDocumentStructure;

function TagContentStream(const FileName, OutName: string;
  ElementID, StreamObj, StreamGen: Integer): Boolean;
var
  Source: TMemoryStream;
  Options: TPdfStructureEditOptions;
  Snapshot: TPdfDocumentStructureSnapshot;
  Ops: TPdfStructureEditOperations;
  Report: TPdfStructureEditReport;
  Output: TBytes;
begin
  Source := TMemoryStream.Create;
  try
    Source.LoadFromFile(FileName);
    Options := TPdfStructureEditOptions.Default;
    if not ReadPdfDocumentStructure(Source, Options, Snapshot, Report) then
      raise Exception.Create(Report.ErrorMessage);

    SetLength(Ops, 1);
    Ops[0] := Default(TPdfStructureEditOperation);
    Ops[0].Kind := psekAddMarkedContent;
    Ops[0].NodeID := ElementID;            // élément existant, par numéro dobjets
    Ops[0].TargetObjectNumber := StreamObj; // flux de contenu de page ou Form XObject de page
    Ops[0].TargetGeneration := StreamGen;

    Result := EditPdfDocumentStructure(Source, Snapshot.SourceSha256,
      Ops, Options, Output, Report);
    if not Result then
      raise Exception.Create(Report.ErrorMessage);
    TFile.WriteAllBytes(OutName, Output);
  finally
    Source.Free;
  end;
end;

Pour cette seule opération, EditPdfDocumentStructure enveloppe le flux cible dans /P <</MCID n>> BDC ... EMC, ajoute le MCR au /K de lélément, donne à la page (ou à la forme) une clé /StructParents si elle nen a pas, et écrit lélément n dans le tableau darbre parent pour cette clé. Le résultat est écrit comme une mise à jour incrémentale après les octets dorigine, et Report.CreatedMarkedContents vous dit combien de MCRs ont été créés. Quand le flux contient déjà des paires BDC/EMC équilibrées, seule la queue non balisée après le dernier EMC de premier niveau est enveloppée ; sinon tout le flux lest

Comment les nouveaux MCIDs et clés darbre parent sont-ils choisis ?

Un nouveau MCID est le maximum courant plus un, et une nouvelle clé darbre parent est Max(MaxKey + 1, ParentTreeNextKey). Pour le MCID, EditPdfDocumentStructure prend le maximum au travers des MCRs déjà enregistrés pour cette page (ou ce Form XObject) et de chaque /MCID quil trouve dans les listes de propriétés propres du flux. Compter les deux compte : un arbre de structure peut avoir perdu un MCR alors que le contenu porte encore la marque, et réemployer ce nombre ferait que deux éléments revendiquent le même contenu

La logique de clé suit la même idée défensive. Si le propriétaire a déjà une valeur /StructParents, le tableau existant est étendu à lindex MCID, avec null comblant tout trou, et aucune nouvelle clé nest allouée. Sinon, le composant balaye chaque feuille /Nums pour la clé la plus haute, la compare à /ParentTreeNextKey, et prend la plus grande. Une /ParentTreeNextKey périmée plus basse que des clés déjà employées et une qui réserve des clés au-delà de la dernière entrée sont toutes deux gérées, parce que la plus grande des deux valeurs est toujours libre. Après lallocation, /ParentTreeNextKey est avancée à la nouvelle clé plus un

Allocation de MCID et de clé darbre parent dans léditeur de structure PDFium Component pour Delphi : le nouveau MCID est un au-dessus du maximum déjà enregistré dans les MCRs et les listes de propriétés de flux, et la clé est la plus grande entre la clé /Nums la plus haute plus un et /ParentTreeNextKey, avec null comblant les trous du tableau
EditPdfDocumentStructure dérive le nouveau MCID à la fois des MCRs et des marques dans le flux et alloue la clé darbre parent de façon défensive, si bien quune /ParentTreeNextKey périmée ou un MCID réemployé ne peuvent faire revendiquer le même contenu à deux éléments

La nouvelle paire est insérée dans /Nums en ordre croissant plutôt quajoutée, parce que les recherches dans les arbres de nombres comptent sur cet ordre. Si la feuille porte /Limits, les bornes sont recalculées depuis le tableau réécrit. Quand la racine de larbre de structure na aucun /ParentTree du tout, ce qui est courant dans des fichiers balisés par des outils qui nont écrit que larbre direct, EditPdfDocumentStructure synthétise un nouvel objet indirect << /Nums [] >> et le lie depuis /StructTreeRoot avant dinsérer la première entrée

Form XObjects, /StmOwn et références dobjets

Le contenu marqué à lintérieur dun Form XObject nest pris en charge que quand cette forme est employée par exactement une page. Le MCR quEditPdfDocumentStructure écrit pour une forme porte /Pg, un /Stm nommant le flux de la forme, et un /StmOwn nommant la page, et la clé /StructParents va sur le dictionnaire de la forme plutôt que sur la page. Une forme dessinée par trois pages a trois propriétaires plausibles, si bien que tout /StmOwn unique serait faux pour deux dentre elles. Le composant refuse lédition avec une erreur au lieu de deviner

Règle du propriétaire unique pour baliser du contenu de Form XObject avec PDFium Component en Delphi : un MCR sur une forme employée par une seule page porte /Pg, /Stm et /StmOwn avec /StructParents sur le dictionnaire de la forme, tandis quune forme dessinée par trois pages rend /StmOwn ambigu et lédition est refusée
La clé /StmOwn ne a du sens que quand une page possède la forme, voilà pourquoi les formes partagées et les annotations partagées sont refusées au lieu dêtre balisées avec un propriétaire devinable

La même règle dunicité sapplique à psekAddObjectReference. La cible doit être une annotation qui apparaît dans le /Annots dexactement une page, ou un Form ou Image XObject qui apparaît dans les ressources dexactement une page ; tout le reste est rejeté. LOBJR reçoit /Obj et /Pg, la cible reçoit une clé /StructParent (réemployée si présente), et la valeur darbre parent est une référence simple vers lélément. Cest la correction habituelle pour une annotation de lien ou une image de figure qui était balisée visuellement mais jamais reliée à larbre

// Envelopper une image non balisée dans un nouvel élément Figure, puis la lier deux façons :
// comme un OBJR pour limage XObject et comme contenu marqué sur la page
SetLength(Ops, 3);
Ops[0] := Default(TPdfStructureEditOperation);
Ops[0].Kind := psekInsertContainer;
Ops[0].NodeID := -1;              // ID temporaire, doit être négatif et unique
Ops[0].ParentNodeID := SectID;    // élément Sect existant
Ops[0].Value := 'Figure';         // rôle standard ou entrée RoleMap

Ops[1] := Default(TPdfStructureEditOperation);
Ops[1].Kind := psekAddObjectReference;
Ops[1].NodeID := -1;              // réfère le conteneur créé ci-dessus
Ops[1].TargetObjectNumber := ImageObj;
Ops[1].TargetGeneration := 0;

Ops[2] := Default(TPdfStructureEditOperation);
Ops[2].Kind := psekAddMarkedContent;
Ops[2].NodeID := -1;
Ops[2].TargetObjectNumber := ContentObj;
Ops[2].TargetGeneration := 0;

if EditPdfDocumentStructure(Source, Snapshot.SourceSha256, Ops, Options,
  Output, Report) then
  Writeln(Format('Figure is object %d, %d OBJR, %d MCR', [
    Report.InsertedNodes[0].ObjectNumber,
    Report.CreatedObjectReferences, Report.CreatedMarkedContents]));

Des valeurs NodeID négatives nomment des conteneurs créés plus tôt dans le même lot, et Report.InsertedNodes mappe chaque ID demandé vers le numéro dobjets quil a reçu. psekInsertContainer accepte les rôles standards PDF 1.7, PDF 2.0 et PDF/UA tels que P, H1 à H6, Figure, Table ou LI, plus les rôles personnalisés déjà mappés dans /RoleMap. Régler BeforeNodeID sur les deux opérations de contenu place le nouveau MCR ou OBJR avant un enfant existant au lieu de le mettre à la fin

Pourquoi éditer deux fois le même flux dans un lot exige-t-il du soin ?

Un lot peut frapper le même objet de façon répétée, et chaque opération ultérieure doit voir le résultat des antérieures. Deux opérations psekAddMarkedContent contre la même page réécrivent toutes deux son flux de contenu, et toutes deux mettent à jour la même feuille de larbre parent. Une implémentation qui monte chaque édition comme un objet ajouté séparé écrit deux versions de lobjet 12 dans la mise à jour. Selon laquelle le lecteur garde, vous perdez la première enveloppe BDC ou la première paire /Nums, et le second MCID peut entrer en collision avec le premier parce quil a été calculé depuis des octets périmés

EditPdfDocumentStructure indexe les mises à jour montées par (ObjectNumber, Generation) et écrase une entrée existante en place, et il rafraîchit en même temps la copie en mémoire de lobjet. La seconde opération analyse donc le flux déjà enveloppé, voit MCID 0, alloue MCID 1, et étend le même tableau darbre parent. Un objet, une version finale. Avant que quoi que ce soit soit renvoyé, la sortie est relue et comparée au modèle en mémoire : compte déléments, comptes darticles de contenu et chaque MCR ou OBJR retenu doivent correspondre, sinon lappel échoue sans sortie

Les flux compressés suivent le même chemin. Un flux de contenu avec /Filter /FlateDecode (ou un tableau [/FlateDecode] à un élément) et sans prédicteur /DecodeParms est dégonflé avec un décompresseur borné plafonné à 32 Mio ou MaxOutputBytes, le plus petit des deux, enveloppé, et recomprimé en /FlateDecode avec un nouveau /Length. Un flux qui gonflerait au-delà du plafond est rejeté plutôt que tronqué, ce qui compte pour des fichiers de sources non fiables

Limites à connaître avant une passe de réparation PDF/UA

EditPdfDocumentStructure est conservateur par conception, et la plupart de ses limites surgissent comme un Report.ErrorMessage clair plutôt que comme un fichier discrètement cassé. Gardez ceci en tête quand vous planifiez une réparation par lots :

  • Seuls les flux non filtrés et FlateDecode simples peuvent être enveloppés ; les autres filtres, ou Flate avec prédicteur, sont refusés
  • Un flux de contenu doit appartenir au /Contents dexactement une page, et les Form XObjects, annotations et XObjects doivent être employés par exactement une page
  • Lenveloppe emploie toujours la balise /P ; le rôle sémantique vient du /S de lélément de structure, pas du nom de balise BDC
  • La synchronisation de larbre parent lit une racine /Nums plate ou un niveau de feuilles /Kids ; les nœuds intermédiaires plus profonds ne sont pas descendus, donc revalidez de tels fichiers après édition
  • Les fichiers qui contiennent des signatures ou des champs de signature sont refusés carrément, donc signez après la réparation de structure, pas avant

Létape de validation nest pas optionnelle pour un pipeline de réparation. Lancez la validation de larbre de structure PDF/UA dans PDFium Component avant et après le lot, et employez lAPI de contenu marqué dobjets de page pour lire les balises BDC et les MCIDs quand vous devez confirmer quels objets de page ont fini à lintérieur des nouvelles marques. Un arbre parent propre vous dit que les recherches se résolvent ; il ne vous dit pas que lordre de lecture ou le texte alternatif est bon

FAQ

EditPdfDocumentStructure reconstruit-il tout le /ParentTree ?

Non. EditPdfDocumentStructure ne met à jour que les entrées du contenu quil balise dans le lot courant, en réemployant les clés /StructParents existantes et en allouant les nouvelles là où il le faut, si bien que les entrées darbre parent existantes restent intactes

Que se passe-t-il si le PDF a un arbre de structure mais pas de /ParentTree ?

Le composant crée un arbre de nombres indirect << /Nums [] >>, le lie depuis /StructTreeRoot, et insère la première clé pour le contenu en cours de balisage

Puis-je baliser du contenu dans un Form XObject partagé par plusieurs pages ?

Pas avec cette opération. Une forme partagée rend /StmOwn ambigu, si bien que psekAddMarkedContent exige que la forme soit référencée par exactement une page et rapporte une erreur sinon

La lecture et lédition de larbre de structure, la validation PDF/UA et laccès au contenu marqué font partie du PDFium Component for Delphi and C++Builder, qui enveloppe PDFium pour des projets Delphi, C++Builder et Lazarus