Uniscribe делает больше работы, чем большинство вызывающих осознаёт. ScriptItemize выполняет двунаправленный анализ и сегментацию по письму за один проход, а ScriptLayout выдаёт визуальный порядок получившихся прогонов. HarfBuzz, переносимая замена, за которой люди идут, не делает ни того, ни другого: он формирует одиночный прогон, чьи направление и письмо уже решены кем-то другим. Поэтому трудная часть переноса конвейера текста PDF с Windows на Linux или macOS — не привязка движка шейпинга. Это поставка двунаправленного алгоритма, который Uniscribe тихо предоставлял, и в компоненте PDFium для этого существует FPdfBidi
Модуль реализует UAX #9 напрямую: правила P2 и P3 для направления абзаца, X1–X10 для явных вложений и изолятов, W1–W7 для слабых типов, N0–N2 для нейтральных и скобок, I1 и I2 для неявных уровней, L1 и L2 для финального переупорядочивания. Несут его две функции: PdfResolveBidiLevels возвращает по одному уровню вложенности на кодовую единицу UTF-16, а PdfBidiVisualOrder превращает эти уровни в перестановку, расставляющую кодовые единицы слева направо
Что алгоритм вам даёт, а что нет
Он даёт вам числа. Чётные уровни — слева направо, нечётные — справа налево, и уровень каждого символа кодирует вложенность направленных прогонов, внутри которых символ сидит. Из этих чисел L2 выводит перестановку. Чего алгоритм сознательно не делает, так это не решает, какой шрифт использовать, не образует лигатуры и не переупорядочивает глифы внутри кластера; это заботы шейпинга, принадлежащие стадии после этой
uses
FPdfBidi;
var
Levels: TPdfBidiLevels;
Order: TPdfBidiOrder;
ParagraphLevel: Byte;
Text, Visual: WideString;
I: Integer;
begin
Text := SourceLine;
// pbdAuto применяет P2-P3: первый сильный символ решает
if PdfResolveBidiLevels(Text, pbdAuto, Levels, ParagraphLevel) then
begin
Order := PdfBidiVisualOrder(Text, Levels);
SetLength(Visual, Length(Order));
for I := 0 to High(Order) do
Visual[I + 1] := Text[Order[I] + 1];
// Visual теперь читается слева направо; Levels[] по-прежнему
// говорит, какие прогоны RTL, чтобы шейпер получил верные направления
end;
end;
Таблица классов символов генерируется, а не пишется
У каждой кодовой точки есть свойство Bidi_Class, и алгоритм справляется о нём постоянно, поэтому таблица — фундамент, на котором стоит всё остальное. Она генерируется из базы символов Unicode, а не ведётся вручную: пятое поле UnicodeData.txt даёт назначенные классы, а объявления @missing в DerivedBidiClass.txt дают умолчания для кодовых точек, которые база не назначает, — вот почему нераспределённые блоки корректно умолчаются к R, AL, ET или BN, а не к L
Компрессионный трюк — выдавать лишь диапазоны, чей класс не L. Всё, что попадает вне всех диапазонов, есть L, что одновременно и умолчание Unicode, и класс подавляющего большинства кодовых точек. Это сжимает таблицу, которая иначе разбежалась бы на тысячи записей, до 745 диапазонов и около 6,7 КБ. Операционное следствие стоит высказать: переходя на новую версию Unicode, перезапустите генератор. Ручная правка включаемого файла сработает, и она же молча разойдётся с базой при следующем обновлении
L2 должна переупорядочивать кодовые точки, а не кодовые единицы UTF-16
Это ошибка, производящая по-настоящему испорченный вывод, и первая реализация её совершила. L2 велит обращать смежные прогоны на каждом уровне от высшего вниз до низшего нечётного. Написанный против строки UTF-16, «обрати прогон» естественно означает обращение кодовых единиц в нём. Для символов основной многоязычной плоскости это нормально. Для RTL-символа в астральной плоскости, как те, что в блоках кипрского или древнего южноаравийского письма возле U+10800, — нет: символ есть суррогатная пара, обращение прогона ставит младший суррогат перед старшим, и строка теперь содержит две непарные суррогаты вместо одного символа. Ничто ниже по потоку не восстановит её
Исправление — делать L2 на единицах кодовых точек. Реализация сливает кодовые единицы в единицы кодовых точек, выполняет обращения на этих единицах и в конце расширяет результат обратно в индексы кодовых единиц. Именно поэтому PdfBidiVisualOrder принимает текст, а не только массив уровней: по одним уровням она не может сказать, где границы суррогатов. Та же дисциплина суррогатных пар проходит через текстовые API вообще, как описано в статье об эмодзи, CJK и суррогатных парах
Спуск по уровням должен включать уровни, которые не встречаются
Вторая ошибка тоньше и не даёт падения, лишь текст, который не переупорядочен. L2 велит начинать с высшего присутствующего уровня и спускаться до низшего нечётного. Естественная оптимизация — собрать множество уровней, которые реально встречаются, и идти по этому множеству. Она неверна
Возьмите строку латинского текста внутри вложения справа налево. Уровень абзаца 0, вложение толкает латинские символы на уровень 2, и ни один символ не сидит на уровне 1. Итерация по встречающимся уровням находит лишь 0 и 2, и нечётного уровня вовсе нет, поэтому цикл не выполняет ни одного обращения. Этот ответ верен, но по причине, которой оптимизация не знает: обращение на уровне 2, за которым следует обращение на уровне 1, взаимно уничтожилось бы в точности, поэтому неисполнение обоих — верный исход. Слегка измените вход, чтобы существовали символы и уровня 1, и уровня 3, но не уровня 2, и цикл по множеству пропустит обращение уровня 2, которого требует алгоритм
// Верно: пройдите каждый уровень от максимума вниз до низшего
// нечётного, включая уровни, которых ни у одного символа нет
Level := MaxLevel;
while Level >= LowestOddLevel do
begin
ReverseRunsAtOrAbove(Level); // пустая операция, когда ни один прогон не подходит
Dec(Level);
end;
Записанный простым убывающим циклом, режим поведения выходит бесплатно, а пустые итерации не стоят ничего измеримого. Это случай, где очевидная оптимизация не слегка неверна, а неверна зависящим от ввода образом, который малый тестовый корпус никогда не покажет
Скобки: BD16 с прагматичной таблицей
Правило N0 и алгоритм скобочных пар BD16 существуют, чтобы скобка в смешанно-направленном тексте разрешалась в направление того, что она охватывает, а не в то, что случайно оказалось рядом. Для этого нужна таблица скобочных пар. Реализация несёт пары общего употребления, а не полное содержимое файла скобок Unicode: ASCII, CJK, полноширинные, математические и орнаментальные скобки
Неперечисленная скобка — не ошибка. Она разрешается как обычная нейтральная через N1 и N2, что в точности поведение каждой реализации до того, как Unicode 6.3 ввёл N0. Поэтому граница — «менее утончено для редких скобок», а не «неверно». Одна деталь всё же требует явной обработки: каноническая эквивалентность между угловыми скобками U+2329 и U+232A и скобками U+3008 и U+3009 должна сворачиваться при сопоставлении пар, иначе открывающая скобка, написанная одним способом, не спарится с закрывающей, написанной другим
Как тестировать тридцать взаимодействующих правил
Не большим корпусом, по крайней мере не первым. Продуктивным подходом были шестнадцать проверенных вручную случаев, каждый выбран, чтобы упражнить конкретное правило, и каждый сверен с уровнями, которые UAX #9 велит выдать: обнаружение направления абзаца по P2 и P3, правила слабых типов W2, W3 и W7, правила неявных уровней I1 и I2, явное вложение через X2 и X7, изоляты через X5a и X6a, сброс L1 концевых пробелов и разделителей, случай скобки N0 и один случай с астральным символом, чтобы зафиксировать обработку суррогатов
Шестнадцать случаев с заведомо верными ожидаемыми уровнями ловят больше, чем шестнадцать сотен случаев с правдоподобно выглядящим выводом, потому что режим отказа двунаправленной реализации — текст, читающийся почти правильно. Когда они проходят, корпус полезен для поиска пробелов таблицы и проблем производительности, которые суть другие классы дефектов
Внутри компонента PDFium уровни питают двух потребителей. На стороне письма они сообщают бэкенду шейпинга направление каждого прогона — вход, которого требует HarfBuzz. На стороне чтения они информируют геометрию выделения и порядок чтения, ведь щелчок в RTL-тексте должен отобразиться в логическую позицию, а не визуальную; это отображение разобрано в статье о выделении визуальных строк, а модель порядка чтения — в материале о структурных текстовых блоках и порядке чтения. Подробности поддержки платформ компонента на странице продукта PDFium Delphi component