PDFium Component pro Delphi vkládá systémová písma používaná TPdf.AddText jako CID fonty klíčované Unicode code pointem, takže každý CID nese přesně jedno ToUnicode mapování. Právě to zabraňuje tomu, aby extrahované mezery přicházely zpět jako U+00A0 (no-break space) a spojovníky jako U+00AD (soft hyphen), a to jak z živého dokumentu, tak z uloženého souboru
Symptom je zákeřný, protože je neviditelný. Vyhledávací index nenajde „two-x“, protože uložený řetězec obsahuje soft hyphen, CSV export se dělí jinak a diff nástroj označí řádky, které v každém prohlížeči vypadají identicky. Na vykreslené stránce není nic špatně; špatně je jen Unicode za glyfy
Proč se extrahované mezery vrací jako U+00A0?
Extrahované mezery se mění v U+00A0 proto, že ToUnicode CMap, kterou PDFium generuje v FPDFText_LoadFont, je klíčovaná glyfem a ke glyfu se dá dojít ze dvou code pointů. V Arial obsluhuje glyph 3 i U+0020, i U+00A0, a hyphen glyph i U+002D, i U+00AD. Generovaná CMap proto mapuje tentýž CID dvakrát, jednou přes položku bfchar a jednou přes array bfrange, a extrahovaným textem se stane ta položka, které dává přednost pravidlo priority čtečky
1 beginbfchar
<0003> <0020>
endbfchar
1 beginbfrange
<0003> <0010> [<00A0> ...]
endbfrange
Dlouho byla tahle rozpornost neškodná, protože čtečka PDFium nechávala vyhrát nejnižší mapování. Upstream změna přepnula čtečku na poslední-vyhrává a od toho buildu se každá mezera zapsaná AddText extrahovala jako NBSP a každý spojovník jako soft hyphen. Všimněte si vzoru v párech: 0x20/0xA0 a 0x2D/0xAD se liší jen vysokým bitem, což je přesně to, co byste čekali od fontu, jehož cmap posílá latin-1 dvojníky na stejnou obrysovou dráhu. Pokud váš extrakční kód byl včera v pořádku a teď padá na neviditelných znacích, vypalte code pointy místo důvěřování pohledu debuggeru; základy vytahování textu rozebírá článek o extrakci textu z PDF dokumentů s PDFium v Delphi
uses
SysUtils, PDFium;
const
// Mezera/U+00A0 a spojovník/U+00AD sdílejí jeden glyph Arial, stejně jako
// řecké 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 úplném uložení a znovunačtení
finally
Pdf.Free;
end;
if (Live <> Sample) or (Reloaded <> Sample) then
Writeln('Mismatch: ', CodePoints(Live), '/ ', CodePoints(Reloaded));
end;
Proč záplatování CMap po uložení nestačilo
Záplata uloženého souboru opraví jen uložený soubor a jen když záplata nechá strukturu CMap bajt za bajtem intaktní. První oprava, RepairSubsetToUnicodeCMaps v unitu FPdfCompress, běží po každém neinkrementálním TPdf.SaveAs a rozliší každý konfliktní CID: položka bfchar vyhrává, pár lišící se jen vysokým bitem se rozliší na menší base-latin code point a cokoli jiného si drží své první mapování
Zajímavá jsou negativní zjištění. Čisté znovusestavení konfliktní CMap, v podobě start-code nebo array, vypadalo jako zjevný tah a PDFium odmítlo každou znovusestavenou CMap rovnou, s fallbackem na Identity. Jediný výstup, který nativní čtečka přijala, bylo nahrazení konfliktních hex hodnot stejně dlouhou náhradou na místě, s nezměněným rozložením bloků a pokrytím CID. Druhé poučení bylo skromnější: naše poznámka tehdy sváděla in-memory případ na to, že živý dokument nemá žádný ToUnicode stream. Přímé volání DLL to vyvrátilo, protože živý dokument nese tentýž dvojznačný stream — což znamenalo, že skutečná oprava se musí stát dřív, než PDFium CMap vůbec vygeneruje. Opravná rutina zůstává v knihovně jako obrana pro PDF produkovaná jinými nástroji založenými na 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
// Jen editace stejné délky; soubory bez opravitelného konfliktu,
// a soubory s cross-reference-stream nebo object-stream se kopírují as-is
RepairSubsetToUnicodeCMaps(Source, Dest);
finally
Dest.Free;
end;
finally
Source.Free;
end;
end;
Klíčování fontu code pointem místo glyfem
Kořenová oprava je přestat žádat PDFium o generování CMap úplně. TPdf.LoadCachedFont teď předá systémové fontové byty TPdf.LoadUnicodeKeyedCidFont, která čte vlastní sfnt tabulku cmap fontu, s předností subtabulky formátu 12 a fallbackem na formát 4. Code pointy se vrátí setříděné a deduplikované a CID k+1 se přidělí k-tému code pointu, s CID 0 ponechaným jako .notdef. Explicitní CIDToGIDMap pošle každý CID ke svému glyfu, takže U+0020 a U+00A0 dostanou dva různé CID, které kreslí stejnou obrysovou dráhu, a ToUnicode CMap mapuje každý CID na jeden code point. Font se pak načte přes FPDFText_LoadCidType2Font, tentýž vstupní bod za zápisem na úrovni glyfů v článku o vkládání CID Type 2 fontů s explicitními CID-to-GID mapami
// Zhuštěno 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); // jeden CID, jeden code point
Result := FPDFText_LoadCidType2Font(Document, @Data[0], Length(Data),
PAnsiChar(ToUnicode), @CidToGidMap[0], Length(CidToGidMap));
Když FPDFText_SetText později zapisuje řetězec, zpětné vyhledání dopadne na jediný CID na znak, takže NBSP, soft hyphen i znak Ohm přežijí jako sami sobě pod kterýmkoli pravidlem priority, v paměti i po každém uložení. Protože uložený soubor nese vlastní ToUnicode stream komponenty místo enginem generovaného, RepairSubsetToUnicodeCMaps v něm nenajde nic k opravě
Jak dokáže jedna položka bfrange zahodit celý blok?
Jediný bfrange, jehož CID běh překročí hranici xxFF, donutí PDFium zahodit celý blok, ve kterém sedí. ISO 32000-1 §9.10.3 nechává v rámci range měnit jen poslední byte cíle, ale stran CID má vlastní past: HandleBeginBFRange PDFium odvodí vysoký CID jako (low and $FFFFFF00) or (high and $FF). Běh od CID 00FE do 0101 se tedy přečte jako 00FE do 0001, nízký větší než vysoký, a celý blok se označí za neplatný. Selhání je tiché: SetText uspěje, stránka se vykreslí dokonale a extrakce vrátí U+0000 pro každý znak v tom bloku
BuildUnicodeKeyedCidCMap ukončí běh dřív, než code point nebo CID dosáhne nízkého bytu FF, drží každý blok v limitu 100 položek gramatiky CMap a zapisuje code pointy doplňkových rovin jako samostatné položky bfchar s cíli v UTF-16 surrogate párech, protože inkrementace surrogate páru uvnitř range nemá definovaný význam; surrogate stránka toho příběhu je v článku o práci s emoji, CJK a surrogate páry v Delphi. CMap jen z bfchar by problém hranic obešla úplně, za několikanásobnou velikost
Co font klíčovaný code pointem nepokrývá?
Cesta klíčovaná code pointem pokrývá každé písmo, které vystavuje Unicode subtabulku cmap, a na zbytek spadne ke starému chování klíčovanému glyfem. Meze, které stojí za znát, než se na to spolehnete:
- Symbolová písma s jen (3,0) cmap a jakékoli písmo, které CID cesta nedokáže načíst, jdou přes
FPDFText_LoadFontjako dřív, takže glyf sdílený dvěma code pointy se i tam může extrahovat dvojznačně - Bez subtabulky formátu 12 je mapa omezená na BMP a počet položek má strop 65535, aby se každý CID vešel do dvou bytů nad nulou
- Inkrementální úložby (
saIncremental) vynechávajíRepairSubsetToUnicodeCMapsze zásady, protože inkrementální revize musí zůstat append-only; fonty klíčované code pointem dělají tu starost pro text, který si komponenta píše sama, irrelevantní - TrueType Collections potřebují extra péči: GDI
GetFontDatavrátí celé .ttc aFPDFText_LoadCidType2Fontnemá parametr face index, takže žádost o NSimSun z simsun.ttc dřív vložila a renderovala SimSun, face 0. Komponenta teď páruje název rodiny proti name tabulce (nameID 1 a 16) a vytáhne požadovaný face jako samostatný sfnt, než se cmap parsuje; když parsování selže, projdou byty kolekce a chování se vrátí k face 0
Zápis textu, vkládání fontů a extrakce sdílejí jeden model stránky napříč Delphi, C++Builder a Lazarus a kompletní API popisuje produktová stránka PDFium Component pro Delphi