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 ejecuta el análisis bidireccional y la segmentación por escritura en una sola 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: moldea un solo run cuya dirección y escritura ya decidió otra persona. 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 proporcionando 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 e isolatos explícitos, 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 reordenamiento 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é da el algoritmo y qué no

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; eso son asuntos de shaping y pertenecen a la etapa siguiente 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 ya se lee de izquierda a derecha; Levels[] todavía dice qué
    // runs son RTL para entregarle direcciones correctas a un shaper
  end;
end;

La tabla de clases de caracteres se genera, no se escribe

Todo punto de código tiene una propiedad Bidi_Class, y el algoritmo la consulta constantemente, así que la tabla es el cimiento sobre el que se para 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 predeterminados para los puntos de código que la base no asigna, que es cómo los bloques sin asignar quedan correctamente por defecto en R, AL, ET o BN y no 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 predeterminado de Unicode como la clase de la abrumadora mayoría de los puntos de código. Eso lleva una tabla que de otra forma correría a miles de entradas hasta 745 rangos y unos 6.7 KB. La consecuencia operativa vale enunciarla: cuando pasen a una versión nueva de Unicode, vuelvan a ejecutar el generador. Editar a mano el archivo include funcionará, y también divergirá silenciosamente de la base en la próxima 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 que se inviertan 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 los 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 de Arabia del Sur antigua cerca de U+10800, no lo está: el carácter es un par sustituto, invertir el run pone el sustituto bajo antes del alto, y la cadena contiene ahora dos sustitutos sin pareja 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, ejecuta las inversiones sobre esas unidades y expande el resultado de vuelta a índices de unidad de código al final. Por eso PdfBidiVisualOrder recibe el texto y no solo el arreglo de niveles: no puede saber dónde están las fronteras de los sustitutos a partir de los niveles solos. La misma disciplina de pares sustitutos atraviesa las APIs de texto en general, como se describe en el artículo de emoji, CJK y pares sustitutos

Corrupción de pares sustitutos en el reordenamiento bidi: invertir unidades de código UTF-16 parte un carácter astral cerca de U+10800 en sustitutos sin pareja, 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 después expandirlas de vuelta

El descenso por niveles debe incluir niveles que no ocurren

El segundo error es más sutil y no produce ningún 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

Consideren una línea de texto latino dentro de una incrustación de derecha a izquierda. El nivel del 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 ejecuta 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ía exactamente, así que no ejecutar ninguna es el resultado correcto. Cambien 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 realmente tiene
Level := MaxLevel;
while Level >= LowestOddLevel do
begin
  ReverseRunsAtOrAbove(Level);   // no hace nada cuando ningún run califica
  Dec(Level);
end;

Escrito como un bucle de decremento simple el comportamiento sale gratis, y las iteraciones que no hacen nada no cuestan nada medible. Este es un caso donde la optimización obvia no está levemente mal, está mal de una manera dependiente de la entrada que un corpus pequeño de pruebas 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 de decremento simple desde MaxLevel hasta el 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 se resuelva a la dirección de lo que encierra y no a lo que casualmente esté adyacente. Eso necesita una tabla de pares de corchetes. La implementación lleva los pares de uso general en lugar del contenido completo del archivo de corchetes de Unicode: corchetes ASCII, CJK, de ancho completo, matemáticos y ornamentales

Un corchete no listado no es un error. Se resuelve como un neutro corriente 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 igualar pares, o un corchete de apertura escrito de una manera 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 fue dieciséis casos verificados a mano, cada uno elegido para ejercitar una regla específica y cada uno cotejado contra los niveles que UAX #9 dice que debe 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 nivel implícito I1 e I2, incrustación explícita vía X2 y X7, isolatos vía X5a y X6a, el reinicio L1 de espacios en blanco finales y separadores, un caso de corchete N0, y un caso con un carácter astral para fijar el manejo de sustitutos

Dieciséis casos con niveles esperados de corrección conocida atrapan 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. Del lado de la escritura le dicen al backend de shaping la dirección de cada run, que es la entrada que HarfBuzz exige. Del lado de la 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íneas visuales 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 del producto PDFium Delphi component