Article technique

Texte périmé après édition : le cache FPDF_TEXTPAGE de PDFium

Vous appelez AddText pour tamponner une ligne sur une page PDF avec PDFiumPas, puis immédiatement FindFirst pour confirmer que le tampon a bien atterri, et la recherche revient vide. Le texte est sur la page — Acrobat le montre — mais le composant TPdf de PDFiumPas garde une structure FPDF_TEXTPAGE mise en cache séparément, analysée une fois à partir du flux de contenu de la page, et une édition ne met pas rétroactivement à jour cette structure d'elle-même. Interrogez-la avant qu'elle n'ait été rafraîchie et vous lisez la page exactement telle qu'elle était avant votre changement, pas après

Pourquoi PDFium renvoie-t-il du texte périmé juste après une édition ?

PDFiumPas enveloppe le moteur de rendu PDFium de Google pour Delphi et C++Builder, et ses appels de texte et d'édition atteignent deux sous-systèmes différents à l'intérieur de ce moteur. FPDF_TEXTPAGE appartient au côté lecture : FPDFText_LoadPage parcourt une fois le flux de contenu de la page et construit la page de texte — codes de caractères, positions, métriques de police, limites de mots — et PDFiumPas garde cette structure en cache aussi longtemps que la page reste chargée. Les appels d'édition tels que FPDFPage_InsertObject ou FPDFPage_GenerateContent opèrent sur une représentation complètement différente, le graphe d'objets et de flux de contenu de la page, et PDFium ne pousse pas ces changements dans une page de texte déjà ouverte de lui-même. Reconstruire celle-ci à chaque édition rendrait l'édition par lot inacceptablement lente, si bien que la conception échange ce coût contre une règle à la place — quiconque détient le handle le ferme après une édition modifiant le contenu, et la lecture suivante en construit un nouveau

À l'intérieur du cache de texte de TPdf : FTextPage, LoadTextPage, et UnloadTextPage

TPdf suit le handle mis en cache dans un seul champ privé, FTextPage, et enveloppe son cycle de vie dans deux méthodes. LoadTextPage vérifie si FTextPage est nil et, seulement dans ce cas, appelle FPDFText_LoadPage contre la page actuelle ; si un handle existe déjà, LoadTextPage le réutilise sans demander si la page a changé depuis sa construction. UnloadTextPage est l'autre moitié : elle ferme le handle natif avec FPDFText_ClosePage, remet FTextPage à nil, et abandonne aussi la liste de liens web mise en cache et toute session de recherche en cours, puisque les deux étaient dérivées de la même page de texte et deviennent périmées pour la même raison

Le comportement de réutilisation-sans-vérification de LoadTextPage explique précisément pourquoi le séquençage compte. Chaque requête de texte sur TPdfText, FindFirst, GetWebLinks — passe d'abord par LoadTextPage, si bien que tant que FTextPage détient encore le handle pré-édition, aucun de ces appels n'a moyen de savoir qu'un changement s'est produit. La navigation de page n'a jamais été le risque ici : UnloadPage, qui s'exécute lors des changements de page, des rechargements, et de la fermeture de document, a toujours fermé la page de texte en même temps que la page elle-même. La question ouverte a toujours porté sur les éditions appliquées à la page sur laquelle vous êtes toujours assis

Quelles méthodes PDFiumPas rafraîchissent-elles le cache automatiquement ?

Les propres méthodes d'édition de page de TPdfAddText, SetText, SetTextPositions, AddPath, RemoveObject, et InsertFormObjectFromXObject — appellent chacune UnloadTextPage avant d'appeler UpdatePage (le FPDFPage_GenerateContent de PDFium) pour sérialiser le changement dans le flux de contenu. Appelez l'une d'entre elles et le tout prochain appel Text, FindFirst, ou GetWebLinks reconstruit la page de texte à partir du contenu tel qu'il se présente désormais, sans appel supplémentaire requis de votre part

var
  Pdf: TPdf;
  Index: Integer;
begin
  Pdf := TPdf.Create(nil);
  try
    Pdf.FileName := 'invoice.pdf';
    Pdf.Active := True;
    Pdf.PageNumber := 1;

    Pdf.AddText('Reviewed by J. Alvarez', 'Helvetica', 10, 72, 40, clBlack, 255, 0);
    // AddText already closed the cached text page, so this FindFirst
    // call rebuilds it fresh before it searches
    Index := Pdf.FindFirst('Reviewed by J. Alvarez');
    if Index >= 0 then
      ShowMessage('Stamp confirmed at character ' + IntToStr(Index));
  finally
    Pdf.Free;
  end;
end;

Le schéma qui casse encore : mettre en cache le handle TextPage brut

TPdf expose le handle vivant via une propriété TextPage en lecture seule, pour le cas rare où vous devez appeler une fonction FPDFText_* que PDFiumPas n'a pas enveloppée. Cette échappatoire est aussi le seul endroit où l'invalidation automatique ne peut pas aider : une fois que vous copiez la valeur FPDF_TEXTPAGE hors de la propriété dans une variable locale, PDFiumPas n'a aucun moyen de savoir que vous la détenez encore, et aucun moyen de mettre à jour votre copie quand UnloadTextPage s'exécute ailleurs dans votre code

var
  Pdf: TPdf;
  RawHandle: FPDF_TEXTPAGE;
  StaleCount: Integer;
begin
  Pdf := TPdf.Create(nil);
  try
    Pdf.FileName := 'contract.pdf';
    Pdf.Active := True;
    Pdf.PageNumber := 1;

    RawHandle := Pdf.TextPage;    // FPDFText_LoadPage handle, cached in FTextPage
    Pdf.SetText(0, 'Amended Clause 4.2');
    // SetText already closed RawHandle and set Pdf.TextPage back to nil.
    // Calling any FPDFText_* function against the old value now touches a
    // handle PDFium has already freed — undefined behavior, not a bug you
    // can catch with a nil check
    StaleCount := FPDFText_CountChars(RawHandle);
  finally
    Pdf.Free;
  end;
end;

Utiliser un handle après que FPDFText_ClosePage s'est exécutée dessus est un comportement indéfini dans PDFium lui-même, pas une convention PDFiumPas que vous pouvez choisir d'ignorer — cela peut renvoyer les dernières données connues, ne rien renvoyer, ou faire planter le processus, et lequel de ces comportements se produit sur une version donnée n'est pas quelque chose dont le code applicatif devrait dépendre. La règle sûre est étroite : lisez Pdf.TextPage frais, immédiatement avant l'appel FPDFText_* qui en a besoin, et ne conservez jamais une copie à travers une instruction qui pourrait éditer la page

Regroupez vos éditions, puis interrogez une seule fois

Rien de tout cela ne signifie que chaque appel AddText ou RemoveObject a besoin d'une requête de texte défensive juste après pour vérifier le résultat. Chaque méthode d'édition paie déjà le coût de fermer la page de texte une fois ; interroger après chaque édition individuelle à l'intérieur d'une boucle paie ce coût à nouveau sans aucun bénéfice, puisque FPDFText_LoadPage reparcourt tout le flux de contenu à chaque exécution

var
  Pdf: TPdf;
  I: Integer;
begin
  Pdf := TPdf.Create(nil);
  try
    Pdf.FileName := 'watermarked.pdf';
    Pdf.Active := True;
    Pdf.PageNumber := 1;

    // Strip every text object that looks like a draft watermark. Each
    // RemoveObject call already invalidates the cache on its own, so
    // nothing needs refreshing by hand between iterations
    for I := Pdf.ObjectCount - 1 downto 0 do
      if (Pdf.ObjectType[I] = otText) and (Pdf.ObjectBounds[I].Top > 700) then
        Pdf.RemoveObject(I, True);

    // Query once, after the whole batch is done, not once per removal
    if Pdf.FindFirst('DRAFT') < 0 then
      ShowMessage('Watermark cleared');
  finally
    Pdf.Free;
  end;
end;

La même logique de regroupement s'applique spécifiquement à l'état de recherche. FindNext et FindPrevious continuent une session démarrée par FindFirst, et cette session est démontée par UnloadTextPage en même temps que tout le reste, si bien qu'appeler FindNext à nouveau après une édition — plutôt que d'appeler FindFirst à nouveau — lève une exception plutôt que de reprendre silencieusement une recherche contre du contenu qui n'existe plus. Traitez toute édition comme une frontière stricte à la fois pour le contenu texte et pour la position de recherche, et laissez un FindFirst frais de l'autre côté de vos éditions reprendre la recherche

Où cela s'articule avec l'extraction et le travail d'annotation

L'extraction de texte pur — lire le texte d'une page sans rien changer — ne rencontre jamais rien de tout cela, car rien n'invalide un handle qu'aucune édition n'a touché. Pour savoir comment Text, les rectangles de caractères, et les limites de mots fonctionnent sur une page non modifiée, l'article compagnon sur l'extraction de texte avec PDFiumPas couvre ce terrain sans le cycle de vie du cache de page de texte que cet article ajoute par-dessus

Le cycle de vie du cache compte le plus dans les flux de travail qui éditent puis agissent immédiatement sur le résultat : tamponner une correction et la rechercher, caviarder un paragraphe et confirmer qu'il a disparu, ou localiser une phrase pour ancrer une annotation de balisage juste après avoir inséré du texte à proximité. Ce dernier cas mérite d'être signalé à part — les annotations de balisage à points de quadrilatère sont positionnées à partir de rectangles de caractères lus sur la page de texte, si bien qu'une annotation construite à partir de coordonnées capturées avant une édition finit par surligner le mauvais endroit une fois l'édition en place

Les API d'édition et de texte de TPdf font partie du composant PDFium pour Delphi et C++Builder, et la page produit porte la référence complète des méthodes pour les surfaces d'édition, d'extraction, et de recherche couvertes ici