Artykuł techniczny

Poziomy zagnieżdżenia BiDi dla tekstu PDF bez Uniscribe

Uniscribe robi więcej roboty, niż większość wywołujących sobie uświadamia. ScriptItemize wykonuje analizę dwukierunkową i segmentację skryptów w jednym przebiegu, a ScriptLayout produkuje kolejność wizualną powstałych przebiegów. HarfBuzz, przenośny zamiennik, po który sięgają ludzie, nie robi ani jednego, ani drugiego: kształtuje pojedynczy przebieg, którego kierunek i skrypt ktoś inny już zadecydował. Trudną częścią przeniesienia windowsowego potoku tekstu PDF na Linuksa albo macOS nie jest więc podpięcie silnika kształtowania. To dostarczenie algorytmu dwukierunkowego, który Uniscribe po cichu dostarczał, a w komponencie PDFium po to właśnie jest FPdfBidi

Jednostka implementuje UAX #9 wprost: reguły P2 i P3 dla kierunku akapitu, X1 przez X10 dla jawnych zagnieżdżeń i izolatów, W1 przez W7 dla typów słabych, N0 przez N2 dla neutralnych i nawiasów, I1 i I2 dla poziomów niejawnych oraz L1 i L2 dla końcowego przestawiania. Dwie funkcje to niosą: PdfResolveBidiLevels zwraca jeden poziom zagnieżdżenia na jednostkę kodową UTF-16, a PdfBidiVisualOrder zamienia te poziomy w permutację, która kładzie jednostki kodowe od lewej do prawej

Co algorytm ci daje, a czego nie

Daje ci liczby. Poziomy parzyste to od lewej do prawej, nieparzyste od prawej do lewej, a poziom każdego znaku koduje zagnieżdżenie przebiegów kierunkowych, w których ten znak siedzi. Z tych liczb L2 wyprowadza permutację. Tego, czego algorytm celowo nie robi, to decydowanie, którego fontu użyć, tworzenie ligatur ani przestawianie glifów wewnątrz klastra; to są sprawy kształtowania i należą do etapu po tym

Potok FPdfBidi dla tekstu PDF bez Uniscribe: PdfResolveBidiLevels przydziela poziom zagnieżdżenia UAX #9 na jednostkę kodową UTF-16, a PdfBidiVisualOrder stosuje regułę L2, by wyprodukować kolejność wizualną
Poziomy kodują zagnieżdżenie przebiegów, a reguła L2 zamienia je w permutację czytaną od lewej do prawej
uses
  FPdfBidi;

var
  Levels: TPdfBidiLevels;
  Order: TPdfBidiOrder;
  ParagraphLevel: Byte;
  Text, Visual: WideString;
  I: Integer;
begin
  Text := SourceLine;
  // pbdAuto stosuje P2-P3: pierwszy mocny znak decyduje
  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 czyta się teraz od lewej do prawej; Levels[] wciąż mówi,
    // które przebiegi są RTL, więc kształtownik dostanie poprawne kierunki
  end;
end;

Tabela klas znaków jest generowana, nie pisana

Każdy punkt kodowy ma własność Bidi_Class, a algorytm konsultuje ją nieustannie, więc tabela jest fundamentem, na którym stoi wszystko inne. Jest generowana z Unicode Character Database, a nie pielęgnowana ręcznie: pole piąte UnicodeData.txt daje klasy przydzielone, a deklaracje @missing w DerivedBidiClass.txt dają domyślne dla punktów kodowych, których baza nie przydziela, i dzięki temu nieprzydzielone bloki domyślnie słusznie do R, AL, ET albo BN, a nie do L

Trik kompresji to emitowanie tylko zakresów, których klasa nie jest L. Cokolwiek wypadnie poza każdy zakres, jest L, co jest i domyślnym Unicode, i klasą przytłaczającej większości punktów kodowych. To zbiera tabelę, która inaczej biegłaby do tysięcy wpisów, do 745 zakresów i około 6,7 KB. Konsekwencja operacyjna warta powiedzenia: gdy przechodzisz na nową wersję Unicode, uruchom generator ponownie. Ręczna edycja pliku include zadziała i po cichu też rozjedzie się z bazą przy następnej aktualizacji

L2 musi przestawiać punkty kodowe, nie jednostki kodowe UTF-16

To jest błąd produkujący naprawdę uszkodzone wyjście i pierwsza implementacja go popełniła. L2 mówi o odwracaniu ciągłych przebiegów na każdym poziomie od najwyższego w dół do najniższego nieparzystego. Zapisane względem łańcucha UTF-16 „odwróć przebieg” naturalnie znaczy odwrócenie jednostek kodowych w nim. Dla znaków w Basic Multilingual Plane to w porządku. Dla znaku RTL w płaszczyźnie astralnej, jak te w blokach cypryjskim albo starożytnego południowoarabskiego koło U+10800, już nie: znak jest parą surogatów, odwrócenie przebiegu stawia surogat niski przed wysokim, a łańcuch zawiera teraz dwa niesparowane surogaty zamiast jednego znaku. Nic dalej w potoku tego nie naprawi

Poprawka to robienie L2 na jednostkach punktów kodowych. Implementacja scala jednostki kodowe w jednostki punktów kodowych, wykonuje odwrócenia na tych jednostkach i rozwija wynik z powrotem do indeksów jednostek kodowych na końcu. Dlatego PdfBidiVisualOrder bierze tekst, a nie tylko tablicę poziomów: nie potrafi powiedzieć z samych poziomów, gdzie są granice surogatów. Ta sama dyscyplina par surogatów przechodzi przez API tekstu ogólnie, co opisuje artykuł o emoji, CJK i parach surogatów

Uszkodzenie pary surogatów w przestawianiu dwukierunkowym: odwrócenie jednostek kodowych UTF-16 rozbija znak astralny koło U+10800 na niesparowane surogaty, a odwrócenie scalonych jednostek punktów kodowych zachowuje go w całości
Reguła L2 musi scalać jednostki kodowe w punkty kodowe przed odwracaniem, a potem rozwijać je z powrotem

Zejście przez poziomy musi obejmować poziomy, które nie występują

Drugi błąd jest subtelniejszy i nie produkuje awarii, tylko tekst, który nie został przestawiony. L2 mówi, by zacząć od najwyższego obecnego poziomu i zejść do najniższego nieparzystego. Naturalna optymalizacja to zebranie zbioru poziomów faktycznie występujących i iterowanie po tym zbiorze. To jest złe

Rozważ linię tekstu łacińskiego wewnątrz zagnieżdżenia od prawej do lewej. Poziom akapitu to 0, zagnieżdżenie pcha znaki łacińskie na poziom 2 i żaden znak nie siedzi na poziomie 1. Iterowanie po występujących poziomach znajduje tylko 0 i 2, a nieparzystego poziomu nie ma wcale, więc pętla nie wykonuje żadnego odwrócenia. Ta odpowiedź jest poprawna, ale z powodu, którego optymalizacja nie zna: odwrócenie na poziomie 2, po którym następuje odwrócenie na poziomie 1, anuluje się dokładnie, więc niewykonanie żadnego to właściwy wynik. Zmień wejście nieznacznie, tak by istniały znaki poziomu 1 i poziomu 3, ale nie poziomu 2, a pętla oparta na zbiorze pomi odwrócenie poziomu 2, którego algorytm wymaga

// Poprawnie: przechodź każdy poziom od maksimum w dół do najniższego
// nieparzystego, łącznie z poziomami, których żaden znak faktycznie nie ma
Level := MaxLevel;
while Level >= LowestOddLevel do
begin
  ReverseRunsAtOrAbove(Level);   // no-op, gdy żaden przebieg się kwalifikuje
  Dec(Level);
end;

Zapisany jako zwykła pętla dekrementująca zachowanie wypada samo i iteracje no-op nie kosztują nic mierzalnego. To przypadek, gdzie oczywista optymalizacja nie jest lekko zła, jest zła w sposób zależny od wejścia, którego mały korpus testowy nigdy nie ujawni

Pułapka zejścia po poziomach BiDi w UAX #9: iterowanie tylko występujących poziomów pomija wymagane odwrócenie poziomu 2, podczas gdy zwykła pętla dekrementująca od MaxLevel do najniższego nieparzystego zawsze przestawia poprawnie
Przechodzenie każdego poziomu w dół do najniższego nieparzystego nic nie kosztuje i nigdy nie pomija wymaganego odwrócenia

Nawiasy: BD16 z pragmatyczną tabelą

Reguła N0 i algorytm par nawiasów BD16 istnieją po to, by nawias w tekście o mieszanych kierunkach rozwiązywał się do kierunku tego, co obejmuje, a nie do tego, co akurat jest obok. To potrzebuje tabeli par nawiasów. Implementacja niesie pary w powszechnym użyciu, a nie pełną zawartość pliku nawiasów Unicode: ASCII, CJK, pełnej szerokości, matematyczne i ozdobne

Nawias niewymieniony nie jest błędem. Rozwiązuje się jako zwyczajny neutralny przez N1 i N2, czyli dokładnie zachowanie, jakie miała każda implementacja, zanim Unicode 6.3 wprowadził N0. Granica jest więc „mniej dopracowana dla rzadkich nawiasów”, a nie „niepoprawna”. Jeden szczegół wymaga jawnego traktowania: kanoniczna równoważność między nawiasami kątowymi na U+2329 i U+232A a tymi na U+3008 i U+3009 musi być złożona przy dopasowywaniu par, bo nawias otwierający zapisany jednym sposobem nie sparuje się z nawiasem zamykającym zapisanym drugim

Jak testować trzydzieści współdziałających reguł

Nie dużym korpusem, przynajmniej nie na początku. Produktywne podejście to szesnaście przypadków zweryfikowanych ręcznie, każdy wybrany, by ćwiczyć konkretną regułę, i każdy sprawdzony względem poziomów, które UAX #9 mówi, że powinien wyprodukować: detekcja kierunku akapitu pod P2 i P3, reguły typów słabych W2, W3 i W7, reguły poziomów niejawnych I1 i I2, jawne zagnieżdżenie przez X2 i X7, izolaty przez X5a i X6a, reset L1 końcowej białej spacji i separatorów, przypadek nawiasu N0 oraz jeden przypadek ze znakiem astralnym, by przypiąć obsługę surogatów

Szesnaście przypadków ze znanymi poprawnymi oczekiwanymi poziomami łapie więcej niż szesnaścieset przypadków z wyjściem wyglądającym wiarygodnie, bo trybem awarii implementacji dwukierunkowej jest tekst czytający się prawie dobrze. Gdy te przejdą, korpus jest użyteczny do znajdowania luk tabeli i problemów wydajności, które są innymi klasami defektów

Wewnątrz komponentu PDFium poziomy zasilają dwóch konsumentów. Po stronie zapisu mówią backendowi kształtowania kierunek każdego przebiegu, co jest wejściem, którego wymaga HarfBuzz. Po stronie odczytu informują geometrię zaznaczenia i kolejność czytania, bo kliknięcie w tekście RTL musi mapować się na pozycję logiczną, a nie wizualną; to mapowanie omawia artykuł o zaznaczaniu linii wizualnych, a model kolejności czytania bloki tekstu strukturalnego i kolejność czytania. Szczegóły wsparcia platform komponentu są na stronie produktu PDFium Delphi component