Техническа статия

Emoji и CJK символи в PDFium повреждат WideChar в Delphi

Извлечете emoji или японско име от семеен регистър като текст от PDF и на мястото на символа може да получите квадратче, въпросителен знак или празно място. Обичайната причина е свойството Character[] на PDFium Component: то чете всеки глиф чрез FPDFText_GetUnicode, който връща пълна Unicode кодова точка като беззнакова 32-битова стойност, а после я подава на Delphi като единичен 16-битов WideChar. Кодова точка над U+FFFF не може да премине по този начин цяла, а проблемът остава скрит при преглед на визуализираната страница, защото визуализирането и извличането на текст използват отделни пътища в PDFium — документът може да показва emoji безупречно и въпреки това да върне повредени данни веднага щом прочетете Character[] в цикъл и сглобите низ от резултатите

Основната многоезична равнина и границата U+FFFF на WideChar

WideChar в Delphi е 16-битов тип и побира само една UTF-16 кодова единица. Основната многоезична равнина на Unicode обхваща U+0000 до U+FFFF и се побира изцяло в нея, затова латиница, кирилица, гръцки и обичайният блок CJK Unified Ideographs преминават през един WideChar без проблем. В реални документи често се срещат два вида символи извън този диапазон: emoji, много от които са в блока Emoticons, започващ от U+1F600, и редки CJK идеограми от CJK Unified Ideographs Extension B между U+20000 и U+2A6DF, където са по-рядко използвани китайски, японски и корейски символи, включително много лични и географски имена. UTF-16 обработва всичко над U+FFFF със сурогатна двойка — две 16-битови кодови единици, висок сурогат от $D800 до $DBFF и нисък сурогат от $DC00 до $DFFF, които заедно кодират една кодова точка — а формулата за тази двойка е достатъчно определена, за да се покаже директно в Pascal

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;

Подайте на функцията U+1F600, emoji с усмихнато лице, и ще получите висок сурогат $D83D и нисък сурогат $DE00, тоест две 16-битови стойности, а не една. Нито една половина няма самостоятелно значение: самотен $D83D без последващ $DE00 е висящ сурогат, който повечето кодове за обработка на текст премахват, заменят с глиф за заместване или превръщат в грешка

Защо FPDFText_GetUnicode връща стойност, която Character[] не побира

FPDFText_GetUnicode връща LongWord, тоест пълна 32-битова стойност, защото текстовото кодиране на PDF вече съдържа пълната Unicode скаларна стойност за всеки глиф. CMap на PDF ToUnicode свързва кодовете на символите с Unicode текст, а когато глифът представлява символ от астралната равнина — всичко извън Основната многоезична равнина — съответствието съдържа пълна кодова точка, а не 16-битов фрагмент. PDFium вътрешно декодира стойността до скаларна стойност и я връща през границата на DLL чрез FPDFText_GetUnicode, където 32-битовата стойност трябва да се превърне в нещо, което свойство на Delphi може да върне към вашия код

Очевидната реализация е WideChar(FPDFText_GetUnicode(TextPage, Index)), но тя е неправилна. Твърдото преобразуване от 32-битова към 16-битова стойност запазва само младшите 16 бита и тихо изхвърля останалите, без изключение и без проверка на диапазона. При U+1F600 това запазва $F600 и губи информацията, че истинската стойност е била над U+FFFF, като създава кодова единица, която дори не е валиден висящ сурогат, а несвързан символ от Основната многоезична равнина със същите младши битове. Съберете хиляди такива стойности в низ и последващият код вече няма как да различи повреден символ от истински символ

Какво връщат Character[] и Charcode[] за кодови точки от астралната равнина

Свойствата Character[] и Charcode[] на PDFium Component връщат U+FFFD, Unicode символа за заместване, когато действителната кодова точка е над U+FFFF, вместо да я съкратят без предупреждение. Тази проверка се намира директно в аксесора зад свойството 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;

Връщането на U+FFFD вместо съкратен фрагмент е съзнателна, ограничена поправка, а не нов дизайн. Character[] и Charcode[] са от тип WideChar и в TPdf, и в TPdfView, така че промяна към тип, способен да носи пълна кодова точка, би нарушила съществуващия код, който очаква един глиф на индекс да означава една 16-битова стойност. U+FFFD е определеният от Unicode Standard заместител за точно тази ситуация, затова извикващият код може да получи ясен и документиран сигнал вместо тихо грешни данни. Има обаче една гранична особеност: U+FFFD е легитимен символ сам по себе си, така че в рядък документ, който вече съдържа истински глиф за заместване, по самата стойност не може да се разбере дали индексът съдържа него или съкратен астрален символ

Как да извличате правилно emoji и CJK Extension B в Delphi

Използвайте Text вместо да обхождате Character[], когато ви е нужно самото текстово съдържание, защото Text минава през FPDFText_GetText и връща пълен WString с правилни сурогатни двойки за всяка поддържана кодова точка от астралната равнина, вместо една стойност с фиксирана ширина на индекс. Pdf.Text(0, MaxInt) или краткият запис Pdf.Text извлича цяла страница с едно извикване, а Pdf.Text(StartIndex, Count) извлича по-малък диапазон по същия начин. Character[] остава подходящо, когато ви трябват само позиция, шрифт или флаг за даден индекс и не използвате самата кодова точка — CharacterOrigin[], FontSize[] и CharacterMapError[] не зависят от това дали глифът е астрален

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;

Проверката за пропускане на генерирани и несъпоставени символи в този цикъл следва същия модел като извличането на обикновен текст в извличане на текст от PDF документи с PDFium Component; разликата е само в последния ред, който заменя директното добавяне на Character[I] с извикване на Text за един индекс, така че астралните символи да пристигат като пълни сурогатни двойки, а не като заместители

Къде възниква проблемът: чатове, лични имена и вградени CJK шрифтове

Emoji се срещат навсякъде, където PDF съхранява неформална комуникация: експортирани чатове, извадки от отзиви в магазини за приложения и преписи от системи за поддръжка, запазени като PDF за архив. CJK Extension B се появява по-рядко, но залогът е по-висок при лични и географски имена, защото японските семейни регистри, китайските документи за регистрация на домакинства и тайванските документи за самоличност често съдържат символи извън обичайния CJK блок. Система за заплати или проверка на самоличност, която извлича имена от сканирани държавни документи, е точно сред сценариите, в които тихо повреден символ води до неуспешно съвпадение, а не до дребен визуален дефект

Редките CJK идеограми носят и риск, свързан със шрифтовете, не само с кодирането, защото шрифтът трябва да съдържа глиф за кодова точка от диапазона U+20000, за да може тя изобщо да се визуализира, а малко системни шрифтове я поддържат. Ако вече обхождате FontIsEmbedded[] за всеки символ по начина, описан в четене на свойствата на PDF шрифтове с PDFium Component, проверете същия индекс и за двата проблема: индекс, който връща U+FFFD от Character[] и показва невграден шрифт, сочи към документ, който няма да извлече или отпечата този символ правилно, а поправката е в начина на създаване на PDF, не в кода за извличане

Описаните тук свойства Character[], Charcode[] и Text са част от стандартния PDFium Component за Delphi и C++Builder