Technischer Artikel

Color-Emoji-Fonts in PDF: COLR v1, SVG und Bitmaps in Delphi

Ein Grinning-Face-Emoji, das mit einem gewöhnlichen Text-Aufruf ins PDF geschrieben wird, mit Segoe UI Emoji als Font, kommt als flache schwarze Silhouette heraus: richtige Form, kein Gelb, keine Schattierung. Nimmt man stattdessen einen CBDT-Emoji-Font, zeichnet derselbe Aufruf unter Umständen gar nichts, weil dieser Font seine Bilder als PNG-Dateien mitbringt und wenig oder keine Outline-Daten hat. Kopiert man die schwarze Form aus dem Viewer heraus, hält die Zwischenablage weiterhin U+1F600: Das Zeichen ist richtig, nur die Color-Tabellen wurden nie gelesen

Warum erscheint ein Color-Emoji im PDF als schwarze Silhouette?

Weil ein PDF-Font-Programm den Begriff einer farbigen Glyphe nicht kennt. ISO 32000-1 behandelt jede Glyphe als Form, die mit der aktuellen Füllfarbe gemalt wird, und die Color-Tabellen, die OpenType später hinzugefügt hat (COLR/CPAL, SVG , CBDT/CBLC und sbix), sind nicht Teil des PDF-Imaging-Modells, also hat kein Viewer einen Grund, sie aus einem eingebetteten Font zu lesen. Welche Farbe das Emoji auch hat, sie muss während des Schreibens des PDF in Seiteninhalt übersetzt werden, an einem Punkt, an dem der Producer die Font-Bytes noch in der Hand hat und genau weiß, welche Glyphe er will. Diese Übersetzung ist je Format verschieden, und echte Emoji-Fonts nutzen alle vier: gestapelte Vektor-Layer, Gradient-Paint-Graphs, ganze SVG-Dokumente und PNG-Strikes

HotPDF erledigt diese Übersetzung in THotPDF.DrawRegisteredColorGlyph. Es meldet, zu welchem Format eine Glyphe aufgelöst hat, als THPDFOpenTypeColorFormat, mit den Werten otcfNone, otcfCOLRv0, otcfCOLRv1, otcfCBDT, otcfSVG und otcfSBIX, und sondiert den Font in fester Reihenfolge: COLR, dann SVG, dann CBDT, dann sbix. Ein Font, der sowohl Vektoren als auch Bitmaps trägt, bekommt also den Vektorpfad. Für alles, was gezoomt oder gedruckt wird, ist das die richtige Reihenfolge

HotPDF-Diagramm der Color-Glyph-Sondierung: Ein PDF-Font-Programm malt Glyph-Outlines mit der aktuellen Farbe, also müssen die OpenType-Color-Tabellen COLR, SVG, CBDT und sbix zur Generierungszeit in Seiteninhalt übersetzt werden, und HotPDF sondiert einen registrierten Font in fester Priorität COLR, dann SVG, dann CBDT, dann sbix, gemeldet als THPDFOpenTypeColorFormat von otcfCOLRv0 bis otcfSBIX
Vektordaten schlagen Bitmaps, wann immer ein Font beides trägt, genau das wollen Sie in einem Dokument, das gezoomt oder gedruckt werden kann, und eine Glyphe ohne Color-Pfad bleibt Ihrem Fallback überlassen

Einen einzelnen Codepoint auflösen und zeichnen

THotPDF.GetRegisteredColorGlyphInfo verrät, welchen Pfad ein Codepoint nehmen wird, und DrawRegisteredColorGlyph nimmt ihn. Beide schlagen den Codepoint in der Character Map des Fonts nach, der zuletzt an RegisterUnicodeTTF übergeben wurde, der Emoji-Font muss beim Aufruf also der registrierte Unicode-Font sein. Die Zeichenfunktion gibt False zurück, wenn die Glyphe keine Color-Daten hat oder kein Pfad sie rendern konnte, und die Seite bleibt in dem Fall unangetastet

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';
    // Paletten-Eintrag 0xFFFF („Text-Vordergrund“) löst zu dieser Farbe auf
    Pdf.ColorGlyphForegroundColor := clNavy;
    Pdf.BeginDoc;
    Pdf.RegisterUnicodeTTF('C:\Windows\Fonts\seguiemj.ttf');

    // U+1F600, CPAL-Palette 0, Bitmap-Strike am nächsten an 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
      // Kein Color-Pfad: auf die monochrome Outline ausweichen
      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 wählt eine CPAL-Palette, ein Font mit Dark-Background-Palette lässt sich also umschalten, ohne die Glyphe anzufassen. Der Paletten-Eintrag 0xFFFF ist in der OpenType-Spezifikation etwas Besonderes: Er bedeutet „egal, welche Farbe der Text hat“, und HotPDF füllt ihn aus ColorGlyphForegroundColor und ColorGlyphForegroundAlpha (standardmäßig Schwarz und 255). Emoji-Fonts benutzen diesen Eintrag selten, Icon-Fonts schon, und wenn Ihr Font Glyphen darüber einfärbt, stellen Sie die Eigenschaft vor dem Zeichnen auf Ihre Textfarbe

TargetPixelsPerEm zählt nur bei Bitmap-Fonts, und sein Standardwert ist der Parameter, den man am ehesten überschreiben will. Auf null gelassen wird er zu Round(FontSize * 96 / 72), einer Bildschirmauflösung: 48 Pixel für ein 36-Punkt-Emoji. Bei einem Font, der sbix-Strikes bei 20, 32, 40, 48, 64, 96 und 160 ppem deklariert, wählt dieser Standard den 48-Pixel-Strike, und ein 600-dpi-Drucker muss ihn dann rund sechsfach vergrößern. Fordert man 300 an, was 36 Punkte bei 600 dpi brauchen, nimmt derselbe Aufruf den 160er-Strike, den größten, den der Font hat

Die Signatur legt auch den Geltungsbereich fest. Ein Codepoint geht hinein und wird durch cmap gemappt. ZWJ-Familiensequenzen, Skin-Tone-Modifikatoren und Regional-Indicator-Flaggen sind GSUB-Ligaturen, sie müssen also zuerst zu einer Glyphe geshaped werden, die Art von Arbeit, die der OpenType-GSUB-Alternates-Artikel behandelt

COLR-Layer und Paint-Graphs als PDF-Vektoren

COLR v0 ist das einfache Format. Jede Basis-Glyphe listet einen Stapel von Layer-Glyphen, jede mit einer CPAL-Farbe, und HotPDF füllt die Outline jedes Layers in Tabellenreihenfolge. Ein Layer, dessen CPAL-Farbe ein Alpha unter 255 trägt, bekommt ein eigenes /ca, sodass translucente Layer sich mit den darunterliegenden Layern genau so mischen, wie der Font-Designer es vorgesehen hat. In einem gewöhnlichen Dokument wird der ganze Stapel danach in eine isolierte Transparenzgruppe gefasst (ISO 32000-1 §11.4). Es geht dabei um das, was außerhalb der Glyphe passiert: Eine Opacity, ein Blend Mode oder eine Soft Mask, die man um das Emoji legt, wird einmal auf die fertige Glyphe angewandt, statt auf jedem Layer wiederholt zu werden und sich dort zu verdoppeln, wo Layer überlappen

COLR v1 ist ein anderes Tier: ein gerichteter azyklischer Graph von Paint-Records mit Gradients, Transforms, verschachtelten Glyph-Clips und Composite-Modi, in dem Knoten über PaintColrLayers und PaintColrGlyph geteilt werden können. HotPDF parst ihn zuerst in einen begrenzten Graph, gedeckelt bei 4096 Paint-Knoten, 96 Tiefenebenen und 1024 Color-Stops, und markiert jeden Knoten, solange er besucht wird, sodass eine Referenz zurück in den aktuellen Pfad, ein Zyklus, den ein bösartiger Font aus Layer-Wiederverwendung bauen kann, abgewiesen wird, statt hinein zu rekursieren

An den Offsets scheitern handgeschriebene COLR-v1-Parser üblicherweise. BaseGlyphPaintRecord-Offsets sind relativ zum Start von BaseGlyphList, LayerList-Paint-Offsets sind relativ zu LayerList, und jedes Offset24 in einer Paint-Tabelle ist relativ zu dieser Paint-Tabelle selbst. Löst man alle drei gegen dieselbe Basis auf, scheitern völlig legale Glyphen an der Bounds-Prüfung, was exakt so aussieht wie ein korrumpierter Font. Ist der Graph gebaut, ist das Mapping nach PDF größtenteils direkt:

  • PaintGlyph macht die Glyph-Outline zum Clipping-Pfad, und sein Kind-Paint füllt die geclippte Fläche
  • Lineare Gradients werden zu Multi-Stop-axialen Shadings, radiale Gradients zu radialen Shadings (§8.7.4.5)
  • Sweep-Gradients haben keinen PDF-Shading-Typ, also werden sie als 96 flach gefärbte Keile um das Zentrum gezeichnet, jeder aus der Color Line gesampelt
  • Transforms werden zu cm-Operatoren, mit Translationen skaliert um FontSize / UnitsPerEm
  • PaintComposite-Modi 13 bis 27 mappen auf die PDF-Blend Modes desselben Namens, /Multiply, /Screen, /Luminosity und die restlichen (§11.3.5), gesetzt über einen ExtGState-/BM-Eintrag

Gradients mit translucenten Stops verbergen eine weitere Falle. COLR v1 interpoliert Farben premultiplied mit Alpha, während PDF straight color erwartet und Alpha selbst anwendet; ein Konverter, der die premultiplied Werte an PDF durchreicht, bekommt jeden semi-transparenten Streifen zweimal abgedunkelt. Die Farben müssen nach der Interpolation und vor dem Schreiben die Vormultiplikation wieder rückgängig gemacht werden

HotPDF-Diagramm der COLR-v1-Konvertierung: Der Paint-Graph wird in einen begrenzten Graph geparst, gedeckelt bei 4096 Knoten, 96 Tiefenebenen und 1024 Color-Stops mit Zyklus-Abweisung, dann wird PaintGlyph zum Outline-Clipping-Pfad, lineare und radiale Gradients werden zu axialen und radialen Shadings, Sweep-Gradients zu 96 Keilen, und PaintComposite-Modi 13 bis 27 werden zu PDF-Blend Modes
Der ganze Graph wird gegen die unterstützte Teilmenge geprüft, bevor der erste Operator geschrieben wird, eine nicht unterstützte Glyphe lässt die Seite also unangetastet und geht zum Raster-Fallback weiter, statt eine halbe Zeichnung zurückzulassen

Was passiert mit COLR-v1-Composite-Modi unter PDF/A-1 oder PDF/X-1a?

Die Porter-Duff-Modi in PaintComposite (5 bis 12: src_in, dest_out, src_atop, xor, plus und ihre Geschwister) haben keinen PDF-Blend Mode desselben Namens, und die naheliegende Lesart der PDF-Spezifikation ist, dass sie sich nicht ausdrücken lassen. Diese Lesart ist zu streng. In/out-Modi sind eine Alpha-Soft Mask über einem Operanden; atop, xor und plus lassen sich aus isolierten Formen, Gruppen-Alpha, Luminosity-Masken und Blend-Arithmetik zusammenbauen. HotPDF baut sie so, diese Glyphen bleiben also Vektor

Der Preis dafür ist, dass die meisten dieser Konstruktionen PDF-Transparenz brauchen, und PDF/A-1, PDF/X-1a und PDF/X-3 verbieten sie. Ist PDFACompliance ein PDF/A-1-Wert oder ist PDFXCompliance 'X-1a' oder 'X-3', prüft HotPDF die Glyphe vor dem Zeichnen und leitet die Fälle, die sich ohne Transparenz nicht zeichnen lassen, an das Raster-Event weiter: atop-, xor- und plus-Composites, in/out-Composites, deren maskierter Operand mehr als ein einfaches opakes Paint ist, Solid-Paints mit Teil-Alpha, Gradients mit translucenten Stops und COLR-v0-Layer mit Alpha unter 255. Die in- und out-Modi bleiben Vektor, wenn die maskierte Seite eine schlichte Volltonfarbe oder ein Gradient ist, optional gefasst in einen Glyph-Clip oder Transform, denn dieser Fall braucht nur eine opake Graustufen-Maske. Dasselbe gilt für COLR v0, wenn StrictVersionLock die Datei unter PDF 1.4 festnagelt

Pdf.PDFACompliance := 'B';   // PDF/A-1b: keine Transparenz erlaubt
Pdf.BeginDoc;
Pdf.RegisterUnicodeTTF(EmojiFontPath);

if not Pdf.DrawRegisteredColorGlyph(Pdf.CurrentPage, $1F308,
  72, 200, EmojiFontName, 36, 0, 300) then
  // Ohne OnColorGlyphRasterize-Handler endet eine translucente
  // oder komponierte Glyphe hier unter PDF/A-1
  LogMissingColorGlyph($1F308);   // eigene Logging-Routine

Muss das Archiv Emoji in Farbe zeigen, zielt man auf PDF/A-2b statt auf PDF/A-1b. Part 2 erlaubt Transparenz, jedes COLR-Konstrukt bleibt Vektor, und der Fallback-Handler wird zum Sicherheitsnetz statt zum Haupt-Renderer für jedes translucente Emoji. PDF/A-1 behält man für Workflows, die es beim Namen verlangen

Eine Einstellung auf der Leserseite gehört auch hierher. HotPDF komponiert COLR-v1-Gruppen in einem linear-light Blend Space; manche Renderer verlieren sehr dunkle Kanäle in solchen Gruppen und manche ignorieren Gruppen-Blend-Spaces völlig, daher lässt COLRv1DeviceSpaceGroupBlend (Standard False) die Gruppen im gewöhnlichen Device Space komponieren. Den Trade-off erklärt der Artikel zu COLR-v1-Repeat- und Reflect-Gradients; schaltet man ihn ein, wenn dunkle translucente Tints im Viewer verschwinden, den die Nutzer tatsächlich fahren

SVG-Glyphen und Bitmap-Strikes

SVG-Glyphen laufen durch denselben sicheren Builder, den HotPDF für importierte SVG-Dateien nutzt, und das Ergebnis wird als Form XObject registriert (§8.10), wie im Artikel SVG zu Form XObject beschrieben. Das Dokument in der SVG -Tabelle darf gzip-komprimiert sein. Komprimierter Input ist auf 8 MB gedeckelt, die Dekompression läuft in 8-KB-Blöcken und stoppt, sobald die entpackte Größe 32 MB überschreiten würde. Skripte, foreignObject, javascript:-URLs und @import-Stylesheets bringen den Builder dazu, das Dokument rundweg abzulehnen. Die Form wird skaliert, sodass ihre längere Seite der Font-Größe entspricht. Etwas, das man vor dem Produktivbetrieb ausprobieren sollte: Manche Fonts packen viele Glyphen in ein geteiltes SVG-Dokument, und der Builder bekommt dieses ganze Dokument pro Glyphe

Bitmap-Fonts laufen auf Strike-Wahl und Platzierung hinaus. Bei CBDT wählt HotPDF die CBLC-Größe, deren vertikales ppem TargetPixelsPerEm am nächsten liegt, akzeptiert die Bildformate 17, 18 und 19 und liest die Metriken von Format 19 aus der CBLC-Index-Subtabelle, weil dieses Format keine eigenen speichert. Bei sbix gewinnt der nächste Strike, der die Glyphe tatsächlich enthält; Strike-Offsets sind relativ zur Tabelle, Glyph-Offsets relativ zum Strike. Ein dupe-Record nutzt die Grafik einer anderen Glyphe wieder, behält aber seine eigenen Ursprungs-Offsets, und ein Parser, der das Lookup den äußeren Ursprung überschreiben lässt, schiebt das Bild zur Seite. PNG- und JPEG-Payloads werden intern dekodiert, um FontSize / PixelsPerEmY skaliert statt auf die Font-Größe gestreckt und mit einer Soft Mask geschrieben (§11.6.5.3), wann immer ein Pixel nicht voll opak ist. TIFF-Payloads in sbix gehen an das Event

Den Raster-Fallback-Handler schreiben

Wenn kein nativer Pfad greift, feuert HotPDF OnColorGlyphRasterize und platziert die RGBA-Bitmap, die der Handler zurückgibt. Ohne Handler, oder wenn Handled auf False bleibt, gibt DrawRegisteredColorGlyph False zurück und nichts wird gezeichnet. Der Handler bekommt das Format, die Font-Bytes, das extrahierte Asset (das SVG-Dokument, eventuell noch gzippt, oder die Bitmap-Bytes; leer bei COLR), die Glyph-ID, die Palette und die Ziel-Pixelgröße

HotPDF-Diagramm des Raster-Fallbacks: OnColorGlyphRasterize feuert für einen COLR-v1-Graph außerhalb der unterstützten Teilmenge, ein SVG-Dokument, das der sichere Builder abgelehnt hat, oder eine Bitmap-Payload, die die Dekoder nicht lesen, und übergibt Format, Font-Bytes, Asset, GlyphID, PaletteIndex und PixelSize, der zurückgegebene RGBA-Buffer wird nur akzeptiert, wenn seine Länge exakt Width mal Height mal 4 beträgt
Null-Größen, eine falsche Buffer-Länge oder überlaufende Dimensionen werden abgewiesen, bevor die Seite angefasst wird, und ohne Handler oder bei Handled false gibt der Aufruf False zurück und die Seite bleibt unverändert
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 ist Ihr Rasterizer, keine HotPDF-API.
  // Er muss exakt Width * Height * 4 Bytes RGBA zurückgeben,
  // mit geradem (nicht premultiplied) Alpha
  Handled := RenderWithOwnEngine(Format, FontBytes, AssetData,
    GlyphID, PaletteIndex, PixelSize, Width, Height, RGBA);
end;

// Verdrahtung
Pdf.OnColorGlyphRasterize := Fallback.Rasterize;

HotPDF validiert das Ergebnis, bevor die Seite es sieht: Null-Größen, ein Buffer, dessen Länge nicht exakt Width * Height * 4 ist, oder Dimensionen, groß genug zum Überlaufen, werden abgewiesen, und der Aufruf gibt False zurück. Bei einem variablen Color-Font bekommt der Handler die statische Instanz an den registrierten Achsenwerten; sollte er stattdessen einmal den originalen variablen Font erhalten, liefert ein Aufruf von GetRegisteredVariableFontAxes auf Sender (gegossen auf THotPDF) die Koordinaten, an denen zu rendern ist. PixelSize ist derselbe TargetPixelsPerEm-Wert, der Druckauflösungs-Ratschlag von oben gilt also auch hier

Eine Pipeline, die beliebigen Nutzertext druckt, muss zwei Arten von Verlust kennen: Glyphen, die der Font nie hatte, und Glyphen, die ihre Farbe verloren haben. Das Coverage-Tracking zur Zeichenzeit aus dem Artikel zum Tracking fehlender Glyphen meldet das erste; ein False von DrawRegisteredColorGlyph meldet das zweite

COLR-, SVG- und Bitmap-Emoji-Rendering, OpenType-Shaping und der sichere SVG-Builder sind allesamt Teil der HotPDF-Komponente für Delphi und C++Builder