Article technique

Niveaux d'imbrication BiDi pour texte PDF sans Uniscribe

Uniscribe fait plus de travail que la plupart des appelants ne s'en rendent compte. ScriptItemize effectue l'analyse bidirectionnelle et la segmentation par écriture en une passe, et ScriptLayout produit l'ordre visuel des exécutions résultantes. HarfBuzz, le remplaçant portable vers lequel les gens se tournent, ne fait ni l'un ni l'autre : il met en forme une seule exécution dont la direction et l'écriture ont déjà été décidées par quelqu'un d'autre. La partie dure du portage d'un pipeline de texte PDF Windows vers Linux ou macOS n'est donc pas de lier un moteur de mise en forme. C'est de fournir l'algorithme bidirectionnel qu'Uniscribe fournissait discrètement, et dans le composant PDFium c'est à cela que sert FPdfBidi

L'unité implémente l'UAX #9 directement : les règles P2 et P3 pour la direction de paragraphe, X1 à X10 pour les imbrications et isolants explicites, W1 à W7 pour les types faibles, N0 à N2 pour les neutres et les crochets, I1 et I2 pour les niveaux implicites, et L1 et L2 pour le réordonnancement final. Deux fonctions la portent : PdfResolveBidiLevels renvoie un niveau d'imbrication par unité de code UTF-16, et PdfBidiVisualOrder transforme ces niveaux en la permutation qui place les unités de code de gauche à droite

Ce que l'algorithme vous donne, et ce qu'il ne donne pas

Il vous donne des nombres. Les niveaux pairs sont de gauche à droite, les niveaux impairs de droite à gauche, et le niveau de chaque caractère encode l'imbrication des exécutions directionnelles dans lesquelles ce caractère se trouve. De ces nombres, L2 dérive une permutation. Ce que l'algorithme ne fait délibérément pas est décider quelle police utiliser, former des ligatures, ou réordonner les glyphes au sein d'un cluster ; ce sont des soucis de mise en forme et ils appartiennent à l'étape suivante

Pipeline FPdfBidi pour le texte PDF sans Uniscribe : PdfResolveBidiLevels attribue un niveau d'imbrication UAX #9 par unité de code UTF-16 et PdfBidiVisualOrder applique la règle L2 pour produire l'ordre visuel
Les niveaux encodent l'imbrication des exécutions, et la règle L2 les transforme en la permutation qui se lit de gauche à droite
uses
  FPdfBidi;

var
  Levels: TPdfBidiLevels;
  Order: TPdfBidiOrder;
  ParagraphLevel: Byte;
  Text, Visual: WideString;
  I: Integer;
begin
  Text := SourceLine;
  // pbdAuto applique P2-P3 : le premier caractère fort décide
  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 se lit maintenant de gauche à droite ; Levels[] dit toujours
    // quelles exécutions sont RTL pour qu'un moteur reçoive les bonnes directions
  end;
end;

La table de classes de caractères est générée, pas écrite

Chaque point de code a une propriété Bidi_Class, et l'algorithme la consulte constamment, donc la table est le fondement sur lequel tout le reste se tient. Elle est générée depuis la base de données de caractères Unicode plutôt que maintenue à la main : le champ cinq de UnicodeData.txt donne les classes attribuées, et les déclarations @missing dans DerivedBidiClass.txt donnent les défauts pour les points de code que la base n'attribue pas, ce qui fait que les blocs non alloués se mettent par défaut correctement à R, AL, ET ou BN plutôt qu'à L

L'astuce de compression est d'émettre seulement les plages dont la classe n'est pas L. Tout ce qui tombe en dehors de chaque plage est L, qui est à la fois le défaut Unicode et la classe de la majorité écrasante des points de code. Cela ramène une table qui courrait sinon à des milliers d'entrées à 745 plages et environ 6,7 Ko. La conséquence opérationnelle vaut la peine d'être énoncée : quand vous passez à une nouvelle version d'Unicode, relancez le générateur. Éditer à la main le fichier include fonctionnera, et divergera aussi silencieusement de la base à la prochaine mise à niveau

L2 doit réordonner des points de code, pas des unités de code UTF-16

C'est l'erreur qui produit une sortie réellement corrompue, et la première implémentation l'a faite. L2 dit d'inverser les exécutions contiguës à chaque niveau depuis le plus haut jusqu'au niveau impair le plus bas. Écrite contre une chaîne UTF-16, « inverser une exécution » signifie naturellement inverser les unités de code qu'elle contient. Pour les caractères du plan multilingue de base, c'est bon. Pour un caractère RTL dans un plan astral, comme ceux des blocs chypriote ou sud-arabique ancien près de U+10800, ça ne l'est pas : le caractère est une paire de substitution, inverser l'exécution met la substitution basse avant la haute, et la chaîne contient maintenant deux substituts non appariés au lieu d'un caractère. Rien en aval ne peut le récupérer

La correction est de faire L2 sur des unités de points de code. L'implémentation fusionne les unités de code en unités de points de code, effectue les inversions sur ces unités, et réétend le résultat en indices d'unités de code à la fin. C'est pourquoi PdfBidiVisualOrder prend le texte et pas seulement le tableau de niveaux : il ne peut pas dire où sont les frontières de substitution depuis les niveaux seuls. La même discipline de paires de substitution traverse les API de texte en général, comme décrit dans l'article sur les émojis, le CJK et les paires de substitution

Corruption de paire de substitution dans le réordonnancement bidi : inverser les unités de code UTF-16 scinde un caractère astral près de U+10800 en substituts non appariés, tandis qu'inverser des unités de points de code fusionnées le garde intact
La règle L2 doit fusionner les unités de code en points de code avant d'inverser, puis les réétendre ensuite

La descente dans les niveaux doit inclure les niveaux qui ne surviennent pas

La seconde erreur est plus subtile et ne produit aucun plantage, juste du texte qui n'est pas réordonné. L2 dit de commencer au niveau le plus haut présent et de descendre jusqu'au niveau impair le plus bas. Une optimisation naturelle est de collecter l'ensemble des niveaux qui surviennent réellement et d'itérer sur cet ensemble. Elle est fausse

Considérez une ligne de texte latin dans une imbrication de droite à gauche. Le niveau de paragraphe est 0, l'imbrication pousse les caractères latins au niveau 2, et aucun caractère ne se trouve au niveau 1. Itérer sur les niveaux survenants ne trouve que 0 et 2, et il n'y a aucun niveau impair du tout, donc la boucle n'effectue aucune inversion. Cette réponse est correcte, mais pour une raison que l'optimisation ne connaît pas : une inversion au niveau 2 suivie d'une inversion au niveau 1 s'annuleraient exactement, donc n'en effectuer aucune est le bon résultat. Changez l'entrée légèrement, pour que des caractères de niveau 1 et de niveau 3 existent mais pas de niveau 2, et la boucle par ensemble saute l'inversion de niveau 2 que l'algorithme exige

// Juste : parcourir chaque niveau du maximum jusqu'au niveau impair le
// plus bas, y compris les niveaux qu'aucun caractère n'a réellement
Level := MaxLevel;
while Level >= LowestOddLevel do
begin
  ReverseRunsAtOrAbove(Level);   // no-op quand aucune exécution ne qualifie
  Dec(Level);
end;

Écrite comme une simple boucle décrémentante, la mesure tombe gratuitement, et les itérations sans effet ne coûtent rien de mesurable. C'est un cas où l'optimisation évidente n'est pas légèrement fausse, elle est fausse d'une manière dépendante de l'entrée qu'un petit corpus de tests ne révélera jamais

Piège de descente de niveaux bidi dans l'UAX #9 : itérer seulement les niveaux qui surviennent saute l'inversion de niveau 2 requise, tandis qu'une simple boucle décrémentante de MaxLevel au niveau impair le plus bas réordonne toujours correctement
Parcourir chaque niveau jusqu'au plus bas impair ne coûte rien et ne saute jamais une inversion requise

Crochets : BD16 avec une table pragmatique

La règle N0 et l'algorithme de paires de crochets BD16 existent pour qu'une parenthèse dans un texte à directions mixtes se résolve à la direction de ce qu'elle enferme plutôt qu'à ce qui se trouve être adjacent. Cela exige une table de paires de crochets. L'implémentation porte les paires d'usage général plutôt que le contenu complet du fichier de crochets Unicode : ASCII, CJK, pleine chasse, mathématiques et ornements

Un crochet non listé n'est pas une erreur. Il se résout comme un neutre ordinaire par N1 et N2, qui est exactement le comportement de toute implémentation avant que Unicode 6.3 n'introduise N0. Donc la frontière est « moins raffiné pour les crochets rares », pas « incorrect ». Un détail exige une gestion explicite : l'équivalence canonique entre les crochets angulaires en U+2329 et U+232A et ceux en U+3008 et U+3009 doit être fondue lors de l'appariement des paires, ou un crochet ouvrant écrit d'une façon ne réussira pas à s'apparier avec un crochet fermant écrit de l'autre

Comment tester trente règles en interaction

Pas avec un grand corpus, du moins pas d'abord. L'approche productive a été seize cas vérifiés à la main, chacun choisi pour exercer une règle spécifique et chacun confronté aux niveaux que l'UAX #9 dit qu'il devrait produire : la détection de direction de paragraphe sous P2 et P3, les règles de types faibles W2, W3 et W7, les règles de niveaux implicites I1 et I2, l'imbrication explicite par X2 et X7, les isolants par X5a et X6a, la réinitialisation L1 des espaces et séparateurs de fin, un cas de crochet N0, et un cas avec un caractère astral pour verrouiller la gestion des substitutions

Seize cas avec des niveaux attendus connus comme corrects attrapent plus que seize cents cas avec une sortie d'apparence plausible, car le mode de défaillance d'une implémentation bidirectionnelle est du texte qui se lit presque bien. Une fois ceux-ci passés, un corpus est utile pour trouver des écarts de table et des problèmes de performance, qui sont des classes de défaut différentes

À l'intérieur du composant PDFium, les niveaux alimentent deux consommateurs. Du côté écriture, ils disent au backend de mise en forme la direction de chaque exécution, qui est l'entrée que HarfBuzz exige. Du côté lecture, ils informent la géométrie de sélection et l'ordre de lecture, puisqu'un clic dans du texte RTL doit se mapper à une position logique plutôt que visuelle ; ce mappage est couvert dans l'article sur la sélection de lignes visuelles et le modèle d'ordre de lecture dans les blocs de texte structuré et l'ordre de lecture. Les détails de prise en charge de plateformes du composant sont sur la page produit du PDFium Delphi component