Teknisk artikel

BiDi embedding-niveauer til PDF-tekst uden Uniscribe

Uniscribe gør mere arbejde, end de fleste kaldere er klar over. ScriptItemize udfører bidirektionel analyse og scriptsegmentering i ét gennemløb, og ScriptLayout producerer den visuelle orden af de resulterende runs. HarfBuzz, den portable erstatning, folk rækker efter, gør ingen af delene: den shaper et enkelt run, hvis retning og script allerede er besluttet af en anden. Så den svære del af at tage en Windows PDF-tekstpipeline til Linux eller macOS er ikke at binde en shaping-motor. Det er at levere den bidirektionelle algoritme, som Uniscribe stille leverede, og i PDFium-komponenten er det det, FPdfBidi er til for

Uniten implementerer UAX #9 direkte: reglerne P2 og P3 for afsnitsretning, X1 til X10 for eksplicitte embeddings og isolater, W1 til W7 for svage typer, N0 til N2 for neutrale og klammer, I1 og I2 for implicitte niveauer samt L1 og L2 til den afsluttende reordering. To funktioner bærer det: PdfResolveBidiLevels returnerer ét embedding-niveau pr. UTF-16 kodeenhed, og PdfBidiVisualOrder omdanner de niveauer til den permutation, der placerer kodeenheder fra venstre mod højre

Hvad algoritmen giver dig, og hvad den ikke gør

Den giver dig tal. Lige niveauer er venstre-mod-højre, ulige niveauer er højre-mod-venstre, og hvert tegns niveau koder nesting af de retningsbestemte runs, tegnet sidder inden i. Ud fra de tal afleder L2 en permutation. Hvad algoritmen bevidst ikke gør, er at afgøre, hvilken font der skal bruges, danne ligaturer eller omordne glyphs inden for et cluster; det er shaping-anliggender og hører til trinnet efter dette

FPdfBidi-pipeline til PDF-tekst uden Uniscribe: PdfResolveBidiLevels tildeler ét UAX #9 embedding-niveau pr. UTF-16 kodeenhed, og PdfBidiVisualOrder anvender regel L2 til at producere den visuelle orden
Niveauer koder run-nesting, og regel L2 omdanner dem til den permutation, der læses fra venstre mod højre
uses
  FPdfBidi;

var
  Levels: TPdfBidiLevels;
  Order: TPdfBidiOrder;
  ParagraphLevel: Byte;
  Text, Visual: WideString;
  I: Integer;
begin
  Text := SourceLine;
  // pbdAuto anvender P2-P3: det første stærke tegn afgør
  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 læses nu fra venstre mod højre; Levels[] siger stadig,
    // hvilke runs der er RTL, så en shaper kan få korrekte retninger
  end;
end;

Tegnklassetabellen er genereret, ikke skrevet

Hvert kodepunkt har en Bidi_Class-egenskab, og algoritmen slår den op konstant, så tabellen er fundamentet, alt andet står på. Den genereres fra Unicode Character Database frem for at vedligeholdes i hånden: felt fem i UnicodeData.txt giver de tildelte klasser, og @missing-erklæringerne i DerivedBidiClass.txt giver standarderne for de kodepunkter, databasen ikke tildeler, hvilket er sådan, ikke-allokerede blokke korrekt standardiseres til R, AL, ET eller BN frem for til L

Komprimeringstricket er kun at udsende de intervaller, hvis klasse ikke er L. Alt, der falder uden for ethvert interval, er L, hvilket både er Unicode-standarden og klassen af det overvældende flertal af kodepunkter. Det tager en tabel, der ellers ville løbe op i tusindvis af poster, ned til 745 intervaller og omkring 6,7 KB. Den operationelle konsekvens er værd at slå fast: når du flytter til en ny Unicode-version, kør generatoren igen. At redigere include-filen i hånden vil virke, og den vil også i stilhed afvige fra databasen ved næste opgradering

L2 skal omordne kodepunkter, ikke UTF-16 kodeenheder

Dette er den fejl, der producerer genuint korrupt output, og den første implementation lavede den. L2 siger at vende sammenhængende runs ved hvert niveau fra det højeste ned til det laveste ulige niveau. Skrevet mod en UTF-16-streng betyder "at vende et run" naturligt at vende kodeenhederne i det. For tegn i Basic Multilingual Plane er det fint. For et RTL-tegn i et astralt plan, som dem i de cypriotiske eller oldsydarabiske blokke nær U+10800, er det ikke: tegnet er et surrogate-par, og at vende run'et sætter den lave surrogate før den høje, og strengen indeholder nu to uparrede surrogater i stedet for ét tegn. Intet længere nede kan redde det

Rettelsen er at gøre L2 på kodepunkt-enheder. Implementationen fletter kodeenheder til kodepunkt-enheder, udfører vendingerne på de enheder og ekspanderer resultatet tilbage til kodeenhedsindekser til sidst. Det er grunden til, at PdfBidiVisualOrder tager teksten og ikke kun niveauerne-arrayet: den kan ikke se, hvor surrogate-grænserne er ud fra niveauer alene. Samme surrogate-par-disciplin løber gennem tekst-API'erne generelt, som beskrevet i artiklen om emoji, CJK og surrogate-par

Surrogate-par-korruption i bidi-reordering: at vende UTF-16 kodeenheder splitter et astralt tegn nær U+10800 i uparrede surrogater, mens vending af flettede kodepunkt-enheder holder det intakt
Regel L2 skal flette kodeenheder til kodepunkter før vending og ekspandere dem tilbage bagefter

Nedstigningen gennem niveauer skal inkludere niveauer, der ikke optræder

Den anden fejl er mere subtil og producerer intet crash, bare tekst, der ikke er omordnet. L2 siger at starte ved det højeste tilstedeværende niveau og arbejde ned til det laveste ulige niveau. En naturlig optimering er at indsamle mængden af niveauer, der reelt optræder, og iterere over den mængde. Det er forkert

Overvej en linje latinsk tekst inde i en højre-mod-venstre-embedding. Afsnitsniveauet er 0, embeddingen skubber de latinske tegn til niveau 2, og intet tegn sidder på niveau 1. At iterere over optrædende niveauer finder kun 0 og 2, og der er slet intet ulige niveau, så løkken udfører ingen vending. Det svar er korrekt, men af en grund, optimeringen ikke kender: en vending på niveau 2 efterfulgt af en vending på niveau 1 ville annullere eksakt, så at udføre ingen af dem er det rigtige udfald. Ændr inputtet en anelse, så både niveau 1- og niveau 3-tegn findes, men niveau 2 ikke gør, og den mængdebaserede løkke springer niveau 2-vendingen over, som algoritmen kræver

// Korrekt: gå hvert niveau fra maksimum ned til det laveste ulige
// niveau, inklusive niveauer, intet tegn reelt har
Level := MaxLevel;
while Level >= LowestOddLevel do
begin
  ReverseRunsAtOrAbove(Level);   // no-op, når intet run kvalificerer
  Dec(Level);
end;

Skrevet som en almindelig nedtællende løkke falder adfærden gratis ud af det, og no-op-iterationerne koster intet måleligt. Dette er et tilfælde, hvor den oplagte optimering ikke er en anelse forkert, den er forkert på en inputafhængig måde, som et lille testkorpus aldrig vil afsløre

Bidi-niveaunedstigningsfælde i UAX #9: at iterere kun de niveauer, der optræder, springer den krævede niveau 2-vending over, mens en almindelig nedtællende løkke fra MaxLevel til det laveste ulige niveau altid omordner korrekt
At gå hvert niveau ned til det laveste ulige koster ingenting og springer aldrig en krævet vending over

Klammer: BD16 med en pragmatisk tabel

Regel N0 og BD16 klammerpar-algoritmen findes, så en parentes i blandet-retning tekst opløses til retningen af det, den omslutter, snarere end til hvad end der tilfældigvis er tilstødende. Det behøver en tabel over klammerpar. Implementationen bærer parrene i almindelig brug frem for hele indholdet af Unicode-klammefilen: ASCII, CJK, fullwidth, matematiske og ornamentale klammer

En ulistet klamme er ikke en fejl. Den opløses som en almindelig neutral gennem N1 og N2, hvilket præcis er den adfærd, enhver implementation havde, før Unicode 6.3 indførte N0. Så grænsen er "mindre raffineret for sjældne klammer", ikke "forkert". Én detalje behøver eksplicit håndtering: den kanoniske ækvivalens mellem vinkelklammerne ved U+2329 og U+232A og dem ved U+3008 og U+3009 skal foldes ved parringsmatch, ellers vil en åbningsklamme skrevet på den ene måde ikke parre med en lukkeklamme skrevet på den anden

Sådan tester du tredive interagerende regler

Ikke med et stort korpus, i det mindste ikke først. Den produktive tilgang var seksten håndverificerede tilfælde, hvert valgt til at afprøve en specifik regel og hvert tjekket mod de niveauer, UAX #9 siger, det skal give: afsnitsretningsdetektion under P2 og P3, de svage type-regler W2, W3 og W7, de implicitte niveauregler I1 og I2, eksplicit embedding via X2 og X7, isolater via X5a og X6a, L1-nulstillingen af afsluttende whitespace og separatorer, et N0-klammertilfælde og ét tilfælde med et astralt tegn for at låse surrogate-håndteringen

Seksten tilfælde med kendt-korrekte forventede niveauer fanger mere end seksten hundrede tilfælde med plausibelt udseende output, for fejltilstanden hos en bidirektionel implementation er tekst, der læses næsten rigtigt. Når først de består, er et korpus nyttigt til at finde tabelhuller og ydelsesproblemer, som er forskellige klasser af defekter

Inde i PDFium-komponenten føder niveauerne to forbrugere. På skrivesiden fortæller de shaping-backenden retningen af hvert run, hvilket er det input, HarfBuzz kræver. På læsesiden informerer de markeringsgeometri og læserækkefølge, da et klik i RTL-tekst skal mappes til en logisk position snarere end en visuel; den mapping er dækket i artiklen om visuel linjemarkering og læserækkefølge-modellen i strukturerede tekstblokke og læserækkefølge. Platformunderstøttelsesdetaljer for komponenten findes på produktsiden PDFium Delphi component