Article technique

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

Le moteur de rendu de pages du HotPDF Delphi Component avance désormais le texte en calculant chaque déplacement de glyphe en espace texte, tx = ((w0 − Tj/1000) × Tfs + Tc + Tw) × Th comme le définit ISO 32000-1 §9.4.4, puis en déplaçant la matrice de texte à travers sa partie linéaire avec HPDFTranslateTextMatrix. Le clipping est sauvegardé par trame q et restauré sur Q, mais une région GDI n'est capturée que quand cette trame change réellement le clip. Les deux correctifs sont arrivés dans HotPDF 2.754.0, et tous deux viennent de pages réelles rendues avec des mots écrasés ou des régions de clip fuyant au-delà de leur Q. Le premier bug est une arithmétique qui a l'air juste jusqu'à ce qu'un producteur écrive sa taille de police dans la matrice. Le second est un correctif de justesse qui a failli nous coûter le gain de rendu parallèle, et la façon dont nous avons récupéré la vitesse vaut d'être connue si vous écrivez un périphérique PDF adossé au GDI

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

Parce que l'ancien code d'avancée ajoutait une distance en espace texte directement à la composante de translation de Tm, comme si espace texte et espace utilisateur avaient toujours la même échelle. Une foule de producteurs réels règlent la taille de police à 1 avec Tf et portent la vraie taille dans la matrice de texte. Avec /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, soit 6 points sur la page une fois que Tm l'a mise à l'échelle. L'ancien moteur exécutait Tm.e := Tm.e + Adv et déplaçait le stylo de 0.5 point. Chaque glyphe atterrissait un douzième de caractère après le précédent, si bien qu'une ligne de texte courant se rendait en tache sombre à la marge gauche pendant que le même fichier paraissait parfait dans tous les autres lecteurs

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 porte à 6 points, tandis que l'ancien code ajoutait 0.5 directement à Tm.e et rendait une ligne de texte courant en tache d'un douzième de caractère par glyphe
Les producteurs qui encodent la taille de police dans la matrice de texte faisaient atterrir chaque glyphe un douzième de caractère après le précédent, un défaut invisible sur la sortie propre de la bibliothèque
// 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

// Ancienne avancée (simplifiée) : distance 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 faussement mis à l'échelle par Tfs
Tm.e := Tm.e + Adv;                          // ignore Tm.a, Tm.b, Tm.c, Tm.d

// Ancien ajustement TJ : pas de Th, et encore seulement Tm.e
Tm.e := Tm.e - NumValue * FontSize / 1000;

Le raccourci Tm.e n'était pas le seul défaut de ce bloc. L'espacement des mots Tw s'exprime en unités d'espace texte non mises à l'échelle, pourtant l'ancien code le multipliait par FontSize / 1000, si bien qu'en Tf 12 une ligne justifiée perdait presque tout son espace inter-mots. La mise à l'échelle horizontale Th s'appliquait à la largeur du glyphe mais pas à Tc ni Tw, et l'ajustement de crénage TJ l'escamotait complètement. Le chemin sans peinture qui avance le texte invisible en mode de rendu 3, le genre que les couches de texte OCR emploient, et le texte dans l'optional content caché portaient une copie privée de la même arithmétique, si bien que tout ce qui était dessiné après une course invisible partait de la mauvaise position. Les bugs d'état de texte dans un moteur de rendu échouent rarement bruyamment : comme les bugs d'index d'opérande et de nom de ressource qui avaient un jour mis Tc, Tw et Tz à zéro sans une seule erreur, ceux-ci produisaient des pages plausibles sur la sortie propre de la bibliothèque et ne cassaient que sur des fichiers d'autres producteurs

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

ISO 32000-1 §9.4.4 définit l'avancée entièrement en espace texte et l'applique à la matrice de texte comme matrice de translation, donc la réponse est de calculer tx d'abord et de laisser Tm faire mise à l'échelle, rotation et cisaillement. En écriture horizontale, tx égale ((w0 − Tj/1000) × Tfs + Tc + Tw) × Th, où w0 est la largeur du glyphe en millièmes d'em, Tj est l'ajustement TJ, et Th est Tz divisé par 100. Le nouveau Tm est [1 0 0 1 tx 0] × Tm, ce qui dans HotPDF est le helper HPDFTranslateTextMatrix : il ajoute X et Y à travers les coefficients de matrice a, b, c et d au lieu d'écrire directement dans e et f. Selon §9.3.3, Tw ne s'applique qu'au code de caractère à un octet 32, donc les codes CID multioctets ne prennent jamais d'espacement de mots sur le chemin horizontal. Le même helper pilote désormais Td, TD, T*, les opérateurs ' et ", les ajustements TJ et le chemin de texte caché, ce qui veut dire qu'une seule fonction détient la règle

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 HPDFTranslateTextMatrix pour que le déplacement passe par les coefficients de matrice a, b, c et d et que Td, TD, TJ et le chemin de texte caché partagent une seule règle
Ajouter l'avancée directement à Tm.e ne marche que quand espace texte égale espace utilisateur ; la router par les coefficients de matrice garde le texte mis à l'échelle, tourné et cisaillé correct
procedure HPDFTranslateTextMatrix(var Matrix: THPDFAffineMatrix; X, Y: Double);
begin
  Matrix.e := Matrix.e + Matrix.a * X + Matrix.c * Y;
  Matrix.f := Matrix.f + Matrix.b * X + Matrix.d * Y;
end;

// Avancée de glyphe horizontale, ISO 32000-1 9.4.4
W   := HPDFFontCharWidth(F, Code);
Adv := W * State.Text.FontSize / 1000 + State.Text.CharSpace;
if (Code = 32) and not F.CID2Byte then
  Adv := Adv + State.Text.WordSpace;         // Tw en espace texte, non mis à l'échelle
Adv := Adv * State.Text.HorizScale / 100;    // Th s'applique à toute la somme
HPDFTranslateTextMatrix(Tm, Adv, 0);

// Élément nombre de TJ : même espace, même Th
Adjustment := -Items[I].NumValue * State.Text.FontSize / 1000;
HPDFTranslateTextMatrix(State.Text.Tm, Adjustment * State.Text.HorizScale / 100, 0);

Le placement des glyphes a dû suivre la même logique. Quand aucun contour embarqué n'est disponible et que le moteur retombe sur le TextOutW GDI, il bâtit maintenant la matrice complète du glyphe depuis CTM × Tm × rise × échelle em, Th compris, et l'installe avec SetWorldTransform en mode GM_ADVANCED dans une paire SaveDC / RestoreDC. La police GDI est créée à une hauteur fixe de 1000 unités et la transformation fait la taille, si bien que le texte tourné et cisaillé garde son orientation au lieu d'être dessiné droit à un point d'origine transformé. Le mode d'écriture vertical est la seule asymétrie délibérée : une police WMode 1 avance vers le bas de l'axe y selon sa métrique verticale, et la mise à l'échelle horizontale ne s'applique pas à cet axe

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

ISO 32000-1 §8.4.2 liste le chemin de clipping courant comme partie de l'état graphique, donc Q doit restaurer le clip exactement comme il était au q apparié, pas seulement les paramètres numériques. HotPDF tenait déjà une pile d'état graphique avec la CTM, les couleurs, les paramètres de ligne et l'état de texte, mais le GDI garde le clip dans le contexte de périphérique, hors de cette pile. Une copie de l'état numérique restaurait donc tout sauf le clip, et un clip posé avec W n dans un bloc q ... Q continuait de rogner chaque opération ultérieure de la page. Les Form XObjects ont ajouté une seconde route vers le même échec, parce que §8.10 donne à un formulaire une sauvegarde et une restauration implicites autour de son contenu, et le contenu de formulaires réels laisse parfois ses propres opérateurs q déséquilibrés alors même que la spécification exige qu'ils s'apparient. Le moteur appelle maintenant CaptureClipBeforeChange et SaveDC avant d'exécuter un formulaire, puis une fois le formulaire terminé, il jette toute région sauvegardée plus profonde que la profondeur d'entrée et appelle RestoreDC, si bien que chaque HRGN sauvegardée a exactement un chemin de libération

Capture de clip paresseuse avec THPDFSavedClipState

Le correctif livré sauvegarde un enregistrement THPDFSavedClipState par q, mais diffère la partie coûteuse jusqu'à ce que la trame modifie le clip pour la première fois. L'enregistrement porte le handle de région, la profondeur de pile à laquelle il appartient, le contexte de périphérique dont il vient et un drapeau Captured. DevPushState ne remplit que la profondeur et le DC et fait croître le tableau de trames en doublant depuis 16, si bien qu'un flux de contenu plein de q 1 0 0 1 x y cm ... Q n'alloue aucun objet GDI du tout. Les opérateurs sur le point de changer le clipping, à savoir peinture de chemin avec un W ou W* en attente, l'opérateur n, les remplissages de motif et l'entrée de formulaire, appellent d'abord CaptureClipBeforeChange

Capture de clip GDI paresseuse dans le renderer HotPDF : DevPushState n'enregistre que la profondeur et le DC par q, CaptureClipBeforeChange lit la région juste avant que W, n ou une entrée de formulaire change le clipping, DevPopState la restaure et la supprime sur Q, et la version impatiente qui capturait à chaque q faisait tomber le débit parallèle à environ 1.15 fois le mono-thread
Créer une région GDI à chaque q affamait les threads de rendu, si bien que la capture n'a lieu désormais que quand un opérateur s'apprête à changer le clipping et que le garde-fou de 1.5 fois de gain repasse
procedure THPDFPageRenderer.CaptureClipBeforeChange;
var
  Index, ClipResult: Integer;
  Region: HRGN;
begin
  ClearSavedClipRegions(FGSStack.Count);
  Index := FSavedClipCount - 1;
  if (Index < 0) or FSavedClips[Index].Captured or
     (FSavedClips[Index].StackDepth <> FGSStack.Count) or
     (FSavedClips[Index].DC <> FDC) then Exit;   // déjà sauvegardé, ou pas le nôtre
  Region := CreateRectRgn(0, 0, 0, 0);
  if Region = 0 then RaiseLastOSError;
  ClipResult := GetClipRgn(FDC, Region);         // 0 signifie pas de clip du tout
  if ClipResult <= 0 then
  begin
    DeleteObject(Region);
    Region := 0;
    if ClipResult < 0 then RaiseLastOSError;
  end;
  FSavedClips[Index].Region := Region;
  FSavedClips[Index].Captured := True;
end;

procedure THPDFPageRenderer.DevPopState;
var
  Index: Integer;
begin
  ClearSavedClipRegions(FGSStack.Count);
  Index := FSavedClipCount - 1;
  if (Index >= 0) and (FSavedClips[Index].StackDepth = FGSStack.Count) then
  begin
    if FSavedClips[Index].Captured and (FSavedClips[Index].DC = FDC) then
      SelectClipRgn(FDC, FSavedClips[Index].Region);  // Région 0 retire le clip
    if FSavedClips[Index].Region <> 0 then
      DeleteObject(FSavedClips[Index].Region);
    Dec(FSavedClipCount);
  end;
  FGSStack.Pop;
end;

Le coût mesuré de la version impatiente est la raison d'être de cette conception. La première implémentation correcte créait et lisait une région GDI à chaque q, et sur les pages faites surtout de transformations numériques, les threads de rendu passaient leur temps à se disputer les objets région GDI au lieu de rastériser. Le pipeline de rendu parallèle est tombé de son gain attendu à environ 1.13 à 1.20 fois le débit mono-thread et a raté le garde-fou de 1.5 fois de gain de la suite de benchmarks. Avec la capture paresseuse et la capacité de trames réutilisée, le même benchmark repasse le garde-fou d'origine de 1.5 fois. L'anticrénelage des petits glyphes TrueType est arrivé dans la même version et était le suspect évident, mais la régression remontait à l'allocation de régions, ce qui rappelle utilement de mesurer avant d'accuser la fonctionnalité la plus récente

Où sont les limites de cette approche ?

Le clip sauvegardé est une région GDI en pixels de périphérique, donc il est exact pour le bitmap en cours de rendu et sans signification pour toute autre cible. C'est pourquoi chaque trame enregistre son contexte de périphérique et que DevPopState saute la restauration quand le DC a changé, par exemple pendant qu'un groupe de transparence se rend dans son propre bitmap de couche. Un GetClipRgn renvoyant zéro est un résultat légitime qui signifie pas de clip, et le restaurer avec SelectClipRgn(FDC, 0) est ce qui retire correctement un clip qui n'existait pas au q apparié. Côté texte, le correctif corrige où va chaque glyphe, mais il n'invente pas les largeurs : si une police omet son tableau /Widths et que le programme embarqué est indisponible, l'avancée reste bonne seulement autant que le repli de largeur. Quand vous testez cette zone en régression, gardez au moins une fixture avec Tf 1 et un Tm mis à l'échelle, une avec Tz et Tw non nuls, et une avec un clip dans q ... Q suivi de contenu hors du bloc, parce qu'aucune de ces configurations n'apparaît dans les documents produits par la bibliothèque elle-même

Si vous pilotez le moteur de rendu depuis du code applicatif, rien ne change dans le schéma d'appel décrit dans rendre une page PDF en bitmap, et les pages qui montraient auparavant des lignes tachées ou du contenu rogné devraient simplement se rendre correctement à partir de la 2.754.0. Les détails sur le composant, les versions prises en charge de Delphi et C++Builder et les licences sont sur la page produit du HotPDF Delphi PDF Component