Les glyphes formés s'affichent sous forme de rectangles .notdef lorsqu'un sous-ensembleur de police ne conserve que les glyphes atteignables depuis les points de code émis. HotPDF, le composant VCL natif pour Delphi et C++Builder, portait exactement ce défaut jusqu'à la version 2.435.0 : la sortie OpenType GSUB était enregistrée dans un bitmap d'usage interne que le sous-ensembleur déclarait vouloir honorer, et qu'il ne lisait en réalité jamais
Il s'agit d'un défaut différent de celui décrit dans le bogue EndDoc qui désactivait silencieusement le sous-ensemblage de polices. Ce bogue-là concernait quand le sous-ensemblage s'exécutait par rapport à la sérialisation, et il désactivait le sous-ensemblage en bloc. Celui-ci concerne ce que contient le sous-ensemble quand le sous-ensemblage s'exécute parfaitement au bon moment. Le pipeline se déclenche au bon instant, le préfixe de sous-ensemble à six lettres apparaît sur /BaseFont exactement comme l'exige ISO 32000-1 §9.6.4, le fichier rétrécit, chaque page latine s'imprime proprement, et une page arabe ressort sous forme d'une rangée de rectangles vides. Les bogues d'ordonnancement sont bruyants une fois qu'on regarde. Les bogues de closure restent silencieux pour toujours, car le sous-ensemble est structurellement valide et se trompe seulement sur sa propre liste d'appartenance
Pourquoi les glyphes formés s'affichent-ils en .notdef ?
Parce que l'ensemble des points de code qu'un document émet n'est pas l'ensemble des glyphes que le document dessine, et un sous-ensembleur qui confond les deux abandonne chaque glyphe produit par la formation. La formation de texte (text shaping) transforme une séquence logique de caractères en une séquence de glyphes positionnés, et son but même est de produire des glyphes vers lesquels aucun caractère d'entrée unique ne pointe : un heh médian arabe, une ligature fi, un conjoint du devanagari, une variante contextuelle sélectionnée par la fonctionnalité rclt. Chacun de ces éléments est un identifiant de glyphe fabriqué par une recherche GSUB, pas un identifiant que la table cmap fournit pour un caractère quelconque de la chaîne. Un sous-ensembleur piloté uniquement par le cmap parcourt donc le mauvais index. Il conserve fidèlement chaque glyphe que le texte aurait pu utiliser avant la formation et abandonne précisément les glyphes que le texte utilise réellement après la formation. Le moteur de rendu demande alors à la police intégrée le GID 1847, le sous-ensemble a mis à zéro cette entrée dans loca, et c'est l'index de glyphe 0 qui revient à la place. L'index de glyphe 0 est .notdef par définition d'OpenType, ce qui explique pourquoi la signature de l'échec est une case vide plutôt qu'une mauvaise lettre ou un plantage. Rien n'est mal formé dans le PDF ; la police ne contient tout simplement pas le glyphe que le flux de contenu a demandé
Les points de code ne sont pas des glyphes : les trois sources d'un sous-ensemble
Une closure de sous-ensemble correcte doit fusionner trois sources indépendantes, chacune avec son propre accumulateur. La première est l'ensemble dérivé des points de code : HotPDF accumule FUnicodeUsedCps à mesure que des caractères du BMP sont émis, et FUnicodeSmpUsed pour les caractères des plans supplémentaires atteints via des paires de substitution, puis fait correspondre chacun via FUnicodeCpToGid à un identifiant de glyphe. La deuxième est l'ensemble dérivé de la formation, les identifiants de glyphes qu'une substitution GSUB a produits, enregistrés via MarkUnicodeGlyphUsed et EnableShapingFeatureForSubset dans FUnicodeExtraUsedGlyphs. La troisième est la closure composite : un glyphe dont numberOfContours vaut -1 dans glyf est assemblé à partir d'identifiants de glyphes composants, et conserver le composite tout en abandonnant ses composants produit un contour vide plutôt qu'un .notdef, ce qui est sans doute pire, car cela se lit comme un bogue d'espacement
HotPDF a toujours géré la première et la troisième source. BuildAndApplyUnicodeFontSubset, le point d'entrée du sous-ensemblage que EndDoc appelle avant la sérialisation, amorce le tableau des glyphes utilisés avec le GID 0, parcourt les points de code du BMP, parcourt la liste d'usage du SMP, et transmet le tableau à un constructeur de sous-ensemble qui résout les composants composites en interne. La deuxième source était écrite mais jamais consommée, et comme les trois sources échouent sur des contenus différents, l'écart peut rester caché pendant des années dans une base de code dont le corpus de non-régression est essentiellement latin
Le tableau qui était écrit et jamais lu
Le contrat était documenté à trois endroits et honoré à aucun d'eux. La déclaration de FUnicodeExtraUsedGlyphs affirmait que le sous-ensembleur d'EndDoc le fusionnait avec l'usage dérivé des points de code ; le commentaire d'en-tête sur ApplyArabicGSUBRefinement promettait que chaque GID de substitution émis passe par MarkUnicodeGlyphUsed afin que le sous-ensembleur intègre le glyphe dans la police incorporée ; la même promesse apparaît mot pour mot sur ApplyArabicGSUBContextualRefinement pour le chemin rclt. Les deux appelants ont tenu leur moitié. Un grep sur chaque référence au champ a réglé l'autre moitié en environ quatre-vingt-dix secondes : une déclaration, une allocation SetLength à l'intérieur de RegisterUnicodeTTF, et des écritures dans les deux routines de marquage. Pas une seule lecture. C'est le diagnostic à retenir, car il se généralise bien au-delà des polices. Quand un champ est écrit par plusieurs points d'appel et lu par aucun, la fonctionnalité qu'il représente n'existe pas, aussi soigneusement commentée soit-elle. L'étape 1 du sous-ensembleur tient sur un seul écran, et l'écart devient évident une fois qu'on sait où regarder
// Step 1: derive the used-glyph set (as it stood before 2.435.0)
SetLength(UsedGlyphs, FUnicodeNumGlyphs);
for I := 0 to FUnicodeNumGlyphs - 1 do
UsedGlyphs[I] := False;
UsedGlyphs[0] := True; // .notdef is always present
for Cp := 0 to $FFFF do // source 1a: BMP code points
if (Cp < Length(FUnicodeUsedCps)) and FUnicodeUsedCps[Cp]
and (Cp < Length(FUnicodeCpToGid)) then
begin
GID := FUnicodeCpToGid[Cp];
if (GID > 0) and (GID < FUnicodeNumGlyphs) then
UsedGlyphs[GID] := True;
end;
for I := 0 to High(FUnicodeSmpUsed) do // source 1b: SMP code points
begin
GID := FUnicodeSmpUsed[I].GID;
if (GID > 0) and (GID < FUnicodeNumGlyphs) then
UsedGlyphs[GID] := True;
end;
// source 2 was missing here: nothing ever consulted FUnicodeExtraUsedGlyphs
La correction en une boucle, et marquer les glyphes soi-même
La correction est une union, et son argument de sécurité vient du sens de l'opération : elle ne fait que positionner des bits, jamais les effacer, si bien qu'aucun glyphe qui survivait auparavant au sous-ensemble ne peut se mettre à être abandonné
// v2.435.0: pull GSUB-derived extra glyphs into the subset.
// MarkUnicodeGlyphUsed / EnableShapingFeatureForSubset record GIDs that
// shaping produced but that no emitted code point maps to directly.
for I := 0 to FUnicodeNumGlyphs - 1 do
if (I < Length(FUnicodeExtraUsedGlyphs)) and FUnicodeExtraUsedGlyphs[I] then
UsedGlyphs[I] := True;
Trois propriétés font de ceci un changement à faible risque plutôt qu'une réécriture du moteur de police. Il est monotone, comme ci-dessus. Il est un no-op sur les polices qui n'ont jamais rien formé, puisque FUnicodeExtraUsedGlyphs reste entièrement à False et la sortie d'octets pour un document purement latin est inchangée. Et il s'intègre avant l'étape 2, si bien que les deux constructeurs de sous-ensemble en héritent : le constructeur épars qui préserve la numérotation d'origine des GID, et le constructeur compact _BuildCompactSubsetTTF que HotPDF sélectionne sous PDF/A pour renuméroter les glyphes conservés dans une plage dense, réduire maxp.numGlyphs, et émettre la correspondance ancien-vers-nouveau sous forme du flux /CIDToGIDMap exigé par ISO 32000-1 §9.7.4.2. Les deux appellent en interne _TTFWalkCompositeClosure, si bien qu'un glyphe formé qui se trouve être composite entraîne désormais aussi ses composants. La closure composite n'a jamais été cassée ; elle n'était simplement jamais atteinte pour ces identifiants de glyphes, parce que ces identifiants n'étaient pas dans l'ensemble qu'elle parcourt. Si vous pilotez directement le moteur GSUB au lieu de vous appuyer sur les passes de raffinement intégrées, la closure devient votre responsabilité, et chaque identifiant de glyphe de substitution que vous émettez doit être marqué avant que EndDoc ne fige l'ensemble des glyphes utilisés
var
Pdf: THotPDF;
GIDs: array[0..1] of Word;
LigGID: Word;
begin
Pdf := THotPDF.Create(nil);
try
Pdf.FileName := 'shaped.pdf';
Pdf.BeginDoc;
Pdf.RegisterUnicodeTTF('C:\Fonts\NotoNaskhArabic-Regular.ttf');
Pdf.ShapingFeatures := [sfArabicGSUB, sfStandardLigatures,
sfContextualAlternates];
GIDs[0] := Pdf.GetUnicodeGlyphForCodepoint($0644); // lam
GIDs[1] := Pdf.GetUnicodeGlyphForCodepoint($0627); // alef
if Pdf.ApplyLigatureSubstitution(GIDs, 0, 'liga', LigGID) then
Pdf.MarkUnicodeGlyphUsed(LigGID); // omit this and you get .notdef
Pdf.EnableShapingFeatureForSubset('rclt');
Pdf.CurrentPage.RtLTextOut(50, 700, 0, WideString(ArabicText));
Pdf.EndDoc;
finally
Pdf.Free;
end;
end;
EnableShapingFeatureForSubset est le pendant en lot de l'appel à GID unique, et il est délibérément prudent. Il parcourt la liste de recherches GSUB pour celles qui sont câblées à une étiquette de fonctionnalité de quatre octets sous le chemin script/langue actuellement sélectionné, et marque les identifiants de glyphes de substitution que ces recherches peuvent produire. C'est un no-op défensif lorsque la police ne porte aucune table GSUB ou lorsque la fonctionnalité est absente de ce chemin, si bien que l'appeler sans condition est sûr. C'est aussi, par conception, une surapproximation : il peut conserver des glyphes qu'un document donné ne dessine jamais. Pour le sous-ensemblage, la sur-inclusion coûte des octets et la sous-inclusion coûte la justesse, ce qui rend ce compromis facile à trancher. La structure de ces recherches, et les tables de couverture qui décident quels glyphes participent, sont couvertes dans le tour d'horizon des variantes stylistiques GSUB en Delphi pur
Comment prouver que le glyphe est réellement dans le sous-ensemble ?
En lisant la police émise, pas en observant la page dans un lecteur qui pourrait substituer une police système à votre insu. La vérification qui attrape toute cette classe de bogue est mécanique : extraire le flux /FontFile2 du PDF de sortie, analyser loca, et confirmer que l'identifiant de glyphe attendu porte une entrée non vide, c'est-à-dire que ses décalages de début et de fin diffèrent. Une entrée vide signifie que le sous-ensembleur a décidé que le glyphe était inutilisé. Deux habitudes rendent alors beaucoup plus difficile de reproduire cet échec. Conserver une page en écriture formée dans le corpus de tests de fumée automatisés plutôt que seulement dans l'ensemble de relecture manuelle, car l'arabe, le devanagari et le khmer sollicitent des chemins de closure qu'aucune couverture latine ne touchera. Et chaque fois qu'un accumulateur existe, vérifier par assertion que quelque chose le consomme, car un champ en écriture seule est une fonctionnalité qui compile, passe les tests sur le mauvais corpus, et ne fait rien
Où s'arrête la correction
La closure de sous-ensemble est nécessaire pour qu'un glyphe formé s'affiche, elle n'est pas suffisante. Le glyphe doit aussi être adressable depuis le flux de contenu, ce qui est un problème séparé avec sa propre limite. Les passes de raffinement arabe intégrées à HotPDF ne valident une substitution que lorsque chaque identifiant de glyphe de substitution est atteignable via un point de code de forme de présentation Unicode, au moyen d'un balayage inverse de cmap sur environ 690 points de code répartis entre U+FB50 et U+FDFF et U+FE70 et U+FEFF. Lorsqu'une substitution atterrit sur un identifiant de glyphe hors de cette plage, la fenêtre d'entrée passe inchangée plutôt que d'émettre quelque chose que le lecteur ne peut pas adresser ; les variantes propres à une police à des identifiants de glyphes arbitraires nécessitent un point de code d'usage privé synthétique alloué entre U+E000 et U+F8FF pour les faire transiter par le chemin d'émission. Le résumé honnête est donc que la correction de 2.435.0 a supprimé un blocage dur plutôt que de terminer l'histoire. Avant elle, un glyphe pouvait être formé correctement, émis correctement, et malgré tout disparaître au moment du sous-ensemblage, ce qui signifiait que le moteur de formation ne pouvait pas être fiable de bout en bout, aussi bonnes que soient ses recherches. Ce qui reste, c'est l'adressabilité, et cette contrainte échoue au moins visiblement au moment de l'émission plutôt que silencieusement dans une étape de construction s'exécutant après tout ce que vous observiez. Pour le côté émission du même pipeline, voir le guide sur la formation de texte arabe et RTL dans les PDF Delphi
Le sous-ensemblage de polices, le moteur GSUB et la formation de texte à écriture complexe décrits ici sont livrés dans le composant HotPDF standard pour Delphi et C++Builder ; la page produit propose la référence API complète pour les appels Unicode et de formation cités ci-dessus