Article technique

Lire et Écrire le Contenu Marqué PDF en Delphi

Dans un PDF balisé, l'en-tête courant en haut de chaque page se trouve généralement à l'intérieur de /Artifact <</Type /Pagination>> BDC … EMC, tandis que le premier paragraphe qui suit se trouve à l'intérieur de /P <</MCID 0>> BDC … EMC. Tous deux sont du contenu marqué au sens d'ISO 32000-1 §14.6. Seul le paragraphe est accessible depuis l'arbre de structure, car seul le paragraphe porte un identifiant de contenu marqué, et ce seul entier est ce qui relie les glyphes peints à un élément de structure tel que /H1 ou /P. Un code qui audite ou répare le balisage doit donc lire trois choses sur chaque objet de page (la balise, sa liste de propriétés et le MCID), puis confronter ce nombre aux MCID que les éléments de structure de la page revendiquent réellement

Lire les marques BDC et les listes de propriétés depuis Delphi

Dans PDFium Component, TPdf.PageObjectMarks(Index) renvoie toutes les marques qui entourent l'objet de page à Index sous forme d'un tableau d'enregistrements TPdfContentMark. Chaque enregistrement porte la balise dans Name, un tableau Params contenant la liste de propriétés, et un Handle. Le nom revient sans la barre oblique initiale : une séquence /P signale P et un artéfact signale Artifact. Les séquences de contenu marqué peuvent s'imbriquer, et une plage de glyphes à l'intérieur de /Sect, puis /P, puis /Span signale trois enregistrements sur le même objet, du plus externe au plus interne

ISO 32000-1 §14.6.2 autorise deux formes pour l'opérande liste de propriétés de BDC : un dictionnaire en ligne, comme dans /P <</MCID 3>> BDC, ou un nom tel que /OC /oc1 BDC qui pointe vers le sous-dictionnaire /Properties des ressources de page. Le contenu optionnel (§8.11.3.2) s'écrit sous la seconde forme. PDFium résout la forme nommée lors de l'analyse de la page, si bien que dans les deux cas Params liste les entrées du dictionnaire qui s'applique à la séquence

TPdf.PageObjectMarkedContentID(Index) répond à la question qui intéresse l'arbre de structure. Il renvoie le MCID trouvé dans les marques de l'objet, ou -1 quand aucune ne porte d'entier /MCID. L'en-tête de l'exemple d'ouverture renvoie -1, et c'est correct : le §14.8.2.2 tient les artéfacts à l'écart de la structure logique, si bien qu'un artéfact n'a rien à quoi se raccrocher

Diagramme de l'anatomie du contenu marqué PDF en Delphi : une balise /P avec une liste de propriétés MCID entre BDC et EMC, les champs de l'enregistrement TPdfContentMark, et le lien MCID vers l'arbre de structure
Une marque est une balise et une liste de propriétés typée entre BDC et EMC, rapportée comme un enregistrement dont le MCID rejoint l'arbre de structure
var
  Marks: TPdfContentMarks;
  M: TPdfContentMark;
  P: TPdfContentMarkParam;
  I: Integer;
begin
  Pdf.PageNumber := 1;                    // PageNumber est en base 1
  for I := 0 to Pdf.ObjectCount - 1 do    // les index d'objets de page sont en base 0
  begin
    Marks := Pdf.PageObjectMarks(I);
    if Length(Marks) = 0 then
      Continue;
    Memo1.Lines.Add(Format('object %d, MCID %d',
      [I, Pdf.PageObjectMarkedContentID(I)]));
    for M in Marks do                     // marque la plus externe d'abord
    begin
      Memo1.Lines.Add('  mark ' + M.Name);
      for P in M.Params do
        case P.Kind of
          pmpInt:     Memo1.Lines.Add('    ' + P.Key + ' = ' + IntToStr(P.IntValue));
          pmpFloat:   Memo1.Lines.Add('    ' + P.Key + ' = ' + FloatToStr(P.FloatValue));
          pmpString:  Memo1.Lines.Add('    ' + P.Key + ' = ' + P.StringValue);
          pmpBlob:    Memo1.Lines.Add('    ' + P.Key + ' = ' +
                        IntToStr(Length(P.BlobValue)) + ' bytes');
          pmpUnknown: Memo1.Lines.Add('    ' + P.Key + ' = (no readable value)');
        end;
    end;
  end;
end;

Ce que pmpUnknown peut et ne peut pas vous dire

Chaque TPdfContentMarkParam a une Key, un Kind et un unique champ de valeur déterminé par ce kind. La correspondance est assez courte pour tenir en tête :

  • Un nombre à valeur entière devient pmpInt, avec IntValue renseigné et FloatValue rempli avec le même nombre, si bien que /MCID 3 comme un 3.0 égaré se lisent comme des entiers
  • Un nombre avec une partie fractionnaire devient pmpFloat
  • Les chaînes PDF comme les noms PDF deviennent pmpString ; les chaînes texte qui commencent par une marque d'ordre des octets UTF-16 (FE FF ou FF FE) sont décodées dans StringValue, si bien que /ActualText arrive en texte lisible
  • Les autres types de valeur reviennent en pmpBlob quand PDFium en fournit les octets bruts, et restent pmpUnknown sinon

pmpUnknown couvre deux cas impossibles à distinguer. L'un est une valeur que PDFium signale comme FPDF_OBJECT_UNKNOWN et pour laquelle il ne fournit aucun octet. L'autre est une clé listée mais illisible : une clé orthographiée avec des octets non ASCII dans un fichier endommagé peut apparaître dans la liste et revenir quand même en pmpUnknown. L'enregistrement se présente de la même façon dans les deux cas

Traitez pmpUnknown comme « aucune valeur utilisable » et signalez la marque au lieu de l'accepter. Si votre flux de travail dépend de la présence de /Lang ou /ActualText, cherchez la clé avec un kind que vous reconnaissez ; ne concluez jamais d'un kind inconnu que la propriété est absente

Un Handle identifie une marque dans la page actuellement chargée et devient périmé dès que la marque est supprimée, que l'objet de page est détruit ou que la page est déchargée. Copiez Name et Params dans vos propres structures avant de changer de page. Pour des données qui doivent survivre à la page courante, comme une liste ou un audit en tâche de fond, ReadPageObjectsSnapshot renvoie des enregistrements TPdfContentMarkSnapshot qui ne portent que le nom et les paramètres, sans aucun handle. La même règle vaut pour les handles d'objets de page en général, comme le détaillent les notes sur les handles d'objets de page qui deviennent périmés après une transformation

Ajouter, mettre à jour et supprimer des marques

Quatre appels couvrent l'édition. AddPageObjectMark(Index, Name, Params) crée une nouvelle marque sur l'objet, applique les paramètres dans l'ordre et renvoie l'index de la nouvelle marque sur cet objet, que les trois autres appels reçoivent comme MarkIndex. Les noms passent sans barre oblique : 'P', 'Span'. Pour changer une clé, SetPageObjectMarkParam(Index, MarkIndex, Param) l'insère ou la remplace et laisse tous les autres paramètres de la marque intacts. Les suppressions sont plus brutales. RemovePageObjectMarkParam(Index, MarkIndex, Key) retire une clé ; RemovePageObjectMark(Index, MarkIndex) retire la marque

AddPageObjectMark traite la marque et ses paramètres comme une seule étape. Si un paramètre est rejeté (un kind pmpUnknown, une clé vide, un blob vide), la marque qu'il vient de créer est retirée à nouveau avant que l'exception ne se propage, si bien que l'objet ne conserve jamais une balise à moitié dépourvue de sa liste de propriétés

Chacun de ces appels reconstruit le flux de contenu de la page avant de retourner. Cela compte, car SaveAs écrit le flux de contenu tel qu'il est et ne le régénère pas depuis le modèle objet de PDFium. Une marque qui n'aurait existé que sur l'objet en mémoire laisserait le fichier enregistré sans un seul opérateur BDC. Si vous éditez aussi des objets de page via les imports PDFium bruts sur ObjectHandle, appelez vous-même UpdatePage avant d'enregistrer

Diagramme de l'ajout d'une balise de contenu marqué en Delphi : AddPageObjectMark reconstruit le flux de contenu de la page avant SaveAs, tandis qu'une modification limitée au modèle objet est silencieusement absente du fichier enregistré
AddPageObjectMark reconstruit le flux de contenu afin que SaveAs persiste la balise, et un changement qui s'arrête au modèle d'objets n'atteint jamais le fichier

Reconstruire tout le flux à chaque appel convient pour quelques éditions et devient du gâchis pour quelques centaines. Baliser un rapport non balisé page par page appartient à la seconde catégorie, et là les éditions se placent entre BeginPageObjectEditTransaction et CommitPageObjectEditTransaction. À l'intérieur de la transaction, chaque édition se contente de marquer la page comme modifiée ; le commit génère le flux de contenu une seule fois, et RollbackPageObjectEditTransaction remet la page dans son état d'origine. Une transaction est liée à une seule page, et affecter PageNumber à une autre page pendant qu'elle est ouverte lève une exception. Sur un document signé, l'appel de début refuse de démarrer sauf si vous passez AllowSignedDocument = True, car éditer le contenu d'une page casse de toute façon la signature

Les paramètres chaîne exigent une décision de plus. Les valeurs pmpString sont écrites comme des chaînes d'octets, ce qui convient parfaitement aux valeurs ASCII telles que /Lang (en-US). Un texte de remplacement avec accents, ligatures ou caractères CJK relève d'une chaîne texte PDF encodée en UTF-16BE avec un préfixe FE FF (§7.9.2.2). Passez ces octets en pmpBlob : PDFium les stocke comme un objet chaîne, et le lecteur ci-dessus les décode à nouveau en StringValue comme pmpString

// Votre propre aide, pas dans la bibliothèque : chaîne texte PDF en UTF-16BE avec BOM
function Utf16TextString(const S: string): TBytes;
var
  I: Integer;
begin
  SetLength(Result, 2 + Length(S) * 2);
  Result[0] := $FE;
  Result[1] := $FF;
  for I := 1 to Length(S) do
  begin
    Result[I * 2] := Byte(Ord(S[I]) shr 8);
    Result[I * 2 + 1] := Byte(Ord(S[I]));
  end;
end;

procedure TagWord(Pdf: TPdf; ObjectIndex, Mcid: Integer; const Spoken: string);
var
  Params: TPdfContentMarkParams;
  Lang: TPdfContentMarkParam;
  MarkIndex: Integer;
begin
  Pdf.BeginPageObjectEditTransaction;
  try
    SetLength(Params, 2);
    Params[0].Key := 'MCID';
    Params[0].Kind := pmpInt;
    Params[0].IntValue := Mcid;
    Params[1].Key := 'ActualText';
    Params[1].Kind := pmpBlob;            // stocké comme chaîne texte, relu en pmpString
    Params[1].BlobValue := Utf16TextString(Spoken);
    MarkIndex := Pdf.AddPageObjectMark(ObjectIndex, 'Span', Params);

    Lang := Default(TPdfContentMarkParam);
    Lang.Key := 'Lang';
    Lang.Kind := pmpString;               // valeur ASCII, une simple chaîne d'octets convient
    Lang.StringValue := 'de-DE';
    Pdf.SetPageObjectMarkParam(ObjectIndex, MarkIndex, Lang);
  except
    Pdf.RollbackPageObjectEditTransaction;
    raise;
  end;
  Pdf.CommitPageObjectEditTransaction;    // une seule reconstruction du flux pour tout le lot
end;

Le commit se place hors du bloc try à dessein. Si la génération du contenu échoue, CommitPageObjectEditTransaction restaure la page elle-même avant de relancer l'exception, et un second appel de rollback ne ferait que lever « No page-object edit transaction is active » par-dessus la véritable erreur

Pourquoi un paragraphe se retrouve-t-il avec trois séquences MCID 5 ?

Parce que chaque appel à AddPageObjectMark crée un nouveau dictionnaire de marque indépendant, même quand les valeurs sont identiques. Un paragraphe composé de trois objets texte, un par ligne, balisé par trois appels qui passent chacun /MCID 5, porte trois marques distinctes. Quand PDFium régénère le flux de contenu, il ne garde des objets consécutifs à l'intérieur d'une même paire BDC/EMC que s'ils partagent la même marque ; il compare l'identité de la marque, et des valeurs égales ne comptent pas. La page enregistrée contient alors /P <</MCID 5>> BDC … EMC trois fois de suite

ISO 32000-1 §14.7.4.2 décrit le MCID comme identifiant une séquence de contenu marqué de manière unique dans son flux de contenu. Trois séquences partageant un même identifiant ne satisfont pas cette exigence, et les outils qui résolvent les MCID via l'entrée d'arbre parent de la page restent à deviner laquelle des trois l'élément de structure désigne

SharePageObjectMark(TargetIndex, SourceIndex, SourceMarkIndex) attache à l'objet cible le dictionnaire de propriétés propre à la marque source au lieu d'en construire une copie. Ajoutez la marque une fois à la première ligne, partagez-la aux autres, et gardez les objets consécutifs dans l'ordre de dessin ; le flux régénéré enveloppe alors les trois lignes dans une seule séquence

procedure TagParagraph(Pdf: TPdf; const LineObjects: array of Integer; Mcid: Integer);
// LineObjects : index d'objets des lignes du paragraphe, consécutifs dans l'ordre de dessin
var
  Params: TPdfContentMarkParams;
  MarkIndex, I: Integer;
begin
  SetLength(Params, 1);
  Params[0].Key := 'MCID';
  Params[0].Kind := pmpInt;
  Params[0].IntValue := Mcid;

  Pdf.BeginPageObjectEditTransaction;
  try
    MarkIndex := Pdf.AddPageObjectMark(LineObjects[0], 'P', Params);
    for I := 1 to High(LineObjects) do
      Pdf.SharePageObjectMark(LineObjects[I], LineObjects[0], MarkIndex);
  except
    Pdf.RollbackPageObjectEditTransaction;
    raise;
  end;
  Pdf.CommitPageObjectEditTransaction;
end;

Le partage a un second avantage quand vous copiez une marque existante sur du nouveau contenu, par exemple étendre un artéfact sur un filet de pied de page redessiné. Les kinds de paramètres que PDFium sait écrire sont entier, flottant, chaîne et blob d'octets, et il n'existe pas de kind nom. Un /Artifact <</Type /Pagination>> lu comme pmpString et reconstruit via AddPageObjectMark ressort avec (Pagination), une chaîne, là où le §14.8.2.2 attend le nom /Pagination. Une marque partagée est le dictionnaire d'origine, si bien que ses noms restent des noms. Chaque fois que la marque source existe déjà sur la page, partagez-la ; ne reconstruisez depuis Params que lorsque vous écrivez une marque qui n'a jamais existé

Confronter les MCID à l'arbre de structure

Un MCID sur un objet de page ne signifie quelque chose que si un élément de structure de la même page le revendique. StructureElementsForPage[PageNumber] renvoie les éléments de la page comme enregistrements TPdfStructureElement, et trois champs portent des MCID : MarkedContentID (-1 quand l'élément n'en a pas), MarkedContentIDs pour les éléments qui possèdent plusieurs séquences, et ChildMarkedContentIDs, avec -1 dans les emplacements des enfants qui sont des éléments plutôt que du contenu marqué. Un audit doit examiner les trois, car l'entrée /K d'un élément de structure peut mêler éléments enfants et références de contenu marqué dans un même tableau

L'audit ci-dessous signale deux constats. Du texte ou des images dont aucun élément ne revendique le MCID, ou qui n'ont pas de MCID du tout, sont du contenu que les technologies d'assistance ignoreront. Un Artifact dont le MCID est revendiqué contredit frontalement le §14.8.2.2. Les objets de page proviennent de ReadPageObjectsSnapshot, si bien que les résultats ne portent aucun handle et survivent à un changement de page

// Votre propre aide, pas dans la bibliothèque
function McidClaimed(const Elements: TPdfStructureElements; Mcid: Integer): Boolean;
var
  E: TPdfStructureElement;
  Id: Integer;
begin
  for E in Elements do
  begin
    if E.MarkedContentID = Mcid then
      Exit(True);
    for Id in E.MarkedContentIDs do
      if Id = Mcid then
        Exit(True);
    for Id in E.ChildMarkedContentIDs do
      if Id = Mcid then
        Exit(True);
  end;
  Result := False;
end;

procedure AuditPage(Pdf: TPdf; PageNo: Integer; Log: TStrings);
var
  Snap: TPdfPageObjectsSnapshot;
  Status: TPdfPageObjectSnapshotStatus;
  Elements: TPdfStructureElements;
  Obj: TPdfPageObjectSnapshot;
  IsArtifact: Boolean;
  J: Integer;
begin
  Snap := Pdf.ReadPageObjectsSnapshot(PageNo,
    TPdfPageObjectSnapshotOptions.Full, Status);
  if not Status.Completed then
    Log.Add(Format('page %d: snapshot hit a limit, results are partial', [PageNo]));
  Elements := Pdf.StructureElementsForPage[PageNo];
  for Obj in Snap.Objects do
  begin
    IsArtifact := False;
    for J := 0 to High(Obj.Marks) do
      if Obj.Marks[J].Name = 'Artifact' then
        IsArtifact := True;
    if IsArtifact then
    begin
      if (Obj.MarkedContentID >= 0) and McidClaimed(Elements, Obj.MarkedContentID) then
        Log.Add(Format('object %d: artifact claimed by the structure tree', [Obj.Index]));
    end
    else if Obj.ObjectType in [otText, otImage] then
      if (Obj.MarkedContentID < 0) or not McidClaimed(Elements, Obj.MarkedContentID) then
        Log.Add(Format('object %d: content with no structure owner', [Obj.Index]));
  end;
end;

Les chemins sont écartés à dessein. Les filets décoratifs et les bordures de tableaux sont souvent non balisés, et les signaler tous enfouit les constats textuels. Les Form XObjects méritent la prudence inverse : un MCID à l'intérieur d'un formulaire appartient à l'espace de noms /StructParents propre à ce formulaire, si bien que le MCID 0 de la page et le MCID 0 à l'intérieur d'un formulaire sont des séquences différentes. La visite guidée sur les espaces de noms MCID des Form XObjects balisés couvre leur lecture correcte

Les marques ne restent que la moitié d'un PDF balisé. ISO 32000-1 §14.7 demande un arbre de structure dont les éléments référencent ces MCID, un dictionnaire /MarkInfo dans le catalog avec /Marked true, et des types de structure soit standard, soit associés aux types standard via le role map. Pdf.IsTagged vous dit si le catalog déclare le document balisé. Une marque /P dont aucun élément ne pointe le MCID produit du contenu qui se prétend balisé sur une page que l'arbre de structure ne mentionne jamais

Diagramme du PDFium Component montrant que les marques BDC seules ne font pas un PDF balisé, qui exige aussi un arbre de structure référençant les MCIDs, une déclaration MarkInfo et des noms de rôles standards
Les marques soutiennent l'audit et la réparation des orphelins tandis qu'un document balisé conforme exige aussi un arbre de structure, MarkInfo et des noms de rôles standard

Pour le versant arbre de structure de ce travail, la visite guidée de la validation d'arbre de structure PDF/UA prend le relais là où l'audit des MCID s'arrête. L'expérience de lecture pour laquelle les balises existent est le sujet des notes sur la construction d'un lecteur PDF accessible en Delphi

Les appels utilisés ici (lecture de marques, transactions d'édition, marques partagées, instantanés d'objets de page et éléments de structure) font partie de la même API VCL que PDFium Component offre aux projets Delphi, C++Builder et Lazarus par-dessus le moteur PDFium. La page produit PDFium Component couvre le reste de l'API d'accessibilité et d'édition de page