PDFium Component voor Delphi sluit de systeemfonts die TPdf.AddText gebruikt in als CID-fonts gesleuteld op Unicode-codepunt, zodat elke CID precies één ToUnicode-mapping draagt. Dat is wat voorkomt dat geëxtraheerde spaties terugkomen als U+00A0 (no-break space) en koppeltekens als U+00AD (soft hyphen), zowel uit het live document als uit het opgeslagen bestand
Het symptoom is gemeen omdat het onzichtbaar is. Een zoekindex mist "two-x" omdat de opgeslagen string een soft hyphen bevat, een CSV-export splitst anders, een diff-tool vlagt regels die er in elke viewer identiek uitzien. Aan de gerenderde pagina is niets mis; alleen de Unicode achter de glyphs klopt niet
Waarom komen geëxtraheerde spaties terug als U+00A0?
Geëxtraheerde spaties worden U+00A0 omdat de ToUnicode-CMap die PDFium genereert in FPDFText_LoadFont op glyph is gesleuteld, en één glyph vanuit twee codepunten bereikbaar is. In Arial bedient glyph 3 zowel U+0020 als U+00A0, en het koppelteken-glyph bedient zowel U+002D als U+00AD. De gegenereerde CMap mapt dezelfde CID daardoor twee keer, één keer via een bfchar-invoer en één keer via een array-vorm bfrange, en welke invoer de precedentieregel van de reader bevoordeelt, wordt de geëxtraheerde tekst
1 beginbfchar
<0003> <0020>
endbfchar
1 beginbfrange
<0003> <0010> [<00A0> ...]
endbfrange
Lange tijd was die tegenstrijdigheid onschadelijk, want de PDFium-reader liet de laagste mapping winnen. Een upstream-wijziging zette de reader om naar last-wins, en vanaf die build werd elke spatie geschreven met AddText geëxtraheerd als NBSP en elk koppelteken als soft hyphen. Let op het patroon in de paren: 0x20/0xA0 en 0x2D/0xAD verschillen alleen in de hoge bit, precies wat u verwacht van een font waarvan de cmap Latin-1-lookalikes naar dezelfde outline stuurt. Was uw extractiecode gisteren nog goed en faalt hij nu op onzichtbare tekens, dump dan de codepunten in plaats van de debuggerview te vertrouwen; de basis van tekst eruit trekken staat in tekst extraheren uit PDF-documenten met PDFium in Delphi
uses
SysUtils, PDFium;
const
// Spatie/U+00A0 en koppelteken/U+00AD delen één Arial-glyph, net als
// Griekse Omega (U+03A9) en het Ohm-teken (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, niet-opgeslagen document
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; // na een volledige save en herlaad
finally
Pdf.Free;
end;
if (Live <> Sample) or (Reloaded <> Sample) then
Writeln('Mismatch: ', CodePoints(Live), '/ ', CodePoints(Reloaded));
end;
Waarom de CMap na het opslaan bijwerken niet genoeg was
Een patch op het opgeslagen bestand herstelt alleen dat bestand, en nog maar als de patch de CMap-structuur byte voor byte intact laat. De eerste fix, RepairSubsetToUnicodeCMaps in de unit FPdfCompress, draait na elke niet-incrementele TPdf.SaveAs en lost elke conflicterende CID op: de bfchar-invoer wint, een paar dat alleen in de hoge bit verschilt lost op naar het kleinere base-Latin-codepunt, en alles daarvoor houdt zijn eerste mapping
Het interessante deel is de negatieve uitkomst. De conflicterende CMap netjes herbouwen, in start-code- of arrayvorm, leek de voor de hand liggende zet, en PDFium verwierp elke herbouwde CMap ronduit, met terugval op Identity. De enige uitvoer die de native reader accepteerde was een vervanging van gelijke lengte op dezelfde plek van de conflicterende hex-waarden, met bloklay-out en CID-dekking onaangeroerd. De tweede les was nederiger: onze notitie destijds gaf de in-memory-casus de schuld doordat het live document helemaal geen ToUnicode-stream zou hebben. De DLL rechtstreeks aanroepen weerlegde dat, want het live document draagt dezelfde dubbelzinnige stream, en dat betekende dat de echte fix vóór het moment moest plaatsvinden waarop PDFium de CMap genereerde. De reparatieroutine blijft in de library als verdediging voor PDF's uit andere op PDFium gebaseerde tools
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
// Alleen bewerkingen van gelijke lengte; bestanden zonder repareerbaar conflict,
// en cross-reference-stream- of object-stream-bestanden, worden gekopieerd zoals ze zijn
RepairSubsetToUnicodeCMaps(Source, Dest);
finally
Dest.Free;
end;
finally
Source.Free;
end;
end;
Het font sleutelen op codepunt in plaats van op glyph
De wortelfix is om PDFium helemaal niet meer de CMap te laten genereren. TPdf.LoadCachedFont geeft de systeemfontbytes nu door aan TPdf.LoadUnicodeKeyedCidFont, dat de eigen sfnt-tabel cmap van het font leest, bij voorkeur via een subtable formaat 12 en met terugval op formaat 4. Codepunten komen gesorteerd en gededupliceerd terug, en CID k+1 wordt toegekend aan het k-ste codepunt, met CID 0 gereserveerd voor .notdef. Een expliciete CIDToGIDMap stuurt elke CID naar zijn glyph, dus U+0020 en U+00A0 krijgen twee verschillende CIDs die dezelfde outline tekenen, en de ToUnicode-CMap mapt elke CID op precies één codepunt. Het font wordt daarna geladen via FPDFText_LoadCidType2Font, hetzelfde toegangspunt achter het schrijven op glyphniveau in CID Type 2-fonts insluiten met expliciete CID-to-GID-maps
// Ingekort uit 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); // één CID, één codepunt
Result := FPDFText_LoadCidType2Font(Document, @Data[0], Length(Data),
PAnsiChar(ToUnicode), @CidToGidMap[0], Length(CidToGidMap));
Wanneer FPDFText_SetText later een string schrijft, landt de omgekeerde opzoeking op één CID per teken, dus NBSP, soft hyphen en het Ohm-teken overleven elk als zichzelf onder welke precedentieregel dan ook, in het geheugen en na elke save. Omdat het opgeslagen bestand de eigen ToUnicode-stream van de component draagt in plaats van een door de engine gegenereerde, vindt RepairSubsetToUnicodeCMaps er niets te repareren
Waarom kan één bfrange-invoer een heel blok uitwissen?
Een enkele bfrange waarvan de CID-run een xxFF-grens overschrijdt laat PDFium het hele blok waarin hij zit wegwerpen. ISO 32000-1 §9.10.3 laat alleen de laatste byte van de bestemming binnen een bereik variëren, maar de CID-kant heeft zijn eigen valkuil: de HandleBeginBFRange van PDFium leidt de hoge CID af als (low and $FFFFFF00) or (high and $FF). Een run van CID 00FE tot 0101 wordt daardoor gelezen als 00FE tot 0001, laag groter dan hoog, en het hele blok wordt als ongeldig gemarkeerd. De faling is geruisloos: SetText slaagt, de pagina rendert perfect, en extractie geeft U+0000 terug voor elk teken in dat blok
BuildUnicodeKeyedCidCMap eindigt een run voordat het codepunt of de CID een lage byte van FF bereikt, houdt elk blok binnen de limiet van 100 invoeren uit de CMap-grammatica en schrijft codepunten uit het supplementaire vlak als individuele bfchar-invoeren met UTF-16-surrogate-pair-bestemmingen, want een surrogate pair binnen een bereik ophogen heeft geen gedefinieerde betekenis; het surrogate-verhaal daarvan staat in emoji, CJK en surrogate pair-afhandeling in Delphi. Een CMap met alleen bfchar zou het grensprobleem helemaal omzeilen, tegen een paar keer de grootte
Wat dekt het op codepunt gesleutelde font niet?
Het codepunt-gesleutelde pad dekt elk font dat een Unicode-cmap-subtable blootgeeft en valt voor de rest terug op het oude op glyph gesleutelde gedrag. De grenzen die goed om te kennen zijn voordat u erop vertrouwt:
- Symbolfonts met alleen een (3,0)-cmap, en elk font dat het CID-pad niet kan laden, gaan zoals eerst via
FPDFText_LoadFont, dus een glyph gedeeld door twee codepunten kan daar nog dubbelzinnig extraheren - Zonder een subtable formaat 12 blijft de map beperkt tot de BMP, en het aantal invoeren is gekapt op 65535 zodat elke CID in twee bytes boven nul past
- Incrementele saves (
saIncremental) slaanRepairSubsetToUnicodeCMapsmet opzet over, omdat een incrementele revisie append-only moet blijven; de codepunt-gesleutelde fonts maken dat irrelevant voor tekst die de component zelf schrijft - TrueType Collections vragen extra zorg: GDI
GetFontDatageeft de hele .ttc terug, enFPDFText_LoadCidType2Fontheeft geen face-indexparameter, dus het aanvragen van NSimSun uit simsun.ttc sloot en renderte vroeger SimSun, face 0, in. De component matcht de familienaam nu tegen de name table (nameID 1 en 16) en haalt de gevraagde face er als op zichzelf staande sfnt uit voordat de cmap wordt geparst; faalt het parsen, dan gaan de collection-bytes er ongefilterd doorheen en keert het gedrag terug naar face 0
Tekst schrijven, fonts insluiten en extractie delen één paginamodel over Delphi, C++Builder en Lazarus, en de volledige API staat beschreven op de productpagina van PDFium Component voor Delphi