Article technique

Embarquer des polices variables CFF2 en PDF avec HotPDF

HotPDF embarque les polices variables OpenType à contours CFF2 en les convertissant, aux valeurs d'axes choisies, en un programme de police CFF1 à clés CID que les lecteurs PDF comprennent déjà. Le texte natif ordinaire enregistré via RegisterUnicodeTTF utilise les tables cmap et hmtx de la police, et le writer embarque le programme converti sous /FontFile3 avec /Subtype /CIDFontType0C, en gardant chaque CID égal au glyph ID d'origine

Pipeline HotPDF pour embarquer une police variable CFF2 en Delphi comme CFF1 à clés CID sous FontFile3, avec le détour silencieux de substitution GDI que le chemin des octets enregistrés évite
HotPDF fige la variation aux valeurs d'axes choisies et écrit un programme CFF1 à clés CID où chaque CID égale le glyph ID d'origine, donc aucune couche ne traduit entre numérotations

La route évidente vaut qu'on la décrive, parce qu'elle échoue sans prévenir personne. Parsez et mettez en cache les tables CFF2, puis laissez le chemin de texte brut résoudre la police via le GDI Windows avec un appariement TrueType uniquement : le GDI ne sait pas quoi faire de contours CFF2 et substitue ArialUnicodeMS en silence. Tout ce qui lit en aval la substituée reçoit les mauvais contours, les mauvaises largeurs et la mauvaise extraction pour les caractères du plan supplémentaire. Le PDF s'ouvre très bien. Il n'est simplement pas composé dans la police demandée, voilà pourquoi le chemin de texte doit travailler depuis les octets enregistrés plutôt qu'une recherche GDI

Pourquoi un PDF ne peut-il pas embarquer une police CFF2 directement ?

Le PDF n'a pas de case pour un programme CFF2. L'ISO 32000-2 §9.9 liste les types de programmes de polices embarquées qu'un lecteur doit gérer : Type 1 sous /FontFile, TrueType sous /FontFile2, et sous /FontFile3 un programme CFF nu (Type1C ou CIDFontType0C) ou un wrapper OpenType autour de contours TrueType ou CFF. Chaque saveur CFF de cette liste désigne la version 1 du CFF, le format que les lecteurs parsent depuis deux décennies

Les trois cases de polices embarquées du PDF, toutes limitées à la version 1 du CFF, et la case CFF2 manquante qui oblige HotPDF à convertir avant d'embarquer en Delphi
chaque saveur CFF que les lecteurs PDF doivent gérer est de version 1, donc une police variable CFF2 n'a pas de case tant qu'elle n'est pas convertie

CFF2 est un format différent qui partage un nom par hasard. Il abandonne les INDEX Name et String, abandonne le charset, sort les avances de glyphes des CharStrings pour les laisser à hmtx, et ajoute des opérateurs blend pour qu'un seul contour décrive tout le design space. Un parseur CFF1 qui rencontre une table CFF2 n'a aucune base pour deviner, et rien dans la spécification PDF ne l'oblige à essayer. Envelopper la police CFF2 dans un flux OpenType en espérant que ça passe, c'est exactement le genre de fichier qui se rend sur une machine et échoue sur la suivante, donc HotPDF ne le fait pas

La conversion doit aussi être statique. Comme l'explique l'article sur l'instanciation statique des polices variables, une police PDF est un ensemble fixe de contours avec des métriques fixes, et aucun lecteur n'est tenu d'appliquer des coordonnées d'axes. HotPDF résout donc d'abord la variation, et n'écrit le CFF1 qu'ensuite

Écrire du texte natif dans une police CFF2

Pour le code applicatif, une police CFF2 se comporte comme toute autre police Unicode enregistrée : choisissez les valeurs d'axes, enregistrez le fichier, sélectionnez la famille par son nom, et dessinez. RegisterUnicodeTTF garde une copie séparée de la table de noms d'origine, donc SetFont apparie le nom de famille, le nom complet, le nom PostScript ou le nom de famille typographique et sélectionne les octets de police statique figés, sans installation Windows ni recherche GDI

var
  Pdf: THotPDF;
begin
  Pdf := THotPDF.Create(nil);
  try
    Pdf.FileName := 'brochure.pdf';
    Pdf.BeginDoc;
    // Les valeurs d'axes s'appliquent aux polices enregistrées après cet appel
    Pdf.SetVariableFontAxis('wght', 650);
    Pdf.RegisterUnicodeTTF('C:\Fonts\AcmeSerifVF.otf');
    // Nom de famille, complet, PostScript ou famille typographique depuis la table de noms
    Pdf.CurrentPage.SetFont('Acme Serif VF', [], 12, DEFAULT_CHARSET);
    Pdf.CurrentPage.UnicodeTextOut(72, 100, 0, 'Variable weight 650, CFF2 outlines');
    Pdf.EndDoc;
  finally
    Pdf.Free;
  end;
end;

Les glyph IDs viennent du cmap enregistré et les avances du hmtx enregistré. Un glyphe dont l'avance vaut réellement zéro reste à zéro au lieu de retomber sur une mesure GDI, et une paire de substitution UTF-16 produit exactement une avance, pas deux. Les caractères BMP, les caractères du plan supplémentaire et les porteurs de shaping synthétiques gardent des correspondances Unicode par police, donc passer à une autre police embarquée ou commencer une nouvelle page ne permet pas à cette autre police de mettre à jour l'état de subset de celle-ci ni d'emprunter ses glyph IDs

La mesure suit les mêmes octets. THPDFPage.GetWideTextWidth et THPDFPage.GetWideCharAdvances lisent les métriques de la police sélectionnée adossée aux octets, donc la coupure de lignes et l'alignement à droite collent avec ce qui est dessiné. Un détail d'ordre piège les gens : des changements SetVariableFontAxis faits après l'enregistrement affectent la police suivante que vous enregistrez, pas l'instance déjà figée. Pour obtenir une seconde graisse, posez la nouvelle valeur et enregistrez à nouveau

Comment HPDFConvertCFF2ToCFF construit-il le programme à clés CID ?

HPDFConvertCFF2ToCFF produit un programme CFF1 brut à clés CID avec un seul Font DICT, dans lequel le CID égale le glyph ID d'origine pour chaque emplacement, y compris les emplacements vides et inutilisés. Cette identité est tout le design. Le nom de police, les largeurs /W du PDF, les avances CFF et la CMap ToUnicode se réfèrent tous à la même numérotation, donc aucune couche ne doit traduire entre glyph IDs et CIDs, et rien ne peut se désynchroniser

Côté PDF, le descendant est une police CIDFontType0 avec encodage Identity-H ou Identity-V et le flux du programme se trouve sous /FontFile3 avec /Subtype /CIDFontType0C. Comme le CID égale déjà le GID, le descendant ne porte pas de /CIDToGIDMap, et le flux est exclu du hook ultérieur de remplacement de flux utilisé pour les polices TrueType enregistrées. Contrairement au wrapper /Subtype /OpenType, le flux converti ne monte pas l'exigence de version PDF 1.6

Les largeurs ont demandé une petite astuce. Un CharString Type 2 CFF1 stocke l'avance comme premier opérande optionnel relatif à nominalWidthX, et les opérandes entiers sont limités à la plage signée 16 bits. Une avance OpenType est une valeur non signée 16 bits, jusqu'à 65535. HotPDF met nominalWidthX à 32768 et defaultWidthX à 0, puis écrit chaque avance explicitement comme advance - 32768. Une avance zéro devient -32768 et une avance de 65535 devient 32767, donc toute la plage uint16 est représentable sans écrêter un seul glyphe

Comment HotPDF mappe des avances 16 bits non signées en opérandes CFF1 signés avec nominalWidthX 32768, en gardant chaque avance de police dans la sortie PDF Delphi sans écrêtage
nominalWidthX 32768 décale la fenêtre des opérandes pour qu'une avance zéro se stocke en -32768 et 65535 en 32767, sans écrêter aucun glyphe

La géométrie reçoit le même soin. La FontMatrix est [1/UnitsPerEm 0 0 1/UnitsPerEm 0 0], donc une police de 2048 unités garde ses coordonnées natives. Les déplacements et lignes relatifs trop grands pour un seul opérande sont découpés en pas représentables, et les courbes cubiques sont subdivisées de façon à préserver leur forme. Les enregistrements de copyright et de licence d'origine, la table de noms originale et les drapeaux d'embarquement voyagent dans la chaîne Notice du CFF

Appeler le convertisseur en dehors d'un document

Le convertisseur est une fonction publique de l'unité HPDFOpenTypeAdvanced, écrite en Pascal, sans dépendance HarfBuzz ni étape d'installation de police système. Cela le rend utilisable tout seul, par exemple pour pré-converter une police au build ou pour inspecter ce que le writer PDF embarquera

uses
  System.SysUtils, System.IOUtils, HPDFOpenTypeAdvanced;

procedure ConvertWeight650;
var
  Source, Cff: TBytes;
  Axes: THPDFVariableFontAxisValueArray;
begin
  Source := TFile.ReadAllBytes('C:\Fonts\AcmeSerifVF.otf');
  SetLength(Axes, 1);
  Axes[0].Tag := 'wght';
  Axes[0].Value := 650;
  Cff := HPDFConvertCFF2ToCFF(Source, Axes);
  if Length(Cff) = 0 then
    raise Exception.Create('Not convertible: not CFF2, embedding denied or bad axes');
  TFile.WriteAllBytes('AcmeSerif-650.cff', Cff);
end;

Une entrée variable passe par une instanciation statique de géométrie seule aux coordonnées données, couvrant contours et avances. Une police CFF2 statique n'accepte qu'une sélection vide ; lui passer des valeurs d'axes est traité comme une erreur plutôt qu'ignoré. Le résultat est un programme CFF1 nu, pas un conteneur sfnt, donc ne le donnez pas à du code qui attend un fichier OpenType

L'échec est un TBytes vide, pas une exception, pour les cas attendus : une entrée qui n'est pas du CFF2, une sélection d'axes que l'instancer n'accepte pas, des valeurs non finies comme NaN, et les polices dont le fsType OS/2 pose l'embarquement à licence restreinte (0x0002) ou bitmap seul (0x0200). Testez toujours la longueur. Dans EndDoc, le writer le fait pour vous et lève « StoreFont: CFF2 outline conversion failed », ce qui est le bon choix : un programme de police vide dans un PDF livré est bien pire qu'une erreur visible dans votre log

Budgets et annulation pour des charges serveur

Le troisième paramètre optionnel, THPDFVariableFontControl, borne la mémoire et permet d'annuler une longue conversion. Sans lui, le convertisseur utilise ses défauts : une limite de 128 MiB sur l'entrée et la sortie de police, un budget de travail cumulé de 1 GiB, et la limite CFF1 de 65535 octets par CharString

type
  TFontJob = class
  private
    FCancelled: Boolean;
    procedure CheckCancel;
  public
    function Convert(const Source: TBytes): TBytes;
    property Cancelled: Boolean read FCancelled write FCancelled;
  end;

procedure TFontJob.CheckCancel;
begin
  if FCancelled then
    Abort;  // se propage hors de HPDFConvertCFF2ToCFF
end;

function TFontJob.Convert(const Source: TBytes): TBytes;
var
  Control: THPDFVariableFontControl;
begin
  Control := THPDFVariableFontControl.Create(
    32 * 1024 * 1024,           // octets de sortie maximum
    Int64(256) * 1024 * 1024,   // budget de travail cumulé
    CheckCancel);
  try
    // sélection nil : instance par défaut d'une police variable, ou police CFF2 statique
    Result := HPDFConvertCFF2ToCFF(Source, nil, Control);
  finally
    Control.Free;
  end;
end;

Dépassements de budget et annulation sont rapportés en exceptions, délibérément distincts du chemin de résultat vide. Un tableau vide signifie que la police ne peut pas être convertie ; une exception signifie que cette tentative a été arrêtée. Gardez les deux séparés dans votre gestion d'erreurs, car retenter une police non convertible avec un plus gros budget n'aidera pas

Combien pèse une police CFF2 embarquée ?

La principale chose à planifier est la taille de fichier : HotPDF embarque le jeu de glyphes converti complet pour les polices CFF2, que EnableFontSubsetting soit actif ou non. Une police de plusieurs milliers de glyphes les ajoute tous à chaque document qui l'utilise, même si la page ne montre qu'un titre. Pour une brochure, c'est acceptable. Pour un serveur de rapports qui écrit des milliers de petits PDF, c'est un coût réel à mesurer avant de s'engager

Les fonctionnalités de taille connexes suivent la même règle. Les polices CFF2 converties sont embarquées entières, donc ni les sous-ensembles de polices compacts activés par CompressDocument ni le cache persistant de sous-ensembles de polices ne s'y appliquent. Les polices TrueType du même document restent subsetées et mises en cache comme d'habitude. Le seul bénéfice de garder chaque glyphe, c'est que les polices dont la licence interdit le subsetting sont embarquées sous une forme qui la respecte

Les limites de ressources sont des limites de conversion finies, et le CFF1 lui-même plafonne ce qu'un seul CharString peut contenir. Un glyphe dont le CharString converti dépasse 65535 octets, ou une police qui crève le budget de travail, échoue visiblement au lieu d'être écrite avec un contour vide. C'est l'arbitrage voulu : un échec bruyant sur une police exotique vaut mieux qu'un document avec des glyphes silencieusement absents

Le texte CFF2 natif et le convertisseur CFF1 portable se livrent avec le composant PDF HotPDF pour Delphi pour Delphi et C++Builder, aux côtés des fonctionnalités d'instanciation de polices variables, de glyphes couleur et de subsetting de polices couvertes ailleurs sur ce blog