Technischer Artikel

PDFium: Emoji und CJK-Zeichen brechen WideChar in Delphi

Ziehen Sie Emoji oder einen japanischen Familienregister-Namen als Text aus einem PDF, und die Ausgabe zeigt eine Box, ein Fragezeichen oder gar nichts, wo das Zeichen stehen sollte. Die Character[]-Eigenschaft der PDFium-Komponente ist meist der Grund: Sie liest jede Glyphe über FPDFText_GetUnicode, das einen vollen Unicode-Codepunkt als vorzeichenlosen 32-Bit-Wert zurückgibt, und legt ihn dann Delphi als einzelnes 16-Bit-WideChar frei. Jeder Codepunkt jenseits von U+FFFF kann diese Reise nicht in einem Stück machen, und die Beschädigung zeigt sich nie, während Sie auf die gerenderte Seite schauen, weil Rendering und Textextraktion in PDFium separate Codepfade durchlaufen — ein Dokument kann sein Emoji perfekt anzeigen und Ihnen trotzdem Müll übergeben, in dem Moment, in dem Sie Character[] in einer Schleife lesen und daraus einen String bauen

Die Basic Multilingual Plane und warum WideChar bei U+FFFF endet

Delphis WideChar ist ein 16-Bit-Typ, der nur eine UTF-16-Codeeinheit halten kann. Unicodes Basic Multilingual Plane, der Bereich U+0000 bis U+FFFF, passt genau hinein, weshalb Lateinisch, Kyrillisch, Griechisch und der gewöhnliche CJK-Unified-Ideographs-Block alle problemlos durch ein einzelnes WideChar hin- und zurücklaufen. Zwei Zeichenfamilien fallen in echten Dokumenten routinemäßig außerhalb davon: Emoji, viele davon im Emoticons-Block ab U+1F600, und seltene CJK-Ideogramme aus CJK Unified Ideographs Extension B, dem für weniger gebräuchliche chinesische, japanische und koreanische Zeichen reservierten Bereich U+20000 bis U+2A6DF, einschließlich vieler Personen- und Ortsnamen. UTF-16 behandelt alles über U+FFFF mit einem Surrogatpaar — zwei 16-Bit-Codeeinheiten, ein hohes Surrogat im Bereich $D800 bis $DBFF, gefolgt von einem niedrigen Surrogat in $DC00 bis $DFFF, die zusammen einen Codepunkt kodieren —, und die Mathematik hinter dieser Paarung ist fest genug, um sie direkt in Pascal zu demonstrieren

function ToSurrogatePair(CodePoint: LongWord; out Hi, Lo: WideChar): Boolean;
var
  V: LongWord;
begin
  Result := CodePoint > $FFFF;
  if Result then
  begin
    V := CodePoint - $10000;
    Hi := WideChar($D800 + (V shr 10));
    Lo := WideChar($DC00 + (V and $3FF));
  end;
end;

Speist man U+1F600, das grinsende Gesicht-Emoji, durch diese Funktion, ist das Ergebnis ein hohes Surrogat von $D83D und ein niedriges Surrogat von $DE00, zwei 16-Bit-Werte, nicht einer. Keine der beiden Hälften bedeutet für sich allein etwas; ein einsames $D83D in einem String ohne $DE00 dahinter ist ein baumelndes Surrogat, und der meiste Text-verarbeitende Code, der auf eines trifft, verwirft es entweder, ersetzt es durch eine Ersatz-Glyphe oder wirft einen Fehler

Warum gibt FPDFText_GetUnicode einen Wert zurück, den Character[] nicht halten kann?

FPDFText_GetUnicode gibt einen LongWord zurück, einen vollen 32-Bit-Wert, weil die PDF-Textkodierung für jede Glyphe bereits den vollständigen Unicode-Skalarwert trägt. Die ToUnicode-CMap eines PDFs bildet Zeichencodes auf Unicode-Text ab, und wenn eine Glyphe das repräsentiert, was informell Astral-Plane-Zeichen genannt wird — alles jenseits der Basic Multilingual Plane —, ist diese Abbildung ein voller Codepunkt, kein 16-Bit-Fragment. PDFium dekodiert ihn intern zurück zu einem Skalarwert und gibt ihn über die DLL-Grenze über FPDFText_GetUnicode zurück, und genau an dieser Grenze muss ein 32-Bit-Wert zu etwas werden, das eine Delphi-Eigenschaft an Ihren Code zurückgeben kann

Die naheliegende Implementierung ist WideChar(FPDFText_GetUnicode(TextPage, Index)), und sie ist auch die falsche. Eine harte Umwandlung von einem 32-Bit-Wert in einen 16-Bit-Typ behält nur die unteren 16 Bit und wirft den Rest still weg, ohne Exception und ohne Bereichsprüfung. Bei U+1F600 bedeutet das, $F600 zu behalten und die Tatsache zu verlieren, dass der wahre Wert je über U+FFFF lag, was eine Codeeinheit erzeugt, die nicht einmal ein gültiges baumelndes Surrogat ist, nur ein nicht verwandtes Basic-Multilingual-Plane-Zeichen, das zufällig diese unteren Bits teilt. Verketten Sie ein paar Tausend davon zu einem String, und nachgelagerter Code hat keine Möglichkeit mehr, ein beschädigtes Zeichen von einem legitimen zu unterscheiden

Was Character[] und Charcode[] jetzt für Astral-Plane-Codepunkte zurückgeben

Die Eigenschaften Character[] und Charcode[] der PDFium-Komponente geben U+FFFD zurück, das Unicode-Ersatzzeichen, wann immer der zugrunde liegende Codepunkt U+FFFF überschreitet, statt ihn still abzuschneiden. Diese Absicherung sitzt direkt innerhalb des Property-Getters hinter Character[]

function TPdf.GetCharacter(Index: Integer): WideChar;
var
  Code: LongWord;
begin
  LoadTextPage;
  Code := FPDFText_GetUnicode(FTextPage, Index);
  if Code > $FFFF then
    Result := #$FFFD          // astral-plane code point: cannot fit in one WideChar
  else
    Result := WideChar(Code);
end;

U+FFFD statt eines abgeschnittenen Fragments zurückzugeben ist eine bewusste, eng gefasste Lösung statt eines Neuentwurfs. Character[] und Charcode[] sind sowohl auf TPdf als auch auf TPdfView als WideChar typisiert, und diesen Rückgabetyp zu erweitern, um einen vollen Codepunkt zu tragen, würde jeden bestehenden Aufrufer brechen, der erwartet, dass eine Glyphe pro Index einen 16-Bit-Wert bedeutet. U+FFFD ist der eigens vom Unicode-Standard vorgesehene Platzhalter für genau diese Situation, sodass ein Aufrufer, der darauf prüft, ein definiertes, dokumentiertes Signal statt still falscher Daten bekommt. Ein Grenzfall, den man kennen sollte: U+FFFD ist auch ein legitimes Zeichen für sich, sodass auf dem seltenen Dokument, das bereits eine echte Ersatzzeichen-Glyphe enthält, dieser Index allein anhand des Werts nicht von einem abgeschnittenen astralen Zeichen unterscheidbar ist

Wie extrahiert man Emoji- und CJK-Extension-B-Text in Delphi korrekt?

Rufen Sie Text auf statt Character[] zu durchlaufen, wann immer der tatsächliche Textinhalt zählt, denn Text liest über FPDFText_GetText und gibt einen vollen WString mit korrekten Surrogatpaaren für jedes astrale Zeichen im Bereich zurück, statt einen Wert fester Breite pro Index. Pdf.Text(0, MaxInt), oder die Kurzform Pdf.Text, extrahiert eine ganze Seite korrekt in einem Aufruf, und Pdf.Text(StartIndex, Count) zieht einen kleineren Bereich auf dieselbe Weise. Character[] verdient sich weiterhin seinen Platz, wenn Sie nur Position-, Font- oder Flag-Daten an einem Index brauchen und den Codepunkt selbst nie anfassen — CharacterOrigin[], FontSize[] und CharacterMapError[] kümmert es nicht, ob die zugrunde liegende Glyphe astral war

function ExtractLineSafely(Pdf: TPdf): WString;
var
  I: Integer;
begin
  Result := '';
  for I := 0 to Pdf.CharacterCount - 1 do
    if not (Pdf.CharacterGenerated[I] or Pdf.CharacterMapError[I]) then
      Result := Result + Pdf.Text(I, 1);   // full code point, never a truncated WideChar
end;

Die Skip-generated-and-unmapped-Prüfung in dieser Schleife ist dasselbe Muster, das für reine Textextraktion in der Textextraktion aus PDF-Dokumenten mit der PDFium-Komponente verwendet wird; die einzige Änderung ist die letzte Zeile, die ein direktes Character[I]-Anhängen gegen einen Ein-Index-Aufruf in Text eintauscht, sodass astrale Zeichen als vollständige Surrogatpaare ankommen statt als Ersatz-Platzhalter

Wo das tatsächlich beißt: Chat-Exporte, Personennamen und eingebettete CJK-Fonts

Emoji tauchen überall dort auf, wo ein PDF informelle Kommunikation festhält: exportierte Chat-Logs, App-Store-Bewertungs-Dumps, Ticketing-System-Transkripte, gespeichert als PDF für ein Compliance-Archiv. CJK Extension B taucht an einer engeren, aber folgenreicheren Stelle auf, Personen- und Ortsnamen, weil japanische Familienregister, chinesische Haushaltsregistrierungsdokumente und taiwanesische Identitätsdokumente klassische Quellen für Zeichen sind, die es nie in den gewöhnlichen CJK-Block geschafft haben. Eine Gehaltsabrechnungs- oder Identitätsprüfungs-Pipeline, die Namen aus gescannten Regierungsunterlagen extrahiert, ist genau die Art von Workload, bei der ein still verstümmeltes Zeichen zu einem gescheiterten Abgleich wird statt zu einem kosmetischen Fehler

Seltene CJK-Ideogramme neigen auch dazu, mit Font-Problemen zu reisen, nicht nur mit Kodierungsproblemen, denn eine Schriftart muss eine Glyphe für einen Codepunkt im U+20000-Bereich tragen, bevor überhaupt etwas rendern kann, und nur wenige installierte Systemschriften tun das. Wer bereits FontIsEmbedded[] pro Zeichen durchläuft, so wie es das Lesen von PDF-Font-Eigenschaften mit der PDFium-Komponente beschreibt, sollte denselben Index auf beide Probleme zusammen prüfen: Ein Index, der von Character[] U+FFFD zurückgibt und eine nicht eingebettete Schrift meldet, ist ein Dokument, das dieses Zeichen weder korrekt extrahieren noch drucken wird, und die Lösung gehört stromaufwärts dorthin, wie das PDF erzeugt wurde, nicht in Ihren Extraktionscode

Die hier beschriebenen Eigenschaften Character[], Charcode[] und Text sind Teil der Standard-PDFium-Komponente für Delphi und C++Builder