Artículo técnico

Texto PDF en Delphi con coordenadas de acierto: PDFlibPas

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

PDFlibPas es una biblioteca PDF nativa en 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 ya existente: SearchText recorre un rango de páginas y devuelve cada acierto 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 del rectángulo de cada bloque dentro de una región en lugar de reducirlos a una lista de cadenas. Ninguna toca la ruta de escritura; son añadidos puros del lado de lectura sobre maquinaria que la biblioteca ya tenía

Por qué la geometría vive en la lista de bloques de texto y no en el conducto

La intuición natural es reutilizar lo que sea que ejecute internamente GetPageText. Ese camino pasa por un conducto de extracción transitorio que produce la cadena de la página y luego se libera antes de que la llamada devuelva. Cuando tienes el resultado en la mano, las coordenadas de cada bloque ya han desaparecido. Nunca fueron tuyas para quedártelas

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

Así se entiende la forma de SearchText a partir de esa restricción. Para cada página del rango extrae la lista de bloques, lee el texto de cada bloque con GetTextBlockText, lo compara con la consulta y, para los bloques que coinciden, reduce el quad a un rectángulo. El acierto 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;

La matriz de límites alterna X/Y, no cuatro esquinas

Este es el detalle que muerde primero. 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 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, cuatro puntos en total. Si los lees con el emparejamiento equivocado, tu rectángulo no tiene sentido

La razón de que exista un quad y no un rectángulo simple es la rotación. Un bloque de texto colocado en ángulo tiene un polígono de contorno real de cuatro puntos, y los ocho dobles lo describen fielmente. Para el caso de uso de resaltar y saltar, casi siempre quieres una caja recta en su lugar, así que la biblioteca reduce el quad a un rectángulo alineado a los ejes recorriendo los cuatro puntos para obtener sus mínimos y máximos en X e Y. El texto rotado se convierte en la caja recta que lo envuelve, que es lo que necesita una superposición 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;

Nótese 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 pasas a las llamadas de dibujo y anotación. Eso es intencionado: el rectángulo que recibes de un acierto de búsqueda es el que puedes pasar directamente a una anotación de resaltado o a un comando de "ir aquí" sin convertir nada

Distinción entre mayúsculas y minúsculas, palabras completas y dónde cambia 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 subcadenas sin distinción de mayúsculas y minúsculas. Añade soCaseSensitive para distinguir Indemnity de indemnity, añade soWholeWord para evitar que sign coincida dentro de signature, o combina ambas opciones

La coincidencia de palabra completa necesita una definición de qué es un límite de palabra, y aquí conviene decirlo claramente 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_] que ya conoces por las reglas de los identificadores. Una coincidencia solo cuenta como palabra completa cuando los caracteres justo 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 conviene conocerla antes de sacar adelante un cuadro de búsqueda multilingüe. Como los caracteres Han, kana y otras letras no ASCII quedan fuera de esa clase, cualquier frontera junto a ellos se interpreta como un borde que no pertenece a una palabra. En la práctica eso hace que la búsqueda de palabra completa sobre texto CJK se comporte como si cualquier posición fuera un límite de palabra válido, así que la opción acaba degradándose a una coincidencia de subcadena en ese caso. Es una limitación documentada, no un error, y coincide con el comportamiento en el que se modeló la función. Si tu corpus es sobre todo CJK, el modo de palabra completa no te dará la segmentación que ofrecería un tokenizador dedicado; planifícalo así en lugar de confiar en él

Una nota de implementación que explica una clase de fallos sutiles en otros sitios: la comparación sin distinción de mayúsculas usa UpperCase sobre el WideString, no AnsiUpperCase. La variante Ansi devuelve un AnsiString, que no encajaría con el WideString que usa el resto del flujo, y mezclar ambos provoca incompatibilidades de tipos y, peor aún, un plegado con pérdida para caracteres fuera de la página de códigos activa. Unicode entra, Unicode sale, hasta el final

Un solo analizador 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 propio en su análisis: el mismo PLParsePageRangeList que alimenta PrintPages y las rutinas de copia de páginas se encarga también aquí, así que un rango que se imprime bien también se busca bien. Una cadena de rango vacía es el marcador de "todas las páginas", caso en el que SearchText construye la lista completa por sí mismo

El alcance importa para el coste. Buscar un tramo de diez páginas en un documento de mil extrae bloques de diez páginas, no de 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, dilo en el rango y salta el resto del archivo

Internamente, la búsqueda y la enumeración cambian ambas la página seleccionada mientras iteran, así que cada una guarda la página seleccionada por el llamador al entrar y la restaura en un bloque finally. Llamar a SearchText en mitad de la construcción de una página deja la selección exactamente donde la dejaste cuando la llamada termina. Ese contrato de guardar y restaurar es el tipo de cosa que solo notas cuando falta, y precisamente por eso existe

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 total", y eso es EnumPageElements. Devuelve una lista unificada en la que cada elemento es o bien un bloque de texto o bien una imagen incrustada, distinguida 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 salen de la misma pasada ExtractPageTextBlocks, así que cada uno llega ya con su rectángulo, su nombre de fuente y su tamaño rellenos. Los elementos de imagen salen de la lista de imágenes incrustadas de la página a través de FindImages y GetImageID; el ImageID que llevan es el handle que pasas a SelectImage para inspeccionar la imagen más a fondo. Ambos tipos llegan a la misma matriz para que una sola pasada sobre la página vea todo lo que contiene

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;

Aquí hay una convención de conteo que sigue el resto de la biblioteca y que debes respetar o leerás memoria no inicializada. El valor devuelto es el conteo total de elementos, que puede ser mayor que la matriz que pasaste. La función solo rellena 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 conteo devuelto y High(array), nunca iteres a ciegas hasta el conteo. Los ejemplos de arriba muestran la comprobación I <= High(...) por esa razón. Si el valor devuelto supera tu búfer, amplía la matriz y vuelve a llamar

Si ya has usado las llamadas de nivel más bajo de bloques de texto de la biblioteca, esta es la capa tipada y consciente de la geometría que se coloca encima; la extracción subyacente es la misma que se describe en extracción de texto, imágenes y fuentes en Delphi PDF con PDFlibPas. Y cuando el objetivo no es "dónde está este texto" sino "cómo está estructurado este documento para tecnología asistiva", la historia paralela del lado de lectura es el árbol de estructura 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 mirar

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 cubre ese caso. Es el equivalente de GetTextInArea que también lleva límites: 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í que no solo sabes qué hay en la caja, sino también dónde cae cada línea dentro de ella

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 tener claras. GetTextInAreaEx trabaja sobre la página seleccionada en ese momento, así que llama primero a SelectPage; a diferencia de SearchText, no toma un rango. Y un bloque se conserva cuando intersecta el rectángulo consultado, no solo cuando queda totalmente contenido, así que una línea que cruza el borde también aparece. Eso suele ser lo que quieres para una caja de selección dibujada a mano, pero si necesitas contención estricta puedes filtrar tú mismo los rectángulos devueltos, ya que ahora los tienes

Ponerlo en práctica

La idea común de las tres llamadas es que la geometría deja de ser algo que reconstruyes después. Un acierto de búsqueda conoce su página y su caja. Un elemento de página conoce su rectángulo y, en el caso del texto, su fuente. Una consulta por región informa de dónde cae cada línea. Eso basta para construir una función real de buscar y resaltar, un índice de localización con un clic o un extractor consciente del diseño sin bajar por debajo de la API pública ni rehacer a mano la canalización de extracción de texto

Estas API de consulta se incluyen como parte de la Biblioteca PDF para Delphi PDFlibPas, 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