Artículo técnico

Niveles de incrustación BiDi para texto PDF sin Uniscribe

Uniscribe hace más trabajo del que la mayoría de quienes llaman se dan cuenta. ScriptItemize realiza análisis bidireccional y segmentación por escritura en una pasada, y ScriptLayout produce el orden visual de los runs resultantes. HarfBuzz, el reemplazo portable al que la gente recurre, no hace ninguna de las dos: conforma un único run cuya dirección y escritura ya fueron decididas por otro. Así que la parte difícil de llevar un pipeline de texto PDF de Windows a Linux o macOS no es enlazar un motor de shaping. Es suministrar el algoritmo bidireccional que Uniscribe estaba aportando en silencio, y en el componente PDFium para eso está FPdfBidi

La unidad implementa UAX #9 directamente: las reglas P2 y P3 para la dirección de párrafo, X1 a X10 para incrustaciones explícitas y aislamientos, W1 a W7 para tipos débiles, N0 a N2 para neutros y corchetes, I1 e I2 para niveles implícitos, y L1 y L2 para el reordenado final. Dos funciones lo cargan: PdfResolveBidiLevels devuelve un nivel de incrustación por unidad de código UTF-16, y PdfBidiVisualOrder convierte esos niveles en la permutación que coloca las unidades de código de izquierda a derecha

Qué os da el algoritmo y qué no

Os da números. Los niveles pares son de izquierda a derecha, los impares de derecha a izquierda, y el nivel de cada carácter codifica el anidamiento de runs direccionales dentro del que ese carácter está. De esos números L2 deriva una permutación. Lo que el algoritmo deliberadamente no hace es decidir qué fuente usar, formar ligaduras o reordenar glifos dentro de un cluster; esas son cuestiones de shaping y pertenecen a la etapa posterior a esta

Pipeline de FPdfBidi para texto PDF sin Uniscribe: PdfResolveBidiLevels asigna un nivel de incrustación UAX #9 por unidad de código UTF-16 y PdfBidiVisualOrder aplica la regla L2 para producir el orden visual
Los niveles codifican el anidamiento de runs, y la regla L2 los convierte en la permutación que se lee de izquierda a derecha
uses
  FPdfBidi;

var
  Levels: TPdfBidiLevels;
  Order: TPdfBidiOrder;
  ParagraphLevel: Byte;
  Text, Visual: WideString;
  I: Integer;
begin
  Text := SourceLine;
  // pbdAuto aplica P2-P3: el primer carácter fuerte decide
  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 ahora se lee de izquierda a derecha; Levels[] sigue diciendo
    // qué runs son RTL, de modo que se pueden entregar direcciones correctas
    // al shaper
  end;
end;

La tabla de clases de carácter se genera, no se escribe

Cada punto de código tiene una propiedad Bidi_Class, y el algoritmo la consulta constantemente, así que la tabla es el fundamento sobre el que se apoya todo lo demás. Se genera a partir de la Unicode Character Database y no se mantiene a mano: el campo cinco de UnicodeData.txt da las clases asignadas, y las declaraciones @missing de DerivedBidiClass.txt dan los valores por defecto para los puntos de código que la base de datos no asigna, que es cómo los bloques sin asignar quedan por defecto correctamente en R, AL, ET o BN en lugar de en L

El truco de compresión es emitir solo los rangos cuya clase no es L. Todo lo que caiga fuera de todos los rangos es L, que es tanto el valor por defecto de Unicode como la clase de la abrumadora mayoría de los puntos de código. Eso lleva una tabla que de otro modo correría hasta miles de entradas a 745 rangos y unos 6,7 KB. La consecuencia operativa merece enunciarse: cuando paséis a una versión nueva de Unicode, volved a ejecutar el generador. Editar a mano el fichero include funcionará, y también divergirá silenciosamente de la base de datos en la siguiente actualización

L2 debe reordenar puntos de código, no unidades de código UTF-16

Este es el error que produce salida genuinamente corrupta, y la primera implementación lo cometió. L2 dice invertir los runs contiguos en cada nivel desde el más alto hasta el nivel impar más bajo. Escrito contra una cadena UTF-16, «invertir un run» significa naturalmente invertir las unidades de código que contiene. Para caracteres del plano multilingüe básico eso está bien. Para un carácter RTL en un plano astral, como los de los bloques chipriota o árabe meridional antiguo cerca de U+10800, no lo está: el carácter es un par suplente, invertir el run pone el suplente bajo antes del alto, y la cadena contiene ahora dos suplentes sin emparejar en lugar de un carácter. Nada aguas abajo puede recuperarlo

El arreglo es hacer L2 sobre unidades de punto de código. La implementación fusiona unidades de código en unidades de punto de código, realiza las inversiones sobre esas unidades y expande el resultado de vuelta a índices de unidad de código al final. Por eso PdfBidiVisualOrder toma el texto y no solo el array de niveles: no puede saber dónde están los límites de los suplentes solo por los niveles. La misma disciplina de pares suplentes atraviesa las APIs de texto en general, como se describe en el artículo de emoji, CJK y pares suplentes

Corrupción de par suplente en el reordenado bidi: invertir unidades de código UTF-16 parte un carácter astral cerca de U+10800 en suplentes sin emparejar, mientras que invertir unidades de punto de código fusionadas lo mantiene intacto
La regla L2 debe fusionar unidades de código en puntos de código antes de invertir y expandirlas de vuelta después

El descenso por niveles debe incluir niveles que no ocurren

El segundo error es más sutil y no produce fallo, solo texto que no se reordena. L2 dice empezar en el nivel más alto presente y bajar hasta el nivel impar más bajo. Una optimización natural es recolectar el conjunto de niveles que realmente ocurren e iterar sobre ese conjunto. Está mal

Considerad una línea de texto latino dentro de una incrustación de derecha a izquierda. El nivel de párrafo es 0, la incrustación empuja los caracteres latinos al nivel 2, y ningún carácter está en el nivel 1. Iterar sobre los niveles que ocurren encuentra solo 0 y 2, y no hay nivel impar alguno, así que el bucle no realiza inversión alguna. Esa respuesta es correcta, pero por una razón que la optimización no conoce: una inversión en el nivel 2 seguida de una inversión en el nivel 1 se cancelarían exactamente, así que no hacer ninguna es el desenlace correcto. Cambiad la entrada ligeramente, de modo que existan caracteres de nivel 1 y de nivel 3 pero no de nivel 2, y el bucle basado en conjuntos se salta la inversión del nivel 2 que el algoritmo exige

// Correcto: recorrer cada nivel desde el máximo hasta el nivel impar más
// bajo, incluidos los niveles que ningún carácter tiene realmente
Level := MaxLevel;
while Level >= LowestOddLevel do
begin
  ReverseRunsAtOrAbove(Level);   // sin efecto cuando ningún run califica
  Dec(Level);
end;

Escrito como un bucle decreciente sencillo el comportamiento sale gratis, y las iteraciones sin efecto no cuestan nada medible. Este es un caso donde la optimización obvia no está ligeramente mal, está mal de una forma dependiente de la entrada que un corpus de pruebas pequeño nunca revelará

Trampa del descenso de niveles bidi en UAX #9: iterar solo los niveles que ocurren se salta la inversión del nivel 2 requerida, mientras que un bucle decreciente sencillo de MaxLevel al nivel impar más bajo siempre reordena correctamente
Recorrer cada nivel hasta el impar más bajo no cuesta nada y nunca se salta una inversión requerida

Corchetes: BD16 con una tabla pragmática

La regla N0 y el algoritmo de pares de corchetes BD16 existen para que un paréntesis en texto de dirección mixta resuelva a la dirección de lo que encierra y no a lo que resulte estar adyacente. Eso necesita una tabla de pares de corchetes. La implementación lleva los pares de uso general en lugar del contenido completo del fichero de corchetes de Unicode: ASCII, CJK, fullwidth, matemáticos y ornamentales

Un corchete no listado no es un error. Resuelve como neutro ordinario a través de N1 y N2, que es exactamente el comportamiento que toda implementación tenía antes de que Unicode 6.3 introdujera N0. Así que el límite es «menos refinado para corchetes raros», no «incorrecto». Un detalle sí necesita manejo explícito: la equivalencia canónica entre los corchetes angulares en U+2329 y U+232A y los de U+3008 y U+3009 hay que plegarla al emparejar, o un corchete de apertura escrito de una forma no logrará emparejarse con un corchete de cierre escrito de la otra

Cómo se prueban treinta reglas que interactúan

No con un corpus grande, al menos no primero. El enfoque productivo fueron dieciséis casos verificados a mano, cada uno elegido para ejercitar una regla concreta y cada uno comprobado contra los niveles que UAX #9 dice que debería producir: detección de dirección de párrafo bajo P2 y P3, las reglas de tipos débiles W2, W3 y W7, las reglas de niveles implícitos I1 e I2, incrustación explícita vía X2 y X7, aislamientos vía X5a y X6a, el reinicio L1 de espacios y separadores finales, un caso de corchete N0, y un caso con carácter astral para fijar el manejo de suplentes

Dieciséis casos con niveles esperados de corrección conocida cazan más que mil seiscientos casos con salida de apariencia plausible, porque el modo de fallo de una implementación bidireccional es un texto que se lee casi bien. Una vez que esos pasan, un corpus es útil para encontrar huecos de tabla y problemas de rendimiento, que son clases de defecto distintas

Dentro del componente PDFium los niveles alimentan a dos consumidores. En el lado de escritura le dicen al backend de shaping la dirección de cada run, que es la entrada que HarfBuzz exige. En el lado de lectura informan la geometría de selección y el orden de lectura, ya que un clic en texto RTL tiene que mapear a una posición lógica y no visual; ese mapeo se cubre en el artículo de selección de línea visual y el modelo de orden de lectura en bloques de texto estructurado y orden de lectura. Los detalles de soporte de plataformas del componente están en la página de producto de PDFium Delphi component