Artykuł techniczny

Poprawka ToUnicode: NBSP i miękki łącznik w ekstrakcji PDF

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

Dlaczego jeden glif Ariala zepsuł ekstrakcję tekstu PDF w Delphi: U+0020 i U+00A0 trafiają do glifa 3, a U+002D i U+00AD do glifa łącznika, więc wygenerowany ToUnicode CMap mapuje CID 0003 dwa razy — przez wpis bfchar i arrayowe bfrange — a reguła pierwszeństwa readera rozstrzyga, który punkt kodowy się wyekstrahuje
Pierwszeństwo najniższego-wygrywa trzymało spacje czystymi przez lata, aż zmiana upstream na ostatni-wygrywa sprawiła, że każda spacja z AddText ekstrahowała się jako NBSP, a każdy łącznik jako łącznik miękki
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

Poprawka indeksowana punktem kodowym w PDFium Component: LoadUnicodeKeyedCidFont czyta sfnt cmap czcionki, przydziela CID k+1 każdemu posortowanemu punktowi kodowemu z CID 0 jako notdef, prowadzi jawny CIDToGIDMap, więc U+0020 i U+00A0 zachowują różne CID-y, a BuildUnicodeKeyedCidCMap daje każdemu CID-owi dokładnie jeden punkt kodowy
NBSP, łącznik miękki i znak Ohma przeżywają potem same jako siebie pod każdą regułą pierwszeństwa, w żywym dokumencie i po każdym zapisie, więc naprawa CMap nie znajduje już niczego do naprawienia
// 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

Cicha pułapka bfrange w parsowaniu PDF CMap: bieg CID od 00FE do 0101 przecina granicę xxFF, HandleBeginBFRange wyprowadza wysoki CID jako 0001, low większe od high oznacza cały blok jako nieprawidłowy, SetText i renderowanie dalej się udają, a ekstrakcja zwraca U+0000 dla każdego znaku w bloku
BuildUnicodeKeyedCidCMap omija pułapkę, kończąc każdy bieg przed niskim bajtem FF, trzymając bloki w limicie 100 wpisów i zapisując punkty kodowe płaszczyzny uzupełniającej jako osobne wpisy bfchar

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_LoadFont jak 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ą RepairSubsetToUnicodeCMaps z 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 GetFontData zwraca cały .ttc, a FPDFText_LoadCidType2Font nie 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