Articol tehnic

Niveluri de înglobare BiDi pentru text PDF fără Uniscribe

Uniscribe face mai multă muncă decât își dau seama majoritatea apelanților. ScriptItemize realizează analiza bidirecțională și segmentarea după script într-o singură trecere, iar ScriptLayout produce ordinea vizuală a trecerilor rezultate. HarfBuzz, înlocuitorul portabil la care ajung oamenii, nu face niciunul: modelează o singură trecere a cărei direcție și script au fost deja decise de altcineva. Deci partea grea a mutării unui pipeline de text PDF Windows pe Linux sau macOS nu este legarea unui motor de modelare. Este furnizarea algoritmului bidirecțional pe care Uniscribe îl furniza în tăcere, iar în componenta PDFium pentru asta este FPdfBidi

Unitatea implementează UAX #9 direct: regulile P2 și P3 pentru direcția paragrafului, X1 până la X10 pentru înglobări și izolate explicite, W1 până la W7 pentru tipuri slabe, N0 până la N2 pentru neutre și paranteze, I1 și I2 pentru niveluri implicite, și L1 și L2 pentru reordonarea finală. Două funcții o duc: PdfResolveBidiLevels returnează câte un nivel de înglobare per unitate de cod UTF-16, iar PdfBidiVisualOrder transformă acele niveluri în permutarea care așază unitățile de cod de la stânga la dreapta

Ce vă dă algoritmul și ce nu vă dă

Vă dă numere. Nivelurile pare sunt stânga-dreapta, cele impare dreapta-stânga, iar nivelul fiecărui caracter encodează imbricarea trecerilor direcționale în care caracterul acela stă. Din acele numere L2 derivă o permutare. Ce nu face algoritmul în mod deliberat este să decidă ce font să folosească, să formeze ligaturi sau să reordoneze glifele în interiorul unui cluster; acelea sunt preocupări de modelare și aparțin etapei de după aceasta

Pipeline FPdfBidi pentru text PDF fără Uniscribe: PdfResolveBidiLevels atribuie câte un nivel de înglobare UAX #9 per unitate de cod UTF-16, iar PdfBidiVisualOrder aplică regula L2 pentru a produce ordinea vizuală
Nivelurile encodează imbricarea trecerilor, iar regula L2 le transformă în permutarea care se citește de la stânga la dreapta
uses
  FPdfBidi;

var
  Levels: TPdfBidiLevels;
  Order: TPdfBidiOrder;
  ParagraphLevel: Byte;
  Text, Visual: WideString;
  I: Integer;
begin
  Text := SourceLine;
  // pbdAuto aplică P2-P3: primul caracter puternic decide
  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 se citește acum de la stânga la dreapta; Levels[] spune încă
    // ce treceri sunt RTL, astfel încât unui shaper să i se poată da direcții corecte
  end;
end;

Tabela de clase de caractere este generată, nu scrisă

Fiecare punct de cod are o proprietate Bidi_Class, iar algoritmul o consultă constant, deci tabela este fundația pe care stă tot restul. Este generată din Unicode Character Database, nu ținută de mână: câmpul cinci din UnicodeData.txt dă clasele atribuite, iar declarațiile @missing din DerivedBidiClass.txt dau impliciturile pentru punctele de cod pe care baza de date nu le atribuie, ceea ce este modul în care blocurile nealocate revin corect implicit la R, AL, ET sau BN, nu la L

Trucul de compresie este să emiteți doar intervalele a căror clasă nu este L. Orice cade în afara fiecărui interval este L, care este atât implicitul Unicode, cât și clasa majorității copleșitoare de puncte de cod. Asta duce o tabelă care altfel ar merge la mii de intrări la 745 de intervale și circa 6,7 KB. Consecința operațională merită enunțată: când treceți la o versiune Unicode nouă, rulați din nou generatorul. Editarea de mână a fișierului include va funcționa, și se va și diverge în tăcere față de baza de date la următoarea actualizare

L2 trebuie să reordoneze puncte de cod, nu unități de cod UTF-16

Aceasta este eroarea care produce rezultat efectiv corupt, iar prima implementare a făcut-o. L2 spune să inversezi trecerile contigue la fiecare nivel de la cel mai sus până la cel mai jos nivel impar. Scrisă contra unui șir UTF-16, „inversează o trecere" înseamnă în mod natural inversarea unităților de cod din ea. Pentru caracterele din Basic Multilingual Plane este în regulă. Pentru un caracter RTL dintr-un plan astral, precum cele din blocurile Cypriot sau Old South Arabian lângă U+10800, nu este: caracterul este o pereche surogat, inversarea trecerii pune surogatul de jos înaintea celui de sus, iar șirul conține acum doi surogati neperecheați în loc de un caracter. Nimic din aval nu îl poate recupera

Remedierea este să faceți L2 pe unități de punct de cod. Implementarea îmbină unitățile de cod în unități de punct de cod, realizează inversările pe acele unități și expandează rezultatul înapoi la indici de unități de cod la final. De aceea PdfBidiVisualOrder primește textul, nu doar vectorul de niveluri: nu poate spune din niveluri singure unde sunt granițele de surogat. Aceeași disciplină a perechilor de surogat traversează API-urile de text în general, după cum este descris în articolul despre emoji, CJK și perechi de surogat

Corupere de pereche surogat în reordonarea bidi: inversarea unităților de cod UTF-16 despică un caracter astral lângă U+10800 în surogati neperecheați, în timp ce inversarea unităților îmbinate de punct de cod îl păstrează intact
Regula L2 trebuie să îmbine unitățile de cod în puncte de cod înainte de inversare, apoi să le expandeze înapoi după

Descentra prin niveluri trebuie să includă niveluri care nu apar

A doua eroare este mai subtilă și nu produce niciun crash, doar text care nu este reordonat. L2 spune să începeți la cel mai înalt nivel prezent și să lucrați în jos până la cel mai jos nivel impar. O optimizare naturală este să adunați mulțimea nivelurilor care apar efectiv și să iterați peste acea mulțime. Este greșit

Luați în considerare un rând de text latin în interiorul unei înglobări dreapta-stânga. Nivelul paragrafului este 0, înglobarea împinge caracterele latine la nivelul 2, și niciun caracter nu stă la nivelul 1. Iterarea peste nivelurile care apar găsește doar 0 și 2, și nu există deloc un nivel impar, deci bucla nu realizează nicio inversare. Acel răspuns este corect, dar dintr-un motiv pe care optimizarea nu îl știe: o inversare la nivelul 2 urmată de o inversare la nivelul 1 s-ar anula exact, deci a nu realiza niciuna este rezultatul corect. Schimbați ușor datele de intrare, astfel încât să existe și caractere de nivel 1 și de nivel 3, dar nivelul 2 să nu existe, iar bucla bazată pe mulțimi sare peste inversarea de nivel 2 de care are nevoie algoritmul

// Corect: parcurgeți fiecare nivel de la maxim în jos până la cel mai
// jos nivel impar, inclusiv niveluri pe care niciun caracter nu le are
Level := MaxLevel;
while Level >= LowestOddLevel do
begin
  ReverseRunsAtOrAbove(Level);   // fără efect când nicio trecere se califică
  Dec(Level);
end;

Scris ca o buclă simplă de decrementare comportamentul rezultă gratis, iar iterațiile fără efect nu costă nimic măsurabil. Acesta este un caz în care optimizarea evidentă nu este ușor greșită, ci greșită într-un mod dependent de datele de intrare pe care un mic corpus de test nu îl va dezvălui niciodată

Capcana descentrării prin niveluri bidi în UAX #9: iterarea doar a nivelurilor care apar sare peste inversarea necesară de nivel 2, în timp ce o buclă simplă de decrementare de la MaxLevel până la cel mai jos nivel impar reordonează întotdeauna corect
Parcurgerea fiecărui nivel până la cel mai jos impar nu costă nimic și nu sare niciodată peste o inversare necesară

Paranteze: BD16 cu o tabelă pragmatică

Regula N0 și algoritmul de perechi de paranteze BD16 există astfel încât o paranteză în text cu direcții mixte să se rezolve la direcția a ceea ce înconjoară, nu la orice se întâmplă să fie adiacent. Aceasta cere o tabelă de perechi de paranteze. Implementarea poartă perechile de uz general, nu întregul conținut al fișierului de paranteze Unicode: ASCII, CJK, fullwidth, paranteze matematice și ornamentale

O paranteză nelistată nu este o eroare. Se rezolvă ca o neutră obișnuită prin N1 și N2, ceea ce este exact comportamentul pe care îl avea fiecare implementare înainte ca Unicode 6.3 să introducă N0. Deci granița este „mai puțin rafinat pentru paranteze rare", nu „incorect". Un detaliu cere tratament explicit: echivalența canonică dintre parantezele unghiulare de la U+2329 și U+232A și cele de la U+3008 și U+3009 trebuie pliată la potrivirea perechilor, altfel o paranteză deschisă scrisă într-un fel nu va reuși să se potrivească cu o paranteză închisă scrisă în celălalt

Cum testați treizeci de reguli care interacționează

Nu cu un corpus mare, cel puțin nu mai întâi. Abordarea productivă a fost șaisprezece cazuri verificate de mână, fiecare ales să exercite o regulă specifică și fiecare verificat contra nivelurilor pe care UAX #9 spune că ar trebui să le producă: detecția direcției de paragraf sub P2 și P3, regulile de tip slab W2, W3 și W7, regulile de nivel implicit I1 și I2, înglobare explicită prin X2 și X7, izolate prin X5a și X6a, resetarea L1 a spațiilor albe finale și a separatorilor, un caz de paranteză N0 și un caz cu un caracter astral pentru a fixa manipularea surogatelor

Șaisprezece cazuri cu niveluri așteptate cunoscute ca corecte prind mai mult decât o mie șase sute de cazuri cu rezultat cu aspect plauzibil, deoarece modul de eșec al unei implementări bidirecționale este text care se citește aproape corect. Odată ce acelea trec, un corpus este util pentru găsirea lacunelor de tabelă și a problemelor de performanță, care sunt clase diferite de defecte

În interiorul componentei PDFium nivelurile hrănesc doi consumatori. Pe partea de scriere îi spun backend-ului de modelare direcția fiecărei treceri, care este intrarea de care HarfBuzz are nevoie. Pe partea de citire informează geometria selecției și ordinea de lectură, deoarece un clic în text RTL trebuie cartografiat la o poziție logică, nu una vizuală; cartografierea aceea este tratată în articolul despre selecția liniilor vizuale, iar modelul de ordine de lectură în blocurile de text structurat și ordinea de lectură. Detaliile de suport de platformă pentru componentă sunt pe pagina de produs PDFium Delphi component