Artículo técnico

Extracción estructurada de texto PDF en Delphi con PDFium

PDFiumPas devuelve el texto de página como una estructura en lugar de una cadena. GetStructuredText produce una TPdfStructuredTextPage que contiene bloques, cada uno con líneas, cada una con tramos con estilo propio, con límites en el espacio de página en cada nivel y los índices de carácter de origen preservados, así que cualquier fragmento se puede volver a mapear sobre la página de texto subyacente

La extracción como cadena plana con la que empieza la mayoría del código sigue ahí y sigue siendo correcta para su propósito. Deja de ser suficiente en el momento en que necesita saber qué palabras eran un encabezado, a qué columna izquierda pertenecían, o dónde en la página se sitúa realmente una coincidencia

¿Por qué una cadena plana es la salida equivocada para la mayoría de las tareas?

Porque las preguntas que la gente hace sobre el texto extraído casi nunca son "qué caracteres hay en esta página". Son "cuál es el título", "es esto una tabla", "pertenece este párrafo a la sección 4", "dónde dibujo el resaltado". Una única cadena no responde a ninguna, y cada respuesta que reconstruya a partir de ella es una heurística que ahora es responsabilidad suya

Las maquetas a dos columnas ilustran esto con claridad. Extraiga un artículo a dos columnas como cadena y, según cómo escribiera el productor el flujo de contenido, puede obtener la columna uno seguida de la columna dos, o puede obtener la línea uno de la columna uno, la línea uno de la columna dos, la línea dos de la columna uno, y así sucesivamente página abajo. Ambos resultados salen de un PDF conforme. Ninguno es incorrecto a nivel de formato, porque PDF describe marcas sobre una página, no un esquema de documento. Un modelo basado en bloques permite que el extractor tome la decisión de orden de forma explícita y le diga cuál tomó

¿Orden de contenido u orden físico?

TPdfStructuredTextOptions.ReadingOrder elige entre roContentOrder y roPhysicalLayout, y la respuesta correcta depende de en qué confíe más, en el productor o en la geometría

El orden de contenido devuelve el texto en la secuencia en que lo dibuja el flujo de contenido. Eso es rápido, y para documentos generados por un productor bien comportado, suele ser el orden de lectura previsto. El orden físico ignora la secuencia del flujo y reconstruye el orden a partir de dónde están realmente los caracteres, agrupándolos en líneas y luego en columnas. Eso es lo que quiere para páginas escaneadas y pasadas por OCR, para salida de herramientas que emiten texto en orden de fuente en lugar de orden de lectura, y para cualquier caso donde el resultado visual sea lo único con lo que puede contar

uses
  PDFium;

var
  Pdf: TPdf;
  Options: TPdfStructuredTextOptions;
  Page: TPdfStructuredTextPage;
  B, L: Integer;
begin
  Pdf := TPdf.Create(nil);
  try
    Pdf.FileName := 'article.pdf';
    Pdf.LoadDocument;
    Pdf.PageNumber := 1;                     // base 1

    Options := TPdfStructuredTextOptions.Default;
    Options.ReadingOrder := roPhysicalLayout;
    Options.IncludeFontInfo := True;
    Options.IncludeSemantics := True;
    Options.MaxCharacters := 200000;         // presupuesto que falla cerrado

    Page := Pdf.GetStructuredText(Options);

    for B := 0 to High(Page.Blocks) do
    begin
      if Page.Blocks[B].Kind = cfHeading then
        Emit(Format('H%d: %s',
          [Page.Blocks[B].HeadingLevel, Page.Blocks[B].Text]))
      else
        for L := 0 to High(Page.Blocks[B].Lines) do
          Emit(Page.Blocks[B].Lines[L].Text);
    end;
  finally
    Pdf.Free;
  end;
end;

¿Qué añade el etiquetado que la geometría no puede?

Intención. Con IncludeSemantics activado, los bloques de un PDF etiquetado llevan un Kind extraído del árbol de estructura, así que un encabezado es un encabezado porque el productor lo dijo, no porque su fuente fuera mayor que la media. Los tipos cubren las formas que importan para la reutilización: cfParagraph, cfHeading con un HeadingLevel, cfListItem, cfTableCell, cfCaption, cfFigure y el valor por defecto sin etiquetar cfPlain

El campo Source registra de dónde vino cada clasificación, rosStructure para el árbol de estructura y rosHeuristic para la inferencia, que es el campo a registrar cuando esté decidiendo hasta qué punto confiar en una canalización de extracción a lo largo de un conjunto de documentos. Las figuras son un caso especial que conviene conocer: para un bloque cfFigure, el texto procede de la descripción alternativa y no de ningún glifo, ya que una figura no tiene caracteres propios. El texto alternativo sin correspondencia sigue representándose en lugar de descartarse, lo que permite que una auditoría de accesibilidad vea que existe una descripción aunque nada en la página la dibuje. El modelo de etiquetado en sí se cubre en la validación del árbol de estructura PDF/UA

Los tramos llevan el estilo y la procedencia

Cada TPdfStructuredTextSpan lleva su texto, sus límites en espacio de página, FontName, FontSize, FontWeight y Angle, además de SourceStartIndex y SourceCharacterCount. Los tramos se cortan donde cambia el estilo, así que una frase con tres palabras en negrita se convierte en tres tramos, y reconstruir el énfasis en HTML o Markdown es cuestión de leer propiedades en lugar de adivinar a partir de nombres de fuente

Los dos campos de índice de origen son los que convierten la extracción en una función en vez de en un informe. Apuntan de vuelta a la secuencia de caracteres de la página, lo que significa que un bloque que ha localizado en una búsqueda se puede convertir en geometría de selección a nivel de carácter o en un rectángulo de resaltado sin una segunda pasada, ordenada de forma distinta, sobre el texto; la mecánica se describe en la selección visual de líneas de texto con cuadros de carácter. El campo Angle importa más de lo que parece: el texto rotado en un sello o una marca de agua cae en el mismo espacio de coordenadas que el texto del cuerpo, y una canalización que ignora el ángulo fusionará alegremente un "DRAFT" en diagonal en mitad de un párrafo

Presupuesto, y los dos contadores de calidad

MaxCharacters es un presupuesto que falla cerrado, no un ajuste de truncado: una página que lo supere se detiene en lugar de devolver silenciosamente parte del contenido. En una ruta de ingesta no confiable, ese es el comportamiento que quiere, porque una página con un millón de caracteres o bien es un monstruo generado por máquina, o bien es un intento de convertir su extractor en la parte más lenta del sistema

Dos contadores en la página devuelta describen directamente la calidad de la extracción. UnmappedCharacterCount cuenta los caracteres sin mapeo Unicode utilizable, el síntoma clásico de una fuente en subconjunto incrustada sin un CMap /ToUnicode; un texto así se renderiza perfectamente y se extrae como nada útil. GeometryFailureCount cuenta los caracteres cuyo cuadro delimitador no se pudo determinar, lo que degrada el ordenamiento del orden físico. Registre ambos. Un conjunto de documentos donde esos números están de forma consistente cerca de cero se puede indexar con confianza, y uno donde no lo están le está diciendo que algunos productores de su canalización necesitan atención antes de que cualquier resultado posterior sea fiable

var
  Page: TPdfStructuredTextPage;
  B, S, L: Integer;
  Emphasised: Boolean;
begin
  Page := Pdf.GetStructuredText(Options);

  if Page.UnmappedCharacterCount > 0 then
    Log(Format('page %d: %d characters without a Unicode mapping',
      [Page.PageNumber, Page.UnmappedCharacterCount]));
  if Page.GeometryFailureCount > 0 then
    Log(Format('page %d: %d characters without geometry',
      [Page.PageNumber, Page.GeometryFailureCount]));

  for B := 0 to High(Page.Blocks) do
    for L := 0 to High(Page.Blocks[B].Lines) do
      for S := 0 to High(Page.Blocks[B].Lines[L].Spans) do
      begin
        Emphasised := Page.Blocks[B].Lines[L].Spans[S].FontWeight >= 600;
        AppendRun(Page.Blocks[B].Lines[L].Spans[S].Text, Emphasised,
          Page.Blocks[B].Lines[L].Spans[S].SourceStartIndex);
      end;
end;

Rendimiento en páginas reales

La extracción de orden físico es el modo costoso, y la implementación está pensada para páginas que son realmente grandes: el ordenamiento de caracteres corre en O(n log n) en lugar de mediante barridos repetidos, los búferes de línea y tramo crecen de forma geométrica en lugar de reasignarse carácter a carácter, el texto Unicode se construye en búferes en lugar de por concatenación de cadenas, y las búsquedas de fuente para objetos de texto adyacentes se almacenan en caché. Esa combinación es lo que mantiene predecible una página densa de 5000 caracteres en lugar de cuadrática

Para un trabajo con muchas páginas, sigue mereciendo la pena elegir el modo más barato cuando se pueda. Use roContentOrder con la semántica activada para documentos etiquetados en los que confía, y reserve roPhysicalLayout para el material escaneado y heredado donde la geometría es la única señal disponible. Si todo lo que necesita es una cadena plana, la API más simple descrita en la extracción de texto de documentos PDF sigue siendo la ruta más rápida, y cuando necesite rastrear el texto hasta los identificadores de contenido marcado, la lectura y escritura de contenido marcado BDC y MCID cubre esa capa

El modelo de bloques también encaja bien con lo que quieren las canalizaciones de recuperación: un encabezado con sus párrafos es un fragmento con un título, y los límites permiten que una cita apunte a una ubicación en una página en lugar de a un documento entero. PDFiumPas es un componente para Delphi y Lazarus alrededor del motor PDFium, documentado con ejemplos en la página de PDFium para Delphi