Artículo técnico

Text shaping conectable: Uniscribe y HarfBuzz en Delphi

El text shaping en el componente PDFium pasa por un único objeto instalable. ConfigureTextShaper instala el shaper por el que se encamina todo punto de entrada de shaping, reemplazando y liberando lo que hubiera; ActiveTextShaper devuelve el instalado y crea el predeterminado de la plataforma en el primer uso; ActiveTextShaperName informa de qué backend está vivo; ClearTextShaper retira la instalación y deja que el predeterminado se cree de nuevo. En Windows el predeterminado es TPdfUniscribeTextShaper. Bajo Free Pascal está TPdfHarfBuzzTextShaper, que enlaza libharfbuzz en tiempo de ejecución de modo que una biblioteca ausente es una condición reportada y no un fallo de carga

Arquitectura de text shaping conectable en el componente PDFium Delphi: ConfigureTextShaper, ActiveTextShaper y ClearTextShaper gestionan un único backend instalado, Uniscribe en Windows y un HarfBuzz enlazado en ejecución bajo Free Pascal
Cada llamada de shaping se encamina por el único objeto shaper instalado, con un predeterminado de plataforma en cada destino

Una interfaz, dos backends que reparten el trabajo de forma completamente distinta. Entender esa asimetría es lo que evita que la ruta portable produzca texto conformado correctamente y posicionado mal

Por qué el backend de Windows es una clase y el portable tres piezas?

Porque Uniscribe son cuatro APIs haciendo como que son una. ScriptItemize segmenta una cadena por escritura y resuelve niveles bidireccionales; ScriptShape mapea caracteres a glifos; ScriptPlace calcula avances y offsets; ScriptLayout pone los runs resultantes en orden visual. Un backend construido sobre ella no tiene nada que añadir, por eso el shaper de Windows es una única clase con un único método

HarfBuzz cubre las dos del medio. Conforma y posiciona un run cuya dirección y escritura ya decidió quien llama, y no opina sobre cómo se divide un párrafo en runs ni en qué orden aparecen esos runs. Así que el backend portable aporta el resto: el algoritmo bidireccional resuelve los niveles de incrustación, las funciones Unicode de HarfBuzz segmentan el texto por escritura, y los runs se disponen en el orden visual que produce la regla L2 de UAX #9. La mitad bidireccional es lo bastante sustanciosa para ser su propia unidad, descrita en el artículo de niveles de incrustación UAX #9

Comparación del pipeline de shaping para texto PDF: Uniscribe aporta ScriptItemize, ScriptShape, ScriptPlace y ScriptLayout dentro de una clase, mientras que HarfBuzz cubre solo conformado y posicionamiento alrededor de las etapas UAX #9 propias del componente
Uniscribe cubre las cuatro etapas; la ruta portable debe aportar ella misma la itemización y el orden visual

El shaper no resuelve fuentes, y es deliberado

Uniscribe lee el binario de la fuente de un contexto de dispositivo GDI. No hay equivalente portable de eso, e inventar uno dentro de una unidad de shaping significaría decidir, en nombre de cada aplicación, si las fuentes vienen de fontconfig, de CoreText, de una carpeta de fuentes de la aplicación o de una base de datos. Así que el backend de HarfBuzz toma un resolver: un callback que mapea un nombre de fuente a los bytes TrueType u OpenType. Devolver False hace fallar la petición de shaping igual que una fuente GDI ilegible la hace fallar en Windows

uses
  FPdfTextShaping
{$IFDEF FPC}
  , FPdfTextShapingHb
{$ENDIF}
  ;

function TFontCatalogue.Resolve(const FontName: WideString;
  out FontData: TBytes): Boolean;
var
  Path: string;
begin
  // Vuestra política: fontconfig, CoreText, una carpeta de fuentes de la
  // app, una base de datos
  Result := FLookup.TryGetValue(LowerCase(FontName), Path);
  if Result then
    FontData := TFile.ReadAllBytes(Path);
end;

procedure InstallShaper(Catalogue: TFontCatalogue);
begin
{$IFDEF FPC}
  // La propiedad pasa a la unidad; llamad una vez durante el arranque,
  // antes de que nada conforme texto
  ConfigureTextShaper(TPdfHarfBuzzTextShaper.Create(Catalogue.Resolve));
{$ENDIF}
  // En Delphi el predeterminado de plataforma (Uniscribe) se crea bajo demanda,
  // así que no hace falta instalación alguna
  LogInfo('shaping backend: ' + ActiveTextShaperName);
end;

Mantener el descubrimiento de fuentes fuera del shaper tiene un segundo beneficio que aparece en servidores: el mismo proceso puede conformar con un conjunto de fuentes incrustado que no tiene nada que ver con lo instalado en la máquina, que es lo que se quiere cuando la salida debe ser reproducible byte a byte entre anfitriones. El componente también expone un proveedor de fuentes del sistema anfitrión para los casos en que sí queréis fuentes instaladas, cubierto en el artículo del proveedor de fuentes del sistema

El record de resultado es neutral al backend, y los clusters son la razón

Ambos backends rellenan el mismo TPdfShapedText: el texto fuente, nombre de fuente, tamaño, bytes de la fuente, un array de runs, la anchura total, el recuento de glifos y el recuento de caracteres lógicos. Cada TPdfShapedRun lleva su tramo en el texto fuente, su posición X visual, su anchura, su nivel bidireccional y una bandera de derecha a izquierda, más sus glifos. Cada TPdfShapedGlyph lleva un identificador de glifo, un avance, offsets X e Y, y el cluster al que pertenece como inicio y longitud en el texto fuente

Esos campos de cluster son lo que hace al record utilizable y no meramente informativo. El shaping no es un mapeo uno a uno: una sílaba devanágari se convierte en un glifo a partir de cuatro caracteres, una ligadura árabe fusiona dos, y un solo carácter puede producir varias marcas. Sin tramos de cluster no podéis colocar un caret, hacer hit-test de un clic ni resaltar una selección, porque no podéis decir a qué caracteres pertenece un glifo. Con ellos, la aritmética es local y el mismo código sirve para ambos backends

Tramos de cluster de glifos en TPdfShapedText: un glifo de sílaba devanágari desde cuatro caracteres, una ligadura árabe desde dos, y una base más marca desde un carácter, cada uno mapeado de vuelta por ClusterStart y ClusterLength
Los tramos de cluster mapean cada glifo de vuelta a sus caracteres fuente para que carets, hit tests y selecciones funcionen
var
  Shaped: TPdfShapedText;
  R, G: Integer;
begin
  if ShapePdfText(Line, 'Noto Sans Arabic', 14, ptdAuto, Shaped) then
    for R := 0 to High(Shaped.Runs) do
    begin
      // Los runs ya llegan en orden visual con VisualX rellenado
      X := Shaped.Runs[R].VisualX;
      for G := 0 to High(Shaped.Runs[R].Glyphs) do
      begin
        EmitGlyph(Shaped.Runs[R].Glyphs[G].GlyphID,
          X + Shaped.Runs[R].Glyphs[G].OffsetX,
          Shaped.Runs[R].Glyphs[G].OffsetY);
        X := X + Shaped.Runs[R].Glyphs[G].Advance;
      end;
    end;
end;

Los presupuestos pertenecen al record de opciones

TPdfTextShapingOptions lleva una dirección más tres topes: máximo de caracteres, máximo de glifos y máximo de runs, con una función de clase Default que rellena valores sensatos. Los topes no son paranoia ante entrada malformada; son aritmética. El shaping expande: una fuente con sustitución contextual agresiva puede emitir más glifos que caracteres de entrada, y un párrafo que alterna escrituras cada pocos caracteres produce un run por cambio. Un documento armado para maximizar ambos convierte una cadena modesta en una asignación grande, y un servicio que conforma texto de PDF no confiables necesita un límite que él eligió y no un límite que impone la máquina

Fijar la dirección explícitamente en lugar de dejarla en automático merece hacerlo siempre que ya la conozcáis. El automático aplica las reglas de dirección de párrafo para adivinar a partir del primer carácter fuerte, lo que es correcto para texto libre y erróneo para un campo de formulario cuya dirección es una propiedad del campo y no del valor que alguien tecleó en él

Enlace en tiempo de ejecución, no dependencia de compilación

El backend de HarfBuzz carga la biblioteca dinámicamente. Es una decisión de despliegue con consecuencias reales: un binario corre en una máquina con HarfBuzz y en una sin él, informando de capacidad reducida en el segundo caso en lugar de fallar al arrancar. Para una biblioteca distribuida a otros desarrolladores ese es el único arreglo viable, porque no podéis exigir a cada consumidor de un componente PDF que adquiera y empareje versiones de una biblioteca de shaping que quizá no necesite

La regla correspondiente para quienes llaman es comprobar. ActiveTextShaper devuelve nil cuando la plataforma no tiene predeterminado y no se configuró ninguno, y el punto de entrada de shaping informa de eso como shaper no disponible en lugar de como fallo de shaping. Son problemas distintos y merecen mensajes distintos: uno es un hueco de despliegue, el otro es un problema de fuente o de texto

Instalar una vez, antes de que nada conforme

La instalación reemplaza y libera el shaper anterior, así que llamarla repetidamente es seguro pero inútil, y llamarla mientras otro hilo conforma no es seguro en absoluto. Hacedlo durante el arranque. Si necesitáis volver al predeterminado de la plataforma más tarde, pasad nil, que también es como se deshace un doble de prueba al final de un test

Una vez instalado un backend, la medición y el ajuste de línea se comportan igual en ambas plataformas, porque consumen las métricas de runs y glifos en lugar de llamar a la plataforma directamente; el modelo de ajuste de línea se describe en el artículo de medición de texto y ajuste de línea. Las plataformas y toolchains soportadas por el componente están en la página de producto de PDFium Delphi component