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
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
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
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