Technischer Artikel

ToUnicode-Fix: NBSP und Soft Hyphen in der PDF-Extraktion

PDFium Component for Delphi bettet die Systemfonts, die TPdf.AddText benutzt, als CID-Fonts ein, die per Unicode-Codepunkt keyiert sind, sodass jedes CID exakt ein ToUnicode-Mapping trägt. Genau das verhindert, dass extrahierte Leerzeichen als U+00A0 (no-break space) und Bindestriche als U+00AD (soft hyphen) zurückkommen – im Live-Dokument wie in der gespeicherten Datei

Das Symptom ist gemein, weil es unsichtbar ist. Ein Suchindex verfehlt „two-x“, weil der gespeicherte String ein Soft Hyphen enthält, ein CSV-Export bricht anders um, ein Diff-Tool flaggt Zeilen, die in jedem Viewer identisch aussehen. An der gerenderten Seite ist nichts falsch; nur das Unicode hinter den Glyphen

Warum kommen extrahierte Leerzeichen als U+00A0 zurück?

Extrahierte Leerzeichen werden zu U+00A0, weil die ToUnicode-CMap, die PDFium in FPDFText_LoadFont generiert, per Glyph keyiert ist, und eine Glyph von zwei Codepunkten erreichbar ist. In Arial dient Glyph 3 sowohl U+0020 als auch U+00A0, und die Bindestrich-Glyph dient sowohl U+002D als auch U+00AD. Die generierte CMap mappt dasselbe CID daher zweimal, einmal über einen bfchar-Eintrag und einmal über ein Array-bfrange, und welcher Eintrag die Präzedenzregel des Readers favorisiert, wird zum extrahierten Text

Warum eine einzelne Arial-Glyph die PDF-Textextraktion in Delphi brach: U+0020 und U+00A0 erreichen Glyph 3, U+002D und U+00AD erreichen die Bindestrich-Glyph, sodass die generierte ToUnicode-CMap CID 0003 zweimal mappt – über einen bfchar-Eintrag und ein Array-bfrange – und die Präzedenzregel des Readers entscheidet, welcher Codepunkt extrahiert wird
Die Lowest-wins-Präzedenz hielt Leerzeichen jahrelang schlicht, bis eine Upstream-Umstellung auf Last-wins jedes AddText-Leerzeichen als NBSP und jeden Bindestrich als Soft Hyphen extrahieren ließ
1 beginbfchar
<0003> <0020>
endbfchar
1 beginbfrange
<0003> <0010> [<00A0> ...]
endbfrange

Lange Zeit war dieser Widerspruch harmlos, denn der PDFium-Reader ließ das niedrigste Mapping gewinnen. Eine Upstream-Änderung schaltete den Reader auf Last-wins um, und von diesem Build an wurde jedes mit AddText geschriebene Leerzeichen als NBSP extrahiert und jeder Bindestrich als Soft Hyphen. Beachten Sie das Muster in den Paaren: 0x20/0xA0 und 0x2D/0xAD unterscheiden sich nur im High-Bit, genau das erwartet man von einem Font, dessen cmap Latin-1-Zwillinge auf dieselbe Outline schickt. Wenn Ihr Extraktionscode gestern noch lief und jetzt an unsichtbaren Zeichen scheitert, dumpen Sie die Codepunkte, statt der Debugger-Ansicht zu vertrauen; die Grundlagen des Textabzugs behandelt Text aus PDF-Dokumenten mit PDFium in Delphi extrahieren

uses
  SysUtils, PDFium;

const
  // Leerzeichen/U+00A0 und Bindestrich/U+00AD teilen sich eine Arial-Glyph, ebenso
  // Griechisches Omega (U+03A9) und das Ohm-Zeichen (U+2126)
  Sample: WString = 'two-x'#$00A0'y'#$00AD'z '#$03A9#$2126;

function CodePoints(const S: WString): string;
var
  I: Integer;
begin
  Result := '';
  for I := 1 to Length(S) do
    Result := Result + 'U+' + IntToHex(Ord(S[I]), 4) + ' ';
end;

var
  Pdf: TPdf;
  Live, Reloaded: WString;
begin
  Pdf := TPdf.Create(nil);
  try
    Pdf.CreateDocument;
    Pdf.AddPage(1, 595, 842);
    Pdf.AddText(Sample, 'Arial', 12, 72, 770);
    Live := Pdf.Text;                  // live, ungespeichertes Dokument
    Pdf.SaveAs('codepoints.pdf');
  finally
    Pdf.Free;
  end;

  Pdf := TPdf.Create(nil);
  try
    Pdf.FileName := 'codepoints.pdf';
    Pdf.Active := True;
    Pdf.PageNumber := 1;
    Reloaded := Pdf.Text;              // nach einem vollen Save und Reload
  finally
    Pdf.Free;
  end;

  if (Live <> Sample) or (Reloaded <> Sample) then
    Writeln('Mismatch: ', CodePoints(Live), '/ ', CodePoints(Reloaded));
end;

Warum das Patchen der CMap nach dem Speichern nicht reichte

Die gespeicherte Datei zu patchen repariert nur die gespeicherte Datei, und nur, wenn der Patch die CMap-Struktur Byte für Byte intakt hält. Der erste Fix, RepairSubsetToUnicodeCMaps in der Unit FPdfCompress, läuft nach jedem nicht-inkrementellen TPdf.SaveAs und löst jedes kollidierende CID auf: Der bfchar-Eintrag gewinnt, ein Paar, das sich nur im High-Bit unterscheidet, löst zum kleineren Basis-Latein-Codepunkt auf, und alles andere behält sein erstes Mapping

Der interessante Teil ist das negative Ergebnis. Die kollidierende CMap sauber neu zu bauen, in Start-Code- oder Array-Form, schien die naheliegende Bewegung, und PDFium wies jede neu gebaute CMap rundweg ab und fiel auf Identity zurück. Die einzige Ausgabe, die der native Reader akzeptierte, war ein gleich langer In-Place-Ersatz der kollidierenden Hex-Werte, mit unangetastetem Blocklayout und unveränderter CID-Abdeckung. Die zweite Lektion war bescheidener: Unsere Notiz damals schob den In-Memory-Fall darauf, das Live-Dokument habe überhaupt keinen ToUnicode-Stream. Ein direkter Aufruf der DLL widerlegte das, denn das Live-Dokument trägt denselben mehrdeutigen Stream – der echte Fix musste also passieren, bevor PDFium die CMap überhaupt generierte. Die Reparaturroutine bleibt als Verteidigung für PDFs anderer PDFium-basierter Tools in der Bibliothek

uses
  Classes, FPdfCompress;

var
  Source, Dest: TFileStream;
begin
  Source := TFileStream.Create('from-other-tool.pdf',
    fmOpenRead or fmShareDenyWrite);
  try
    Dest := TFileStream.Create('repaired.pdf', fmCreate);
    try
      // Nur Edits gleicher Länge; Dateien ohne reparierbaren Konflikt
      // und Cross-Reference-Stream- oder Object-Stream-Dateien werden unverändert kopiert
      RepairSubsetToUnicodeCMaps(Source, Dest);
    finally
      Dest.Free;
    end;
  finally
    Source.Free;
  end;
end;

Den Font per Codepunkt keyen statt per Glyph

Der Root-Fix besteht darin, PDFium gar nicht mehr erst zu bitten, die CMap zu generieren. TPdf.LoadCachedFont übergibt die Systemfont-Bytes jetzt an TPdf.LoadUnicodeKeyedCidFont, das die eigene sfnt-cmap-Tabelle des Fonts liest, bevorzugt eine Format-12-Subtable und fällt auf Format 4 zurück. Die Codepunkte kommen sortiert und dedupliziert zurück, und CID k+1 wird dem k-ten Codepunkt zugewiesen, während CID 0 als .notdef bleibt. Eine explizite CIDToGIDMap schickt jedes CID zu seiner Glyph, sodass U+0020 und U+00A0 zwei verschiedene CIDs bekommen, die dieselbe Outline zeichnen, und die ToUnicode-CMap mappt jedes CID auf genau einen Codepunkt. Geladen wird der Font dann über FPDFText_LoadCidType2Font, denselben Einstiegspunkt, der hinter dem Glyph-Level-Schreiben in CID-Type-2-Font-Einbettung mit expliziten CID-zu-GID-Maps steckt

Der Codepunkt-keyierte Fix in PDFium Component: LoadUnicodeKeyedCidFont liest die sfnt-cmap des Fonts, weist jedem sortierten Codepunkt CID k+1 zu, mit CID 0 als notdef, verdrahtet eine explizite CIDToGIDMap, sodass U+0020 und U+00A0 verschiedene CIDs behalten, und BuildUnicodeKeyedCidCMap gibt jedem CID exakt einen Codepunkt
NBSP, Soft Hyphen und das Ohm-Zeichen überleben dann unter beiden Präzedenzregeln als sie selbst, im Live-Dokument wie nach jedem Save, sodass die CMap-Reparatur nichts mehr zu fixen findet
// Kondensiert aus TPdf.LoadUnicodeKeyedCidFont
SetLength(CidToGidMap, (Length(Entries) + 1) * 2);   // CID 0 = .notdef
for I := 0 to High(Entries) do
begin
  CidToGidMap[(I + 1) * 2]     := Byte(Entries[I].GlyphID shr 8);
  CidToGidMap[(I + 1) * 2 + 1] := Byte(Entries[I].GlyphID and $FF);
end;
ToUnicode := BuildUnicodeKeyedCidCMap(Entries);       // ein CID, ein Codepunkt
Result := FPDFText_LoadCidType2Font(Document, @Data[0], Length(Data),
  PAnsiChar(ToUnicode), @CidToGidMap[0], Length(CidToGidMap));

Wenn FPDFText_SetText später einen String schreibt, landet der umgekehrte Lookup auf genau einem CID pro Zeichen, sodass NBSP, Soft Hyphen und das Ohm-Zeichen unter beiden Präzedenzregeln als sie selbst überleben – im Speicher wie nach jedem Save. Weil die gespeicherte Datei den eigenen ToUnicode-Stream der Komponente trägt statt eines engine-generierten, findet RepairSubsetToUnicodeCMaps darin nichts zu reparieren

Warum kann ein einziger bfrange-Eintrag einen ganzen Block auslöschen?

Ein einzelnes bfrange, dessen CID-Lauf eine xxFF-Grenze überschreitet, bringt PDFium dazu, den gesamten Block zu verwerfen, in dem es sitzt. ISO 32000-1 §9.10.3 lässt nur das letzte Byte des Ziels innerhalb eines Bereichs variieren, aber die CID-Seite hat eine eigene Falle: PDFiums HandleBeginBFRange leitet die hohe CID als (low and $FFFFFF00) or (high and $FF) ab. Ein Lauf von CID 00FE bis 0101 wird daher als 00FE bis 0001 gelesen, Low größer High, und der ganze Block wird als ungültig markiert. Das Scheitern ist stumm: SetText läuft durch, die Seite rendert makellos, und die Extraktion liefert U+0000 für jedes Zeichen dieses Blocks

Die stumme bfrange-Falle beim PDF-CMap-Parsen: Ein CID-Lauf von 00FE bis 0101 überschreitet eine xxFF-Grenze, HandleBeginBFRange leitet die hohe CID als 0001 ab, Low größer High markiert den ganzen Block als ungültig, SetText und Rendern laufen weiterhin durch, und die Extraktion liefert U+0000 für jedes Zeichen im Block
BuildUnicodeKeyedCidCMap umgeht die Falle, indem es jeden Lauf vor einem Low-Byte von FF beendet, Blöcke innerhalb des 100-Einträge-Limits hält und Supplementary-Plane-Codepunkte als einzelne bfchar-Einträge schreibt

BuildUnicodeKeyedCidCMap beendet einen Lauf, bevor Codepunkt oder CID ein Low-Byte von FF erreichen, hält jeden Block innerhalb des 100-Einträge-Limits der CMap-Grammatik und schreibt Supplementary-Plane-Codepunkte als einzelne bfchar-Einträge mit UTF-16-Surrogate-Pair-Zielen, denn das Inkrementieren eines Surrogate Pairs innerhalb eines Bereichs hat keine definierte Bedeutung; die Surrogate-Seite dieser Geschichte steht in Emoji, CJK und Surrogate-Pair-Behandlung in Delphi. Eine reine bfchar-CMap würde das Grenzproblem komplett umgehen – zum Mehrfachen der Größe

Was deckt der Codepunkt-keyierte Font nicht ab?

Der Codepunkt-keyierte Pfad deckt jeden Font ab, der eine Unicode-cmap-Subtable aufweist, und fällt für den Rest auf das alte glyph-keyierte Verhalten zurück. Die Grenzen, die man kennen sollte, bevor man sich darauf verlässt:

  • Symbolfonts mit nur einer (3,0)-cmap und jeder Font, den der CID-Pfad nicht laden kann, laufen wie bisher über FPDFText_LoadFont, eine von zwei Codepunkten geteilte Glyph kann dort also weiterhin mehrdeutig extrahieren
  • Ohne Format-12-Subtable ist die Map auf die BMP beschränkt, und die Eintragsanzahl ist auf 65535 gedeckelt, damit jedes CID in zwei Bytes über null passt
  • Inkrementelle Saves (saIncremental) überspringen RepairSubsetToUnicodeCMaps von Design wegen, denn eine inkrementelle Revision muss append-only bleiben; die Codepunkt-keyierten Fonts machen das für Text, den die Komponente selbst schreibt, irrelevant
  • TrueType Collections brauchen besondere Sorgfalt: GDI GetFontData liefert das gesamte .ttc zurück, und FPDFText_LoadCidType2Font hat keinen Face-Index-Parameter, sodass die Anfrage nach NSimSun aus simsun.ttc früher SimSun, Face 0, einbettete und renderte. Die Komponente matcht den Familiennamen jetzt gegen die Name-Tabelle (nameID 1 und 16) und extrahiert die angefragte Face als eigenständiges sfnt, bevor die cmap geparst wird; schlägt das Parsen fehl, laufen die Collection-Bytes durch und das Verhalten fällt auf Face 0 zurück

Textschreiben, Font-Einbettung und Extraktion teilen sich ein Seitenmodell über Delphi, C++Builder und Lazarus hinweg, und die komplette API ist auf der Produktseite zu PDFium Component für Delphi beschrieben