PDFium Component dla Delphi osadza czcionki systemowe używane przez TPdf.AddText jako czcionki CID indeksowane punktem kodowym Unicode, więc każdy CID niesie dokładnie jedno mapowanie ToUnicode. To właśnie zatrzymuje ekstrahowane spacje przed powrotem jako U+00A0 (twarda spacja) i łączniki jako U+00AD (łącznik miękki) — i to zarówno w żywym dokumencie, jak i w zapisanym pliku
Objaw jest paskudny, bo jest niewidzialny. Indeks wyszukiwania nie znajduje „two-x”, bo zapisany string zawiera łącznik miękki, eksport CSV tnie się inaczej, a narzędzie diffu flaguje linie wyglądające identycznie w każdej przeglądarce. W wyrenderowanej stronie nic nie jest zepsute; zepsuty jest tylko Unicode za glifami
Dlaczego ekstrahowane spacje wracają jako U+00A0?
Ekstrahowane spacje zamieniają się w U+00A0, bo ToUnicode CMap generowany przez PDFium w FPDFText_LoadFont jest indeksowany glifem, a do jednego glifa można dojść z dwóch punktów kodowych. W Arialu glif 3 obsługuje i U+0020, i U+00A0, a glif łącznika — i U+002D, i U+00AD. Wygenerowany CMap mapuje więc ten sam CID dwa razy: raz przez wpis bfchar i raz przez arrayową formę bfrange, a to, który wpis wybierze reguła pierwszeństwa readera, staje się tekstem ekstrahowanym
1 beginbfchar
<0003> <0020>
endbfchar
1 beginbfrange
<0003> <0010> [<00A0> ...]
endbfrange
Przez długi czas ta sprzeczność była nieszkodliwa, bo reader PDFium pozwalał wygrywać mapowaniu najniższemu. Zmiana upstream przełączyła readera na ostatni-wygrywa i od tamtej buildy każda spacja zapisana przez AddText ekstrahowała się jako NBSP, a każdy łącznik jako łącznik miękki. Zauważ wzorzec w parach: 0x20/0xA0 i 0x2D/0xAD różnią się tylko najwyższym bitem, czyli dokładnie tego można się spodziewać po czcionce, której cmap wysyła sobowtóry z Latin-1 na ten sam kontur. Jeśli twój kod ekstrakcji był wczoraj zdrowy, a teraz wali się na niewidzialnych znakach, zrzuć punkty kodowe, zamiast ufać widokowi debuggera; podstawy wyciągania tekstu opisuje ekstrakcja tekstu z dokumentów PDF z PDFium w Delphi
uses
SysUtils, PDFium;
const
// Spacja/U+00A0 i łącznik/U+00AD dzielą jeden glif Ariala, podobnie jak
// greckie Omega (U+03A9) i znak Ohma (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; // żywy, niezapisany 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 pełnym zapisie i ponownym wczytaniu
finally
Pdf.Free;
end;
if (Live <> Sample) or (Reloaded <> Sample) then
Writeln('Mismatch: ', CodePoints(Live), '/ ', CodePoints(Reloaded));
end;
Dlaczego łatanie CMap po zapisie nie wystarczyło
Łatanie zapisanego pliku naprawia tylko zapisany plik — i to tylko wtedy, gdy łata trzyma strukturę CMap nietkniętą bajt w bajt. Pierwsza poprawka, RepairSubsetToUnicodeCMaps w jednostce FPdfCompress, biegnie po każdym nieprzyrostowym TPdf.SaveAs i rozwiązuje każdy konfliktowy CID: wygrywa wpis bfchar, para różniąca się tylko najwyższym bitem rozwiązuje się do mniejszego bazowo-łacińskiego punktu kodowego, a wszystko inne zachowuje swoje pierwsze mapowanie
Ciekawa jest wynik negatywny. Przebudowanie konfliktowego CMap-a po bożemu, w formie start-code albo array, wyglądało na oczywisty ruch, a PDFium odrzucał każdą przebudowaną mapę w całości, spadając do Identity. Jedyne wyjście, które natywny reader przyjął, to równoliczna podmiana konfliktowych wartości hex w miejscu, z nietkniętym układem bloków i pokryciem CID. Druga lekcja była skromniejsza: nasza tamtejsza notka winiła przypadek w pamięci o to, że żywy dokument w ogóle nie ma strumienia ToUnicode. Bezpośrednie wołanie DLL obaliło to, bo żywy dokument niesie ten sam wieloznaczny strumień, co znaczyło, że prawdziwa poprawka musiała zajść, zanim PDFium w ogóle wygeneruje CMap. Rutyna naprawy zostaje w bibliotece jako obrona przed PDF-ami wyprodukowanymi przez inne narzędzia 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
// Wyłącznie edycje równoliczne; pliki bez naprawialnego konfliktu,
// a także pliki ze strumieniami xref albo strumieniami obiektów, są kopiowane jak są
RepairSubsetToUnicodeCMaps(Source, Dest);
finally
Dest.Free;
end;
finally
Source.Free;
end;
end;
Indeksowanie czcionki punktem kodowym zamiast glifem
Poprawka u korzenia to przestanie proszenia PDFium o generowanie CMap w ogóle. TPdf.LoadCachedFont przekazuje teraz bajty czcionki systemowej do TPdf.LoadUnicodeKeyedCidFont, która czyta własną tabelę sfnt cmap czcionki, preferując podtablicę formatu 12 i spadając do formatu 4. Punkty kodowe wracają posortowane i od-duplikowane, a CID k+1 jest przydzielany k-temu punktowi kodowemu, z CID 0 zostawionym jako .notdef. Jawny CIDToGIDMap kieruje każdy CID do jego glifa, więc U+0020 i U+00A0 dostają dwa różne CID-y rysujące ten sam kontur, a ToUnicode CMap mapuje każdy CID na dokładnie jeden punkt kodowy. Czcionka jest potem wczytywana przez FPDFText_LoadCidType2Font — ten sam punkt wejścia, który stoi za zapisem na poziomie glifów w osadzaniu czcionek CID Type 2 z jawnymi mapami CID-to-GID
// Skrócone 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 punkt kodowy
Result := FPDFText_LoadCidType2Font(Document, @Data[0], Length(Data),
PAnsiChar(ToUnicode), @CidToGidMap[0], Length(CidToGidMap));
Gdy FPDFText_SetText zapisuje później string, odwrotne wyszukiwanie ląduje na jednym CID na znak, więc NBSP, łącznik miękki i znak Ohma przeżywają każdy jako siebie pod każdą regułą pierwszeństwa — w pamięci i po każdym zapisie. Bo zapisany plik niesie własny strumień ToUnicode komponentu, a nie silnikowy, RepairSubsetToUnicodeCMaps nie znajduje w nim niczego do naprawy
Dlaczego jeden wpis bfrange potrafi skasować cały blok?
Pojedynczy bfrange, którego bieg CID przecina granicę xxFF, każe PDFium wyrzucić cały blok, w którym siedzi. ISO 32000-1 §9.10.3 pozwala, by w zakresie zmienny był tylko ostatni bajt celu, ale strona CID ma własną pułapkę: HandleBeginBFRange w PDFium wyprowadza wysoki CID jako (low and $FFFFFF00) or (high and $FF). Bieg od CID 00FE do 0101 czytany jest więc jako 00FE do 0001, czyli low większe od high, a cały blok dostaje znacznik nieprawidłowy. Porażka jest cicha: SetText się udaje, strona renderuje się idealnie, a ekstrakcja zwraca U+0000 dla każdego znaku w tym bloku
BuildUnicodeKeyedCidCMap kończy bieg, zanim punkt kodowy albo CID dosięgnie niskiego bajta FF, trzyma każdy blok w limicie 100 wpisów gramatyki CMap i zapisuje punkty kodowe płaszczyzny uzupełniającej jako osobne wpisy bfchar z celami w parach surrogate UTF-16, bo inkrementowanie pary surrogate wewnątrz zakresu nie ma zdefiniowanego znaczenia; strona surrogatów tej historii jest w tekście o emoji, CJK i parach surrogate w Delphi. CMap wyłącznie na bfchar ominąłby problem granic w całości — kosztem kilka razy większego rozmiaru
Czego nie obejmuje czcionka indeksowana punktem kodowym?
Ścieżka indeksowana punktem kodowym obejmuje każdą czcionkę, która wystawia podtablicę cmap Unicode, i dla reszty spada do starego zachowania indeksowanego glifem. Granice warte poznania, zanim na tym polegniesz:
- Czcionki symboli z samym cmap (3,0) i każda czcionka, której ścieżka CID nie uda się wczytać, idą przez
FPDFText_LoadFontjak dawniej, więc glif współdzielony przez dwa punkty kodowe może się tam dalej ekstrahować wieloznacznie - Bez podtablicy formatu 12 mapa jest ograniczona do BMP, a liczba wpisów ma kap 65535, żeby każdy CID mieścił się w dwóch bajtach powyżej zera
- Zapisy przyrostowe (
saIncremental) pomijająRepairSubsetToUnicodeCMapsz założenia, bo rewizja przyrostowa musi pozostać append-only; czcionki indeksowane punktem kodowym czynią to bez znaczenia dla tekstu, który komponent sam zapisuje - TrueType Collections wymagają dodatkowej staranności: GDI-owe
GetFontDatazwraca cały .ttc, aFPDFText_LoadCidType2Fontnie ma parametru indeksu face, więc żądanie NSimSun z simsun.ttc osadzało i renderowało kiedyś SimSun, face 0. Komponent dopasowuje teraz nazwę rodziny do tabeli name (nameID 1 i 16) i wyciąga żądaną face jako samodzielne sfnt, zanim cmap zostanie sparsowany; gdy parsowanie zawodzi, bajty kolekcji przechodzą jak są, a zachowanie wraca do face 0
Zapis tekstu, osadzanie czcionek i ekstrakcja dzielą jeden model strony w Delphi, C++Builderze i Lazarusie, a pełne API jest opisane na stronie produktu PDFium Component dla Delphi