Artículo técnico

Extraer tablas de páginas PDF en Delphi con PDFium

PDFium Component detecta tablas en una página PDF y las devuelve como una cuadrícula de celdas con spans de fila y columna, filas de cabecera y un valor de confianza, mediante ExtractTables para una página y ExtractDocumentTables para un documento entero. Cada tabla se convierte a CSV o JSON con una sola llamada, y las tablas que continúan a través de un salto de página pueden enlazarse en una cadena de continuación

PDF no tiene un objeto tabla. Una tabla en un PDF es un conjunto de trazos de texto posicionados de modo que un humano los lea como una cuadrícula, a veces con líneas dibujadas alrededor y a menudo sin ellas. Recuperar la cuadrícula significa reconstruir una intención que el archivo nunca registró, razón por la cual cada herramienta de extracción produce resultados ligeramente distintos y por la cual una herramienta que te dice su confianza es más útil que una que no lo hace

Dos modos de detección para dos tipos de tabla

La detección con líneas usa las líneas dibujadas. Cada segmento de trazo se transforma a coordenadas de página mediante la matriz del objeto de página, las líneas horizontales y verticales se intersecan, y las intersecciones forman componentes conectados. Cada componente se convierte en su propia cuadrícula ordenada de posiciones X e Y, lo cual es lo que evita que dos tablas separadas en la misma página se combinen en una única cuadrícula sin sentido

La detección por espacios en blanco maneja tablas dibujadas mediante alineación en lugar de líneas. Las cajas de palabras se agrupan en filas visuales, los huecos dentro de una fila la dividen en columnas candidatas, y una tabla se acepta solo cuando al menos MinRows filas repiten al menos MinColumns anclas alineadas a la izquierda dentro de AlignmentTolerance. El factor de hueco entre filas es 3 por defecto, lo cual cubre el espaciado de línea base de aproximadamente 30 puntos típico de un texto de 12 puntos, sin dejar que una sola línea con varios trazos de texto se haga pasar por una tabla

Diagrama del pipeline de detección de tablas de PDFium Component en Delphi, donde las intersecciones de líneas dibujadas y las filas de palabras alineadas por espacios en blanco alimentan un único registro de tabla puntuado con exportación a CSV y JSON
La detección con líneas interseca los trazos dibujados mientras que la detección por espacios en blanco cuenta filas de cajas de palabras alineadas; los candidatos que superan MinRows y MinColumns salen con una puntuación de confianza y un DetectionMode asociado
uses
  PDFium;

var
  Pdf: TPdf;
  Options: TPdfTableExtractionOptions;
  Tables: TPdfTables;
  I: Integer;
begin
  Pdf := TPdf.Create(nil);
  try
    Pdf.FileName := 'annual-report.pdf';
    Pdf.LoadDocument;
    Pdf.PageNumber := 12;                    // basado en 1

    Options := TPdfTableExtractionOptions.Default;
    Options.DetectRuledTables := True;
    Options.DetectWhitespaceTables := True;
    Options.MinConfidence := 0.6;            // el valor por defecto es 0.5
    Options.HeaderRowCount := 1;

    Tables := Pdf.ExtractTables(Options);
    for I := 0 to High(Tables) do
      Writeln(Format('table %d: %dx%d cells, confidence %.2f, mode %d',
        [I, Tables[I].RowCount, Tables[I].ColumnCount,
         Tables[I].Confidence, Ord(Tables[I].DetectionMode)]));

    if Length(Tables) > 0 then
      SaveText('page12-table0.csv', Tables[0].ToCsv);
  finally
    Pdf.Free;
  end;
end;

¿Cómo se recuperan las celdas combinadas?

Esta es la parte que los extractores ingenuos hacen mal. Una celda combinada no puede identificarse a partir de la cuadrícula global por sí sola, porque la cuadrícula se deriva de todas las líneas de la página y una región combinada simplemente carece de la línea interior que la habría separado

La regla usada aquí es local: dos celdas base adyacentes están combinadas cuando ninguna línea de límite cubre el intervalo entre ellas. Union-find las une, los componentes rectangulares resultantes se convierten en valores RowSpan y ColumnSpan, y el texto se asigna a una celda base por su punto central y luego sigue a esa celda hasta su raíz de combinación. Hacerlo así también mantiene el coste lineal en palabras más celdas, en lugar del escaneo cuadrático que resultaría de comprobar cada palabra contra cada celda

Diagrama de la recuperación de celdas combinadas en la extracción de tablas de PDFium para Delphi, donde union-find une celdas base adyacentes siempre que ninguna línea de límite cubra el intervalo entre ellas, produciendo RowSpan y ColumnSpan
Union-find combina celdas base vecinas cuyo intervalo compartido no lleva ningún límite dibujado, de modo que una cabecera combinada vuelve como una sola celda con ColumnSpan establecido en lugar de una celda con contenido flanqueada por vacías

El efecto práctico es que una tabla financiera con una cabecera «Total» combinada que abarca tres columnas sale con una celda de span tres, en lugar de una celda con contenido y dos misteriosamente vacías

Continuación entre páginas

Las tablas largas se cortan entre páginas, y tratar el fragmento de cada página como una tabla independiente obliga al llamador a coserlas. ExtractDocumentTables puede enlazarlas en su lugar, pero solo bajo condiciones estrictas: el fragmento debe ser la tabla más baja de la página anterior, la siguiente debe ser la tabla más alta de la página siguiente, los números de página deben ser adyacentes, y los límites de columna deben coincidir

Las cuatro condiciones juntas son lo que evita el error obvio, que sería encadenar cada tabla de cuatro columnas de un documento en una única mega-tabla imaginaria solo porque comparten un número de columnas. Cuando las condiciones se cumplen, las tablas comparten un identificador de grupo de continuación y llevan metadatos de continuación; cuando no se cumplen, obtienes tablas separadas y puedes decidir por ti mismo

Diagrama de la continuación de tablas entre páginas de PDF en Delphi, donde cuatro condiciones estrictas deciden si el fragmento más bajo de una página se une al fragmento más alto de la siguiente
La extracción a nivel de documento enlaza fragmentos solo cuando se cumplen las cuatro condiciones, evitando que tablas de cuatro columnas sin relación se fusionen en una única mega-tabla imaginaria

La extracción a nivel de documento comparte los presupuestos MaxCells y MaxTables entre páginas en lugar de reiniciarlos por página, y restaura la página activa dentro de un bloque finally, de modo que una extracción ejecutada en un visor deja al usuario mirando la página en la que estaba

Exportar sin corromper los datos

Ambos exportadores son deliberados con el escapado. CSV siempre entrecomilla los campos y duplica las comillas internas, lo cual evita el fallo clásico donde una celda que contiene una coma se convierte en silencio en dos columnas. Para las celdas combinadas, el contenido se emite únicamente en el ancla superior izquierda, de modo que un ciclo de ida y vuelta por CSV no duplica una cabecera de span a lo largo de las columnas que cubre

JSON conserva Unicode en lugar de escaparlo a ASCII, escapa los caracteres de control, e incluye los metadatos que un consumidor necesita para juzgar la calidad: modo de detección, confianza, límites, valores de span, marcadores de cabecera e información de continuación. Si estás alimentando tablas extraídas a un sistema posterior, prefiere JSON, porque una fila de CSV no puede decirte que la tabla de la que provino obtuvo una confianza de 0,51:

// Extracción a nivel de documento, conservando solo las tablas dignas de confianza
Tables := Pdf.ExtractDocumentTables(Options);
for I := 0 to High(Tables) do
begin
  if Tables[I].Confidence < 0.75 then
  begin
    Log(Format('page %d table needs review (%.2f)',
      [Tables[I].PageNumber, Tables[I].Confidence]));
    Continue;
  end;
  if Tables[I].ContinuationGroup > 0 then
    AppendToGroup(Tables[I].ContinuationGroup, Tables[I].ToJson)
  else
    EmitStandalone(Tables[I].ToJson);
end;

Ajustar, y saber cuándo parar

Tres ajustes importan más que el resto. MinConfidence es la puerta de calidad, y 0,5 es deliberadamente permisivo; súbelo para la ingesta automatizada y bájalo para una interfaz de revisión donde un humano confirma cada resultado. MinColumnGap decide qué cuenta como límite de columna en modo de espacios en blanco, y las tablas ajustadas en informes densos pueden necesitar reducirlo desde el valor por defecto de 12 puntos. MaxRowGapFactor decide cuándo la distancia vertical termina una tabla, algo que importa para tablas con filas en blanco ocasionales

Sé honesto sobre los límites. Las tablas con líneas se extraen de forma fiable. Las tablas alineadas por espacios en blanco de forma limpia se extraen bien. Las tablas con texto rotado, tablas anidadas, o celdas cuyo contenido se ajusta en lo que parece otra fila necesitarán revisión sea cual sea la configuración de los parámetros. Para esos casos, el modelo de texto estructurado te da la materia prima para construir un lector específico de dominio, descrito en bloques de texto estructurado y orden de lectura

Un emparejamiento útil: cuando un documento escaneado no tiene texto en absoluto, la detección de tablas no tiene con qué trabajar hasta que exista una capa de texto. Añade una primero, como se describe en añadir una capa de texto con capacidad de búsqueda a PDF escaneados, y luego extrae. Las cajas de palabras que devuelve un proveedor de OCR son exactamente la entrada que necesita la detección por espacios en blanco

La extracción de tablas, el texto estructurado y el reflujo leen todos del mismo modelo de página en Delphi, C++Builder y Lazarus; la API completa se describe en la página de PDFium Component para Delphi