Teknisk artikel

BiDi-inbäddningsnivåer för PDF-text utan Uniscribe

Uniscribe gör mer arbete än de flesta anropare inser. ScriptItemize utför tvåriktad analys och skriptsegmentering i ett pass, och ScriptLayout producerar den visuella ordningen av de resulterande körningarna. HarfBuzz, den portabla ersättare folk sträcker sig efter, gör inget av dem: den formar en enda körning vars riktning och skript redan har beslutats av någon annan. Så den svåra delen av att ta en Windows-PDF-textpipeline till Linux eller macOS är inte att binda en formningsmotor. Det är att tillhandahålla den tvåriktade algoritm som Uniscribe tyst tillhandahöll, och i PDFium-komponenten är det vad FPdfBidi är till för

Enheten implementerar UAX #9 direkt: reglerna P2 och P3 för styckriktning, X1 till X10 för uttryckliga inbäddningar och isolat, W1 till W7 för svaga typer, N0 till N2 för neutraler och parenteser, I1 och I2 för implicita nivåer, och L1 och L2 för den slutliga omordningen. Två funktioner bär den: PdfResolveBidiLevels returnerar en inbäddningsnivå per UTF-16-kodenhet och PdfBidiVisualOrder gör de nivåerna till permutationen som placerar kodenheter vänster till höger

Vad algoritmen ger dig, och vad den inte gör

Den ger dig tal. Jämna nivåer är vänster-till-höger, udda nivåer är höger-till-vänster, och nivån för vart och ett tecken kodar nästlingen av de riktade körningar tecknet sitter inuti. Ur de talen härleder L2 en permutation. Vad algoritmen med flit inte gör är att avgöra vilket teckensnitt som ska användas, bilda ligaturer eller omordna glyfer inuti ett kluster; det är formningsangelägenheter och hör till steget efter denna

FPdfBidi-pipeline för PDF-text utan Uniscribe: PdfResolveBidiLevels tilldelar en UAX #9-inbäddningsnivå per UTF-16-kodenhet och PdfBidiVisualOrder tillämpar regel L2 för att producera den visuella ordningen
Nivåer kodar körningsnästling, och regel L2 gör dem till permutationen som läses vänster till höger
uses
  FPdfBidi;

var
  Levels: TPdfBidiLevels;
  Order: TPdfBidiOrder;
  ParagraphLevel: Byte;
  Text, Visual: WideString;
  I: Integer;
begin
  Text := SourceLine;
  // pbdAuto tillämpar P2-P3: första starka tecknet avgö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 vänster till höger; Levels[] säger fortfarande
    // vilka körningar som är RTL så att en formare kan få korrekta
    // riktningar
  end;
end;

Teckenklasstabellen genereras, skrivs inte

Varenda kodpunkt har en egenskap Bidi_Class, och algoritmen rådfrågar den ständigt, så tabellen är grunden allt annat står på. Den genereras ur Unicode Character Database i stället för att underhållas för hand: fält fem i UnicodeData.txt ger de tilldelade klasserna, och @missing-deklarationerna i DerivedBidiClass.txt ger standardvärdena för kodpunkter databasen inte tilldelar, vilket är hur oallokerade block korrekt blir standard R, AL, ET eller BN i stället för L

Komprimeringsknepet är att avge bara de intervall vars klass inte är L. Allt som faller utanför varenda intervall är L, vilket är både Unicode-standarden och klassen för den överväldigande majoriteten av kodpunkter. Det tar en tabell som annars skulle löpa till tusentals poster ner till 745 intervall och cirka 6,7 KB. Den operativa konsekvensen är värd att ange: när du flyttar till en ny Unicode-version, kör generatorn igen. Att handredigera includefilen fungerar, och den kommer också tyst divergera från databasen vid nästa uppgradering

L2 måste omordna kodpunkter, inte UTF-16-kodenheter

Detta är misstaget som producerar genuint korrumperad utmatning, och den första implementeringen gjorde det. L2 säger att vända sammanhängande körningar på varje nivå från den högsta ner till den lägsta udda nivån. Skriven mot en UTF-16-sträng betyder ”vänd en körning” naturligt att vända kodenheterna i den. För tecken i Basic Multilingual Plane är det fint. För ett RTL-tecken i ett astralplan, som dem i de kypriotiska eller gammalsydarabiska blocken nära U+10800, är det inte det: tecknet är ett surrogatpar, att vända körningen lägger det låga surrogatet före det höga, och strängen innehåller nu två oparade surrogat i stället för ett tecken. Ingenting nedströms kan återvinna den

Fixen är att göra L2 på kodpunktsenheter. Implementeringen slår ihop kodenheter till kodpunktsenheter, utför vändningarna på de enheterna och expanderar resultatet tillbaka till kodenhetsindex i slutet. Det är därför PdfBidiVisualOrder tar texten och inte bara nivåarrayen: den kan inte säga var surrogatgränserna är ur nivåerna ensamma. Samma surrogatpar-disciplin löper genom text-API:erna i allmänhet, som beskrivs i artikeln om emoji, CJK och surrogatpar

Surrogatparksorrumpering i bidi-omordning: att vända UTF-16-kodenheter delar ett astraltecken nära U+10800 i oparade surrogat, medan att vända sammanslagna kodpunktsenheter håller det intakt
Regel L2 måste slå ihop kodenheter till kodpunkter innan vändning och expandera dem tillbaka därefter

Nedstigningen genom nivåer måste omfatta nivåer som inte förekommer

Det andra misstaget är subtilare och ger ingen krasch, bara text som inte omordnas. L2 säger att börja på den högsta nivån som finns och arbeta ner till den lägsta udda nivån. En naturlig optimering är att samla mängden nivåer som faktiskt förekommer och iterera över den mängden. Det är fel

Betrakta en rad latinsk text inuti en höger-till-vänster-inbäddning. Stycknivån är 0, inbäddningen knuffar de latinska tecknen till nivå 2, och inget tecken sitter på nivå 1. Att iterera över förekommande nivåer hittar bara 0 och 2, och det finns ingen udda nivå alls, så loopen utför ingen vändning. Det svaret är korrekt, men av en orsak optimeringen inte känner till: en vändning på nivå 2 följt av en vändning på nivå 1 skulle annuleras exakt, så att utföra ingendera är det rätta utfallet. Ändra indatan lätt, så att både nivå 1- och nivå 3-tecken finns men nivå 2 inte gör det, och den mängdsbaserade loopen hoppar över vändningen på nivå 2 som algoritmen kräver

// Rätt: gå igenom varenda nivå från maximum ner till den lägsta
// udda nivån, inklusive nivåer inget tecken faktiskt har
Level := MaxLevel;
while Level >= LowestOddLevel do
begin
  ReverseRunsAtOrAbove(Level);   // no-op när ingen körning kvalificerar
  Dec(Level);
end;

Skriven som en enkel dekrementerande loop faller beteendet ut gratis, och no-op-iterationerna kostar inget mätbart. Detta är ett fall där den uppenbara optimeringen inte är lätt fel, den är fel på ett indataberoende sätt som en liten testkorpus aldrig kommer avslöja

Fallgrop i bidi-nivånedstigning i UAX #9: att iterera bara de nivåer som förekommer hoppar över den krävda vändningen på nivå 2, medan en enkel dekrementerande loop från MaxLevel till den lägsta udda nivån alltid omordnar korrekt
Att gå igenom varenda nivå ner till den lägsta udda kostar inget och hoppar aldrig över en krävd vändning

Parenteser: BD16 med en pragmatisk tabell

Regel N0 och BD16-parentesparalgoritmen finns så att en parentes i text med blandad riktning löser upp sig till riktningen av det den omsluter i stället för till vadhelst som råkar ligga bredvid. Det behövs en tabell över parentespar. Implementeringen bär paren i allmänt bruk i stället för hela innehållet i Unicode-parentesfilen: ASCII, CJK, fullbredd, matematiska och ornamentala parenteser

En olistad parentes är inte ett fel. Den löser upp sig som en vanlig neutral genom N1 och N2, vilket är exakt beteendet varje implementering hade innan Unicode 6.3 introducerade N0. Så gränsen är ”mindre förfinad för sällsynta parenteser”, inte ”inkorrekt”. En detalj behöver uttrycklig hantering: den kanoniska ekvivalensen mellan vinkelparenteserna på U+2329 och U+232A och dem på U+3008 och U+3009 måste vikas ihop vid parmatchning, eller kommer en öppnande parentes skriven på ett sätt inte att para sig med en stängande parentes skriven på det andra

Hur du testar trettio samverkande regler

Inte med en stor korpus, åtminstone inte först. Den produktiva metoden var sexton handverifierade fall, vart och ett valt att trära en specifik regel och vart och ett kontrollerat mot de nivåer UAX #9 säger att det ska producera: styckriktningsdetektering under P2 och P3, reglerna för svaga typer W2, W3 och W7, reglerna för implicita nivåer I1 och I2, uttrycklig inbäddning via X2 och X7, isolat via X5a och X6a, L1-återställningen av avslutande blanksteg och separatorer, ett N0-parentesfall och ett fall med ett astraltecken för att låsa surrogathanteringen

Sexton fall med kändkorrekta förväntade nivåer fångar mer än sextonhundra fall med plausibelt seende utmatning, eftersom felläget hos en tvåriktad implementering är text som läses nästan rätt. När de väl passerar är en korpus användbar för att hitta tabellgap och prestandaproblem, vilket är olika klasser av defekt

Inuti PDFium-komponenten matar nivåerna två konsumenter. På skrivarsidan talar de för formningsbackenden riktningen för varje körning, vilket är indatan HarfBuzz kräver. På läsarsidan informerar de urvalsgeometri och läsordning, eftersom ett klick i RTL-text måste mappas till en logisk position snarare än en visuell; den mappningen tas upp i artikeln om urval av visuella rader och läsordningsmodellen i strukturerade textblock och läsordning. Detaljer om plattformsstöd för komponenten finns på produktsidan för PDFium Delphi component