Technisch artikel

BiDi-embeddingniveaus voor PDF-tekst zonder Uniscribe

Uniscribe doet meer werk dan de meeste aanroepers beseffen. ScriptItemize voert bidirectionele analyse en scriptsegmentatie in één passe uit, en ScriptLayout produceert de visuele volgorde van de resulterende runs. HarfBuzz, de draagbare vervanger waar mensen naar grijpen, doet geen van beide: die vormt één run waarvan de richting en het schrift al door iemand anders zijn beslist. Dus het moeilijke deel van het brengen van een Windows PDF-tekstpijplijn naar Linux of macOS is niet het binden van een shaping-engine. Het is het leveren van het bidirectionele algoritme dat Uniscribe geruisloos leverde, en in de PDFium-component is FPdfBidi daarvoor bedoeld

De unit implementeert UAX #9 rechtstreeks: regels P2 en P3 voor alineabrichting, X1 tot en met X10 voor expliciete embeddings en isolaten, W1 tot en met W7 voor zwakke types, N0 tot en met N2 voor neutralen en haken, I1 en I2 voor impliciete niveaus, en L1 en L2 voor de definitieve herordening. Twee functies dragen die: PdfResolveBidiLevels geeft één embeddingniveau per UTF-16 code unit terug en PdfBidiVisualOrder zet die niveaus om in de permutatie die code units van links naar rechts plaatst

Wat het algoritme u geeft, en wat niet

Het geeft u getallen. Even niveaus zijn links-naar-rechts, oneven niveaus zijn rechts-naar-links, en het niveau van elk teken codeert de nesteling van richtingsruns waarin dat teken zit. Uit die getallen leidt L2 een permutatie af. Wat het algoritme bewust niet doet, is beslissen welk font wordt gebruikt, ligaturen vormen of glyphs binnen een cluster herordenen; dat zijn zaken van shaping en ze horen bij de fase na deze

FPdfBidi-pijplijn voor PDF-tekst zonder Uniscribe: PdfResolveBidiLevels wijst een UAX #9-embeddingniveau toe per UTF-16 code unit en PdfBidiVisualOrder past regel L2 toe om de visuele volgorde te produceren
Niveaus coderen de nesteling van runs en regel L2 zet ze om in de permutatie die van links naar rechts leest
uses
  FPdfBidi;

var
  Levels: TPdfBidiLevels;
  Order: TPdfBidiOrder;
  ParagraphLevel: Byte;
  Text, Visual: WideString;
  I: Integer;
begin
  Text := SourceLine;
  // pbdAuto past P2-P3 toe: het eerste sterke teken beslist
  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 leest nu van links naar rechts; Levels[] zegt nog steeds
    // welke runs RTL zijn zodat een shaper correcte richtingen krijgt
  end;
end;

De tekenklassestabel is gegenereerd, niet geschreven

Elk codepoint heeft een eigenschap Bidi_Class, en het algoritme raadpleegt die voortdurend, dus de tabel is de fundering waarop al het andere staat. Die wordt gegenereerd uit de Unicode Character Database in plaats van met de hand bijgehouden: veld vijf van UnicodeData.txt geeft de toegewezen klassen, en de @missing-declaraties in DerivedBidiClass.txt geven de standaarden voor codepoints die de database niet toewijst, en zo komen niet-toegewezen blokken terecht bij de standaarden R, AL, ET of BN in plaats van L

De compressietruc is alleen de bereiken uit te zenden waarvan de klasse niet L is. Alles wat buiten elk bereik valt is L, wat zowel de Unicode-standaard is als de klasse van de overweldigende meerderheid van codepoints. Dat brengt een tabel die anders tot duizenden ingangen zou lopen terug op 745 bereiken en ongeveer 6,7 KB. De operationele consequentie is het benoemen waard: verhuist u naar een nieuwe Unicode-versie, draai de generator dan opnieuw. Het includebestand met de hand bewerken zal werken, en het zal ook geruisloos afwijken van de database bij de volgende upgrade

L2 moet codepoints herordenen, geen UTF-16 code units

Dit is de fout die echt gecorrumpeerde uitvoer oplevert, en de eerste implementatie maakte die. L2 zegt aangrenzende runs op elk niveau om te keren, van het hoogste tot het laagste oneven niveau. Geschreven tegen een UTF-16-string betekent "keer een run om" natuurlijk dat de code units erin worden omgekeerd. Voor tekens in de Basic Multilingual Plane is dat prima. Voor een RTL-teken in het astrale vlak, zoals die in de blokken Cypriot of Old South Arabian rond U+10800, is dat het niet: het teken is een surrogaatpaar, het omkeren van de run zet het lage surrogaat vóór het hoge, en de string bevat nu twee ongepaarde surrogaten in plaats van één teken. Niets stroomafwaarts kan dat herstellen

De oplossing is L2 op codepoint-units uit te voeren. De implementatie voegt code units samen tot codepoint-units, voert de omkeringen op die units uit en zet het resultaat aan het eind terug naar code unit-indices. Daarom neemt PdfBidiVisualOrder de tekst mee en niet alleen de niveaus-array: op grond van de niveaus alleen kan die niet zien waar de surrogaatgrenzen liggen. Dezelfde surrogaatpaar-discipline loopt door de tekst-API's in het algemeen, zoals beschreven in het artikel over emoji, CJK en surrogaatparen

Surrogaatpaar-corruptie bij bidi-herordening: UTF-16 code units omkeren splitst een astrale teken rond U+10800 in ongepaarde surrogaten, terwijl samengevoegde codepoint-units omkeren het intact houdt
Regel L2 moet code units samenvoegen tot codepoints vóór het omkeren en ze daarna terug uitvouwen

De afdaling door niveaus moet niveaus meenemen die niet voorkomen

De tweede fout is subtieler en produceert geen crash, alleen tekst die niet is herordeeld. L2 zegt te beginnen bij het hoogste aanwezige niveau en af te dalen tot het laagste oneven niveau. Een natuurlijke optimalisatie is de verzameling niveaus verzamelen die werkelijk voorkomen en over die verzameling itereren. Die is fout

Denk aan een regel Latijnse tekst binnen een rechts-naar-links-embedding. Het alineaniveau is 0, de embedding duwt de Latijnse tekens naar niveau 2, en geen enkel teken zit op niveau 1. Itereren over voorkomende niveaus vindt alleen 0 en 2, en er is helemaal geen oneven niveau, dus de lus voert geen omkering uit. Dat antwoord is correct, maar om een reden die de optimalisatie niet kent: een omkering op niveau 2 gevolgd door een omkering op niveau 1 zou exact opheffen, dus geen van beide uitvoeren is de juiste uitkomst. Wijzig de input licht, zodat zowel niveau 1- als niveau 3-tekens bestaan maar niveau 2 niet, en de lus op basis van de verzameling slaat de niveau 2-omkering over die het algoritme vereist

// Correct: loop elk niveau van het maximum af tot het laagste oneven
// niveau, inclusief niveaus die geen enkel teken werkelijk heeft
Level := MaxLevel;
while Level >= LowestOddLevel do
begin
  ReverseRunsAtOrAbove(Level);   // geen actie wanneer geen run in aanmerking komt
  Dec(Level);
end;

Geschreven als een gewone aftellende lus valt het gedrag gratis uit de lucht, en de geen-actie-iteraties kosten niets meetbaars. Dit is een geval waarin de voor de hand liggende optimalisatie niet licht fout is, maar fout op een inputafhankelijke manier die een klein testcorpus nooit zal onthullen

Valkuil van bidi-niveauafdaling in UAX #9: alleen de voorkomende niveaus itereren slaat de vereiste niveau 2-omkering over, terwijl een gewone aftellende lus van MaxLevel tot het laagste oneven niveau altijd correct herordent
Elk niveau afdalen tot het laagste oneven kost niets en slaat nooit een vereiste omkering over

Haken: BD16 met een pragmatische tabel

Regel N0 en het BD16-hakenpaar-algoritme bestaan zodat een haakje in tekst met gemengde richting resolveert naar de richting van wat het omsluit in plaats van naar wat er toevallig naast staat. Dat vraagt een tabel van hakenparen. De implementatie draagt de paren in algemeen gebruik mee in plaats van de volledige inhoud van het Unicode-hakenbestand: ASCII, CJK, fullwidth, wiskundige en sierlijke haken

Een niet-opgenomen haak is geen fout. Die resolveert als een gewone neutraal via N1 en N2, wat precies het gedrag is dat elke implementatie had voordat Unicode 6.3 N0 introduceerde. De grens is dus "minder verfijnd voor zeldzame haken", niet "onjuist". Eén detail vraagt expliciete afhandeling: de canonieke gelijkwaardigheid tussen de hoekhaken op U+2329 en U+232A en die op U+3008 en U+3009 moet worden samengevouwen bij het matchen van paren, anders zal een openend haak op één manier geschreven niet paren met een sluitend haak dat anders geschreven is

Hoe u dertig interagerende regels test

Niet met een groot corpus, in elk geval niet eerst. De productieve aanpak waren zestien met de hand geverifieerde gevallen, elk gekozen om een specifieke regel te oefenen en elk gecontroleerd tegen de niveaus die UAX #9 zegt dat die zou moeten opleveren: alineabrichtingdetectie onder P2 en P3, de zwakke-typeregels W2, W3 en W7, de impliciete-niveauregels I1 en I2, expliciete embedding via X2 en X7, isolaten via X5a en X6a, de L1-herstelling van afsluitende witruimte en scheidingstekens, een N0-haakgeval, en één geval met een astrale teken om de surrogaatafhandeling vast te zetten

Zestien gevallen met verwachte niveaus die als bekend correct gelden vangen meer dan zestienhonderd gevallen met plausibel ogende uitvoer, want de faalmodus van een bidirectionele implementatie is tekst die bijna goed leest. Zodra die slagen, is een corpus nuttig voor het vinden van tabelgaten en prestatieproblemen, en dat zijn andere klassen defecten

Binnen de PDFium-component voeden de niveaus twee verbruikers. Aan de schrijfkant vertellen ze de shaping-backend de richting van elke run, wat de invoer is die HarfBuzz vereist. Aan de leeskant ondersteunen ze selectiegeometrie en leesvolgorde, want een klik in RTL-tekst moet mapten op een logische positie in plaats van een visuele; die mapping wordt behandeld in het artikel over visuele regelselectie en het leesvolgordemodel in gestructureerde tekstblokken en leesvolgorde. Platformondersteuningsdetails voor de component staan op de productpagina van de PDFium Delphi component