Artículo Técnico

Búsqueda de texto PDF en Delphi con coordenadas: PDFlibPas

Extraer el texto de una página es la mitad fácil del problema. En cuanto un usuario escribe una palabra en un cuadro de búsqueda y espera que el visor salte hasta ella y la rodee con un recuadro amarillo, necesitas algo que la cadena de texto plana no puede darte: la página en la que vive cada coincidencia y el rectángulo que ocupa en coordenadas PDF. Una cadena concatenada a lo largo de una página ya perdió esa geometría. Puedes encontrar la subcadena, pero no puedes señalarla

PDFlibPas es una biblioteca PDF nativa de Object Pascal para Delphi y C++Builder, y desde v3.78.0 responde exactamente a esa pregunta. Tres API de consulta se apoyan sobre el extractor de bloques de texto existente: SearchText recorre un rango de páginas y devuelve cada coincidencia con su página y su rectángulo alineado a los ejes, EnumPageElements enumera todo lo que hay en una página, tanto bloques de texto como imágenes incrustadas, y GetTextInAreaEx informa el rectángulo de cada bloque dentro de una región en lugar de aplanarlos en una lista de cadenas. Ninguna toca la ruta de escritura; son adiciones de solo lectura sobre maquinaria que la biblioteca ya tenía

Por qué la geometría vive en la lista de bloques de texto, no en el embudo

La intuición natural es reutilizar lo que sea que GetPageText ejecuta internamente. Esa ruta pasa por un "embudo" de extracción transitorio que produce la cadena de la página y luego se libera antes de que la llamada regrese. Para cuando tienes el resultado en la mano, las coordenadas de cada bloque ya desaparecieron. Nunca fueron tuyas para conservarlas

Las coordenadas sí sobreviven en otra estructura. ExtractPageTextBlocks(3) devuelve un manejador de lista de bloques de texto cuyos elementos llevan un cuadrilátero delimitador de ocho double, un nombre de fuente, un tamaño de fuente y el texto del bloque. Ese manejador es el único lugar donde la geometría queda retenida después de la extracción, por eso cada una de las nuevas API de consulta se construye sobre él y no sobre el embudo. Reutilizar la lista de bloques significa que la búsqueda, la enumeración y las consultas por región comparten una sola pasada de extracción y una sola definición de dónde está un bloque

Así, la forma de SearchText se desprende de esa restricción. Para cada página del rango extrae la lista de bloques, lee el texto de cada bloque con GetTextBlockText, lo prueba contra la consulta y, para los bloques que coinciden, reduce el cuadrilátero a un rectángulo. La coincidencia que devuelve es un registro pequeño:

type
  TPDFlibSearchHit = record
    Page: Integer;                       // 1-based page of the match
    Left, Top, Right, Bottom: Double;    // axis-aligned hit rectangle
    MatchText: WideString;               // the block text that contained the query
  end;

El arreglo de límites está intercalado X/Y, no en cuatro esquinas

Este es el detalle que más rápido muerde. GetTextBlockBound(ListID, Index, BoundIndex) toma un BoundIndex de 1 a 8, y esos ocho valores no son "esquina 1, esquina 2, esquina 3, esquina 4" con dos campos agrupados cada una como podrías imaginar. Son X, Y, X, Y, X, Y, X, Y: los índices impares son coordenadas X y los pares son coordenadas Y, en total cuatro puntos. Si los lees con el emparejamiento equivocado, tu rectángulo no tiene sentido

La razón de que exista un cuadrilátero en lugar de un rectángulo simple es la rotación. Un bloque de texto colocado en ángulo tiene un polígono delimitador real de cuatro puntos, y los ocho double lo describen con fidelidad. Para el caso de resaltar y saltar casi siempre quieres un cuadro recto, así que la biblioteca reduce el cuadrilátero a un rectángulo alineado a los ejes barriendo los cuatro puntos para obtener su mínimo y su máximo en X y Y. El texto rotado se convierte en el cuadro vertical que lo encierra, que es lo que necesita una capa de resaltado:

var
  Pdf: TPDFlib;
  Hits: array[0..255] of TPDFlibSearchHit;
  Found, I: Integer;
begin
  Pdf := TPDFlib.Create(nil);
  try
    Pdf.LoadFromFile('contract.pdf', '');
    // Search pages 1 to 10, case-insensitive, substring match.
    Found := Pdf.SearchText('indemnity', [], '1-10', Hits);
    for I := 0 to Found - 1 do
      if I <= High(Hits) then
        WriteLn(Format('p%d: [%.1f %.1f %.1f %.1f] %s',
          [Hits[I].Page, Hits[I].Left, Hits[I].Top,
           Hits[I].Right, Hits[I].Bottom, Hits[I].MatchText]));
  finally
    Pdf.Free;
  end;
end;

Nota que el rectángulo está en puntos del espacio de usuario PDF con el origen en la esquina inferior izquierda de la página, el mismo sistema de coordenadas que usas para llamadas de dibujo y anotación. Es deliberado: el rectángulo que recibes de una coincidencia de búsqueda es el mismo que puedes pasar directamente a una anotación de resaltado o a un comando de “desplázate hasta aquí” sin convertir nada

Sensibilidad a mayúsculas, palabras completas y dónde difiere CJK

El segundo parámetro es un conjunto TPDFlibSearchOptions formado por soCaseSensitive y soWholeWord. El conjunto vacío [] es el caso habitual: una búsqueda de subcadena sin distinguir mayúsculas. Agrega soCaseSensitive para que Indemnity e indemnity sean distintos, agrega soWholeWord para evitar que sign coincida dentro de signature, o combina ambos

La coincidencia de palabra completa necesita una definición de qué es un límite de palabra, y aquí vale la pena decir la regla con claridad porque está centrada en ASCII por diseño. Un carácter cuenta como parte de una palabra cuando es una letra ASCII, un dígito ASCII o un guion bajo: la clase [A-Za-z0-9_] conocida de las reglas de identificadores. Una coincidencia califica como palabra completa solo cuando los caracteres inmediatamente antes y después no son caracteres de palabra, o cuando la coincidencia está en el borde del bloque

La consecuencia para los scripts no latinos es algo que conviene saber antes de lanzar un cuadro de búsqueda multilingüe. Como los caracteres Han, kana y otras letras no ASCII quedan fuera de esa clase, cada límite junto a ellos se interpreta como borde no perteneciente a palabra. En la práctica eso significa que la búsqueda de palabra completa sobre texto CJK se comporta como si cada posición fuera un límite de palabra válido, así que el indicador se degrada de hecho a una búsqueda de subcadena. Esa es una limitación documentada, no un error, y coincide con el comportamiento en que se modeló la función. Si tu corpus es principalmente CJK, el modo de palabra completa no te dará la segmentación que ofrecería un tokenizador dedicado; conviene planear en torno a eso y no depender de él

Una nota de implementación que explica una clase de fallos sutiles en otros lugares: la comparación sin distinción de mayúsculas usa UpperCase sobre WideString, no AnsiUpperCase. La variante Ansi devuelve un AnsiString, que no se alinearía con el WideString que usa el resto de la ruta, y mezclar ambos produce discrepancias de tipo y, peor aún, plegado con pérdida para caracteres fuera de la página de códigos activa. Unicode entra, Unicode sale, de punta a punta

Un parser de rangos de páginas para toda la biblioteca

El tercer parámetro es una cadena de rango de páginas como "1,3,5-9". No hay nada personalizado en su análisis: el mismo PLParsePageRangeList que alimenta PrintPages y las rutinas de copia de páginas lo maneja aquí también, así que un rango que imprime bien también busca bien. Una cadena de rango vacía es el centinela de “cada página”, caso en el que SearchText arma por sí mismo la lista completa

El alcance importa para el costo. Buscar un tramo de diez páginas dentro de un documento de mil páginas extrae bloques solo para diez páginas, no para mil, porque el bucle solo selecciona y extrae las páginas que nombra el rango. Cuando ya sabes que una cláusula vive en el apéndice, díselo al rango y salta el resto del archivo

Internamente, tanto la búsqueda como la enumeración cambian la página seleccionada a medida que avanzan, por lo que cada una guarda la página seleccionada del llamador al entrar y la restaura en un bloque finally. Llama a SearchText en medio de la construcción de una página y la selección quedará exactamente donde la dejaste cuando la llamada regrese. Ese contrato de guardar y restaurar es el tipo de cosa que solo notas cuando falta, y precisamente por eso está ahí

Enumerar una página completa: texto e imágenes en una sola lista

La búsqueda responde “dónde está esta palabra”. La otra mitad de la introspección es “qué hay en esta página en absoluto”, y eso lo hace EnumPageElements. Devuelve una sola lista unificada donde cada elemento es un bloque de texto o una imagen incrustada, diferenciados por un campo Kind:

type
  TPDFlibPageElementKind = (ekText, ekImage);

  TPDFlibPageElement = record
    Kind: TPDFlibPageElementKind;
    Page: Integer;
    Left, Top, Right, Bottom: Double;
    Text: WideString;        // ekText
    FontName: WideString;    // ekText
    FontSize: Double;        // ekText
    ImageID: Integer;        // ekImage; usable with SelectImage / GetImageID
  end;

Los elementos de texto provienen de la misma pasada de ExtractPageTextBlocks, así que cada uno llega con su rectángulo, su nombre de fuente y su tamaño ya cargados. Los elementos de imagen provienen de la lista de imágenes incrustadas de la página mediante FindImages y GetImageID; el ImageID que llevan es el identificador que pasas a SelectImage para inspeccionar la imagen con más detalle. Los dos tipos terminan en un solo arreglo para que una sola pasada por la página vea todo lo que hay en ella

var
  Pdf: TPDFlib;
  Elems: array[0..511] of TPDFlibPageElement;
  Total, I: Integer;
begin
  Pdf := TPDFlib.Create(nil);
  try
    Pdf.LoadFromFile('report.pdf', '');
    Total := Pdf.EnumPageElements(1, Elems);
    for I := 0 to Total - 1 do
      if I <= High(Elems) then
        if Elems[I].Kind = ekText then
          WriteLn(Format('text  %s/%.1f  "%s"',
            [Elems[I].FontName, Elems[I].FontSize, Elems[I].Text]))
        else
          WriteLn(Format('image id=%d', [Elems[I].ImageID]));
  finally
    Pdf.Free;
  end;
end;

Hay una convención de conteo aquí que sigue el resto de la biblioteca y que debes respetar o leerás memoria sin inicializar. El valor de retorno es el recuento total de elementos, que puede ser mayor que el arreglo que pasaste. La función llena solo tantas posiciones como caben y sigue contando el resto, exactamente igual que funciona la enumeración de firmas. Así que la protección es siempre la misma: limita tu bucle al menor entre el recuento devuelto y High(array), nunca iteres a ciegas hasta el recuento. Los ejemplos de arriba muestran la comprobación I <= High(...) por esa razón. Si el valor devuelto supera tu búfer, usa un arreglo más grande y vuelve a llamar

Si has usado las llamadas de nivel más bajo para bloques de texto de la biblioteca, esta es la capa tipada y consciente de la geometría sobre ellas; la extracción subyacente es la misma que se describe en Extracción de texto, imágenes y fuentes en Delphi con PDFlibPas. Y cuando el objetivo no es “dónde está este texto” sino “cómo está estructurado este documento para tecnología de apoyo”, la historia paralela del lado de lectura es el árbol de estructura de PDF etiquetado, que expone el orden lógico de lectura en lugar del diseño físico de los bloques

Consultas por región cuando ya sabes dónde buscar

A veces no tienes un término de búsqueda en absoluto; tienes un rectángulo. Una plantilla de formulario siempre coloca el número de factura en la esquina superior derecha, o un diseño escaneado reserva una franja fija para una tabla. GetTextInAreaEx sirve para ese caso. Es el equivalente que conserva límites de GetTextInArea: donde la llamada anterior devuelve una lista plana de cadenas para una región, la nueva devuelve el rectángulo de cada bloque conservado junto con su texto, así sabes no solo qué hay dentro del cuadro sino dónde se ubica cada línea dentro de él

var
  Pdf: TPDFlib;
  Hits: array[0..63] of TPDFlibSearchHit;
  Found, I: Integer;
begin
  Pdf := TPDFlib.Create(nil);
  try
    Pdf.LoadFromFile('invoice.pdf', '');
    Pdf.SelectPage(1);
    // Left, Top, Width, Height in PDF points on the selected page.
    Found := Pdf.GetTextInAreaEx(360, 720, 180, 60, Hits);
    for I := 0 to Found - 1 do
      if I <= High(Hits) then
        WriteLn(Hits[I].MatchText);
  finally
    Pdf.Free;
  end;
end;

Hay dos cosas que conviene mantener claras. GetTextInAreaEx trabaja sobre la página actualmente seleccionada, así que llama primero a SelectPage; a diferencia de SearchText, no toma un rango. Y un bloque se conserva cuando intersecta el rectángulo de consulta, no solo cuando está completamente contenido, así que una línea que cruza el borde todavía aparece. Eso suele ser lo que quieres para un cuadro de selección dibujado a mano, pero si necesitas contención estricta puedes filtrar tú mismo los rectángulos devueltos, ya que ahora los tienes

Ponerlo a trabajar

El hilo conductor de las tres llamadas es que la geometría ya no es algo que reconstruyes después. Una coincidencia de búsqueda conoce su página y su caja. Un elemento de página conoce su rectángulo y, para texto, su fuente. Una consulta por región informa dónde cae cada línea. Con eso alcanza para construir una función real de buscar y resaltar, un índice de salto a la ubicación, o un extractor consciente del diseño sin bajar de la API pública ni reconstruir a mano la canalización de extracción de texto

Estas API de consulta se incluyen como parte de la Biblioteca PDF PDFlibPas para Delphi, junto con la capa completa de extracción de bloques de texto sobre la que se construyen y el resto de la superficie de introspección de solo lectura para Delphi y C++Builder