Artículo técnico

Búsqueda de texto PDF segura para Unicode en Delphi: NFC y NFD

PDF Library for Delphi puede hacer coincidir texto por equivalencia canónica en lugar de por unidad de código, así que una consulta escrita como un carácter precompuesto encuentra contenido almacenado como una letra base más una marca de combinación, y viceversa. Dos opciones de búsqueda lo controlan: soCanonicalEquivalent activa la normalización Unicode durante la coincidencia, y soGraphemeClusters restringe cada coincidencia y cada paso de comodín a clústeres de grafemas completos

El error que esto corrige es uno de los más reportados y menos comprendidos en la búsqueda de documentos. Un usuario busca un nombre, no ve resultados, copia el nombre del documento, lo pega en el cuadro de búsqueda, y lo encuentra. Nada está roto de forma evidente: las dos cadenas parecen idénticas, se imprimen de forma idéntica, y se comparan como distintas, porque una es U+00E9 y la otra es U+0065 seguido de U+0301

¿Por qué la misma palabra se compara como distinta?

Unicode permite varias codificaciones para el mismo carácter abstracto. Las letras latinas con diacríticos existen como puntos de código precompuestos y como secuencias de base más combinación. Las sílabas hangul existen como sílabas precompuestas y como jamo descompuestos. Cuál de ellas contiene un PDF depende del productor, la plataforma y a veces la fuente, y nada de eso es visible para la persona que realiza la búsqueda

El motivo por el que el simple plegado de mayúsculas no resuelve esto es estructural, no incidental. El plegado de mayúsculas y el plegado de acentos son de uno a uno a nivel de unidad de código: la cadena plegada tiene la misma longitud que la original, así que una posición de coincidencia en el texto plegado es una posición de coincidencia en el original. La normalización no es de uno a uno. Un carácter precompuesto se convierte en dos o tres unidades de código, una secuencia descompuesta colapsa de nuevo en una, y tras esa transformación, las posiciones ya no coinciden con el texto que se extrajo

Mantener las coordenadas de coincidencia apuntando al texto original

Esta es la parte que determina si la búsqueda normalizada es utilizable y no solo correcta. Cada unidad de código producida por la normalización registra la posición de inicio y fin del texto UTF-16 original que la produjo. Las descomposiciones recursivas heredan el rango de origen de su padre, las composiciones combinan los rangos de sus entradas, y cuando se encuentra una coincidencia la biblioteca recorre el intervalo de correspondencia buscando el inicio más pequeño y el final más grande

El efecto es que MatchStart, MatchLength, las cadenas de contexto y ambos puntos de entrada de reemplazo siguen refiriéndose al texto extraído original, no al intermedio normalizado. Sin esa correspondencia, una búsqueda normalizada podría indicar que existe una coincidencia pero no de forma fiable dónde estaba, lo cual hace incorrecto el resaltado y peligrosa la redacción

El propio normalizador es autocontenido: tablas compactas para la descomposición canónica, la composición y la clase de combinación canónica de Unicode 15.1, con el hangul gestionado mediante las reglas algorítmicas en lugar de entradas de tabla. No se carga nada de un archivo de datos externo y no se llama a ninguna API de normalización de la plataforma, así que un servicio de Windows, un daemon de Linux y una compilación de FPC producen todos resultados idénticos con la misma entrada

Buscar con equivalencia canónica

Las opciones son un conjunto, así que la equivalencia canónica se combina con los comportamientos existentes como la coincidencia de palabra completa, los comodines y el plegado insensible a diacríticos:

uses
  PDFlibrary;

var
  Lib: TPDFlib;
  Hits: array of TPDFlibSearchHit;
  Found, I: Integer;
begin
  Lib := TPDFlib.Create;
  try
    Lib.LoadFromFile('contracts.pdf', '');
    SetLength(Hits, 500);

    Found := Lib.SearchText('Bäcker', [soCanonicalEquivalent, soWholeWord],
      '', Hits);                       // rango de página vacío = documento completo

    for I := 0 to Found - 1 do
      Log(Format('page %d: "%s" at %d (%d chars)',
        [Hits[I].Page, Hits[I].MatchText, Hits[I].MatchStart,
         Hits[I].MatchLength]));
  finally
    Lib.Free;
  end;
end;

La normalización es opcional por un motivo. Construir el texto NFD y su correspondencia de posiciones cuesta trabajo, y la mayoría de las búsquedas en documentos solo ASCII nunca la necesitan. Cuando se usa la opción, cada bloque de texto almacena en caché dos formas transformadas, una con las marcas de combinación eliminadas y otra sin ellas, así que un lote de consultas sobre el mismo bloque normaliza una vez en lugar de una vez por consulta. El plegado de mayúsculas sigue recorriendo sin cambios la vía más económica de uno a uno

¿Qué se rompe sin límites de clúster de grafemas?

Las unidades de código no son caracteres, y los caracteres no son lo que perciben los usuarios. Un emoji de bandera son dos puntos de código de indicador regional. Un emoji de familia son varios puntos de código unidos por uniones de anchura cero. Un conjunto índico es una consonante, un virama y otra consonante. Una letra con dos acentos apilados son tres puntos de código. Hacer coincidir o cortar en mitad de cualquiera de estos produce un fragmento que se renderiza como basura

soGraphemeClusters restringe ambos extremos de cada coincidencia, literal o comodín, a límites de clúster de grafemas extendidos completos. La segmentación implementa las reglas extendidas: emparejamiento de CR y LF, caracteres de control, clases de sílaba hangul, Extend y SpacingMark, Prepend, secuencias ZWJ de emoji, emparejamiento de indicador regional y cortes de conjunto índico. Nunca se produce un límite dentro de un par sustituto, lo que por sí solo elimina toda una clase de resultados corruptos en cualquier contenido más allá del plano multilingüe básico

La opción también rige el consumo de comodines, que es donde una implementación ingenua seguiría cortando de forma incorrecta. El comodín de un solo carácter avanza exactamente un clúster completo, y el retroceso del comodín de secuencia se mueve solo entre límites de clúster:

// Sin soGraphemeClusters, "?" puede consumir medio clúster y
// devolver una coincidencia cuyo texto termina en una marca de combinación suelta
Found := Lib.SearchText('c?té',
  [soWildcards, soCanonicalEquivalent, soGraphemeClusters], '', Hits);

// Los mismos límites protegen el reemplazo, así que la redacción y
// la reescritura de contenido nunca dividen un emoji ni una letra acentuada
Replaced := Lib.SearchAndReplaceText('naïve', 'plain',
  [soCanonicalEquivalent, soGraphemeClusters], '1-20');

Elegir opciones para una carga de trabajo real

Tres combinaciones cubren la mayoría de los casos. Para un cuadro de búsqueda de documentos interno, soCanonicalEquivalent más soDiacriticInsensitive ofrece el comportamiento indulgente que esperan los usuarios, haciendo coincidir ambas formas de codificación y tanto las grafías acentuadas como las no acentuadas. Para búsquedas legales o de cumplimiento normativo, donde un falso positivo tiene un coste, use soCanonicalEquivalent con soCaseSensitive y soWholeWord y deje desactivado el plegado de acentos, de modo que la equivalencia sea exacta e independiente de la codificación

Para cualquier cosa que modifique el documento, añada soGraphemeClusters sin excepción. Una búsqueda que devuelve un rango ligeramente incorrecto solo confunde a un lector; un reemplazo o una redacción que usa ese mismo rango incorrecto escribe el error en el archivo. Las consecuencias de equivocar los rangos de eliminación se tratan en la redacción real y la eliminación de contenido

Cuando el rendimiento importa, prefiera los puntos de entrada por lotes. SearchTextBatch ejecuta cada consulta no vacía mientras los bloques de texto de cada página están residentes, lo cual evita volver a extraer una página por consulta y reutiliza la normalización en caché, y las variantes en flujo emiten coincidencias sin un búfer dimensionado por quien llama. El modelo de extracción subyacente se describe en la búsqueda de texto y la enumeración de elementos de página

Escrituras donde esto no es opcional

Para el coreano, la equivalencia canónica es la diferencia entre encontrar un nombre y no encontrarlo, porque tanto las sílabas precompuestas como los jamo descompuestos son comunes en documentos reales. Para el vietnamita, los diacríticos apilados hacen que la forma de composición dependa por completo del productor. Para las escrituras índicas, la gestión de conjuntos decide si un límite de coincidencia cae en un lugar legible. Para el japonés y el chino, el lado de la búsqueda es comparativamente simple, aunque el lado de la disposición no lo es, como se describe en la escritura vertical para japonés y chino

La regla general es breve: si el corpus contiene algún idioma que no sea el inglés, active la equivalencia canónica y mida el coste antes de decidir que es demasiado caro. En la mayoría de los conjuntos de documentos no lo es, y la alternativa es una función de búsqueda que falla silenciosamente precisamente con los nombres que más les importa encontrar a sus usuarios

La búsqueda, la extracción, la redacción y la reescritura de texto con reconocimiento de Unicode comparten un único motor para Delphi, C++Builder y Free Pascal; la lista completa de funciones está en la página de PDF Library for Delphi