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
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
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á
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