PDFium Component za Delphi ugrađuje sistemske fontove koje koristi TPdf.AddText kao CID fontove ključane po Unicode kodnoj tački, pa svaki CID nosi tačno jedno ToUnicode mapiranje. To je ono što sprečava da izvučeni razmaci budu vraćeni kao U+00A0 (no-break space), a crtice kao U+00AD (soft hyphen), i iz živog dokumenta i iz sačuvanog fajla
Simptom je gadan jer je nevidljiv. Indeks pretrage promaši „two-x“ jer sačuvani string sadrži meki crtičić, CSV izvoz secka drugačije, diff alat označi linije koje izgledaju identično u svakom viewer-u. Ništa na renderovanoj stranici nije pogrešno; pogrešan je samo Unicode iza glifova
Zašto se izvučeni razmaci vraćaju kao U+00A0?
Izvučeni razmaci postaju U+00A0 jer je ToUnicode CMap koji PDFium generiše u FPDFText_LoadFont ključan po glifu, a do jednog glifa može se doći iz dve kodne tačke. U Arial-u, glif 3 opslužuje i U+0020 i U+00A0, a crtični glif i U+002D i U+00AD. Generisani CMap zato mapira isti CID dvaput, jednom kroz unos bfchar i jednom kroz bfrange u obliku niza, i unos koji pravilo prednosti čitača favorizuje postaje izvučeni tekst
1 beginbfchar
<0003> <0020>
endbfchar
1 beginbfrange
<0003> <0010> [<00A0> ...]
endbfrange
Dugo je to protivrečje bilo bezopasno, jer je PDFium čitač pustio da najniže mapiranje pobedi. Uzvodna izmena prebacila je čitača na last-wins, i od tog build-a svaki razmak napisan sa AddText izvlačio se kao NBSP a svaka crtica kao meki crtičić. Primetite obrazac u parovima: 0x20/0xA0 i 0x2D/0xAD razlikuju se samo u višem bitu, što je baš ono što biste očekivali od fonta čiji cmap šalje Latin-1 dvojnice na istu konturu. Ako je Vaš kod izvlačenja bio ispravan juče a sada pada na nevidljivim znakovima, izbacite kodne tačke umesto da verujete prikazu debugger-a; osnove vađenja teksta pokrivene su u izvlačenju teksta iz PDF dokumenata sa PDFium-om u Delphi-ju
uses
SysUtils, PDFium;
const
// Razmak/U+00A0 i crtica/U+00AD dele jedan Arial glif, kao i
// grčko Omega (U+03A9) i Ohm znak (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, nesačuvan 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; // posle punog čuvanja i ponovnog učitavanja
finally
Pdf.Free;
end;
if (Live <> Sample) or (Reloaded <> Sample) then
Writeln('Mismatch: ', CodePoints(Live), '/ ', CodePoints(Reloaded));
end;
Zašto zakrpljivanje CMap-a posle čuvanja nije bilo dovoljno
Zakrpljivanje sačuvanog fajla popravlja samo sačuvani fajl, i to samo ako zakrpa drži strukturu CMap-a netaknutom bajt po bajt. Prva popravka, RepairSubsetToUnicodeCMaps u jedinici FPdfCompress, radi posle svakog ne-inkrementalnog TPdf.SaveAs-a i razrešava svaki konflikti CID: unos bfchar pobedi, par koji se razlikuje samo u višem bitu razrešava se na manju base-Latin kodnu tačku, a sve ostalo čuva svoje prvo mapiranje
Zanimljivi deo je negativan rezultat. Obnavljanje konfliktnog CMap-a čisto, u obliku početnog koda ili niza, delovalo je kao očigledan potez, i PDFium je odbio svaki obnovljeni CMap odmah, vraćajući se na Identity. Jedini izlaz koji je nativni čitač prihvatio bila je zamena konfliktnih hex vrednosti na mestu, istih dužina, sa rasporedom blokova i CID pokrivenošću netaknutim. Druga lekcija bila je skromnija: naša beleška tada je krivila slučaj u memoriji na to što živi dokument uopšte nema ToUnicode stream. Direktno zvanje DLL-a opovrglo je to, pošto živi dokument nosi isti dvosmislen stream, što je značilo da prava popravka mora da se desi pre nego što PDFium ikada generiše CMap. Popravljačka rutina ostaje u biblioteci kao odbrana za PDF-ove proizvedene drugim alatima na PDFium bazi
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
// Samo izmene istih dužina; fajlovi bez popravljivog konflikta,
// i cross-reference-stream ili object-stream fajlovi, kopiraju se kakvi jesu
RepairSubsetToUnicodeCMaps(Source, Dest);
finally
Dest.Free;
end;
finally
Source.Free;
end;
end;
Ključanje fonta po kodnoj tački umesto po glifu
Korenska popravka je prestati tražiti od PDFium-a da uopšte generiše CMap. TPdf.LoadCachedFont sada predaje sistemske bajtove fonta TPdf.LoadUnicodeKeyedCidFont-u, koji čita sopstvenu sfnt cmap tabelu fonta, više voleći podtabelu formata 12 i vraćajući se na format 4. Kodne tačke vraćaju se sortirane i bez duplikata, i CID k+1 dodeljuje se k-toj kodnoj tački, pri čemu CID 0 ostaje .notdef. Eksplicitan CIDToGIDMap šalje svaki CID njegovom glifu, pa U+0020 i U+00A0 dobijaju dva različita CID-a koja crtaju istu konturu, i ToUnicode CMap mapira svaki CID u tačno jednu kodnu tačku. Font se zatim učitava kroz FPDFText_LoadCidType2Font, isti ulazni punkt iza pisanja na nivou glifova u CID Type 2 ugrađivanju fontova sa eksplicitnim CID-to-GID mapama
// Sažeto iz 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); // jedan CID, jedna kodna tačka
Result := FPDFText_LoadCidType2Font(Document, @Data[0], Length(Data),
PAnsiChar(ToUnicode), @CidToGidMap[0], Length(CidToGidMap));
Kad FPDFText_SetText kasnije napiše string, obrnuta pretraga doskoči na jedan CID po znaku, pa NBSP, meki crtičić i Ohm znak svaki prežive kao sami sebe pod bilo kojim pravilom prednosti, u memoriji i posle bilo kog čuvanja. Pošto sačuvani fajl nosi sopstveni ToUnicode stream komponente umesto engine-generisanog, RepairSubsetToUnicodeCMaps ne nalazi ništa za popraviti u njemu
Zašto jedan bfrange unos može obrisati ceo blok?
Jedan jedini bfrange čiji CID niz prelazi xxFF granicu tera PDFium da odbaci ceo blok u kojem sedi. ISO 32000-1 §9.10.3 dozvoljava da samo poslednji bajt odredišta varira unutar raspona, ali CID strana ima svoju zamku: HandleBeginBFRange PDFium-a izvodi visoki CID kao (low and $FFFFFF00) or (high and $FF). Niz od CID 00FE do 0101 se zato čita kao 00FE do 0001, niži veći od višeg, i ceo blok se označava nevažećim. Kvar je tih: SetText uspe, stranica se savršeno renderuje, i izvlačenje vraća U+0000 za svaki znak u tom bloku
BuildUnicodeKeyedCidCMap završava niz pre nego što kodna tačka ili CID dođe do niskog bajta FF, drži svaki blok unutar granice od 100 unosa CMap gramatike, i piše kodne tačke dopunskog plana kao pojedinačne bfchar unose sa UTF-16 surrogate-pair odredištima, pošto uvećavanje surrogate para unutar raspona nema definisano značenje; surrogate strana te priče je u rukovanju emoji, CJK i surrogate parovima u Delphi-ju. CMap samo sa bfchar-ovima bi zaobišao problem granice potpuno, uz nekoliko puta veću veličinu
Šta font ključan po kodnoj tački ne pokriva?
Putanja ključana po kodnoj tački pokriva svaki font koji izlaže Unicode cmap podtabelu, i vraća se na staro ponašanje ključano po glifu za ostatak. Granice vredne znanja pre nego što se na to oslonite:
- Symbol fontovi sa samo (3,0) cmap-om, i svaki font koji CID putanja ne uspe da učita, idu kroz
FPDFText_LoadFontkao i pre, pa glif deljen između dve kodne tačke može i tamo da se izvuče dvosmisleno - Bez podtabele formata 12 mapa je ograničena na BMP, a broj unosa je pokriven na 65535 da svaki CID stane u dva bajta iznad nule
- Inkrementalna čuvanja (
saIncremental) preskačuRepairSubsetToUnicodeCMapspo dizajnu, jer inkrementalna revizija mora ostati samo-dodajuća; fontovi ključani po kodnoj tački čine to nebitnim za tekst koji komponenta sama piše - TrueType kolekcije traže posebnu pažnju: GDI
GetFontDatavraća ceo .ttc, aFPDFText_LoadCidType2Fontnema parametar indeksa faca, pa je traženje NSimSun iz simsun.ttc ugrađivalo i renderovalo SimSun, facu 0. Komponenta sada poklapa ime familije sa name tabelom (nameID 1 i 16) i izdvaja traženu facu kao samostalan sfnt pre nego što se cmap parsira; ako parsiranje padne, bajtovi kolekcije prođu kroz i ponašanje se vraća na facu 0
Pisanje teksta, ugrađivanje fontova i izvlačenje dele jedan model stranice preko Delphi, C++Builder i Lazarus-a, i ceo API opisan je na stranici proizvoda PDFium Component za Delphi