Article technique

Emoji couleur en PDF : COLR v1, SVG et bitmaps en Delphi

Un emoji visage souriant écrit dans un PDF par un appel de texte ordinaire, avec Segoe UI Emoji comme police, sort en silhouette noire plate : bonne forme, pas de jaune, pas d'ombrage. Changez pour une police emoji CBDT et le même appel peut ne rien dessiner du tout, parce que cette police garde ses images en fichiers PNG et livre peu ou pas de données de contour. Copiez la forme noire hors de la visionneuse et le presse-papiers tient toujours U+1F600 : le caractère est juste, seules les tables de couleur n'ont jamais été lues

Pourquoi un emoji couleur imprime-t-il en silhouette noire dans un PDF ?

Parce qu'un programme de police PDF n'a aucune notion de glyphe coloré. ISO 32000-1 traite chaque glyphe comme une forme peinte avec la couleur de remplissage courante, et les tables de couleur qu'OpenType a ajoutées plus tard (COLR/CPAL, SVG , CBDT/CBLC et sbix) ne font pas partie du modèle d'imagerie PDF, si bien aucune visionneuse n'a de raison de les lire dans une police embarquée. Quelle que soit la couleur de l'emoji, elle doit être transformée en contenu de page pendant que le PDF s'écrit, à un moment où le producteur tient encore les octets de la police et sait exactement quel glyphe il veut. Cette traduction diffère pour chaque format, et les vraies polices emoji utilisent les quatre : couches vectorielles empilées, graphes de paint en dégradés, documents SVG entiers et strikes PNG

HotPDF fait cette traduction dans THotPDF.DrawRegisteredColorGlyph. Il rapporte vers quel format un glyphe s'est résolu comme THPDFOpenTypeColorFormat, avec les valeurs otcfNone, otcfCOLRv0, otcfCOLRv1, otcfCBDT, otcfSVG et otcfSBIX, et il sonde la police dans un ordre fixe : COLR, puis SVG, puis CBDT, puis sbix. Une police qui porte à la fois des vecteurs et des bitmaps prend donc le chemin vectoriel. Pour tout ce qui sera zoomé ou imprimé, c'est le bon ordre

Schéma de sonde de glyphes couleur HotPDF : un programme de police PDF peint les contours de glyphes avec la couleur courante, si bien que les tables de couleur OpenType COLR, SVG, CBDT et sbix doivent être traduites en contenu de page au moment de la génération, et HotPDF sonde une police enregistrée dans la priorité fixe COLR, puis SVG, puis CBDT, puis sbix, en rapportant THPDFOpenTypeColorFormat de otcfCOLRv0 à otcfSBIX
Les données vectorielles gagnent sur les bitmaps dès qu'une police porte les deux, ce que vous voulez dans un document susceptible d'être zoomé ou imprimé, et un glyphe sans chemin couleur est laissé à votre repli

Résoudre et dessiner un seul point de code

THotPDF.GetRegisteredColorGlyphInfo vous dit quel chemin empruntera un point de code ; DrawRegisteredColorGlyph l'emprunte. Tous deux cherchent le point de code dans la table de caractères de la police la plus récemment passée à RegisterUnicodeTTF, si bien que la police emoji doit être la police Unicode enregistrée au moment de l'appel. La fonction de dessin retourne False quand le glyphe n'a pas de données couleur ou que rien n'a pu le rendre, et la page reste intacte dans ce cas

const
  FormatNames: array[THPDFOpenTypeColorFormat] of string =
    ('none', 'COLR v0', 'COLR v1', 'CBDT', 'SVG', 'sbix');
var
  Pdf: THotPDF;
  Info: THPDFOpenTypeColorGlyphInfo;
begin
  Pdf := THotPDF.Create(nil);
  try
    Pdf.AutoLaunch := False;
    Pdf.FileName := 'emoji.pdf';
    // L'entrée de palette 0xFFFF (« texte de premier plan ») se résout à cette couleur
    Pdf.ColorGlyphForegroundColor := clNavy;
    Pdf.BeginDoc;
    Pdf.RegisterUnicodeTTF('C:\Windows\Fonts\seguiemj.ttf');

    // U+1F600, palette CPAL 0, strike bitmap la plus proche de 300 ppem
    if Pdf.GetRegisteredColorGlyphInfo($1F600, 0, 300, Info) then
      Writeln(Format('GID %d via %s',
        [Info.GlyphID, FormatNames[Info.Format]]));

    if not Pdf.DrawRegisteredColorGlyph(Pdf.CurrentPage, $1F600,
      72, 144, 'Segoe UI Emoji', 36, 0, 300) then
    begin
      // Pas de chemin couleur : retomber sur le contour monochrome
      Pdf.CurrentPage.SetFont('Segoe UI Emoji', [], 36, DEFAULT_CHARSET);
      Pdf.CurrentPage.TextOut(72, 144, 0, WideString(#$D83D#$DE00));
    end;
    Pdf.EndDoc;
  finally
    Pdf.Free;
  end;
end;

PaletteIndex choisit une palette CPAL, si bien qu'une police qui livre une palette pour fond sombre peut être commutée sans toucher au glyphe. L'entrée de palette 0xFFFF est spéciale dans la spécification OpenType : elle signifie « quelle que soit la couleur du texte », et HotPDF la remplit depuis ColorGlyphForegroundColor et ColorGlyphForegroundAlpha (noir et 255 par défaut). Les polices emoji l'utilisent rarement. Les polices d'icônes si, et si la vôtre teinte les glyphes par là, mettez la propriété à votre couleur de texte avant de dessiner

TargetPixelsPerEm ne compte que pour les polices bitmap, et son défaut est le paramètre qui mérite le plus d'être passé outre. Laissé à zéro il devient Round(FontSize * 96 / 72), une résolution d'écran : 48 pixels pour un emoji de 36 points. Pour une police qui déclare des strikes sbix à 20, 32, 40, 48, 64, 96 et 160 ppem, ce défaut choisit le strike de 48 pixels, et une imprimante à 600 dpi doit alors l'agrandir environ six fois. Demandez 300, ce dont 36 points a besoin à 600 dpi, et le même appel prend le strike de 160, le plus grand que la police ait

La signature fixe aussi le périmètre. Un seul point de code entre et est mappé via cmap. Les séquences de famille ZWJ, les modificateurs de teint de peau et les drapeaux d'indicateurs régionaux sont des ligatures GSUB, si bien qu'ils doivent d'abord être façonnés en glyphe, le genre de travail couvert dans l'article sur les alternatives GSUB d'OpenType

Couches COLR et graphes de paint en vecteurs PDF

COLR v0 est le format simple. Chaque glyphe de base liste une pile de glyphes de couche, chacun avec une couleur CPAL, et HotPDF remplit le contour de chaque couche dans l'ordre de la table. Une couche dont la couleur CPAL a un alpha sous 255 reçoit son propre /ca, si bien que les couches translucides se mélangent avec les couches en dessous exactement comme le graphiste de la police l'a voulu. Dans un document ordinaire, toute la pile est ensuite capturée dans un seul groupe de transparence isolé (ISO 32000-1 §11.4). Le groupe concerne ce qui se passe hors du glyphe : une opacité, un mode de fusion ou un masque doux que vous posez autour de l'emoji s'applique une fois au glyphe fini, au lieu d'être répété sur chaque couche et de se doubler là où les couches se chevauchent

COLR v1 est une autre bête : un graphe acyclique dirigé d'enregistrements de paint avec dégradés, transformations, clips de glyphes imbriqués et modes composites, où les nœuds peuvent être partagés via PaintColrLayers et PaintColrGlyph. HotPDF l'analyse d'abord en un graphe borné, plafonné à 4096 nœuds de paint, 96 niveaux de profondeur et 1024 arrêts de couleur, et suit chaque nœud pendant sa visite pour qu'une référence en arrière vers le chemin courant (un cycle qu'une police hostile peut bâtir à partir de réutilisation de couches) soit rejetée au lieu d'être parcourue en récursion

Les décalages sont là où les analyseurs COLR v1 écrits à la main cassent en général. Les décalages BaseGlyphPaintRecord sont relatifs au début de BaseGlyphList, les décalages de paint de LayerList à LayerList, et chaque Offset24 à l'intérieur d'une table de paint à cette table de paint elle-même. Résolvez les trois contre une seule base et des glyphes parfaitement légaux échouent à leurs contrôles de bornes, ce qui ressemble exactement à une police corrompue. Une fois le graphe bâti, le mappage vers le PDF est pour l'essentiel direct :

  • PaintGlyph tourne le contour du glyphe en chemin de clipping, et son paint enfant remplit la zone clippée
  • Les dégradés linéaires deviennent des shadings axiaux multi-arrêts et les dégradés radiaux des shadings radiaux (§8.7.4.5)
  • Les dégradés sweep n'ont pas de type de shading PDF, si bien qu'ils sont dessinés comme 96 secteurs de couleur plate autour du centre, chacun échantillonné sur la ligne de couleur
  • Les transformations deviennent des opérateurs cm, avec des translations mises à l'échelle par FontSize / UnitsPerEm
  • Les modes PaintComposite 13 à 27 se mappent aux modes de fusion PDF de même nom, /Multiply, /Screen, /Luminosity et les autres (§11.3.5), via une entrée /BM d'ExtGState

Les dégradés à arrêts translucides cachent un piège de plus. COLR v1 interpole les couleurs prémultipliées par alpha, tandis que le PDF attend une couleur droite et applique l'alpha lui-même, si bien qu'un convertisseur qui remet les valeurs prémultipliées au PDF fait assombrir deux fois chaque bande semi-transparente. Les couleurs doivent être dé-prémultipliées après l'interpolation et avant d'être écrites

Schéma de conversion COLR v1 HotPDF : le graphe de paint est analysé en un graphe borné plafonné à 4096 nœuds, 96 niveaux de profondeur et 1024 arrêts de couleur avec rejet des cycles, puis PaintGlyph devient un chemin de clipping de contour, les dégradés linéaires et radiaux deviennent des shadings axiaux et radiaux, les dégradés sweep deviennent 96 secteurs, et les modes PaintComposite 13 à 27 deviennent des modes de fusion PDF
Le graphe entier est confronté au sous-ensemble supporté avant que le premier opérateur soit écrit, si bien qu'un glyphe non supporté laisse la page intacte et passe au repli raster au lieu de laisser un demi-dessin

Que deviennent les modes composites COLR v1 sous PDF/A-1 ou PDF/X-1a ?

Les modes Porter-Duff de PaintComposite (5 à 12 : src_in, dest_out, src_atop, xor, plus et leurs frères) n'ont aucun mode de fusion PDF de même nom, et la lecture évidente de la spécification PDF est qu'ils ne peuvent pas être exprimés. Cette lecture est trop forte. Les modes in/out sont un masque doux alpha sur un opérande ; atop, xor et plus peuvent être assemblés à partir de formes isolées, d'alpha de groupe, de masques de luminosité et d'arithmétique de fusion. HotPDF les construit ainsi, si bien que ces glyphes restent vectoriels

Le prix, c'est que la plupart de ces constructions demandent la transparence PDF, et PDF/A-1, PDF/X-1a et PDF/X-3 l'interdisent. Quand PDFACompliance est une valeur PDF/A-1 ou que PDFXCompliance vaut 'X-1a' ou 'X-3', HotPDF contrôle le glyphe avant de dessiner et route vers l'événement raster les cas qui ne peuvent pas être dessinés sans transparence : composites atop, xor et plus, composites in/out dont l'opérande masqué dépasse un simple paint opaque, paints unis à alpha partiel, dégradés à arrêts translucides, et couches COLR v0 à alpha sous 255. Les modes in et out restent vectoriels quand le côté masqué est une couleur unie ou un dégradé simple, éventuellement enveloppé d'un clip de glyphe ou d'une transformation, parce que ce cas ne demande qu'un masque gris opaque. La même chose vaut pour COLR v0 quand StrictVersionLock épingle le fichier sous PDF 1.4

Pdf.PDFACompliance := 'B';   // PDF/A-1b: no transparency allowed
Pdf.BeginDoc;
Pdf.RegisterUnicodeTTF(EmojiFontPath);

if not Pdf.DrawRegisteredColorGlyph(Pdf.CurrentPage, $1F308,
  72, 200, EmojiFontName, 36, 0, 300) then
  // Sans handler OnColorGlyphRasterize, un glyphe translucide
  // ou composé se termine ici sous PDF/A-1
  LogMissingColorGlyph($1F308);   // votre propre routine de journalisation

Si l'archive doit montrer les emoji en couleur, visez PDF/A-2b au lieu de PDF/A-1b. La partie 2 permet la transparence, toute construction COLR reste vectorielle, et le handler de repli redevient un filet de sécurité plutôt que le moteur principal de chaque emoji translucide. Gardez PDF/A-1 pour les flux qui l'exigent nommément

Un réglage côté lecteur a aussi sa place ici. HotPDF compose les groupes COLR v1 dans un espace de fusion en lumière linéaire ; certains moteurs y perdent les canaux très sombres et d'autres ignorent entièrement les espaces de fusion de groupe, si bien que COLRv1DeviceSpaceGroupBlend (défaut False) laisse les groupes se composer dans l'espace périphérique ordinaire. Le compromis est expliqué dans l'article sur les dégradés repeat et reflect de COLR v1 ; activez-le si les teintes translucides sombres disparaissent dans la visionneuse que vos utilisateurs font réellement tourner

Glyphes SVG et strikes bitmap

Les glyphes SVG passent par le même constructeur sûr qu'HotPDF utilise pour les fichiers SVG importés, et le résultat est enregistré comme Form XObject (§8.10), comme décrit dans l'article SVG vers Form XObject. Le document de la table SVG peut être compressé en gzip. L'entrée compressée est plafonnée à 8 Mo, la décompression tourne par blocs de 8 Ko, et elle s'arrête dès que la taille décompressée franchirait 32 Mo. Les scripts, foreignObject, les URL javascript: et les feuilles de style @import font refuser le document net par le constructeur. Le formulaire est mis à l'échelle pour que son côté le plus long égale la taille de police. Une chose à essayer avant la production : certaines polices entassent beaucoup de glyphes dans un seul document SVG partagé, et le constructeur reçoit ce document entier pour chaque glyphe

Les polices bitmap se ramènent au choix de strike et au placement. Pour CBDT, HotPDF choisit la taille CBLC dont le ppem vertical est le plus proche de TargetPixelsPerEm, accepte les formats d'image 17, 18 et 19, et lit les métriques du format 19 depuis la sous-table d'index CBLC parce que ce format n'en stocke aucune lui-même. Pour sbix, le strike le plus proche qui contient réellement le glyphe gagne ; les décalages de strike sont relatifs à la table et les décalages de glyphes à la strike. Un enregistrement dupe réutilise l'image d'un autre glyphe mais garde ses propres décalages d'origine, et un analyseur qui laisse la recherche écraser l'origine extérieure décale l'image sur le côté. Les charges PNG et JPEG sont décodées en interne, mises à l'échelle par FontSize / PixelsPerEmY plutôt qu'étirées à la taille de police, et écrites avec un masque doux (§11.6.5.3) dès qu'un pixel est moins que totalement opaque. Les charges TIFF de sbix vont à l'événement

Écrire le handler de repli raster

Quand aucun chemin natif ne marche, HotPDF lève OnColorGlyphRasterize et pose le bitmap RGBA que votre handler retourne. Sans handler, ou avec Handled laissé à False, DrawRegisteredColorGlyph retourne False et rien n'est dessiné. Le handler reçoit le format, les octets de la police, l'actif extrait (le document SVG, éventuellement encore gzippé, ou les octets du bitmap ; vide pour COLR), l'ID du glyphe, la palette et la taille cible en pixels

Schéma du repli raster HotPDF : OnColorGlyphRasterize se déclenche pour un graphe COLR v1 hors du sous-ensemble supporté, un document SVG que le constructeur sûr a refusé ou une charge bitmap que les décodeurs ne lisent pas, en passant le format, les octets de police, l'actif, GlyphID, PaletteIndex et PixelSize, et le tampon RGBA renvoyé n'est accepté que si sa longueur vaut exactement Width fois Height fois 4
Tailles nulles, longueur de tampon fausse ou dimensions qui débordent sont rejetées avant que la page ne soit touchée, et sans handler ou avec Handled false l'appel renvoie False et la page reste inchangée
type
  TEmojiFallback = class
  public
    procedure Rasterize(Sender: TObject;
      Format: THPDFOpenTypeColorFormat; const FontBytes: TBytes;
      const AssetData: TBytes; GlyphID: Word;
      PaletteIndex, PixelSize: Integer;
      out Width, Height: Integer; out RGBA: TBytes;
      out Handled: Boolean);
  end;

procedure TEmojiFallback.Rasterize(Sender: TObject;
  Format: THPDFOpenTypeColorFormat; const FontBytes: TBytes;
  const AssetData: TBytes; GlyphID: Word;
  PaletteIndex, PixelSize: Integer;
  out Width, Height: Integer; out RGBA: TBytes;
  out Handled: Boolean);
begin
  Width := 0;
  Height := 0;
  RGBA := nil;
  // RenderWithOwnEngine est votre rasterizer, pas une API HotPDF.
  // Il doit renvoyer exactement Width * Height * 4 octets de RGBA
  // avec un alpha droit (non prémultiplié)
  Handled := RenderWithOwnEngine(Format, FontBytes, AssetData,
    GlyphID, PaletteIndex, PixelSize, Width, Height, RGBA);
end;

// Câblage
Pdf.OnColorGlyphRasterize := Fallback.Rasterize;

HotPDF valide le résultat avant que la page le voie : tailles nulles, tampon dont la longueur n'est pas exactement Width * Height * 4, ou dimensions assez grandes pour déborder sont rejetés, et l'appel retourne False. Pour une police couleur variable, le handler reçoit l'instance statique aux valeurs d'axes enregistrées ; s'il reçoit jamais la police variable d'origine à la place, appeler GetRegisteredVariableFontAxes sur Sender (casté en THotPDF) retourne les coordonnées auxquelles rendre. PixelSize est la même valeur que TargetPixelsPerEm, si bien le conseil de résolution d'impression ci-dessus vaut ici aussi

Un pipeline qui imprime du texte utilisateur arbitraire a besoin de connaître deux sortes de perte : les glyphes que la police n'a jamais eus, et les glyphes qui ont perdu leur couleur. Le suivi de couverture au dessin de l'article sur le suivi des glyphes manquants rapporte les premiers ; un False de DrawRegisteredColorGlyph rapporte les seconds

Le rendu d'emoji COLR, SVG et bitmap, le shaping OpenType et le constructeur SVG sûr font tous partie du composant HotPDF pour Delphi et C++Builder