Artículo técnico

Extraer texto PDF en Delphi: espacios y saltos de línea

HotPDF Delphi Component reconstruye los espacios de palabra y los saltos de línea en THotPDF.ExtractLoadedPageText a partir de la geometría de los glifos, no de caracteres de espacio. Un espacio entra cuando el hueco tras el ancho propio de un glifo supera el 0.15 de la altura del texto, y una línea nueva empieza solo cuando el origen del texto cruza la dirección de escritura más de la mitad de la altura del texto. Desde v2.768.3 el texto de la página incluye también el texto pintado por Form XObjects y deja fuera los glifos fuera del área visible del crop. El resto del artículo explica por qué cada regla tiene la forma que tiene, porque cada una sustituyó a una regla más simple que producía output plausible pero equivocado en documentos reales

Los síntomas le sonarán a quien haya metido texto PDF en un índice de búsqueda. Una portada se extrae como PDFReferenceManualNovember4,1998, un formulario fiscal se parte en 156 líneas, una marca de agua en diagonal llega a un carácter por línea, y una prueba recortada encabeza con la slug line de la impresora que ningún visor muestra jamás. Ninguno de estos archivos está roto. Cada uno usa una forma perfectamente legal de colocar texto que un extractor ingenuo lee mal

¿Por qué el texto PDF extraído pierde sus espacios de palabra?

El texto extraído pierde los espacios porque a un PDF nunca se le exige contenerlos. Un productor puede separar palabras mostrando un carácter de espacio, pero igual de bien puede mover el lápiz con un número dentro de un array TJ (ISO 32000-1 §9.4.3) o con un Td fresco (§9.4.2), y el output de TeX, muchos archivos de Distiller y la mayoría de las maquetaciones justificadas hacen justo eso. Antes de v2.766.76, HPDFAssemblePageText solo miraba el movimiento vertical, así que un salto de palabra hecho a base de posicionamiento sencillamente desaparecía. El assembler mide ahora, sobre la dirección de escritura del glifo anterior, la distancia desde el final del ancho propio de ese glifo hasta el origen del glifo actual, e inserta un espacio cuando la distancia supera el 0.15 de la altura de la caja del glifo actual, medida de ascendente a descendente en user space. No se añade espacio cuando cualquiera de los dos lados ya está en blanco, ni entre dos caracteres CJK, porque la justificación separa los ideogramas sin que ese estiramiento signifique frontera de palabra. Los registros de glifos exponen la misma geometría, así que puede reproducir la decisión cuando un archivo concreto le deja perplejo

uses
  SysUtils, HPDFDoc, HPDFContentStream;

procedure DumpWordGaps(Pdf: THotPDF; PageIndex: Integer);
var
  Glyphs: THPDFGlyphArray;
  I: Integer;
  Height, Gap: Double;
begin
  if not Pdf.ExtractLoadedPageGlyphs(PageIndex, Glyphs) then
    Exit;
  for I := 1 to High(Glyphs) do
  begin
    // altura de ascendente a descendente de la caja del glifo, en user space
    Height := Sqrt(Sqr(Glyphs[I].QuadX[3] - Glyphs[I].QuadX[0]) +
      Sqr(Glyphs[I].QuadY[3] - Glyphs[I].QuadY[0]));
    // texto horizontal: hueco desde el final del propio ancho del glifo anterior
    Gap := Glyphs[I].BaselineStartX - Glyphs[I - 1].GlyphEndX;
    if (Height > 0) and (Gap > 0.15 * Height) then
      Writeln(Format('U+%.4x gap %.2f height %.2f: space',
        [Glyphs[I].Unicode, Gap, Height]));
  end;
end;

¿Por qué medir desde el ancho propio del glifo y no desde la posición del lápiz?

HotPDF mide los huecos de palabra desde GlyphEndX / GlyphEndY porque la posición del lápiz tras un glifo ya arrastra espaciado que no es un hueco. ISO 32000-1 §9.4.4 define el desplazamiento horizontal como el ancho del glifo por el tamaño de fuente, más el character spacing Tc, más el word spacing Tw, todo escalado por Tz. BaselineEndX / BaselineEndY guardan ese desplazamiento completo, mientras que GlyphEndX / GlyphEndY guardan solo el advance de la fuente y Tz. La diferencia importa con productores que aprietan el tracking con un Tc negativo y luego devuelven el espacio por un ajuste de TJ tras cada glifo: medido desde la posición del lápiz, la devolución parece un hueco, y el término chino «95后» se extraía como «9 5 后». El umbral va atado a la altura de la caja del glifo en lugar del tamaño del Tf por una razón parecida. Word exporta a menudo 1 Tf y lleva el tamaño real en un Tm escalado, así que Tfs dice 1 mientras el texto mide 10 puntos, y una regla anclada a Tfs trataría distinto las dos deletreaduras de la misma página

La regla de espacios de palabra de HotPDF para ExtractLoadedPageText en Delphi: un espacio se inserta solo cuando la distancia desde el GlyphEndX del glifo anterior al BaselineStartX del siguiente supera el 0.15 de la altura de caja de ascendente a descendente, porque la posición del lápiz en BaselineEndX ya contiene Tc, Tw y Tz y convierte las devoluciones de tracking justificado en huecos falsos como 9 5 后
La geometría, no los caracteres de espacio, decide dónde parten las palabras — los registros de glifos exponen las mismas medidas, así que puede reproducir la decisión ante cualquier archivo desconcertante

La regla tiene bordes honestos. Un título compuesto con tracking muy suelto, donde el Tc por sí solo abre más de 0.15 de la altura del texto entre letras, se extrae con un espacio entre cada letra, que es lo que la página muestra pero probablemente no lo que usted quería indexar. Las piezas dibujadas fuera de orden sobre una misma baseline producen un hueco negativo y se unen sin espacio. Ninguno de los dos casos es común en texto corrido, y sobre un corpus de test el cambio subió las coincidencias de palabra contra un extractor de referencia en 28 páginas sin bajar ninguna

¿Cuándo empieza HotPDF una línea nueva en el texto extraído?

Desde v2.766.79, una línea nueva empieza cuando el movimiento desde el origen del glifo anterior al actual, proyectado sobre la normal de la dirección de escritura anterior, supera la mitad de la mayor altura de caja de los dos glifos. La regla anterior comparaba el movimiento Y crudo con la mitad de Tfs, lo que fallaba en dos direcciones. Con 1 Tf y un Tm escalado el umbral se encogía a media unidad, así que un superíndice levantado por un text rise de 0.4 o el temblor corriente de la baseline partía la línea. La regla además ignoraba la X por completo, así que el texto bajo un Tm rotado bajaba escalera abajo con cada glifo y salía un glifo por línea. Proyectar sobre la normal de la dirección hace que los tramos rotados se comporten como los horizontales, y quedarse con la mayor de las dos alturas mantiene en una línea una palabra muestra grande y su epígrafe pequeño cuando comparten baseline. En el formulario fiscal mencionado arriba, el conteo de líneas bajó de 156 a 97. El texto vertical en writing mode 1 (§9.7.4.3) sigue un camino aparte: esos glifos se agrupan en columnas, se leen de derecha a izquierda y de arriba abajo, con un salto de línea en cada cambio de columna

Cómo decide ExtractLoadedPageText de HotPDF los saltos de línea en Delphi: el movimiento entre orígenes de glifos se proyecta sobre la normal de la dirección de escritura y se compara con la mitad de la mayor altura de caja, así que un superíndice levantado por un text rise pequeño bajo una fuente 1 Tf y un texto que baja escalera abajo bajo un Tm rotado ya no se parten en un glifo por línea
La proyección hace que los tramos rotados se comporten como los horizontales, y quedarse con la mayor de las dos alturas de caja mantiene en una línea una palabra muestra grande y su epígrafe pequeño

¿Qué texto incluye o deja fuera ExtractLoadedPageText?

ExtractLoadedPageText devuelve el texto que un visor muestra. Desde v2.766.80 trabaja solo con los glifos visibles, soltando cada glifo cuyo centro de caja caiga fuera de GetLoadedPageVisibleBox, que es la CropBox recortada al MediaBox (§14.11.2). Eso elimina las slug lines y otras marcas de impresora compuestas como texto fuera del área de corte. ExtractLoadedPageGlyphs deliberadamente sigue devolviendo cada glifo del content stream de la página, así que ese material sigue encontrándose cuando lo necesite. El filtro es un test de caja, no un test de visibilidad: el texto escondido por un clipping path, pintado en blanco o tapado por una imagen se extrae igualmente

var
  Pdf: THotPDF;
  Glyphs: THPDFGlyphArray;
  PageText: UnicodeString;
  L, B, R, T: Single;
begin
  Pdf := THotPDF.Create(nil);
  try
    Pdf.LoadFromFile('trimmed-proof.pdf');
    if Pdf.GetLoadedPageVisibleBox(0, L, B, R, T) then
      Writeln(Format('Visible box: %.1f %.1f %.1f %.1f', [L, B, R, T]));
    // cada glifo del content stream de la página, slug line incluida
    if Pdf.ExtractLoadedPageGlyphs(0, Glyphs) then
      Writeln(Length(Glyphs), ' glyphs in the page content stream');
    // solo lo que la página muestra, con texto de Form XObject empalmado
    if Pdf.ExtractLoadedPageText(0, PageText) then
      Writeln(PageText);
  finally
    Pdf.Free;
  end;
end;

El texto pintado por Form XObjects forma parte del texto de la página desde v2.768.3. Las cabeceras, los sellos y las marcas de agua viven muy a menudo en formularios, y algunos documentos de estándares perdían del 30 al 35 por ciento de sus caracteres antes del cambio. THotPDF.InterpretContentWithForms registra cada Do junto con la CTM en vigor, interpreta el formulario en su /Matrix por esa CTM (§8.10.1), y empalma los glifos del formulario en la posición del Do, recursando en formularios anidados. Un formulario sin /Resources propias toma prestadas las del stream que lo pinta, como permite §7.8.3. Los glifos de formulario llevan TokenIndex = -1, y ExtractLoadedPageGlyphs sigue devolviendo solo glifos del stream de página, porque buscar, reemplazar y redactar escriben los cambios de vuelta por TokenIndex y editarían los bytes equivocados si un glifo de formulario se colara. Dos simplificaciones conviene conocer: el texto de formulario no se recorta al /BBox del formulario, y la recursión se para a 12 niveles en lugar de detectar ciclos, así que un formulario malformado que se pinta a sí mismo repite su texto hasta alcanzar ese tope

Qué glifos incluye HotPDF al extraer texto de páginas PDF en Delphi: ExtractLoadedPageText conserva solo los glifos cuyo centro de caja cae dentro de GetLoadedPageVisibleBox, la CropBox recortada al MediaBox, así que las slug lines de impresora desaparecen, mientras que InterpretContentWithForms empalma los glifos de Form XObject en cada posición Do con TokenIndex a -1 y la API a nivel de glifo sigue devolviéndolo todo
Un test de caja sobre el centro del glifo no es un test de visibilidad — el texto blanco, el recortado y el tapado siguen saliendo, y el texto de formulario cuenta desde v2.768.3

¿Por qué el texto tras un operador Q se decodificaba como basura?

El texto tras Q podía decodificarse mal antes de v2.766.73 porque el extractor guardaba solo la CTM en el q. Los parámetros de estado de texto, esto es, fuente, tamaño, Tc, Tw, Tz, TL, modo de renderizado y rise, pertenecen al estado gráfico (§9.3.1), así que Q tiene que restaurarlos junto con todo lo demás de la pila (§8.4.2). Un informe del sector seleccionaba una fuente Identity-H de dos bytes dentro de un q … Q y luego mostraba texto WinAnsi de un byte sin Tf propio. El extractor conservaba la fuente interior, leía los puntos guía y la palabra «Adobe» del índice como códigos de dos bytes, y soltaba el 15% de los caracteres de la página. La pila de q/Q del intérprete guarda ahora el estado de texto completo. Las reglas de extracción descritas aquí valen para todas las páginas, así que un documento entero puede ir a un archivo en una sola llamada

var
  Output: TFileStream;
  Pages: Integer;
begin
  Output := TFileStream.Create('report.txt', fmCreate);
  try
    // rango vacío = todas las páginas; form feed entre páginas; BOM UTF-8
    Pages := Pdf.ExtractLoadedPagesTextToStream(Output, '', #12, True);
    Writeln(Pages, ' pages extracted');
  finally
    Output.Free;
  end;
end;

¿Qué API de texto de HotPDF debería usar?

ExtractLoadedPageText se queda en el orden del content stream, que es el valor por defecto correcto para búsqueda e indexación; la cadena de decodificación que hay debajo está cubierta en extraer texto de PDFs cargados con HotPDF. Para documentos etiquetados donde el orden de autoría importa, la extracción de texto en orden de estructura recorre el árbol de estructura en lugar de adivinar por geometría, y para datos atrapados en tablas, la extracción de tablas tipadas a través de saltos de página devuelve celdas en lugar de líneas. La referencia completa de la API y una descarga de prueba están en la página de producto de HotPDF Delphi PDF Component