Uniscribe leistet mehr Arbeit, als die meisten Aufrufer realisieren. ScriptItemize erledigt bidirektionale Analyse und Skriptsegmentierung in einem Durchlauf, und ScriptLayout produziert die visuelle Reihenfolge der entstehenden Runs. HarfBuzz, der portable Ersatz, zu dem die Leute greifen, tut beides nicht: Es shaped einen einzelnen Run, dessen Richtung und Skript schon von jemand anderem entschieden wurden. Der schwere Teil also, eine Windows-PDF-Textpipeline nach Linux oder macOS zu bringen, ist nicht, eine Shaping-Engine zu binden. Es ist, den bidirektionalen Algorithmus zu liefern, den Uniscribe stillschweigend bereitstellte, und in der PDFium-Komponente ist dafür FPdfBidi da
Die Unit implementiert UAX #9 direkt: Regeln P2 und P3 für die Absatzrichtung, X1 bis X10 für explizite Embeddings und Isolates, W1 bis W7 für schwache Typen, N0 bis N2 für Neutrale und Klammern, I1 und I2 für implizite Ebenen, und L1 und L2 für die abschließende Neuanordnung. Zwei Funktionen tragen sie: PdfResolveBidiLevels gibt eine Embedding-Ebene je UTF-16-Codeeinheit zurück, und PdfBidiVisualOrder verwandelt diese Ebenen in die Permutation, die Codeeinheiten links nach rechts platziert
Was der Algorithmus gibt und was nicht
Er gibt Ihnen Zahlen. Gerade Ebenen sind links nach rechts, ungerade Ebenen sind rechts nach links, und die Ebene jedes Zeichens codiert die Verschachtelung der Richtungs-Runs, in denen dieses Zeichen sitzt. Aus diesen Zahlen leitet L2 eine Permutation ab. Was der Algorithmus bewusst nicht tut: entscheiden, welche Schrift zu verwenden ist, Ligaturen bilden oder Glyphen innerhalb eines Clusters umordnen; das sind Shaping-Angelegenheiten und gehören zur Stufe nach dieser
uses
FPdfBidi;
var
Levels: TPdfBidiLevels;
Order: TPdfBidiOrder;
ParagraphLevel: Byte;
Text, Visual: WideString;
I: Integer;
begin
Text := SourceLine;
// pbdAuto wendet P2-P3 an: Das erste starke Zeichen entscheidet
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 liest nun links nach rechts; Levels[] sagt weiterhin,
// welche Runs RTL sind, damit ein Shaper korrekte Richtungen bekommt
end;
end;
Die Zeichenklassen-Tabelle ist generiert, nicht geschrieben
Jeder Codepoint hat eine Bidi_Class-Property, und der Algorithmus konsultiert sie ständig, also ist die Tabelle das Fundament, auf dem alles andere steht. Sie wird aus der Unicode Character Database generiert statt von Hand gepflegt: Feld fünf von UnicodeData.txt liefert die zugewiesenen Klassen, und die @missing-Deklarationen in DerivedBidiClass.txt liefern die Defaults für Codepoints, die die Datenbank nicht zuweist, weshalb nicht zugewiesene Blöcke korrekt auf R, AL, ET oder BN defaulten statt auf L
Der Kompressionstrick: Nur die Ranges emittieren, deren Klasse nicht L ist. Alles, was außerhalb jeder Range fällt, ist L, was sowohl der Unicode-Default als auch die Klasse der überwältigenden Mehrheit der Codepoints ist. Das bringt eine Tabelle, die sonst auf Tausende Einträge liefe, auf 745 Ranges und rund 6,7 KB. Die operationelle Konsequenz ist es wert, ausgesprochen zu werden: Beim Wechsel zu einer neuen Unicode-Version den Generator erneut laufen lassen. Das Include-File von Hand zu editieren funktioniert, und es wird auch still von der Datenbank beim nächsten Upgrade abweichen
L2 muss Codepoints neu ordnen, nicht UTF-16-Codeeinheiten
Das ist der Fehler, der wirklich korrumpierte Ausgabe produziert, und die erste Implementierung machte ihn. L2 sagt, zusammenhängende Runs auf jeder Ebene von der höchsten bis zur niedrigsten ungeraden Ebene umzukehren. Gegen eine UTF-16-Zeichenkette geschrieben bedeutet „Kehre einen Run um“ natürlich, die Codeeinheiten darin umzukehren. Für Zeichen in der Basic Multilingual Plane ist das in Ordnung. Für ein RTL-Zeichen in einer Astralebene, etwa die in den zyprischen oder altsüdarabischen Blöcken nahe U+10800, ist es das nicht: Das Zeichen ist ein Surrogate-Pair, das Umkehren des Runs setzt den Low Surrogate vor den High Surrogate, und die Zeichenkette enthält nun zwei ungepaarte Surrogates statt eines Zeichens. Nichts Downstream kann das bergen
Der Fix ist, L2 auf Codepoint-Einheiten zu machen. Die Implementierung verschmilzt Codeeinheiten zu Codepoint-Einheiten, führt die Umkehrungen auf diesen Einheiten aus und expandiert das Ergebnis am Ende zurück zu Codeeinheits-Indizes. Deshalb nimmt PdfBidiVisualOrder den Text und nicht nur das Ebenen-Array: An den Ebenen allein kann es nicht erkennen, wo die Surrogate-Grenzen liegen. Dieselbe Surrogate-Pair-Disziplin zieht sich durch die Text-APIs im Allgemeinen, beschrieben in dem Artikel zu Emoji, CJK und Surrogate-Pairs
Der Abstieg durch die Ebenen muss Ebenen einschließen, die nicht vorkommen
Der zweite Fehler ist subtiler und produziert keinen Absturz, nur Text, der nicht neu geordnet wurde. L2 sagt, bei der höchsten vorhandenen Ebene zu beginnen und bis zur niedrigsten ungeraden Ebene herunterzuarbeiten. Eine naheliegende Optimierung ist, die Menge der tatsächlich vorkommenden Ebenen zu sammeln und über diese Menge zu iterieren. Sie ist falsch
Betrachten Sie eine Zeile lateinischen Texts innerhalb eines Rechts-nach-links-Embeddings. Die Absatzebene ist 0, das Embedding schiebt die lateinischen Zeichen auf Ebene 2, und kein Zeichen sitzt auf Ebene 1. Über vorkommende Ebenen zu iterieren findet nur 0 und 2, und es gibt überhaupt keine ungerade Ebene, also führt die Schleife keine Umkehrung aus. Diese Antwort ist korrekt, aber aus einem Grund, den die Optimierung nicht kennt: Eine Umkehrung auf Ebene 2, gefolgt von einer Umkehrung auf Ebene 1, würde sich exakt aufheben, also ist es das richtige Ergebnis, keine von beiden auszuführen. Ändert man die Eingabe leicht, sodass sowohl Ebene-1- als auch Ebene-3-Zeichen existieren, Ebene 2 aber nicht, überspringt die mengenbasierte Schleife die Ebene-2-Umkehrung, die der Algorithmus verlangt
// Richtig: jede Ebene vom Maximum bis zur niedrigsten ungeraden Ebene
// durchlaufen, einschließlich Ebenen, die kein Zeichen tatsächlich hat
Level := MaxLevel;
while Level >= LowestOddLevel do
begin
ReverseRunsAtOrAbove(Level); // No-op, wenn kein Run zutrifft
Dec(Level);
end;
Als schlichte abwärts zählende Schleife geschrieben fällt das Verhalten gratis ab, und die No-op-Iterationen kosten nichts Messbares. Das ist ein Fall, in dem die naheliegende Optimierung nicht leicht falsch ist, sondern falsch auf eine eingabeabhängige Weise, die ein kleines Testkorpus niemals enthüllen wird
Klammern: BD16 mit einer pragmatischen Tabelle
Regel N0 und der BD16-Klammerpaar-Algorithmus existieren, damit eine Klammer in Text mit gemischten Richtungen zur Richtung dessen aufgelöst wird, was sie einschließt, statt zu dem, was zufällig danebenliegt. Das braucht eine Tabelle von Klammerpaaren. Die Implementierung führt die allgemein gebräuchlichen Paare statt des vollen Inhalts der Unicode-Klammerdatei: ASCII, CJK, vollbreite, mathematische und ornamentale Klammern
Eine nicht gelistete Klammer ist kein Fehler. Sie löst sich als gewöhnliches Neutrales über N1 und N2 auf, was exakt das Verhalten jeder Implementierung war, bevor Unicode 6.3 N0 einführte. Die Grenze ist also „weniger verfeinert für seltene Klammern“, nicht „inkorrekt“. Ein Detail braucht explizite Behandlung: Die kanonische Äquivalenz zwischen den spitzen Klammern an U+2329 und U+232A und denen an U+3008 und U+3009 muss beim Paar-Matching gefaltet werden, sonst paart sich eine öffnende Klammer, auf die eine Art geschrieben wurde, nicht mit einer schließenden, die auf die andere geschrieben wurde
Wie man dreißig interagierende Regeln testet
Nicht mit einem großen Korpus, zumindest nicht zuerst. Der produktive Ansatz waren sechzehn von Hand verifizierte Fälle, jeder gewählt, um eine bestimmte Regel auszuüben, und jeder geprüft gegen die Ebenen, die UAX #9 sagt, dass sie produziert werden sollten: Absatzrichtungserkennung unter P2 und P3, die Schwachtyp-Regeln W2, W3 und W7, die impliziten Ebenenregeln I1 und I2, explizites Embedding über X2 und X7, Isolates über X5a und X6a, der L1-Reset von nachlaufenden Leerzeichen und Trennern, ein N0-Klammerfall und ein Fall mit einem Astralzeichen, um die Surrogate-Behandlung festzunageln
Sechzehn Fälle mit bekannten korrekten erwarteten Ebenen fangen mehr als sechzehnhundert Fälle mit plausibel aussehender Ausgabe, denn der Fehlermodus einer bidirektionalen Implementierung ist Text, der sich fast richtig liest. Sobald die bestehen, ist ein Korpus nützlich, um Tabellenlücken und Leistungsprobleme zu finden, was andere Fehlerklassen sind
Innerhalb der PDFium-Komponente speisen die Ebenen zwei Konsumenten. Auf der Schreibseite sagen sie dem Shaping-Backend die Richtung jedes Runs, was die Eingabe ist, die HarfBuzz verlangt. Auf der Leseseite informieren sie die Auswahlgeometrie und die Lesereihenfolge, denn ein Klick in RTL-Text muss auf eine logische Position gemappt werden, nicht auf eine visuelle; dieses Mapping behandelt der Artikel zur visuellen Zeilenauswahl und das Lesereihenfolge-Modell strukturierte Textblöcke und Lesereihenfolge. Plattformunterstützungsdetails für die Komponente stehen auf der Produktseite der PDFium Delphi-Komponente