Artículo técnico

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

PDF Library for Delphi puede comparar 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 combinada, y viceversa. Dos opciones de búsqueda lo controlan: soCanonicalEquivalent activa la normalización Unicode durante la comparación, 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 se ven idénticas, se imprimen de forma idéntica, y comparan como distintas, porque una es U+00E9 y la otra es U+0065 seguido de U+0301

¿Por qué la misma palabra 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 marca combinada. 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 hace la búsqueda

La razón por la que el simple plegado de mayúsculas no resuelve esto es estructural y 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 vuelta a una, y después de esa transformación, las posiciones ya no coinciden con el texto que extrajiste

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 mapeo en busca del inicio más pequeño y el fin más grande

El efecto es que MatchStart, MatchLength, las cadenas de contexto y ambos puntos de entrada de reemplazo siguen todos direccionando el texto extraído original, no el intermedio normalizado. Sin ese mapeo, una búsqueda normalizada podría decirte que existe una coincidencia pero no de forma confiable dónde estaba, lo que 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 manejado mediante las reglas algorítmicas en lugar de entradas de tabla. Nada se carga desde un archivo de datos externo y no se llama a ninguna API de normalización de la plataforma, así que un servicio Windows, un demonio Linux y una compilación FPC producen todos resultados idénticos sobre 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 una razón. Construir el texto NFD y su mapeo de posiciones cuesta trabajo, y la mayoría de las búsquedas sobre documentos solo con ASCII nunca lo necesitan. Cuando se usa la opción, cada bloque de texto almacena en caché dos formas transformadas, una con las marcas combinadas eliminadas y otra sin ellas, así que un lote de consultas sobre el mismo bloque se normaliza una vez en lugar de una vez por consulta. El plegado de mayúsculas sigue recorriendo sin cambios la ruta 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 ancho cero. Un conjunto índico es una consonante, un virama y otra consonante. Una letra con dos acentos apilados son tres puntos de código. Comparar o cortar en medio 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 de emoji con ZWJ, emparejamiento de indicador regional y cortes de conjunto índico. Un límite nunca se produce dentro de un par sustituto, lo cual 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 gobierna el consumo de comodines, que es donde una implementación ingenua todavía cortaría de forma incorrecta. El comodín de un solo carácter avanza exactamente un clúster completo, y el retroceso para el comodín de tramo 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 combinada colgante
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 o 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 interno de documentos, soCanonicalEquivalent más soDiacriticInsensitive da el comportamiento tolerante que esperan los usuarios, comparando ambas formas de codificación y las grafías con y sin acento. Para búsqueda legal o de cumplimiento, donde un falso positivo tiene un costo, usa soCanonicalEquivalent con soCaseSensitive y soWholeWord y deja desactivado el plegado de acentos, para que la equivalencia sea exacta e independiente de la codificación

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

Cuando importa el rendimiento, prefiere 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 que evita volver a extraer una página por cada consulta y reutiliza la normalización en caché, y las variantes de flujo emiten coincidencias sin un búfer dimensionado por quien llama. El modelo de extracción subyacente se describe en búsqueda de texto y 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 las sílabas precompuestas y los jamo descompuestos son ambos 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, el manejo 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 del diseño no lo es, como se describe en escritura vertical para japonés y chino

La regla general es corta: si el corpus contiene algún idioma distinto del inglés, activa la equivalencia canónica y mide el costo 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 en silencio exactamente en los nombres que más les importa encontrar a tus usuarios

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