HotPDF rendert Buchstaben mit Akzenten aus eingebetteten Fonts korrekt, indem es zwei Font-Mechanismen beherrscht, die eine Glyphe aus anderen zusammenbauen: TrueType-Composite-Glyphen, deren Component-Offsets in Glyph-Einheiten vor der Glyph-zu-Device-Matrix anzuwenden sind, und die CFF-seac-Form von endchar, die einen Basisbuchstaben und einen Akzent über StandardEncoding-Codes zusammensetzt. Einen davon falsch zu behandeln und é, Å, ü oder ein typografisches Anführungszeichen kommt mit fehlendem Teil heraus, während die schlichten Buchstaben ringsum perfekt aussehen
Genau dieses Detail macht die Bugs teuer. Ein Report rendert, der Fließtext ist in Ordnung, und nur „München“ hat ein Loch, oder ein Zitat beginnt mit einem Apostroph und einer Lücke. Niemand verdächtigt den Renderer. Die erste Theorie ist immer ein beschädigter Font oder ein schlechter Producer, und das Ticket wandert eine Woche lang herum. Dieser Artikel geht durch, was in jedem Mechanismus tatsächlich schiefläuft, wie die HotPDF Delphi Component es seit v2.768.10 und v2.768.11 handhabt, und durch den verwandten Subsetting-Fix in v2.770.83, der Composite-Glyphen intakt hält, wenn HotPDF Fonts schreibt statt liest
Warum verschwindet nur der Akzent aus einer PDF-Glyphe?
Der Akzent verschwindet, weil er der einzige Teil der Glyphe mit einem Nicht-Null-Offset ist. TrueType- und CFF-Fonts sparen beide Platz, indem sie Outlines wiederverwenden: é wird nicht als neue Form gezeichnet, sondern als „die e-Glyphe plus die acute-Glyphe, nach oben und rechts verschoben“. Der Basisbuchstabe sitzt fast immer bei Offset (0, 0), also lässt jeder Fehler beim Anwenden der Offsets ihn unangetastet. Der Akzent ist die Komponente, die sich bewegt, also ist er die Komponente, die am falschen Ort landet oder nie gezeichnet wird
Das Symptommuster ist selbst eine Diagnose. Rendern die Basisbuchstaben akzentuierter Zeichen und die Diakritika nicht, oder zeigt sich gepaarte Interpunktion wie “ und ” als ein Zeichen statt zwei, hören Sie auf, in die Font-Datei zu schauen. Ein kaputter Font bricht meist ganze Glyphen, und zwar in jedem Viewer. Fehlende Teile zusammengesetzter Glyphen in einem Renderer, während Acrobat die Seite sauber zeigt, zeigen auf den Code, der die Komponenten montiert
Wie platzieren TrueType-Composite-Glyphen ihre Komponenten?
Eine TrueType-Composite-Glyphe platziert jede Komponente mit ihrer eigenen kleinen affinen Transform, ausgedrückt in Font-Einheiten, und diese Transform muss auf die Komponenten-Outline wirken, bevor die Glyph-zu-Device-Matrix es tut. In der OpenType-glyf-Tabelle hat eine Composite-Glyphe eine negative Konturenzahl, gefolgt von einer Liste von Component-Records. Jeder Record hält ein Flags-Wort, den Komponenten-Glyph-Index, zwei Argumente, die Bytes oder Words sein können (ARG_1_AND_2_ARE_WORDS, 0x0001), und einen optionalen Scale: einen F2Dot14-Wert für WE_HAVE_A_SCALE (0x0008), zwei für WE_HAVE_AN_X_AND_Y_SCALE (0x0040) oder eine volle 2x2-Matrix für WE_HAVE_A_TWO_BY_TWO (0x0080). Ist ARGS_ARE_XY_VALUES (0x0002) gesetzt, sind die zwei Argumente ein dx/dy-Offset in Font-Einheiten. MORE_COMPONENTS (0x0020) sagt, dass ein weiterer Record folgt
Für jede Komponente baut man also aus dem Scale-Teil und dem Offset eine Komponentenmatrix CM und kombiniert sie mit M, der Matrix, die Font-Einheiten auf Device-Pixel mappt. Die Reihenfolge dieser Kombination ist der ganze Bug. Die interne Matrixmultiplikation von HotPDF nutzt die Spaltenvektor-Konvention, das Produkt aus A und B wendet also erst B und dann A an. Die korrekte Komposition wendet CM zuerst an, im Glyph-Raum, und M danach. Andersherum geschrieben läuft M zuerst, und der CM-Offset wird danach addiert, bereits in Device-Pixeln und nie skaliert
// Nur Illustration: ein 2D-affiner Record mit der Spaltenvektor-Konvention,
// wobei Mul(A, B) einen Punkt erst durch B und dann durch A mappt
type
TAffine = record
a, b, c, d, e, f: Double;
end;
function Mul(const A, B: TAffine): TAffine;
begin
Result.a := A.a * B.a + A.c * B.b;
Result.b := A.b * B.a + A.d * B.b;
Result.c := A.a * B.c + A.c * B.d;
Result.d := A.b * B.c + A.d * B.d;
Result.e := A.a * B.e + A.c * B.f + A.e;
Result.f := A.b * B.e + A.d * B.f + A.f;
end;
// M : Font-Einheiten -> Device-Pixel (winziger Scale, y geflippt)
// CM : Component-Transform; e/f halten dx/dy in Font-Einheiten
Sub := Mul(M, CM); // korrekt: Offset wird mit der Outline mitskaliert
// Sub := Mul(CM, M); // falsch: dx/dy landen als rohe Device-Pixel
Die Zahlen machen den Fehler offensichtlich, sobald man sie sieht. Arial benutzt ein em von 2048 Einheiten, und die Akzent-Komponente des é trägt ihren Offset in diesen Font-Einheiten. Mit der richtigen Ordnung schrumpft der Offset samt Outline auf ein paar Pixel bei normalem Zoom. In umgekehrter Ordnung wird der rohe Font-Einheiten-Wert als Device-Pixel addiert, und im HotPDF-Testrendering landete der Akzent von Arials é 243 Pixel von seinem Buchstaben entfernt, weit außerhalb der Glyphe und meist außerhalb alles Sichtbaren. Das linke doppelte Anführungszeichen in Arial ist zwei Kopien der Apostroph-Form, die zweite verschoben, also renderte es als einzelnes ‘ gefolgt von Leerraum
Der lineare Teil enttarnt den Bug selten. Akzent-Komponenten tragen meist gar keinen Scale, und ein uniformer Scale kommutiert ohnehin mit M, nur die Translation verrät also das verdrehte Produkt. Deshalb besteht auch ein schneller Test mit schlichtem lateinischen Text: Die Verdrehung saß über viele Releases hinweg im Code, bevor v2.768.10 sie korrigierte, und nur Komponenten mit Offsets haben sie je gezeigt
Rekursion und Zyklen
Composite-Glyphen dürfen andere Composite-Glyphen referenzieren, ein Renderer durchläuft sie also rekursiv und muss sich verteidigen. HotPDF deckelt die Verschachtelungstiefe bei 5 Ebenen und die Gesamtzahl der Komponenten pro Glyphe bei 1024, überspringt eine Komponente, die ihren eigenen Glyph-Index nennt, und behandelt den selten genutzten Point-Matching-Mode (Argumente, die Punktnummern statt Offsets sind) als Null-Offset. Ein feindlicher oder korrupter Font kann so eine Komponente verlieren, aber er kann das Seiten-Rendering nicht aufhängen
Was ist die seac-Form von CFF endchar?
Die seac-Form von endchar ist eine veraltete Art, mit der ein CFF-Type-2-Charstring sagt „diese Glyphe ist ein Basisbuchstabe plus ein Akzent“: Hat endchar vier Argumente, sind sie adx ady bchar achar. Adobe Technical Note #5177 beschreibt das in seinem Anhang zur Type-1-Kompatibilität. Der Name kommt vom Type-1-Operator seac (standard encoding accented character), und CFF-Subsets, die aus Type-1-Fonts konvertiert wurden, benutzen ihn für akzentuierte lateinische Buchstaben nach wie vor reichlich
Drei Details bringen Implementierer zu Fall. Erstens sind bchar und achar keine Glyph-IDs. Sie sind Codes in Adobe StandardEncoding, der Renderer mappt also jeden Code auf einen Glyph-Namen und schlägt diesen Namen dann über den Charset des Fonts nach, um die GID zu finden. Zweitens hat Type 2 kein asb-Argument, anders als Type-1-seac, das asb adx ady bchar achar nimmt. In Type 2 geht der Ursprung des Akzents direkt auf (adx, ady) relativ zum Ursprung der Basis-Glyphe, ohne Side-Bearing-Korrektur. Drittens dürfen CID-keyed CFF-Fonts seac gar nicht benutzen, weil es keinen Glyph-Namen-Charset gibt, über den sich StandardEncoding-Codes auflösen ließen
Dann ist da noch die Width. Jeder Type-2-Charstring darf seinen ersten Stack-leerenden Operator mit einem optionalen Advance-Width-Argument beginnen, und endchar ist einer dieser Operatoren. Ein schlichtes endchar mit einem Argument trägt eine Width; ein seac-endchar mit fünf Argumenten trägt eine Width gefolgt von den vier seac-Werten. Die Regel lautet, die Argumente gegen die erwartete feste Anzahl zu zählen, die für seac vier ist. Vor v2.768.11 behandelte HotPDF jedes endchar als „nimm die Width und stopp“, seac-Glyphen produzierten also eine leere Outline: kein Basis, kein Akzent, nur eine korrekt dimensionierte Lücke in der Zeile
// Illustration der endchar-Entscheidung in einem Type-2-Interpreter.
// Der Stack hält die Operanden vor endchar; Count ist deren Anzahl.
if (Count >= 4) and not IsCIDFont then
begin
// 5 Operanden: der erste ist die Advance-Width, der Rest ist seac
if Count = 5 then
TakeWidthAndShift; // Width verwerfen, 4 Operanden behalten
if StackDepth = 4 then
begin
BaseGID := GIDForName(StdEncName(Round(Stack[2]))); // bchar
AccentGID := GIDForName(StdEncName(Round(Stack[3]))); // achar
DrawGlyph(BaseGID, Identity);
DrawGlyph(AccentGID, Translate(Stack[0], Stack[1])); // adx, ady
Exit;
end;
end;
// sonst: gewöhnliches endchar, optionale Width, Pfad schließen
Im echten Interpreter laufen beide Komponenten-Glyphen durch dieselbe Charstring-Engine, die Akzent-Matrix ist die aktuelle Glyph-Matrix, komponiert mit einer Translation zu (adx, ady), und danach wird die Matrix restauriert, damit spätere Glyphen der Zeile unberührt bleiben. Löst einer der Namen nicht zu einer GID auf, wird diese Komponente übersprungen, statt die ganze Glyphe failen zu lassen – das gnädige Verhalten, das man will, wenn ein Subset etwas zu aggressiv beschnitten wurde
Den Fix aus dem eigenen Delphi-Code prüfen
Am schnellsten prüft man Composite-Glyphen- und seac-Behandlung, indem man eine Seite mit akzentuiertem Text aus eingebetteten Fonts durch beide öffentlichen Ausgabepfade rendert: THotPDF.RenderLoadedPageToBitmap für Rasterausgabe und THotPDF.BuildLoadedPageSVG mit EmbedFonts auf True für Vektorausgabe. Beide teilen denselben Glyph-Outline-Code, beide haben also die v2.768.10- und v2.768.11-Fixes bekommen. Die Pipeline als Ganzes behandelt wie HotPDF eingebettete TrueType-, CFF- und Type-3-Glyphen rendert, Bitmap-Optionen PDF-Seiten in Delphi zu Bitmaps rendern
uses
System.Classes, Vcl.Graphics, HPDFDoc;
procedure RenderAccentProbe(const FileName: string);
var
Pdf: THotPDF;
Bmp: TBitmap;
Svg: AnsiString;
Lines: TStringList;
begin
Pdf := THotPDF.Create(nil);
try
if Pdf.LoadFromFile(FileName) <= 0 then
Exit;
// Raster-Pfad: 144 DPI macht falsch platzierte Akzente leicht erkennbar
Bmp := Pdf.RenderLoadedPageToBitmap(0, 144);
if Bmp <> nil then
try
Bmp.SaveToFile('accent-probe.bmp');
finally
Bmp.Free;
end;
// Vektor-Pfad: EmbedFonts = True schreibt Glyphen-Outlines, sodass
// Composite- und seac-Montage in den SVG-Pfaden selbst sichtbar ist
Svg := Pdf.BuildLoadedPageSVG(0, True);
if Svg <> '' then
begin
Lines := TStringList.Create;
try
Lines.Text := string(Svg);
Lines.SaveToFile('accent-probe.svg');
finally
Lines.Free;
end;
end;
finally
Pdf.Free;
end;
end;
Wählen Sie Testdokumente bewusst. Sie wollen ein PDF mit einem eingebetteten TrueType-Subset, das é, Å und typografische doppelte Anführungszeichen enthält, und eines mit einem eingebetteten CFF-Subset (FontFile3, Type1C), erzeugt aus einem Type-1-Font – dort lebt seac bis heute, typischerweise in älterem Satzausgabe-Material und manchen Druck-Workflows. Text in Fonts, die der Producer nicht eingebettet hat, sagt hier nichts aus, denn diese Glyphen kommen aus installierten Windows-Fonts statt aus dem in ISO 32000-1 §9.9 beschriebenen eingebetteten Font-Programm
Warum brachen Composite-Glyphen beim Font-Subsetting?
Composite-Glyphen brachen beim Subsetting, weil der Subsetter durch die Component-Records mit falschem Stride stapfte, sobald eine Komponente einen Scale oder eine 2x2-Transform trug, und danach den Glyph-Index der nächsten Komponente falsch las. HotPDF hat das in v2.770.83 gefixt. Subsetting ist der Schreib-Seiten-Zwilling des Rendering-Bugs: Betten wir ein Subset eines installierten TrueType-Fonts ein, muss HotPDF jede Glyphe behalten, die ein Composite referenziert, und diese Referenzen auf die neuen, kompakten Glyph-IDs umnummerieren
Der alte Walker schaute nur auf das Argumentgrößen-Flag und das More-Components-Flag, rückte also 6 oder 8 Bytes pro Record vor. Er ignorierte die 2, 4 oder 8 Bytes Transform-Daten, die die Scale-Flags hinzufügen. Nach einer skalierten Komponente war der nächste „Glyph-Index“, den er las, in Wirklichkeit Transform-Bytes, die echte Komponente konnte also aus dem Subset herausfallen, und der Renumbering-Schritt konnte eine neue Glyph-ID mitten in die Transform schreiben. Das Ergebnis war ein akzentuierter Buchstabe, der in manchen Viewern mit falschem Akzent, verzerrtem Akzent oder gar keinem renderte – und nur bei Fonts, die ihre Komponenten skalieren, weshalb die meisten Tests ihn nie erwischten
Zwei weitere Probleme saßen neben dem Stride-Bug. Das Umnummerieren eines Glyph-Index ist nicht idempotent, und verschachtelte Composites wurden doppelt umnummeriert: einmal vom Durchlauf über jedes markierte Composite und nochmal von einem rekursiven Abstieg in es. HotPDF nummeriert jedes Composite jetzt genau einmal um und treibt den Schritt „welche Glyphen braucht dieses Composite“ aus einer Work Queue, ein Zyklus sich gegenseitig referenzierender Composites terminiert also, Glyph-Indizes jenseits der maxp-Glyphenzahl des Fonts werden verworfen, und leere Glyphen werden nie als Composites geparst
var
Pdf: THotPDF;
begin
Pdf := THotPDF.Create(nil);
try
Pdf.FileName := 'accents-subset.pdf';
Pdf.FontEmbedding := True; // die benutzten Glyphen einbetten
Pdf.BeginDoc;
Pdf.CurrentPage.SetFont('Arial', [], 14);
// é, Å, ü und typografische Anführungszeichen sind in Arial alles Composite-Glyphen
Pdf.CurrentPage.UnicodeTextOut(50, 720, 0,
'Café, Ångström, München, “quoted”');
Pdf.EndDoc;
finally
Pdf.Free;
end;
end;
Schicken Sie diese Datei als Rundtrip durch die Probe oben und durch einen zweiten Viewer. Seit v2.770.83 überlebt jede Komponente jeder Composite-Glyphe das Subsetting, auch Komponenten hinter einem Scale oder einer 2x2-Transform, die Akzente rendern also in HotPDF, Acrobat und Browser-Viewern gleich
Was die Composite-Glyphen-Behandlung nicht abdeckt
Der Composite-Glyphen-Fix deckt Offset-basierte Platzierung ab, was fast jeder echte Font benutzt, und nähert den Rest konservativ an. TrueType-Point-Matching-Composites, bei denen die zwei Argumente auszurichtende Punkte nennen statt eines Offsets, werden mit Null-Offset gezeichnet. Seac in CID-keyed CFF-Fonts wird designbedingt ignoriert, weil die Spezifikation es dort verbietet. Ein seac, dessen Basis- oder Akzent-Code in einem beschnittenen Subset keine Glyphe hat, zeichnet, welche Komponente er finden kann. Keiner dieser Fälle bringt den Renderer zum Absturz, aber wer PDFs für Archiv oder PDF/A produziert, fährt sicherer mit Fonts, die Offset-Composites und Type-2-Charstrings ohne seac nutzen, damit jeder Consumer, nicht nur ein Renderer, dieselben Buchstaben sieht
FAQ
Warum rendern akzentuierte Buchstaben in meinem PDF-Renderer ohne ihre Akzente?
Akzentuierte Buchstaben sind meist Composite-Glyphen oder CFF-seac-Glyphen, und der Akzent ist die einzige Komponente mit einem Offset. Ein Renderer, der den Component-Offset nach der Device-Matrix anwendet oder seac in endchar ignoriert, verwirft oder verlegt den Akzent, während der Basisbuchstabe normal rendert
Unterstützt HotPDF die CFF-seac-Form von endchar?
Ja. Seit v2.768.11 zeichnet HotPDF endchar mit vier Argumenten, oder fünf inklusive der Width, als Basis-Glyphe plus einen auf (adx, ady) verschobenen Akzent, beide aufgelöst über StandardEncoding-Namen und den Font-Charset, für Bitmap-Rendering und SVG-Export mit eingebetteten Fonts gleichermaßen
Bleiben Composite-Glyphen erhalten, wenn HotPDF TrueType-Fonts subsettet?
Ja. Seit v2.770.83 springt HotPDF über Component-Transforms von 2, 4 oder 8 Bytes korrekt hinweg, behält jede referenzierte Komponente im Subset, nummeriert verschachtelte Composites einmal um und stoppt sicher bei zyklischen oder außerhalb liegenden Glyph-Referenzen
Composite-Glyphen-Rendering, CFF-seac-Unterstützung und sicheres TrueType-Subsetting kommen als Teil der HotPDF Delphi PDF Component, einer nativen VCL-Bibliothek für Delphi und C++Builder, die PDF-Dokumente ohne externe DLLs erstellt, bearbeitet, extrahiert und rendert