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
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
// 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
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) überspringenRepairSubsetToUnicodeCMapsvon 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
GetFontDataliefert das gesamte .ttc zurück, undFPDFText_LoadCidType2Fonthat 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