Технічна стаття

Рівні вкладення BiDi для тексту PDF без Uniscribe

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 виводить перестановку. Чого алгоритм свідомо не робить — вирішувати, який шрифт вжити, творити лігатури чи перевпорядковувати гліфи всередині кластера; це справи формування і належать стадії після цієї

Конвеєр FPdfBidi для тексту PDF без Uniscribe: PdfResolveBidiLevels призначає рівень вкладення UAX #9 на одиницю коду UTF-16, а PdfBidiVisualOrder застосовує правило L2, щоб творити візуальний порядок
Рівні кодують вкладення пробігів, а правило 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, перезапустіть генератор. Ручне редагування include-файла працюватиме, і воно ж мовчки розійдеться з базою на наступному оновленні

L2 мусить перевпорядковувати кодові точки, а не одиниці коду UTF-16

Це помилка, що творить справді пошкоджений результат, і перша реалізація її зробила. L2 каже обертати суміжні пробіги на кожному рівні від найвищого вниз до найнижчого непарного. Написане проти рядка UTF-16, «обернути пробіг» природно означає обертання одиниць коду в ньому. Для символів основної багатомовної площини це гаразд. Для RTL-символа в астральній площині, як-от у блоках кіпрського чи давньопівденноаравійського письма біля U+10800, — ні: символ є сурогатною парою, обертання пробіга кладе низьку сурогату перед високою, і рядок тепер містить дві непарові сурогати замість одного символу. Ніщо нижче за течією не може це відновити

Виправлення — робити L2 на одиницях кодових точок. Реалізація зливає одиниці коду в одиниці кодових точок, виконує обертання на тих одиницях і в кінці розгортає результат назад в індекси одиниць коду. Саме тому PdfBidiVisualOrder бере текст, а не лише масив рівнів: із самих рівнів вона не може сказати, де межі сурогатів. Та сама дисципліна сурогатних пар проходить крізь текстові API загалом, як описано в статті про емодзі, CJK та сурогатні пари

Пошкодження сурогатної пари в двонаправленому перевпорядкуванні: обертання одиниць коду UTF-16 розколює астральний символ біля U+10800 на непарові сурогати, тоді як обертання злитих одиниць кодових точок тримає його цілим
Правило L2 мусить зливати одиниці коду в кодові точки перед обертанням, а потім розгортати їх назад

Спуск рівнями мусить включати рівні, яких не буває

Друга помилка тонша і не творить падіння, лише текст, що не перевпорядкований. L2 каже почати з найвищого присутнього рівня й іти вниз до найнижчого непарного. Природна оптимізація — зібрати множину рівнів, що справді трапляються, і ітерувати по ній. Вона неправильна

Візьміть рядок латинського тексту всередині справа-ліворуч вкладення. Рівень абзацу 0, вкладення штовхає латинські символи на рівень 2, і жоден символ не сидить на рівні 1. Ітерація по рівнях, що трапляються, знаходить лише 0 і 2, і непарного рівня взагалі немає, тож цикл не виконує жодного обертання. Та відповідь правильна, але з причини, якої оптимізація не знає: обертання на рівні 2, за яким пішло б обертання на рівні 1, скасувалося б точно, тож виконання жодного — правильний результат. Змініть вхід трохи, щоб символи рівнів 1 і 3 існували, але рівня 2 не було, — і цикл на множині пропускає обертання рівня 2, якого алгоритм вимагає

// Правильно: пройдіть кожен рівень від максимуму вниз до
// найнижчого непарного, включно з рівнями, яких жоден символ
// справді не має
Level := MaxLevel;
while Level >= LowestOddLevel do
begin
  ReverseRunsAtOrAbove(Level);   // без дії, коли жоден пробіг не кваліфікується
  Dec(Level);
end;

Написане як простий спадний цикл поведінка випадає безкоштовно, а ітерації без дії не коштують нічого вимірюваного. Це випадок, коли очевидна оптимізація не трохи неправильна — вона неправильна залежно від входу так, що малий тестовий корпус ніколи не виявить

Пастка спуску рівнів BiDi в UAX #9: ітерація лише рівнями, що трапляються, пропускає потрібне обертання рівня 2, тоді як простий спадний цикл від MaxLevel до найнижчого непарного завжди перевпорядковує правильно
Прохід кожного рівня вниз до найнижчого непарного нічого не коштує і ніколи не пропускає потрібного обертання

Дужки: 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