Générez un rapport, incorporez une police TrueType, et le résultat s'ouvre correctement dans tous les lecteurs que vous essayez. Les glyphes sont corrects, le texte est sélectionnable, le fichier est valide. Le seul problème est la taille. Un document qui utilise quelques dizaines de caractères latins transporte la totalité de la police de 350 Ko. Un document qui imprime un paragraphe de chinois transporte une police CJK de 14 Mo au lieu de la portion d'un demi-mégaoctet normalement requise. Aucune exception n'a été levée, aucun avertissement n'a été consigné, et le fichier a passé la validation avec succès. C'est à cela que ressemble une étape de finalisation mal ordonnée vue de l'extérieur : aucun échec, et la seule preuve est une taille de fichier trop élevée.
Le bug qui en est à l'origine a existé dans HotPDF pendant une série de versions et a été corrigé depuis. Il mérite d'être documenté, non pas comme un simple avis de défaut, mais comme une leçon, car la forme de l'erreur est générale. Tout moteur de document dispose d'une étape de finalisation qui modifie les objets juste avant de les écrire, et la correction de cette étape dépend entièrement de l'ordre de ses étapes par rapport à la sérialisation. Placez une étape du mauvais côté de l'écriture et elle ne fera rien, en silence.
Ce que le sous-ensemblement de polices est censé faire
Un sous-ensemble de police est la partie d'un fichier TrueType qu'un document utilise réellement. La norme ISO 32000-1 §9.9 décrit comment un programme de police incorporé est acheminé dans un flux référencé par le descripteur de police, et pour un programme TrueType, ce flux est /FontFile2 avec un paramètre /Length1 indiquant le nombre d'octets non compressés. Le sous-ensemblement réécrit les tables glyf et loca afin qu'elles ne contiennent que les glyphes référencés par le document, renumérote les identifiants de glyphes et préfixe le nom de la police de base /BaseFont avec une étiquette à six lettres telle que ABCDEF+ pour marquer la police comme un sous-ensemble, exactement comme l'exige la spécification. Une police latine dont le sous-ensemble ne pèse que dix ou quinze kilo-octets fait toute la différence entre un PDF léger et un document qui embarque une police entière pour un simple titre.
Le moment où cela se produit est crucial. Le sous-ensemblement n'est pas une transformation que l'on applique aux octets déjà présents sur le disque. Il modifie le graphe d'objets en mémoire : il réduit le contenu du flux /FontFile2, corrige /Length1 et réécrit la chaîne /BaseFont. Tout cela doit être en place lorsque le sérialiseur parcourt le graphe et émet les octets. Si les modifications interviennent après l'écriture des octets, elles mettront à jour des objets que personne ne lira jamais.
Le symptôme, et pourquoi aucune erreur n'a été signalée
Le comportement signalé était la présence de polices complètes dans le document généré, sans aucun diagnostic. Un utilisateur qui enregistrait une police TrueType Unicode et produisait un document normal constatait que l'objet de police incorporé avait la même longueur que le fichier source .ttf, et que le nom /BaseFont ne comportait aucun préfixe de sous-ensemble à six lettres. La taille de sortie ne diminuait jamais entre les exécutions utilisant dix glyphes et celles en utilisant dix mille.
L'absence de toute erreur est ce qui rend cette catégorie de bugs coûteuse. Une routine de sous-ensemblement qui s'exécute au mauvais moment s'exécute tout de même. Elle parcourt l'utilisation accumulée des points de code, construit un sous-ensemble parfaitement correct et l'applique au graphe d'objets en mémoire. En interne, le travail est fait et l'appel se termine proprement. Le seul problème est que le graphe d'objets qu'elle a modifié n'est plus celui qui est écrit, car le rédacteur a déjà terminé. Du point de vue de l'appelant, le document a été produit et enregistré sans incident, ce qui est précisément l'impression que donne un échec silencieux.
La cause racine était l'ordre de finalisation
Dans HotPDF, le travail de fermeture se produit dans EndDoc. L'étape de sous-ensemblement est une routine interne appelée BuildAndApplyUnicodeFontSubset. Elle lit l'ensemble des points de code utilisés par document, conservé dans un bitmap que le chemin d'émission de texte remplit au fur et à mesure que les glyphes sont affichés, associe chaque point de code utilisé à un identifiant de glyphe réel via la table de correspondance cache, et réécrit le programme de police autour de cette fermeture. Lorsqu'une police TrueType Unicode est enregistrée, le chemin d'émission définit un bit dans l'ensemble des points de code utilisés pour chaque caractère dessiné, de sorte qu'au moment de la fermeture du document, le moteur sait exactement quels glyphes le sous-ensemble doit conserver.
Le défaut résidait dans le fait que BuildAndApplyUnicodeFontSubset était appelé après que SaveToStream ou SaveToFile avait déjà sérialisé le document. Les modifications apportées par le sous-ensembleur à /FontFile2, sa valeur corrigée de /Length1 et le préfixe à six lettres de /BaseFont étaient tous calculés sur un graphe d'objets déjà converti en octets. La correction a consisté à réordonner une seule ligne : déplacer l'appel de sous-ensemblement avant la sérialisation, afin que le rédacteur émette la police sous-ensemblement plutôt que l'originale. La séquence corrigée exécute d'abord le sous-ensembleur et effectue la sérialisation ensuite.
var
Pdf: THotPDF;
begin
Pdf := THotPDF.Create(nil);
try
Pdf.RegisterUnicodeTTF('C:\Fonts\NotoSansSC-Regular.ttf');
Pdf.BeginDoc;
Pdf.CurrentPage.SetFont('Noto Sans SC', [], 12);
Pdf.CurrentPage.TextOut(72, 760, 0, '报表标题 Report Heading');
Pdf.EndDoc; // subsetting runs here, before the write
Pdf.SaveToFile('Report.pdf');
finally
Pdf.Free;
end;
end;
Une fois l'ordre corrigé, rien ne change dans le code d'appel. Le sous-ensemblement est activé par défaut dès qu'une police TrueType Unicode a été enregistrée. Vous enregistrez la police, commencez le document, dessinez et le fermez, et le sous-ensemble est généré à partir des glyphes que vous avez utilisés avant que les octets ne quittent la mémoire.
Pourquoi une seule étape mal placée constitue une catégorie entière
La raison pour laquelle cela mérite une leçon plutôt qu'une simple note de bas de page est qu'EndDoc émet une liste d'étapes de fermeture, et que chacune d'elles est sensible à sa position par rapport à l'écriture. Le sous-ensemblement de polices en est une. La sortie PDF/A requiert un flux /CIDSet qui énumère exactement les identifiants de glyphes présents dans le sous-ensemble, une contrainte imposée par la norme ISO 19005 pour qu'un validateur puisse confirmer que le programme incorporé correspond à ce que revendique le descripteur de police ; ce flux est émis dans la même fenêtre de finalisation et dépend de la construction préalable du sous-ensemble. La norme PDF/UA-1 exige, selon l'ISO 14289-1 §7.18.3, que chaque page portant une annotation déclare /Tabs avec la valeur /S, et une routine interne nommée EnsurePDFUATabsOnAnnotatedPages applique cette clé durant la même étape. Les vérifications de l'intention de sortie (Output Intent) s'exécutent également à ce moment-là.
Le même défaut d'ordre qui désactivait le sous-ensemblement a également supprimé la clé d'ordre de tabulation PDF/UA sur les pages annotées, car cette étape se situait du même mauvais côté de l'écriture. veraPDF et PAC signalent l'absence de /Tabs /S comme une violation du point de contrôle 21-001 du protocole Matterhorn. Ainsi, un seul appel mal placé n'a pas seulement gonflé la taille du fichier ; il a silencieusement brisé une exigence de conformité d'accessibilité en même temps, avec la même absence d'erreur. C'est le danger d'une étape de finalisation : ses étapes partagent une condition préalable, et une seule erreur d'ordre peut en neutraliser plusieurs à la fois alors que chaque appel renvoie un succès.
Comment un échec d'émission silencieux est réellement détecté
Un bug qui ne lève aucune exception ne se détecte pas en exécutant le programme. Il se détecte en inspectant le résultat et en le comparant avec ce que l'entrée aurait dû produire. Pour le sous-ensemblement de polices, les vérifications sont concrètes. Comparez la taille du fichier de sortie avec une estimation approximative : un document qui n'a utilisé qu'une poignée de glyphes ne devrait pas avoir la taille d'une police complète. Ouvrez l'objet de police incorporé et lisez sa longueur en octets ; un flux /FontFile2 avec sous-ensemble pour une police latine représente une petite fraction du fichier source. Lisez le nom /BaseFont et confirmez que le préfixe à six lettres est présent, car son absence est un signal direct qu'aucun sous-ensemble n'a été appliqué.
var
Pdf: THotPDF;
Output: TMemoryStream;
begin
Output := TMemoryStream.Create;
try
Pdf := THotPDF.Create(nil);
try
Pdf.RegisterUnicodeTTF('C:\Fonts\DejaVuSans.ttf');
Pdf.BeginDoc;
Pdf.CurrentPage.SetFont('DejaVu Sans', [], 11);
Pdf.CurrentPage.TextOut(72, 760, 0, 'Subset me');
Pdf.EndDoc;
Pdf.SaveToStream(Output);
finally
Pdf.Free;
end;
// A few glyphs from a ~700 KB face must not yield a multi-hundred-KB stream.
if Output.Size > 100 * 1024 then
raise Exception.Create('Font subset did not shrink the output');
finally
Output.Free;
end;
end;
Pour la sortie PDF/A, la vérification est encore plus précise, car un validateur fait le travail pour vous. Définissez le niveau de conformité et passez le résultat dans veraPDF : un /CIDSet manquant, ou un sous-ensemble qui ne correspond pas au descripteur, est signalé comme une clause en échec plutôt que de vous laisser le remarquer à l'œil nu. Les commutateurs de conformité qui pilotent ce travail de finalisation sont des propriétés du document. PDFACompliance prend une chaîne telle que '2B' pour PDF/A-2 Niveau B, et PDFUACompliance est un booléen qui active les exigences de PDF balisé et d'ordre de tabulation.
Pdf := THotPDF.Create(nil);
try
Pdf.PDFACompliance := '2B'; // PDF/A-2 Level B, drives /CIDSet emission
Pdf.PDFUACompliance := True; // stamps /Tabs /S on annotated pages
Pdf.RegisterUnicodeTTF('C:\Fonts\NotoSansSC-Regular.ttf');
Pdf.BeginDoc;
Pdf.CurrentPage.SetFont('Noto Sans SC', [], 12);
Pdf.CurrentPage.TextOut(72, 760, 0, '合规报告');
Pdf.EndDoc;
Pdf.SaveToFile('Report_PDFA.pdf');
finally
Pdf.Free;
end;
La leçon d'ingénierie
Deux règles en découlent. La première est que toute étape de finalisation qui modifie des objets doit s'exécuter avant la sérialisation de ces objets, et la phase de fermeture d'un moteur de document doit être conçue comme un pipeline ordonné où la sérialisation est la dernière action, et non une action parmi d'autres. La seconde est celle qui a coûté le plus de temps ici : pour une étape d'émission, l'absence d'erreur n'est pas une preuve de succès. Une routine qui construit le bon sous-ensemble et l'applique à un graphe erroné et déjà écrit ne signale rien d'anormal, car de son propre point de vue, tout s'est bien déroulé. La vérification doit porter sur l'artefact, pas sur le code de retour. Vérifiez la taille de sortie, lisez la longueur en octets de la police incorporée et son préfixe /BaseFont, et laissez veraPDF juger la sortie PDF/A où un /CIDSet manquant transforme un échec silencieux en une anomalie identifiée.
La partie production de la gestion des polices, à savoir comment les polices sont enregistrées et incorporées pour les rapports, est traitée dans notre article sur les polices et les images dans les rapports générés. La partie validation, où ces étapes de finalisation sont vérifiées par rapport aux normes, est traitée dans le guide sur la validation PDF/A et PDF/UA. Ces deux aspects s'associent aux travaux de sous-ensemblement et de conformité décrits ici, fournis avec le composant HotPDF pour Delphi et C++Builder aux côtés des API de chargement, d'édition, de chiffrement et de signature présentées par ailleurs sur ce blog.