Technischer Artikel

PDF-Schriftersetzung in Delphi mit PDFium steuern

PDFium Component erlaubt einer Delphi-Anwendung zu entscheiden, welche Schriftbytes verwendet werden, wenn ein PDF eine nicht eingebettete Schrift referenziert. ConfigureSystemFontProvider installiert eine IPdfSystemFontProvider-Implementierung, die jede von PDFium gestellte Schriftzuordnungsanfrage empfängt, vollständig mit Schriftname, Gewicht, Kursiv-Flag, Zeichensatz und Rasterfamilie, und mit den zu verwendenden TrueType-, TrueType-Collection- oder OpenType-Bytes antwortet

Der Grund dafür ist, dass nicht eingebettete Schriften eine Rendering-Lotterie sind. Ein PDF, das Arial benennt und nichts einbettet, rendert auf einer Workstation mit Arial, auf einem Linux-Server mit einem metrikkompatiblen Ersatz und auf einem abgesicherten Container-Image mit dem, was der Host-Mapper findet. Dieselbe Rechnung sieht auf jedem anders aus, Zeilenumbrüche verschieben sich, und ein Kunde erhält ein Dokument, das nicht mit der archivierten Kopie übereinstimmt

Warum nicht einfach die Schriften auf dem Server installieren?

Manchmal ist das die Antwort, und wenn ja, sollte man sie nutzen. Aber sie versagt in drei häufigen Situationen. Lizenzierung kann verbieten, eine Schrift für automatisiertes Rendering auf einem Server zu installieren. Container-Images werden häufig neu gebaut, und eine von Hand installierte Schrift verschwindet mit dem nächsten Deployment. Und regulierte Workflows brauchen einen Rendering-Stack, der aus versionskontrollierten Artefakten reproduzierbar ist, was eine systemweite Schriftinstallation nicht ist

Ein Provider adressiert alle drei, indem er die Entscheidung in die eigene Anwendung verlagert. Schriften werden als selbst kontrollierte Ressourcen ausgeliefert, die Zuordnungsrichtlinie ist Code, der geprüft werden kann, und dieselbe Binärdatei rendert überall identisch, weil nichts davon abhängt, was zufällig installiert ist

Einen Provider installieren

Die Konfiguration muss erfolgen, bevor die Bibliothek geladen wird. PDFium akzeptiert bei der Initialisierung eine Systemschrift-Info-Struktur und behält danach ausgegebene Handles, sodass ein Providerwechsel bei geöffneten Dokumenten Schrift-Handles ungültig machen würde, die PDFium noch hält; die Komponente weist dies rundweg zurück, statt es einen Renderdurchlauf beschädigen zu lassen:

uses
  PDFium;

type
  TAppFontProvider = class(TInterfacedObject, IPdfSystemFontProvider)
  public
    function ResolveFont(const Request: TPdfSystemFontRequest;
      out Font: TPdfSystemFontData): Boolean;
  end;

function TAppFontProvider.ResolveFont(const Request: TPdfSystemFontRequest;
  out Font: TPdfSystemFontData): Boolean;
var
  Path: string;
begin
  // Deterministische Zuordnung: Schriftname plus Gewicht und Kursiv
  // entscheiden, welche Datei wir für diese Anfrage ausliefern
  Path := MapFaceToBundledFile(Request.FaceName, Request.Weight,
    Request.Italic, Request.Charset);
  Result := Path <> '';
  if not Result then
    Exit;
  Font.FaceName := Request.FaceName;
  Font.FontData := LoadFileBytes(Path);   // vollständige sfnt- oder TTC-Bytes
  Font.Charset := Request.Charset;
  Font.TTCIndex := 0;                     // Index innerhalb einer Collection
end;

var
  Policy: TPdfSystemFontPolicy;
begin
  Policy := TPdfSystemFontPolicy.Default;
  Policy.AllowDefaultFallback := False;   // Host entscheidet alles
  Policy.AllowFaceSubstitution := False;  // einen anderen Schriftnamen ablehnen
  Policy.MaxFontBytes := 32 * 1024 * 1024;
  Policy.MaxCacheEntries := 64;

  ConfigureSystemFontProvider(TAppFontProvider.Create, Policy);
  // Erst jetzt die Bibliothek laden und Dokumente öffnen
end;

Das Herunterfahren läuft in umgekehrter Reihenfolge: Zuerst wird der Provider von PDFium abgekoppelt, dann wird die Bibliothek entladen. Das Abkoppeln zu überspringen lässt native Schrift-Handles auf Pascal-Objekte zeigen, die gleich freigegeben werden, was der klassische Zugriffsfehler beim Herunterfahren in Code ist, der referenzgezählte Schnittstellen mit einer C-Bibliothek mischt

Was die Policy-Flags tatsächlich entscheiden

AllowDefaultFallback ist der Schalter zwischen zwei Betriebsmodi. Ist er deaktiviert, schlägt eine vom Provider abgelehnte Anfrage einfach fehl, was gewünscht ist, während man nachweist, dass jede Schrift in einem Bestand berücksichtigt ist: Jede Lücke wird sofort sichtbar, statt kaschiert zu werden. Ist er aktiviert, werden ungelöste Anfragen an den von FPDF_GetDefaultSystemFontInfo zurückgegebenen Mapper delegiert, während die Außenwelt weiterhin einen einzigen einheitlichen Handle-Wrapper sieht, mit korrekt nach Herkunft geroutetem Schriftnamen, Zeichensatz, Tabellendaten und Schriftlöschung

AllowFaceSubstitution regelt, ob ein Provider mit einem anderen Schriftnamen als dem angeforderten antworten darf. Ihn zu deaktivieren macht Substitution zu einer expliziten Entscheidung statt zu einem Zufall, was wichtig ist, wenn ein Dokument eine Schrift benennt, deren Metriken sich genug unterscheiden, um die Paginierung zu verändern

Die Komponente validiert jede Provider-Antwort, bevor sie PDFium erreicht: Leere Daten werden abgelehnt, überdimensionierte Schriften werden gegen MaxFontBytes abgelehnt, der TTC-Index wird geprüft, und einzelne sfnt-Tabellen werden aus dem Schriftverzeichnis bedient, wenn PDFium nach einer Tabelle statt nach der gesamten Datei fragt. Diese letzte Fähigkeit bedeutet, dass ein Provider eine vollständige Schriftdatei übergeben kann und die Komponente Anfragen auf Tabellenebene beantworten lässt, statt rohe Pascal-Objekte über die C-ABI freizulegen

Caching ohne baumelnde Schriftdaten

Schriftzuordnungsanfragen wiederholen sich während des Renderns ständig, daher werden Antworten mit einem Schlüssel zwischengespeichert, der jeden Schriftauswahlparameter abdeckt, und nach begrenzter Least-Recently-Used-Reihenfolge verdrängt. Die Feinheit liegt in der Lebensdauer: PDFium kann noch die Bytes einer Schrift lesen, deren Cache-Eintrag gerade verdrängt wurde

Der Cache speichert referenzgezählte dynamische Arrays, und jedes native Handle hält seinen eigenen Schnappschuss, sodass Verdrängung eine Referenz verringert, statt genutzten Speicher freizugeben. Der Lösch-Callback gibt das Handle frei und pflegt einen Zähler aktiver Handles. Praktisch bedeutet das, MaxCacheEntries kann für den Speicherbedarf angepasst werden, ohne Risiko, Daten unter einem laufenden Render wegzuziehen

Wird der Provider auf meinem Thread aufgerufen?

Nicht zwangsläufig. PDFium kann den Mapper aus seinen eigenen Worker-Threads aufrufen, daher muss eine Implementierung thread-sicher sein. Gemeinsam genutzte Zähler, der Cache und die Konfigurationsbeobachtung sind innerhalb der Komponente jeweils durch ihren eigenen kritischen Abschnitt geschützt, aber der Code innerhalb von ResolveFont muss man selbst absichern

Die sicherste Form ist ein Provider, der keinen veränderlichen gemeinsamen Zustand berührt: aus einer beim Start erstellten Tabelle lesen, Bytes aus einer Datei oder Ressource laden, zurückgeben. Braucht ein Lookup einen eigenen gemeinsamen Cache, sollte dieser geschützt werden. Und Exceptions sollten innerhalb der eigenen Implementierung bleiben, da eine Pascal-Exception niemals durch den PDFium-Stack abwickeln darf; die Komponente fängt an der C-ABI-Grenze ab und wandelt in einen Fehlschlag oder einen optionalen Standard-Fallback um, aber sich darauf als normalen Kontrollfluss zu verlassen kostet Leistung und verbirgt Fehler. Die Threading-Regeln für den Rest der Komponente folgen denselben Prinzipien wie jene in Render-Lock-Disziplin

Die Zuordnung im Produktivbetrieb nachweisen

Statistiken machen aus Schriftersetzung etwas Überprüfbares statt Rätselraten. GetSystemFontProviderStatistics meldet, ob ein Provider konfiguriert und installiert ist, wie viele Zuordnungsanfragen gestellt wurden und wie sie erfüllt wurden, aufgeteilt in Cache-Treffer, Provider-Treffer und Standard-Fallback-Treffer, zusammen mit abgelehnten Antworten, fehlgeschlagenen Anfragen, aktiven Handles und zwischengespeicherten Schriften:

var
  Stats: TPdfSystemFontStatistics;
begin
  Stats := GetSystemFontProviderStatistics;
  Writeln(Format('requests=%d cache=%d provider=%d fallback=%d',
    [Stats.MapRequests, Stats.CacheHits, Stats.ProviderHits,
     Stats.DefaultFallbackHits]));
  Writeln(Format('rejected=%d failed=%d handles=%d cached=%d',
    [Stats.RejectedProviderResponses, Stats.FailedRequests,
     Stats.ActiveHandles, Stats.CachedFonts]));

  // In einem Konformitätslauf mit deaktiviertem Fallback bedeutet jeder
  // Fallback-Treffer oder jede fehlgeschlagene Anfrage, dass ein Dokument
  // eine Schrift referenziert hat, die wir nicht ausliefern
  if (Stats.DefaultFallbackHits > 0) or (Stats.FailedRequests > 0) then
    raise Exception.Create('unmapped font encountered - update the font set');
end;

Ein steigender RejectedProviderResponses-Zähler ist das Signal, dass ein Provider mit Daten antwortet, die die Policy ablehnt, meist eine überdimensionierte Datei oder ein ersetzter Schriftname, und es lohnt sich, darüber zu alarmieren, weil diese Anfragen still zu Fallback oder Fehlschlag degradieren. Um zu diagnostizieren, welche Schriften ein Dokument tatsächlich benötigt, bevor die Zuordnungstabelle gebaut wird, listet der Inspektionsweg in Analyse von PDF-Schrifteigenschaften eingebettete und nicht eingebettete Schriften pro Dokument auf

Schriftbereitstellung, Rendering und Textextraktion teilen sich dieselbe Bibliotheksinstanz über Delphi, C++Builder und Lazarus hinweg; Bereitstellungsdetails werden auf der PDFium-Component-für-Delphi-Seite beschrieben