Artykuł techniczny

Emoji i znaki CJK psują WideChar w PDFium na Delphi

Wyciągnij emoji lub japońskie imię z rejestru rodzinnego z PDF jako tekst, a wynik pokaże kwadracik, znak zapytania, albo nic tam, gdzie powinien być znak. Właściwość Character[] komponentu PDFium zwykle jest tego przyczyną: odczytuje każdy glif przez FPDFText_GetUnicode, który zwraca pełny punkt kodowy Unicode jako wartość 32-bitową bez znaku, a następnie udostępnia go Delphi jako pojedynczy 16-bitowy WideChar. Żaden punkt kodowy powyżej U+FFFF nie może odbyć tej podróży w jednym kawałku, a uszkodzenie nigdy nie ujawnia się, gdy patrzysz na wyrenderowaną stronę, ponieważ renderowanie i wyodrębnianie tekstu przechodzą przez osobne ścieżki kodu w PDFium — dokument może wyświetlać swoje emoji doskonale i wciąż podać ci śmieci w chwili, gdy odczytasz Character[] w pętli i zbudujesz z tego łańcuch znaków

Podstawowa Płaszczyzna Wielojęzyczna i dlaczego WideChar kończy się na U+FFFF

WideChar w Delphi to typ 16-bitowy, który może pomieścić tylko jedną jednostkę kodu UTF-16. Podstawowa Płaszczyzna Wielojęzyczna Unicode (Basic Multilingual Plane), zakres U+0000 do U+FFFF, mieści się w niej dokładnie, dlatego łacina, cyrylica, greka i wspólny blok Zunifikowanych Ideogramów CJK przechodzą w obie strony przez pojedynczy WideChar bez incydentu. Dwie rodziny znaków rutynowo wypadają poza nią w rzeczywistych dokumentach: emoji, wiele z nich w bloku Emotikonów zaczynającym się od U+1F600, oraz rzadkie ideogramy CJK z Rozszerzenia B Zunifikowanych Ideogramów CJK, zakresu U+20000 do U+2A6DF zarezerwowanego dla mniej powszechnych znaków chińskich, japońskich i koreańskich, w tym wielu nazwisk osobowych i nazw miejscowości. UTF-16 obsługuje wszystko powyżej U+FFFF za pomocą pary surogatów — dwóch 16-bitowych jednostek kodu, wysokiego surogatu w zakresie $D800 do $DBFF, po którym następuje niski surogat w $DC00 do $DFFF, które razem kodują jeden punkt kodowy — a matematyka stojąca za tym parowaniem jest wystarczająco ustalona, by zademonstrować ją bezpośrednio w Pascalu

function ToSurrogatePair(CodePoint: LongWord; out Hi, Lo: WideChar): Boolean;
var
  V: LongWord;
begin
  Result := CodePoint > $FFFF;
  if Result then
  begin
    V := CodePoint - $10000;
    Hi := WideChar($D800 + (V shr 10));
    Lo := WideChar($DC00 + (V and $3FF));
  end;
end;

Podaj U+1F600, emoji uśmiechniętej buzi, przez tę funkcję, a wynikiem będzie wysoki surogat $D83D i niski surogat $DE00, dwie wartości 16-bitowe, nie jedna. Żadna z tych połówek nic sama w sobie nie znaczy; samotny $D83D siedzący w łańcuchu znaków bez $DE00 za nim to zwisający surogat, a większość kodu obsługującego tekst, który na taki natrafi, albo go odrzuca, albo zastępuje glifem zastępczym, albo zgłasza błąd

Dlaczego FPDFText_GetUnicode zwraca wartość, której Character[] nie może pomieścić?

FPDFText_GetUnicode zwraca LongWord, pełną wartość 32-bitową, ponieważ kodowanie tekstu PDF już niesie kompletną wartość skalarną Unicode dla każdego glifu. CMap ToUnicode pliku PDF mapuje kody znaków na tekst Unicode, a gdy glif reprezentuje to, co nieformalnie nazywa się znakiem z płaszczyzny astralnej — cokolwiek poza Podstawową Płaszczyzną Wielojęzyczną — to mapowanie jest pełnym punktem kodowym, nie 16-bitowym fragmentem. PDFium dekoduje go z powrotem do wartości skalarnej wewnętrznie i zwraca przez granicę DLL poprzez FPDFText_GetUnicode, i to dokładnie ta granica, w której wartość 32-bitowa musi stać się czymś, co właściwość Delphi może zwrócić twojemu kodowi

Oczywista implementacja to WideChar(FPDFText_GetUnicode(TextPage, Index)), i jest też niewłaściwa. Twardy rzut z wartości 32-bitowej na typ 16-bitowy zachowuje tylko dolne 16 bitów i po cichu odrzuca resztę, bez wyjątku i bez sprawdzenia zakresu. Dla U+1F600 oznacza to zachowanie $F600 i utratę faktu, że prawdziwa wartość kiedykolwiek była powyżej U+FFFF, co produkuje jednostkę kodu, która nie jest nawet prawidłowym zwisającym surogatem, tylko niepowiązanym znakiem z Podstawowej Płaszczyzny Wielojęzycznej, który akurat dzieli te dolne bity. Połącz kilka tysięcy takich w łańcuch znaków, a kod niżej w potoku nie ma już sposobu, by odróżnić uszkodzony znak od prawidłowego

Co teraz zwracają Character[] i Charcode[] dla punktów kodowych z płaszczyzny astralnej

Właściwości Character[] i Charcode[] komponentu PDFium zwracają U+FFFD, unicode'owy znak zastępczy, za każdym razem, gdy bazowy punkt kodowy przekracza U+FFFF, zamiast po cichu go obcinać. To zabezpieczenie siedzi bezpośrednio wewnątrz gettera właściwości stojącego za Character[]

function TPdf.GetCharacter(Index: Integer): WideChar;
var
  Code: LongWord;
begin
  LoadTextPage;
  Code := FPDFText_GetUnicode(FTextPage, Index);
  if Code > $FFFF then
    Result := #$FFFD          // astral-plane code point: cannot fit in one WideChar
  else
    Result := WideChar(Code);
end;

Zwracanie U+FFFD zamiast obciętego fragmentu to celowa, wąska poprawka, a nie przeprojektowanie. Character[] i Charcode[] są typowane jako WideChar zarówno w TPdf, jak i TPdfView, a rozszerzenie tego typu zwracanego, by nieść pełny punkt kodowy, złamałoby każdego istniejącego wywołującego, który oczekuje, że jeden glif na indeks oznacza jedną wartość 16-bitową. U+FFFD to własny, wyznaczony przez standard Unicode symbol zastępczy dokładnie dla tej sytuacji, więc wywołujący, który go sprawdza, otrzymuje zdefiniowany, udokumentowany sygnał zamiast po cichu błędnych danych. Jeden przypadek graniczny warty poznania: U+FFFD jest też prawowitym znakiem samym w sobie, więc w rzadkim dokumencie, który już zawiera prawdziwy glif znaku zastępczego, ten indeks jest nierozróżnialny od obciętego znaku astralnego wyłącznie po wartości

Jak poprawnie wyodrębnić emoji i tekst z Rozszerzenia B CJK w Delphi?

Wywołaj Text zamiast przechodzić przez Character[] za każdym razem, gdy liczy się faktyczna treść tekstowa, ponieważ Text czyta przez FPDFText_GetText i zwraca pełny WString z prawidłowymi parami surogatów dla każdego znaku astralnego w zakresie, zamiast jednej wartości o stałej szerokości na indeks. Pdf.Text(0, MaxInt), albo skrót Pdf.Text, wyodrębnia całą stronę poprawnie w jednym wywołaniu, a Pdf.Text(StartIndex, Count) pobiera mniejszy zakres w ten sam sposób. Character[] wciąż zdobywa swoje miejsce, gdy potrzebujesz tylko danych pozycji, czcionki lub flagi przy indeksie i nigdy nie dotykasz samego punktu kodowego — CharacterOrigin[], FontSize[] i CharacterMapError[] nie obchodzi, czy bazowy glif był astralny

function ExtractLineSafely(Pdf: TPdf): WString;
var
  I: Integer;
begin
  Result := '';
  for I := 0 to Pdf.CharacterCount - 1 do
    if not (Pdf.CharacterGenerated[I] or Pdf.CharacterMapError[I]) then
      Result := Result + Pdf.Text(I, 1);   // full code point, never a truncated WideChar
end;

Sprawdzenie pomijające znaki wygenerowane i niezmapowane w tej pętli to ten sam wzorzec, jaki jest używany do wyodrębniania zwykłego tekstu w wyodrębnianiu tekstu z dokumentów PDF za pomocą komponentu PDFium; jedyna zmiana to ostatnia linia, która wymienia bezpośrednie dołączenie Character[I] na jednoindeksowe wywołanie Text, tak aby znaki astralne przychodziły jako kompletne pary surogatów zamiast zastępczych placeholderów

Gdzie to faktycznie gryzie: eksporty czatów, imiona i nazwiska oraz osadzone czcionki CJK

Emoji pojawiają się wszędzie tam, gdzie PDF przechwytuje nieformalną komunikację: wyeksportowane logi czatu, zrzuty recenzji ze sklepu z aplikacjami, transkrypty systemów zgłoszeń zapisane do PDF na potrzeby archiwum zgodności. Rozszerzenie B CJK pojawia się w węższym, ale ważniejszym miejscu, w imionach i nazwiskach oraz nazwach miejscowości, ponieważ japońskie rejestry rodzinne, chińskie rejestry gospodarstw domowych i tajwańskie dokumenty tożsamości to klasyczne źródła znaków, które nigdy nie trafiły do powszechnego bloku CJK. Potok naliczania płac lub weryfikacji tożsamości, który wyodrębnia imiona i nazwiska ze skanowanych dokumentów rządowych, to dokładnie ten rodzaj obciążenia, w którym po cichu okaleczony znak zamienia się w nieudane dopasowanie, a nie kosmetyczną usterkę

Rzadkie ideogramy CJK mają też tendencję do podróżowania z problemami czcionek, nie tylko kodowania, ponieważ czcionka musi nieść glif dla punktu kodowego z zakresu U+20000, zanim cokolwiek w ogóle może się wyrenderować, a niewiele zainstalowanych czcionek systemowych to robi. Każdy, kto już przechodzi przez FontIsEmbedded[] na znak, tak jak opisuje odczyt właściwości czcionek PDF za pomocą komponentu PDFium, powinien sprawdzić ten sam indeks pod kątem obu problemów naraz: indeks, który zwraca U+FFFD z Character[] i zgłasza czcionkę niebędącą osadzoną, to dokument, który nie wyodrębni ani nie wydrukuje tego znaku poprawnie, a poprawka należy wyżej w potoku, do tego, jak PDF został wyprodukowany, nie do twojego kodu ekstrakcji

Opisane tutaj właściwości Character[], Charcode[] i Text są częścią standardowego komponentu PDFium dla Delphi i C++Buildera