Article technique

Avancée de texte et clip q/Q dans un renderer PDF Delphi

Un flux de contenu qui pose /F1 1 Tf, puis 12 0 0 12 72 700 Tm, puis affiche (Hello world) Tj est parfaitement légal. Un renderer écrit à la main dessinera souvent les onze caractères dans environ cinq points et demi — une tache sombre à la marge gauche là où un mot de 66 points devrait se trouver. L'arithmétique derrière cette tache tient en une ligne. ISO 32000-1 §9.4.4 définit chaque avancée de glyphe en espace texte, donc le déplacement doit passer par la matrice de texte avant d'atteindre la page, et un clip posé à l'intérieur de q ... Q appartient à l'état graphique tout autant que l'épaisseur de ligne

Pourquoi le texte se tasse-t-il en tas quand un PDF utilise Tf 1 ?

Le texte se tasse parce que le renderer ajoute une distance en espace texte directement à la composante de translation de Tm, ce qui ne marche que tant qu'espace texte et espace utilisateur partagent la même échelle. Bien des producteurs réels écrivent la taille de police à 1 et portent la vraie taille dans la matrice de texte. Sous /F1 1 Tf et 12 0 0 12 72 700 Tm, un glyphe de 500 unités de large avance de 0,5 en espace texte. Tm met cela à l'échelle de 6 points sur la page. Un renderer qui exécute Tm.e := Tm.e + Adv déplace la plume de 0,5 point. Chaque glyphe atterrit alors un douzième de caractère après le précédent, et un lecteur qui suit §9.4.4 affiche le même fichier comme du texte courant normal

Pourquoi le texte se tasse sous Tf 1 dans le renderer HotPDF : avec 12 0 0 12 72 700 Tm, un glyphe de 500 unités doit avancer de 0,5 unité en espace texte, que Tm met à l'échelle de 6 points, alors qu'ajouter 0,5 directement à Tm.e rend une ligne de texte courant comme une traînée d'un douzième de caractère par glyphe
Les producteurs qui encodent la taille de police dans la matrice de texte exposent d'un coup le raccourci Tm.e : chaque glyphe atterrit un douzième de caractère après le précédent, ce qu'une sortie écrite avec la vraie taille dans Tf ne révèle jamais

Le raccourci survit longtemps parce que la plupart des générateurs avec lesquels on teste écrivent /F1 12 Tf et un Tm non mis à l'échelle, et dans ce cas espace texte et espace utilisateur ont vraiment la même taille. Voici la version naïve en entier, avec le flux de contenu qui la casse écrit en commentaire au-dessus :

// Flux de contenu d'un producteur qui encode la taille dans Tm, pas Tf :
//   BT
//   /F1 1 Tf
//   12 0 0 12 72 700 Tm
//   [(Hel) 30 (lo) -250 (world)] TJ
//   ET

// Avancée naïve : une distance en espace texte ajoutée à Tm.e comme si c'était l'espace utilisateur
Adv := W * FontSize / 1000;                  // 0.5 pour un glyphe de 500 unités
if (HorizScale <> 0) and (HorizScale <> 100) then
  Adv := Adv * HorizScale / 100;             // Th sur la largeur seulement
Adv := Adv + CharSpace;                      // Tc non mis à l'échelle par Th
if Code = 32 then
  Adv := Adv + WordSpace * FontSize / 1000;  // Tw mis à l'échelle par Tfs à tort
Tm.e := Tm.e + Adv;                          // ignore Tm.a, Tm.b, Tm.c, Tm.d

// Ajustement TJ naïf : pas de Th, et encore seulement Tm.e
Tm.e := Tm.e - NumValue * FontSize / 1000;

La ligne Tm.e est le bug bruyant. Les autres sont plus discrets, et ils se montrent sur d'autres fichiers. L'espacement des mots Tw s'exprime déjà en unités d'espace texte non mises à l'échelle, donc le multiplier par FontSize / 1000 transforme un 3 Tw sous Tf 12 en 0,036 unité. Un paragraphe justifié perd presque tout son espace inter-mots. L'échelle horizontale Th doit aussi s'appliquer à Tc et Tw, et au nombre de crénage de TJ. Le test Code = 32 se déclenche aussi sur tout octet de valeur 32 à l'intérieur d'un code à deux octets, ce qui sème des espaces aléatoires dans le texte CJK. Les erreurs d'état texte comme celles-ci échouent rarement bruyamment : comme les pièges d'index d'opérande et de nom de ressource qui peuvent mettre Tc, Tw et Tz à zéro avant qu'un seul glyphe soit dessiné, elles produisent des pages plausibles avec un producteur et cassées avec le suivant

Comment ISO 32000-1 §9.4.4 définit-il l'avancée du glyphe ?

ISO 32000-1 §9.4.4 calcule l'avancée entièrement en espace texte et l'applique à la matrice de texte comme une matrice de translation, donc l'échelle, la rotation et l'inclinaison viennent toutes de Tm et jamais de l'avancée elle-même. Pour l'écriture horizontale, tx = ((w0 − Tj/1000) × Tfs + Tc + Tw) × Th, où w0 est la largeur du glyphe en millièmes d'em, Tj l'ajustement TJ et Th le Tz divisé par 100. La nouvelle matrice est [1 0 0 1 tx 0] × Tm. Développé, ce produit ajoute a·tx à e et b·tx à f, et c'est pourquoi une translation en espace texte ne peut jamais être une simple addition à e sauf si a vaut 1 et b vaut 0. Selon §9.3.3, Tw ne s'applique qu'au code de caractère mono-octet 32

Avancée de glyphe ISO 32000-1 9.4.4 dans le renderer HotPDF : tx calculé en espace texte depuis w0, Tj, Tfs, Tc, Tw et Th, puis appliqué via les coefficients a, b, c et d de la matrice de texte pour que Td, TD, TJ et le texte invisible bougent tous selon la même règle
Ajouter directement l'avancée à Tm.e ne marche que si espace texte et espace utilisateur coïncident ; la faire passer par les coefficients de la matrice garde le texte mis à l'échelle, pivoté et incliné correct

Le HotPDF Delphi Component garde cette formule en un seul endroit. L'unité HPDFContentStream exporte HPDFTextGlyphDisplacement pour l'avancée par glyphe et HPDFTextAdjustmentDisplacement pour un nombre TJ, et le renderer de page comme l'interpréteur d'extraction de texte les appellent tous deux, si bien qu'une page qui se rend correctement rapporte aussi des positions de glyphes alignées sur ce que vous voyez. Si vous écrivez votre propre consommateur de flux de contenu par-dessus le parseur de la bibliothèque, vous pouvez appeler les deux mêmes routines et fournir vous-même l'étape matricielle :

uses
  HPDFContentStream;  // HPDFTextGlyphDisplacement, HPDFTextAdjustmentDisplacement

type
  // Votre propre enregistrement de matrice de texte, pas un type HotPDF
  TTextMatrix = record
    a, b, c, d, e, f: Double;
  end;

// Votre propre helper : [1 0 0 1 Tx Ty] x Tm, en passant par a, b, c et d
procedure TranslateInTextSpace(var M: TTextMatrix; Tx, Ty: Double);
begin
  M.e := M.e + M.a * Tx + M.c * Ty;
  M.f := M.f + M.b * Tx + M.d * Ty;
end;

// W0 est la largeur du glyphe en 1/1000 em ; Tz est l'opérande Tz (100 = pas de mise à l'échelle)
procedure AdvanceAfterGlyph(var Tm: TTextMatrix; W0: Double; Code: Cardinal;
  TwoByteCodes: Boolean; Tfs, Tc, Tw, Tz: Double);
var
  Tx, Ty: Double;
begin
  HPDFTextGlyphDisplacement(W0 * Tfs / 1000, False,
    (Code = 32) and not TwoByteCodes,  // Tw seulement sur l'espace mono-octet
    Tc, Tw, Tz, Tx, Ty);
  TranslateInTextSpace(Tm, Tx, Ty);
  // Tf 1, Tm 12 0 0 12 72 700, W0 = 500 : Tx = 0.5 et Tm.e grandit de 6
end;

procedure ApplyTJNumber(var Tm: TTextMatrix; Adjustment, Tfs, Tz: Double);
var
  Tx, Ty: Double;
begin
  HPDFTextAdjustmentDisplacement(Adjustment, Tfs, Tz, False, Tx, Ty);
  TranslateInTextSpace(Tm, Tx, Ty);  // Th s'applique aussi au crénage
end;

À l'intérieur du renderer, la même étape matricielle pilote chaque opérateur qui déplace la position du texte. Td, TD, T*, ' et " translatent la matrice de ligne via ses coefficients, donc 0 -1.2 Td sous une matrice de texte 12× descend la ligne de base de 14,4 points au lieu de 1,2. Les glyphes jamais peints suivent le chemin identique. Le mode de rendu 3, le mode invisible qu'utilisent les couches de texte OCR, et le texte dans du contenu optionnel masqué font tous deux avancer Tm exactement comme le texte peint. Leur donner une copie privée de l'arithmétique est l'erreur classique, parce qu'alors tout texte visible qui suit une séquence invisible dans le même bloc BT démarre d'une position légèrement différente

Placer le glyphe lui-même

Le placement du glyphe suit la même logique. Quand aucun contour embarqué n'est disponible et que le renderer retombe sur le TextOutW de GDI, il construit la matrice de glyphe complète depuis CTM × Tm × rise × échelle em, Th compris. La matrice entre via SetWorldTransform en mode GM_ADVANCED, à l'intérieur d'une paire SaveDC / RestoreDC. La police GDI est créée à une hauteur fixe de 1000 unités et la transformation monde fait tout le dimensionnement. Le texte pivoté et incliné garde donc son orientation au lieu d'être dessiné droit à une origine transformée. Les modes de rendu tracés remettent la transformation monde à zéro avant de tracer, puisqu'une épaisseur de plume mesurée par rapport à une taille de conception de 1000 unités rétrécirait sinon à presque rien. L'écriture verticale est la seule asymétrie délibérée : une police WMode 1 avance le long de l'axe y de sa métrique verticale, Tc et Tw s'appliquent le long de cet axe, et Th ne s'applique pas du tout

Que sauve réellement q/Q dans un état graphique PDF ?

ISO 32000-1 §8.4.2 liste le chemin de découpe courant comme partie de l'état graphique, donc Q doit restaurer le clip exactement tel qu'il était au q correspondant. Cela paraît évident. Dans un renderer adossé à GDI, c'est facile à rater, parce que la CTM, les couleurs, les paramètres de ligne et l'état texte vivent dans la propre pile d'états du renderer alors que le clip vit dans le contexte de périphérique — hors de cette pile. Copier l'état numérique restaure tout sauf le clip. Un W n à l'intérieur d'un bloc q ... Q continue alors de rogner chaque opération ultérieure de la page, et le symptôme ressemble à du contenu manquant loin de l'opérateur qui l'a causé

Les Form XObjects ouvrent une seconde route vers la même défaillance. §8.10 donne à un formulaire une sauvegarde et une restauration implicites autour de son contenu et le rogne à son /BBox, et le contenu de formulaire réel laisse parfois ses propres q et Q déséquilibrés alors même que la spécification exige qu'ils s'apparient. HotPDF enregistre le clip et sauvegarde le contexte de périphérique avant d'exécuter un formulaire, et quand le formulaire se termine il jette les trames de clip que le formulaire a laissées ouvertes et restaure le contexte de périphérique, si bien que chaque région sauvegardée est libérée exactement une fois. Un Q égaré dans le formulaire qui dépilerait l'état de l'appelant est refusé plutôt qu'appliqué. Avec RenderFallbackPolicy à rfpReport, ce refus apparaît dans les diagnostics, ce qui permet de distinguer facilement un producteur négligent d'un problème de rendu :

// Diags : THPDFRenderFallbackDiagnostics (déclaré dans HPDFPageRenderer)
Pdf.RenderFallbackPolicy := rfpReport;
Bmp := Pdf.RenderLoadedPageToBitmap(PageIndex, 150);
try
  Diags := Pdf.GetLastRenderFallbackDiagnostics;
  for J := 0 to High(Diags) do
    if (Diags[J].Kind = rfkInvalidObject) and (Diags[J].OperatorName = 'Q') then
      Log.Add(Format('page %d: unbalanced Q refused - %s',
        [PageIndex + 1, string(Diags[J].Reason)]));
finally
  Bmp.Free;  // Free est sûr sur nil
end;

Capturer le clip seulement quand une trame le modifie

Le correctif direct consiste à lire la région de clip GDI à chaque q et à la remettre à chaque Q. C'est correct. C'est aussi coûteux précisément sur les pages qui utilisent le plus q, parce que les générateurs adorent envelopper chaque petit dessin dans q 1 0 0 1 x y cm ... Q uniquement pour déplacer l'origine. Chacune de ces paires créerait alors une région GDI, y copierait le clip et la supprimerait à nouveau. Quand plusieurs pages se rendent en parallèle, les threads finissent par se disputer les objets région GDI au lieu de rastériser, et l'accélération du pipeline de rendu parallèle rétrécit d'autant

Capture de clip GDI paresseuse dans le renderer HotPDF : q n'enregistre que la profondeur de pile et la surface cible, la région de clip est lue juste avant que W, n, un clip de texte ou l'entrée dans un formulaire ne change le découpage, et Q la restaure et la libère, si bien que les pages pleines de paires q cm Q n'allouent aucune région GDI
Créer une région GDI à chaque q fait se disputer les objets région entre threads de rendu, donc le clip n'est capturé que lorsqu'un opérateur est sur le point de changer le découpage

HotPDF diffère la partie coûteuse. Un q n'enregistre que la profondeur de pile et la surface à laquelle il appartient, dans un tableau de trames qui grandit par doublement à partir de 16 et se réutilise sur toute la page, si bien qu'une série de transformations numériques n'alloue aucun objet GDI. Le clip est capturé la première fois que quelque chose dans cette trame s'apprête à le modifier : peinture de chemin ou n avec un W ou W* en attente, remplissage par motif, le clip laissé par les modes de rendu de texte 4 à 7, ou l'entrée dans un formulaire. Sur Q, une trame capturée remet sa région en place et la libère, et une trame qui n'a jamais changé le clip ne dépile que l'état numérique. La page courante n'alloue alors aucune région, et une page qui découpe paie exactement une région par changement de clip effectif. Si vous construisez votre propre périphérique GDI pour un interpréteur PDF, la même forme tient en quelques dizaines de lignes :

// Une esquisse pour votre propre périphérique adossé à GDI ; rien de ceci n'est l'API HotPDF
// nécessite Windows, SysUtils et Math dans la clause uses
type
  TClipFrame = record
    Region: HRGN;
    Captured: Boolean;
  end;

procedure TMyDevice.PushState;            // q : aucun appel GDI
begin
  if FCount = Length(FFrames) then
    SetLength(FFrames, Max(16, Length(FFrames) * 2));
  FFrames[FCount].Region := 0;
  FFrames[FCount].Captured := False;
  Inc(FCount);
end;

procedure TMyDevice.BeforeClipChange;     // à appeler avant W n, W f, clip de texte, BBox de formulaire
var
  R: HRGN;
  Res: Integer;
begin
  if (FCount = 0) or FFrames[FCount - 1].Captured then Exit;
  R := CreateRectRgn(0, 0, 0, 0);
  if R = 0 then RaiseLastOSError;
  Res := GetClipRgn(FDC, R);              // 1 copié, 0 pas de clip, -1 erreur
  if Res <= 0 then
  begin
    DeleteObject(R);
    R := 0;
    if Res < 0 then RaiseLastOSError;
  end;
  FFrames[FCount - 1].Region := R;
  FFrames[FCount - 1].Captured := True;
end;

procedure TMyDevice.PopState;             // Q
begin
  if FCount = 0 then Exit;                // Q déséquilibré : on l'ignore
  Dec(FCount);
  if FFrames[FCount].Captured then
    SelectClipRgn(FDC, FFrames[FCount].Region);  // Region 0 retire le clip
  if FFrames[FCount].Region <> 0 then
    DeleteObject(FFrames[FCount].Region);
end;

Deux détails de cette esquisse portent l'essentiel du poids. GetClipRgn renvoyant 0 n'est pas une erreur : cela signifie que le contexte de périphérique n'avait aucun clip à ce moment-là. Passer une région zéro à SelectClipRgn sur Q est précisément ce qui retire un clip qui n'existait pas au q correspondant. Traiter 0 comme un échec laisse le clip intérieur en place. L'autre détail est le drapeau Captured, qui empêche un second changement de clip dans la même trame d'écraser la région sauvegardée par une région déjà rétrécie

Où le clip sauvegardé et la règle d'avancée s'arrêtent

Le clip sauvegardé est une région GDI en pixels de périphérique. Il est exact pour le bitmap en cours de rendu et sans signification pour toute autre surface, donc chaque trame enregistre aussi le contexte de périphérique d'où elle vient, et la restauration est sautée quand le contexte a changé, par exemple pendant qu'un groupe de transparence se rend dans son propre bitmap de calque

Côté texte, la règle d'avancée place chaque glyphe d'après la largeur qu'on lui donne. Quand une police simple omet son tableau /Widths et qu'aucun programme de police embarqué n'est disponible, l'avancée ne peut être meilleure que le repli de largeur, et §9.4.4 ne dit rien sur la provenance de cette largeur. Les tests de régression pour cela vivent sur des fichiers que les documents générés simples ne contiennent presque jamais : un avec Tf 1 et un Tm mis à l'échelle, un avec Tz et Tw non nuls sur du texte justifié, et un avec un clip W n à l'intérieur de q ... Q suivi de contenu dessiné hors de la zone découpée. Un rendu correct du troisième montre ce contenu extérieur en entier

Depuis le code applicatif, rien de tout cela ne demande de mise en place : l'appel est celui montré dans le rendu d'une page PDF en bitmap, et les deux règles s'appliquent à chaque page que vous rendez ainsi. Les versions de Delphi et C++Builder prises en charge et les détails de licence sont listés sur la page du HotPDF Delphi PDF Component