PDFium Component pre Delphi vkladá systémové fonty používané TPdf.AddText ako CID fonty kľúčované Unicode code pointom, takže každé CID nesie presne jedno ToUnicode mapovanie. Práve to bráni tomu, aby extrahované medzery prichádzali späť ako U+00A0 (no-break space) a spojovníky ako U+00AD (soft hyphen), a to aj z živého dokumentu, aj z uloženého súboru
Symptóm je zlý, pretože je neviditeľný. Search index nenájde „two-x“, pretože uložený reťazec obsahuje soft hyphen, CSV export sa rozdelí inak, diff nástroj označí riadky, ktoré vyzerajú v každom vieweri identicky. Nič na vyrenderovanej stránke nie je zlé; zlé je len Unicode za glyfmi
Prečo sa extrahované medzery vracajú ako U+00A0?
Extrahované medzery sa menia na U+00A0 preto, lebo ToUnicode CMap, ktorú PDFium generuje v FPDFText_LoadFont, je kľúčovaná glyfom a jeden glyf sa dá dosiahnuť z dvoch code pointov. V Ariali obsluhuje glyf 3 U+0020 aj U+00A0 a hyphen glyf obsluhuje U+002D aj U+00AD. Generovaná CMap preto mapuje to isté CID dvakrát, raz cez záznam bfchar a raz cez array bfrange, a to záznam, ktorému praje pravidlo precedencie čitateľa, sa stane extrahovaným textom
1 beginbfchar
<0003> <0020>
endbfchar
1 beginbfrange
<0003> <0010> [<00A0> ...]
endbfrange
Dlhý čas bola tá kontradikcia neškodná, keďže reader PDFium nechal vyhrať najnižšie mapovanie. Upstream zmena prepla readera na last-wins a od toho buildu sa každá medzera zapísaná AddText extrahovala ako NBSP a každý spojovník ako soft hyphen. Všimnite si vzor v pároch: 0x20/0xA0 a 0x2D/0xAD sa líšia len vysokým bitom, presne to, čo by ste čakali od fontu, ktorého cmap posiela Latin-1 dvojníky na ten istý outline. Ak bol váš extrakčný kód včera v poriadku a teraz zlyháva na neviditeľných znakoch, vypíšte code pointy namiesto dôvery debugger viewu; základy vytiahnutia textu pokrýva extrakcia textu z PDF dokumentov s PDFium v Delphi
uses
SysUtils, PDFium;
const
// Medzera/U+00A0 a spojovník/U+00AD zdieľajú jeden glyf Arialu, rovnako ako
// grécke Omega (U+03A9) a znak Ohm (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; // živý, neuložený 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; // po úplnom uložení a znovunačítaní
finally
Pdf.Free;
end;
if (Live <> Sample) or (Reloaded <> Sample) then
Writeln('Mismatch: ', CodePoints(Live), '/ ', CodePoints(Reloaded));
end;
Prečo záplata CMap po uložení nestačila
Záplata uloženého súboru opraví len uložený súbor, a to len vtedy, keď záplata drží štruktúru CMap bajt-za-bajtom nedotknutú. Prvá oprava, RepairSubsetToUnicodeCMaps v unit FPdfCompress, beží po každom neinkrementálnom TPdf.SaveAs a dorieši každé konfliktné CID: záznam bfchar vyhrá, pár líšiaci sa len vysokým bitom sa dorieši na menší base-Latin code point a čokoľvek iné si drží prvé mapovanie
Zaujímavá časť je negatívny výsledok. Čisté prestavanie konfliktné CMap, v tvare start-code alebo array, vyzeralo ako zjavný ťah a PDFium odmietlo každú prestavanú CMap rovno, s pádom na Identity. Jediný výstup, ktorý natívny reader prijal, bolo rovnako dlhé nahradenie konfliktných hex hodnôt na mieste, s layoutom blokov a CID pokrytím nedotknutým. Druhá lekcia bola pokornejšia: naša poznámka vtedy obvinila prípad v pamäti na tom, že živý dokument nemá žiadny ToUnicode stream. Priame volanie DLL to vyvrátilo, keďže živý dokument nesie ten istý dvojznačný stream, čo znamenalo, že skutočná oprava sa musela stať skôr, než PDFium vôbec CMap vygeneruje. Opravná rutina zostáva v knižnici ako obrana pre PDF produkované inými nástrojmi na báze PDFium
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
// Len rovnako dlhé úpravy; súbory bez opraviteľného konfliktu
// a súbory cross-reference-stream alebo object-stream sa kopírujú, ako sú
RepairSubsetToUnicodeCMaps(Source, Dest);
finally
Dest.Free;
end;
finally
Source.Free;
end;
end;
Kľúčovanie fontu code pointom namiesto glyfom
Koreňová oprava je prestať PDFium vôbec žiadať o generovanie CMap. TPdf.LoadCachedFont teraz podá bajty systémového fontu TPdf.LoadUnicodeKeyedCidFont, ktorý číta vlastnú sfnt tabuľu cmap fontu s prednosťou subtabuľky formátu 12 a padom na formát 4. Code pointy sa vracajú zoradené a deduplikované a CID k+1 sa priradí k-tému code pointu, s CID 0 ponechaným ako .notdef. Explicitné CIDToGIDMap pošle každé CID na jeho glyf, takže U+0020 a U+00A0 dostanú dve rôzne CID kresliace ten istý outline a ToUnicode CMap mapuje každé CID na jediný code point. Font sa potom načíta cez FPDFText_LoadCidType2Font, ten istý vstupný bod za zápisom na úrovni glyfov v vkladaní fontov CID Type 2 s explicitnými CID-to-GID mapami
// Skondenzované z 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); // jedno CID, jeden code point
Result := FPDFText_LoadCidType2Font(Document, @Data[0], Length(Data),
PAnsiChar(ToUnicode), @CidToGidMap[0], Length(CidToGidMap));
Keď neskôr FPDFText_SetText zapisuje reťazec, spätné vyhľadanie pristane na jedinom CID na znak, takže NBSP, soft hyphen aj znak Ohm prežijú každý ako sám sebou pod ktorýmkoľvek pravidlom precedencie, v pamäti aj po každom uložení. Keďže uložený súbor nesie vlastný ToUnicode stream komponentu, nie engine-generovaný, RepairSubsetToUnicodeCMaps v ňom nenájde, čo by opravil
Prečo dokáže jeden záznam bfrange vymazať celý blok?
Jedno bfrange, ktorého CID beh prekračuje hranicu xxFF, donúti PDFium zahodiť celý blok, v ktorom sedí. ISO 32000-1 §9.10.3 povoľuje meniť vnútri range len posledný bajt cieľa, ale CID strana má vlastnú pascu: HandleBeginBFRange v PDFium odvodí vysoké CID ako (low and $FFFFFF00) or (high and $FF). Beh od CID 00FE po 0101 sa preto číta ako 00FE po 0001, low väčšie než high, a celý blok sa označí ako neplatný. Zlyhanie je tiché: SetText uspeje, stránka sa renderuje dokonale a extrakcia vracia U+0000 pre každý znak v tom bloku
BuildUnicodeKeyedCidCMap ukončí beh skôr, než code point alebo CID dosiahne nízky bajt FF, drží každý blok v limite 100 záznamov gramatiky CMap a zapisuje code pointy doplnkovej roviny ako jednotlivé záznamy bfchar s cieľmi ako UTF-16 surrogate pair, keďže inkrementácia surrogate pairu vnútri range nemá definovaný význam; surrogate strana toho príbehu je v obsluhe emoji, CJK a surrogate párov v Delphi. CMap len z bfchar by problém hranice obišla úplne, za niekoľkonásobnú veľkosť
Čo nepokrýva font kľúčovaný code pointom?
Cesta kľúčovaná code pointom pokrýva každý font, ktorý vystavuje Unicode cmap subtabuľku, a padá späť na staré správanie kľúčované glyfom pre zvyšok. Hranice, ktoré stoja za poznanie, skôr než sa na ňu spoľahnete:
- Symbolové fonty len s (3,0) cmap a každý font, ktorého CID cesta nedokáže načítať, idú cez
FPDFText_LoadFontako doteraz, takže glyf zdieľaný dvomi code pointmi môže aj tam extrahovať dvojznačne - Bez subtabuľky formátu 12 je mapa obmedzená na BMP a počet záznamov má strop 65535, aby každé CID sa zmestilo do dvoch bajtov nad nulou
- Inkrementálne ukladania (
saIncremental) preskočiaRepairSubsetToUnicodeCMapszámerne, pretože inkrementálna revízia musí ostať append-only; fonty kľúčované code pointom urobia to pre text, ktorý komponent sám zapisuje, nedôležitým - TrueType Collections potrebujú zvýšenú pozornosť: GDI
GetFontDatavracia celé .ttc aFPDFText_LoadCidType2Fontnemá parameter face index, takže požiadavka NSimSun z simsun.ttc vkladala a renderovala SimSun, face 0. Komponent teraz porovná meno rodiny s name tabuľou (nameID 1 a 16) a vyextrahuje požadovanú tvár ako samostatné sfnt skôr, než sa cmap parsuje; keď parsovanie zlyhá, bajty kolekcie prejdú a správanie sa vráti na face 0
Zápis textu, vkladanie fontov a extrakcia zdieľajú jeden model stránky naprieč Delphi, C++Builder a Lazarus a kompletné API je popísané na produktovej stránke PDFium Component for Delphi