Извлеките эмодзи или японское имя из семейного реестра из PDF как текст, и на выходе вместо символа окажется прямоугольник, знак вопроса или вообще ничего. Причина обычно в свойстве Character[] компонента PDFium: оно читает каждый глиф через FPDFText_GetUnicode, который возвращает полную кодовую точку Unicode как беззнаковое 32-битное значение, а затем предоставляет её Delphi как единственный 16-битный WideChar. Любая кодовая точка за пределами U+FFFF не может совершить это путешествие целиком, и повреждение никогда не проявляется, пока вы смотрите на отрисованную страницу, потому что рендеринг и извлечение текста идут через разные пути кода в PDFium — документ может идеально показывать свои эмодзи и всё равно отдать вам мусор в тот момент, когда вы читаете Character[] в цикле и строите из этого строку
Базовая многоязычная плоскость и почему WideChar останавливается на U+FFFF
Тип WideChar в Delphi — 16-битный тип, способный вместить только одну кодовую единицу UTF-16. Основная многоязычная плоскость (Basic Multilingual Plane) Unicode, диапазон U+0000 – U+FFFF, помещается в него в точности, поэтому латиница, кириллица, греческий и обычный блок CJK Unified Ideographs проходят через один WideChar без происшествий. Два семейства символов регулярно оказываются за его пределами в реальных документах: эмодзи, многие из них в блоке 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, эмодзи улыбающегося лица, и результатом станет старший суррогат $D83D и младший суррогат $DE00 — два 16-битных значения, а не одно. Ни одна из половин сама по себе ничего не значит; одинокий $D83D, лежащий в строке без стоящего за ним $DE00, — это висящий суррогат, и большая часть кода обработки текста, встречая такой, либо отбрасывает его, либо подставляет символ-заменитель, либо выбрасывает ошибку
Почему FPDFText_GetUnicode возвращает значение, которое Character[] вместить не может?
FPDFText_GetUnicode возвращает LongWord, полное 32-битное значение, потому что кодировка текста PDF уже несёт полное скалярное значение Unicode для каждого глифа. CMap ToUnicode в PDF сопоставляет коды символов с текстом 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 возвращают 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 заполнитель именно для такой ситуации, так что вызывающий код, проверяющий его, получает определённый, документированный сигнал вместо молча неверных данных. Одна граничная ситуация, о которой стоит знать: U+FFFD также является легитимным символом сам по себе, так что на редком документе, уже содержащем настоящий глиф символа-заменителя, этот индекс неотличим от усечённого астрального символа по одному лишь значению
Как корректно извлечь эмодзи и текст 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; единственное изменение — последняя строка, меняющая прямое добавление Character[I] на однопозиционный вызов в Text, так что астральные символы приходят как полные суррогатные пары, а не как заполнители-заменители
Где это на самом деле кусается: экспорты чатов, личные имена и встроенные шрифты CJK
Эмодзи появляются везде, где PDF фиксирует неформальное общение: экспортированные логи чатов, дампы отзывов из магазинов приложений, стенограммы системы тикетов, сохранённые в PDF для архива соответствия требованиям. CJK Extension B появляется в более узком, но более серьёзном месте — личных именах и названиях мест, потому что японские семейные реестры, китайские записи регистрации домохозяйств и тайваньские документы, удостоверяющие личность, — классические источники символов, никогда не попавших в обычный блок CJK. Конвейер расчёта зарплаты или проверки личности, извлекающий имена из отсканированных государственных документов, — именно тот вид рабочей нагрузки, где молча искажённый символ превращается в неудавшееся совпадение, а не в косметический глюк
Редкие иероглифы CJK также склонны идти рука об руку с проблемами шрифтов, а не только кодировки, потому что шрифту нужно нести глиф для кодовой точки диапазона U+20000 прежде, чем вообще что-либо сможет отрисоваться, а немногие установленные системные шрифты это делают. Любому, кто уже обходит FontIsEmbedded[] по символам так, как описано в статье о чтении свойств шрифтов PDF с компонентом PDFium, стоит проверять один и тот же индекс на обе проблемы сразу: индекс, возвращающий U+FFFD из Character[] и сообщающий о невстроенном шрифте, — это документ, который не удастся ни извлечь, ни напечатать корректно для этого символа, и исправление относится выше по потоку, к тому, как PDF был создан, а не к вашему коду извлечения
Описанные здесь свойства Character[], Charcode[] и Text — часть стандартного компонента PDFium для Delphi и C++Builder