Technický článek

Úrovně zapouzdření BiDi pro text PDF bez Uniscribe

Uniscribe dělá víc práce, než si většina volajících uvědomuje. ScriptItemize provede obousměrnou analýzu a segmentaci psacích soustav v jednom průchodu a ScriptLayout produkuje vizuální pořadí vzniklých runs. HarfBuzz, přenositelná náhrada, po níž lidé sahají, nedělá ani jedno: tvaruje jediný run, jehož směr a psací soustavu už rozhodl někdo jiný. Takže tvrdou částí přenést Windows potrubí textu PDF na Linux nebo macOS není vázat tvarovací engine. Je to dodat obousměrný algoritmus, který Uniscribe potichu poskytoval, a v komponentě PDFium je k tomu FPdfBidi

Unita implementuje UAX #9 přímo: pravidla P2 a P3 pro směr odstavce, X1 až X10 pro explicitní zapouzdření a izoláty, W1 až W7 pro slabé typy, N0 až N2 pro neutrály a závorky, I1 a I2 pro implicitní úrovně a L1 a L2 pro finální přeřazení. Nesou je dvě funkce: PdfResolveBidiLevels vrací jednu úroveň zapouzdření na kódovou jednotku UTF-16 a PdfBidiVisualOrder mění ty úrovně v permutaci, která klade kódové jednotky zleva doprava

Co algoritmus dává a co nedává

Dává vám čísla. Sudé úrovně jsou zleva doprava, liché zprava doleva a úroveň každého znaku kóduje zanoření směrových runs, uvnitř nichž znak sedí. Z těch čísel L2 odvozuje permutaci. Co algoritmus záměrně nedělá, je rozhodnout, které písmo použít, tvořit ligatury nebo přeřazovat glyfy uvnitř clusteru; to jsou starosti tvarování a patří fázi po této

Potrubí FPdfBidi pro text PDF bez Uniscribe: PdfResolveBidiLevels přiřadí úroveň zapouzdření UAX #9 na kódovou jednotku UTF-16 a PdfBidiVisualOrder aplikuje pravidlo L2 k vytvoření vizuálního pořadí
Úrovně kódují zanoření runs a pravidlo L2 je mění v permutaci čtenou zleva doprava
uses
  FPdfBidi;

var
  Levels: TPdfBidiLevels;
  Order: TPdfBidiOrder;
  ParagraphLevel: Byte;
  Text, Visual: WideString;
  I: Integer;
begin
  Text := SourceLine;
  // pbdAuto aplikuje P2-P3: rozhodne první silný znak
  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 teď čte zleva doprava; Levels[] pořád říká, které
    // runs jsou RTL, takže shaperovi lze podat správné směry
  end;
end;

Tabulka znakových tříd se generuje, nepíše se

Každý kódový bod má vlastnost Bidi_Class a algoritmus se jí neustále dotazuje, takže tabulka je základem, na němž stojí všechno ostatní. Generuje se z Unicode Character Database, nikoli se neudržuje ručně: pole pět v UnicodeData.txt dává přiřazené třídy a deklarace @missing v DerivedBidiClass.txt dávají výchozí hodnoty pro kódové body, které databáze nepřiřazuje, čímž nealokované bloky správně defaultují na R, AL, ET nebo BN, nikoli na L

Kompresním trikem je vypustit jen rozsahy, jejichž třída není L. Cokoli padajícího mimo každý rozsah je L, což je jak výchozí hodnota Unicode, tak třída naprosté většiny kódových bodů. Tím se tabulka, která by jinak běžela do tisíců položek, dostane na 745 rozsahů a kolem 6,7 KB. Provozní důsledek stojí za vyslovení: přejdete-li na novou verzi Unicode, spusťte generátor znovu. Ruční úprava include souboru bude fungovat a zároveň se potichu rozchází s databází při příštím upgradu

L2 musí přeřazovat kódové body, nikoli kódové jednotky UTF-16

Toto je chyba, která produkuje skutečně poškozený výstup, a první implementace ji udělala. L2 říká obrátit souvislé runs na každé úrovni od nejvyšší dolů po nejnižší lichou úroveň. Zapsáno proti řetězci UTF-16 znamená „obrátit run“ přirozeně obrátit kódové jednotky v něm. U znaků v Basic Multilingual Plane je to v pořádku. U znaku RTL v astrální rovině, jako jsou ty v blocích kyperském nebo starojihožnoarabském poblíž U+10800, není: znak je surrogate pár, obrácení runu dá nízký surrogát před vysoký a řetězec teď obsahuje dva nespárované surrogáty namísto jednoho znaku. Nic downstream to nedokáže obnovit

Opravou je dělat L2 na jednotkách kódových bodů. Implementace sloučí kódové jednotky na jednotky kódových bodů, provede obracení na těch jednotkách a na konci rozvine výsledek zpět na indexy kódových jednotek. Proto PdfBidiVisualOrder bere text, nikoli jen pole úrovní: z úrovní samotných nedokáže říct, kde jsou hranice surrogátů. Tatáž disciplína surrogate párů protéká textovými API obecně, jak popisuje článek o emoji, CJK a surrogátových párech

Poškození surrogate páru při bidi přeřazení: obrácení kódových jednotek UTF-16 rozdělí astrální znak poblíž U+10800 na nespárované surrogáty, zatímco obrácení sloučených jednotek kódových bodů jej drží neporušený
Pravidlo L2 musí sloučit kódové jednotky na kódové body před obracením a poté je rozvinout zpět

Sestup úrovněmi musí zahrnovat úrovně, které se nevyskytují

Druhá chyba je jemnější a neprodukuje pád, jen text, který není přeřazen. L2 říká začít na nejvyšší přítomné úrovni a pracovat dolů po nejnižší lichou úroveň. Přirozenou optimalizací je sesbírat množinu úrovní, které skutečně nastávají, a iterovat po té množině. Je chybná

Uvažujte řádek latinského textu uvnitř zapouzdření zprava doleva. Úroveň odstavce je 0, zapouzdření zatlačí latinské znaky na úroveň 2 a žádný znak nesedí na úrovni 1. Iterace po nastávajících úrovních najde jen 0 a 2 a žádná lichá úroveň není, takže smyčka neprovede žádné obrácení. Ta odpověď je správná, ale z důvodu, který optimalizace nezná: obrácení na úrovni 2 následované obrácením na úrovni 1 by se přesně vyrušilo, takže neprovedení ani jednoho je správný výsledek. Změňte vstup nepatrně tak, aby existovaly znaky úrovně 1 i úrovně 3, ale úroveň 2 ne, a smyčka založená na množině přeskočí obrácení úrovně 2, které algoritmus vyžaduje

// Správně: procházejte každou úroveň od maxima dolů po nejnižší
// lichou úroveň, včetně úrovní, které žádný znak skutečně nemá
Level := MaxLevel;
while Level >= LowestOddLevel do
begin
  ReverseRunsAtOrAbove(Level);   // no-op, když nevyhovuje žádný run
  Dec(Level);
end;

Zapsáno jako prostá klesající smyčka to chování vypadne zdarma a no-op iterace nestojí nic měřitelného. To je případ, kdy zjevná optimalizace není mírně chybná, je chybná způsobem závislým na vstupu, který malý testovací korpus nikdy neodhalí

Past sestupu úrovní bidi v UAX #9: iterace jen po úrovních, které nastávají, přeskočí vyžadované obrácení úrovně 2, zatímco prostá klesající smyčka z MaxLevel po nejnižší lichou úroveň přeřazuje vždy správně
Procházení každé úrovně dolů po nejnižší lichou nic nestojí a nikdy nepřeskočí vyžadované obrácení

Závorky: BD16 s pragmatickou tabulkou

Pravidlo N0 a algoritmus párování závorek BD16 existují proto, aby závorka v textu se smíšenými směry se rozřešla na směr toho, co obklopuje, nikoli na cokoli, co se náhodou vyskytuje vedle. To potřebuje tabulku párů závorek. Implementace nese páry v obecném užívání namísto plného obsahu souboru závorek Unicode: ASCII, CJK, plné šířky, matematické a ozdobné závorky

Neuvedená závorka není chyba. Rozřeší se jako obyčejný neutrální skrze N1 a N2, což je přesně chování, které měly všechny implementace předtím, než Unicode 6.3 přineslo N0. Hranice tedy je „méně propracovaná pro vzácné závorky“, nikoli „nesprávná“. Jeden detail vyžaduje explicitní obsluhu: kanonická ekvivalence mezi úhlovými závorkami na U+2329 a U+232A a těmi na U+3008 a U+3009 musí být při párování srovnána, nebo otevírací závorka napsaná jedním způsobem nedokáže spárovat se zavírací napsanou druhým

Jak testujete třicet interagujících pravidel

Ne velkým korpusem, aspoň ne nejdřív. Produktivním přístupem bylo šestnáct ručně ověřených případů, každý zvolený k procvičení konkrétního pravidla a každý zkontrolovaný proti úrovním, které UAX #9 říká, že má produkovat: detekce směru odstavce pod P2 a P3, slabotypová pravidla W2, W3 a W7, pravidla implicitních úrovní I1 a I2, explicitní zapouzdření skrze X2 a X7, izoláty skrze X5a a X6a, reset L1 koncových bílých míst a oddělovačů, případ závorky N0 a jeden případ s astrálním znakem k uzamčení obsluhy surrogátů

Šestnáct případů se známě správnými očekávanými úrovněmi chytí víc než šestnáct set případů s výstupem vypadajícím věrohodně, protože režim selhání obousměrné implementace je text, který se čte téměř správně. Jakmile ty projdou, korpus je užitečný k hledání mezer v tabulce a problémů s výkonem, což jsou jiné třídy defektů

Uvnitř komponenty PDFium živí úrovně dva konzumenty. Na straně zápisu říkají tvarovacímu backendu směr každého runu, což je vstup, který HarfBuzz vyžaduje. Na straně čtení informují geometrii výběru a pořadí čtení, protože kliknutí v textu RTL se musí mapovat na logickou pozici, nikoli vizuální; to mapování pokrývá článek o výběru vizuálních řádků a model pořadí čtení strukturované textové bloky a pořadí čtení. Detaily podpory platforem komponenty jsou na stránce produktu PDFium Delphi component