Artículo técnico

Evite tablas PDF falsas en texto justificado en Delphi

Una página de texto de cuerpo justificado puede volver de un detector de tablas por espacios en blanco como una grilla de varias columnas, cada celda cargando medio penoso de frase cortada y la confianza muy por encima de cualquier umbral que usted se le ocurra fijar. No hay ninguna tabla en esa página. El detector encontró huecos entre palabras lo bastante anchos para pasar por separadores de columna y unas pocas líneas consecutivas donde esos huecos cayeron casi en la misma posición X, y solo por alineación eso es exactamente lo que parece una tabla. La respuesta es dejar de juzgar un límite de columna fila por fila y preguntarse si una franja de papel en blanco baja por la página

¿Por qué el texto justificado parece una tabla?

El texto justificado pasa porque una línea justificada es una fila de palabras separadas por huecos que el motor de maquetación estiró, y en cuanto un hueco estirado cruza el umbral de columna, nada en esa única fila lo distingue de un canal. La estrategia de espacios en blanco de PDFium Component funciona como la mayoría de los detectores de espacios en blanco. TPdf.ExtractTables agrupa cajas de palabras en filas visuales, parte cada fila donde la distancia horizontal de una palabra a la siguiente sea al menos MinColumnGap (12 puntos en TPdfTableExtractionOptions.Default), y agrupa los bordes izquierdos de los grupos de palabras resultantes en anclas de columna dentro de una AlignmentTolerance, que por defecto es de 3 puntos. Un ancla sobrevive solo si se repite en al menos MinRows filas, y cada fila del candidato tiene que alcanzar al menos MinColumns anclas distintas. Para una tabla alineada genuina esa regla es correcta, y es la regla que describe la visión general de detección de tablas

La aritmética del lado de la prosa es menos indulgente de lo que parece. Suponga una línea de 10 puntos en una columna de boletín de 180 puntos que termina 30 puntos antes del margen derecho porque la siguiente palabra no entraba, y que la línea lleva solo tres espacios entre palabras porque sus palabras son largas. Un motor de justificación reparte esos 30 puntos entre los tres espacios, así que cada uno crece de unos 2.5 puntos a 12.5 puntos y cruza el MinColumnGap por defecto. Una segunda línea solo necesita un hueco estirado cuya palabra siguiente arranque a menos de 3 puntos del de la primera línea, y el detector tiene un candidato de dos filas por dos columnas

Cómo esté codificado el estiramiento da igual. ISO 32000-1 §9.3.3 aplica el espaciado de palabras (Tw) solo al código de carácter de un byte 32, así que los productores que usan fuentes multibyte normalmente posicionan palabras con ajustes numéricos en un arreglo TJ (§9.4.3); de cualquier manera las cajas de palabras salen idénticas. La última línea de cada párrafo se compone alineada a la izquierda y lleva espacios ordinarios, razón por la cual los falsos positivos tienden a cubrir el cuerpo de un párrafo y detenerse una línea antes de su fin

Diagrama de PDFium Component de por qué la prosa justificado puntúa como tabla: cada línea se estira hasta el mismo margen así que los espacios individuales cruzan MinColumnGap en una X distinta en cada línea, y dos espacios consecutivos dentro de AlignmentTolerance construyen los candidatos falsos que la prueba del corredor rechaza
Una tabla real repite sus anclas de columna en cada fila, mientras que un párrafo justificado estira un espacio distinto en cada línea, razón por la cual el ajuste a nivel de fila solo no puede separarlos

Una forma rápida de revisar su propio corpus es correr el detector sobre documentos que usted sabe que son solo prosa, como cartas o comunicados de prensa, e imprimir cada tabla en modo espacios en blanco que reporte. La salida esperada ahí es nada

uses
  SysUtils, PDFium;

// Lista cada tabla alineada por espacios en blanco del documento cargado para que
// las páginas que usted sabe que son solo prosa se puedan verificar rápido
procedure ReportWhitespaceTables(Pdf: TPdf; MinRows: Integer = 2);
var
  Options: TPdfTableExtractionOptions;
  Tables: TPdfTables;
  I: Integer;
begin
  Options := TPdfTableExtractionOptions.Default;  // MinColumnGap 12, AlignmentTolerance 3
  Options.MinRows := MinRows;
  Tables := Pdf.ExtractDocumentTables(Options);   // restaura la página activa
  for I := 0 to High(Tables) do
    if Tables[I].DetectionMode = ptdmWhitespace then
      Writeln(Format('page %d: %dx%d whitespace table, confidence %.2f, ' +
        'first cell "%s"',
        [Tables[I].PageNumber, Tables[I].RowCount, Tables[I].ColumnCount,
         Tables[I].Confidence, Tables[I].Cells[0].Text]));
end;

Lo que cuesta cada umbral

Cada umbral a nivel de fila intercambia una clase de documento por otra, y esa es la razón para dejarlos tranquilos con este problema. Subir MinColumnGap a 20 puntos descarta las columnas compactas de los estados financieros densos, donde el canal entre una columna de etiquetas y sus valores suele ser apenas más ancho que el valor por defecto. Subir MinRows a 3 descarta tablas genuinas de dos filas y solo baja las probabilidades con párrafos largos, ya que una página de prosa ofrece muchas chances de que tres líneas se alineen. Apretar AlignmentTolerance por debajo de 3 puntos empieza a partir columnas reales

MinConfidence parece el filtro natural y es el más débil de los cuatro. Una tabla de espacios en blanco en PDFium Component puntúa 0.58 más 0.30 por la proporción de celdas no vacías, con tope de 0.88, así que el puntaje mide qué tan llena está la grilla. Un fragmento de párrafo llena cada celda, lo que significa que un falso positivo de prosa puntúa cerca del tope del rango mientras que una tabla real con celdas en blanco puntúa más bajo

Dos líneas de prosa justificada y dos filas de una tabla de dos columnas pueden producir grupos de palabras idénticos en anclas idénticas. La evidencia que los separa está entre las filas

Un límite de columna real es un corredor vertical

Un límite de columna real es una franja vertical de la página que se mantiene sin texto en cada fila que separa. Una tabla tiene una entre cada par de columnas por construcción, porque sus celdas se maquetaron contra posiciones X compartidas. Un párrafo justificado estira sus espacios en una posición horizontal distinta en cada línea, así que ninguna franja sobrevive más de una o dos líneas. PDFium Component prueba exactamente eso después de que los grupos de palabras de un candidato se asignaron a columnas ancla. Para cada par de columnas adyacentes, cada fila con contenido en las dos celdas aporta el intervalo desde el borde más a la derecha de las palabras de la celda izquierda hasta el borde más a la izquierda de las palabras de la celda derecha; los intervalos se intersecan entre filas, y el candidato entero se rechaza cuando la intersección queda más angosta que la mitad de MinColumnGap — 6 puntos con el valor por defecto

Diagrama de PDFium Component de la prueba de corredor sin texto detrás de ExtractTables: cada fila dona el intervalo desde el borde derecho de su celda izquierda hasta el borde izquierdo de su celda derecha, la intersección se mantiene más ancha que la mitad de MinColumnGap en una tabla real y colapsa a nada en texto justificado
Un límite de columna genuino está vacío en cada fila que separa, así que intersecar los huecos por fila deja una franja compartida para una tabla y ninguna franja para prosa justificada

Dos detalles importan con tablas reales incómodas. Las filas donde cualquiera de las dos celdas está vacía no votan, así que una tabla con celdas en blanco, o un encabezado que abarca menos columnas que el cuerpo, igual pasa. Y el ancho del corredor se deriva de MinColumnGap en lugar de ser una opción aparte, porque los dos valores describen la misma cosa física: el espacio que un diseñador deja entre columnas. Reducirlo a la mitad deja lugar para la fila en la que una entrada larga de la columna izquierda corre cerca de la siguiente

La verificación es lo bastante pequeña para reproducirla en su propio código de maquetación; las listas de celdas de abajo son su propia estructura, un arreglo de índices de palabras por celda en orden fila-mayor

uses
  Math, PDFium;

type
  TIndexList = array of Integer;
  TCellIndexes = array of TIndexList;   // su propia estructura: Row * ColumnCount + Column

// Devuelve False cuando un par de columnas adyacentes no tiene un corredor vertical
// libre de texto de al menos MinColumnGap / 2 de ancho en las filas que lo usan
function HasTextFreeCorridors(const Words: TPdfWordBoxes;
  const Cells: TCellIndexes; RowCount, ColumnCount: Integer;
  MinColumnGap: Double): Boolean;
var
  Col, Row, I, LeftCell, RightCell, Supported: Integer;
  CorridorLeft, CorridorRight, RowLeft, RowRight: Double;
begin
  for Col := 0 to ColumnCount - 2 do
  begin
    CorridorLeft := -MaxDouble;
    CorridorRight := MaxDouble;
    Supported := 0;
    for Row := 0 to RowCount - 1 do
    begin
      LeftCell := Row * ColumnCount + Col;
      RightCell := LeftCell + 1;
      if (Length(Cells[LeftCell]) = 0) or (Length(Cells[RightCell]) = 0) then
        Continue;                             // las celdas vacías no votan
      RowLeft := -MaxDouble;
      RowRight := MaxDouble;
      for I in Cells[LeftCell] do
        RowLeft := Max(RowLeft, Words[I].Rect.Right);
      for I in Cells[RightCell] do
        RowRight := Min(RowRight, Words[I].Rect.Left);
      CorridorLeft := Max(CorridorLeft, RowLeft);
      CorridorRight := Min(CorridorRight, RowRight);
      Inc(Supported);
    end;
    if (Supported > 0) and
       (CorridorRight - CorridorLeft < MinColumnGap * 0.5) then
      Exit(False);
  end;
  Result := True;
end;

Mantener las tablas con líneas fuera del pase de espacios en blanco

Una tabla con líneas se encontraría dos veces si el pase de espacios en blanco viera cada palabra de la página, porque una tabla con líneas limpia es por construcción también una tabla de espacios en blanco perfectamente alineada. Por sí sola, una regla de solape de más de la mitad dejaría pasar un candidato que combine las filas inferiores de una tabla con líneas con unas pocas líneas de texto alineadas debajo, manteniéndose bajo la proporción y saliendo como una segunda tabla más grande que se derrama sobre su vecina

Cuando DetectRuledTables y DetectWhitespaceTables están ambos activados, que es el valor por defecto, ExtractTables corre primero el pase de líneas y quita cada palabra cuyo punto central queda dentro de los límites de una tabla con líneas antes de que arranque el pase de espacios en blanco. Se usa el centro en lugar de la contención completa para que una palabra que monta un borde por una fracción de punto siga a la tabla a la que pertenece visualmente. La estrategia de espacios en blanco ve entonces solo palabras libres, y una tabla pequeña sin líneas sentada directamente bajo una grilla con líneas se juzga por sus propias filas

¿Por qué "Purpose of Request:" puede salir como "of Purpose Request:"?

Las palabras de una celda salen en orden equivocado cuando el código que decide "misma línea" compara centros verticales, porque las cajas de palabras construidas desde cajas de glifos no comparten centro ni siquiera cuando comparten línea base. FPDFText_GetCharBox devuelve la caja ajustada de la tinta de cada glifo en el espacio de página, no una caja rellenada hasta la ascendente y la descendente de la fuente, y una caja de palabra es la unión de las cajas de sus caracteres. En una etiqueta de formulario como "Purpose of Request:", las palabras "Purpose" y "Request:" llevan descendentes y "of" no, así que la caja de "of" es más baja y su centro queda un par de puntos más arriba. Una rutina que ordena por centro Y con una tolerancia de misma línea de 1 punto y después por borde izquierdo pone a "of" en su propia línea encima de las otras dos y la emite primero

El PDF no ofrece atajo. ISO 32000-1 §9.4.4 coloca los glifos por desplazamiento a lo largo de la línea base, y las únicas métricas verticales del archivo son por fuente: Ascent, Descent y FontBBox en el descriptor de fuente (§9.8.1). Nada dice que dos glifos compartan línea. Las cajas ajustadas que hacen que el resaltado de selección se vea bien, cubiertas en selección de líneas de texto con las cajas de caracteres de PDFium, son una mala entrada para una comparación de distancia entre centros

Diagrama de PDFium Component del orden de palabras de Purpose of Request: las cajas de glifo ajustadas de FPDFText_GetCharBox le dan al of sin descendente un centro más alto que una tolerancia de centro Y de 1 pt ordena como línea propia, mientras que una regla de solape vertical del 25 por ciento lo mantiene en la línea base y conserva el orden de las palabras
El centro Y se mueve con las ascendentes y descendentes que la tinta traiga, mientras que dos cajas sobre una misma línea base se solapan a lo largo de la altura x compartida sin importar sus alturas

PDFium Component arma el texto de las celdas por solape. Una palabra se une a una línea visual cuando su solape vertical con los límites acumulados de la línea es de al menos 25 por ciento de la menor de las dos alturas, o cuando los dos centros quedan a un punto de distancia entre sí; cada línea se ordena después por borde izquierdo, y las líneas se unen con un par CR LF. "Purpose" y "of" se solapan a lo largo de toda la altura x, mucho más de un cuarto de la caja más baja, así que caen en una misma línea y se leen en orden

Agrupar líneas de texto por solape en su propio código

La misma trampa espera en cualquier código de maquetación de PDF que decida "misma línea" comparando centros contra una tolerancia fija. Las descendentes son el disparador más leve. Etiquetas en negrita junto a valores más chicos, marcadores de nota al pie en superíndice y cajas de OCR con ruido mueven los centros más que cualquier tolerancia que todavía separe líneas adyacentes de texto de 10 puntos con interlineado de 12. La proporción de solape no le importa el tamaño: dos cajas sobre una misma línea base se solapan a lo largo de su altura x compartida, y dos cajas en líneas adyacentes no se solapan en nada

TPdf.PageWordBoxes devuelve cada palabra de la página activa con su rectángulo en espacio de página en Rect, así que agrupar una página en líneas visuales lleva un predicado y un loop corto

uses
  Math, PDFium;

function SameVisualLine(const A, B: TPdfRectangle): Boolean;
var
  Overlap, MinHeight: Double;
begin
  Overlap := Min(A.Top, B.Top) - Max(A.Bottom, B.Bottom);
  MinHeight := Min(A.Top - A.Bottom, B.Top - B.Bottom);
  Result := (MinHeight > 0) and (Overlap >= MinHeight * 0.25);
end;

procedure GroupPageIntoLines(Pdf: TPdf; out Lines: TArray<TPdfWordBoxes>);
var
  Words: TPdfWordBoxes;
  Bounds: TArray<TPdfRectangle>;   // unión acumulada por línea
  I, J, Found: Integer;
begin
  Words := Pdf.PageWordBoxes;            // necesita una página activa (Pdf.PageNumber)
  Lines := nil;
  Bounds := nil;
  for I := 0 to High(Words) do
  begin
    Found := -1;
    for J := High(Lines) downto 0 do
      if SameVisualLine(Bounds[J], Words[I].Rect) then
      begin
        Found := J;
        Break;
      end;
    if Found < 0 then
    begin
      SetLength(Lines, Length(Lines) + 1);
      SetLength(Bounds, Length(Bounds) + 1);
      Found := High(Lines);
      Bounds[Found] := Words[I].Rect;
    end;
    SetLength(Lines[Found], Length(Lines[Found]) + 1);
    Lines[Found][High(Lines[Found])] := Words[I];
    Bounds[Found].Left := Min(Bounds[Found].Left, Words[I].Rect.Left);
    Bounds[Found].Right := Max(Bounds[Found].Right, Words[I].Rect.Right);
    Bounds[Found].Top := Max(Bounds[Found].Top, Words[I].Rect.Top);
    Bounds[Found].Bottom := Min(Bounds[Found].Bottom, Words[I].Rect.Bottom);
  end;
  // ordene cada línea por Rect.Left antes de leerla: PageWordBoxes devuelve
  // las palabras en orden del content stream, que no tiene que coincidir con el orden visual
end;

El predicado es la parte que vale la pena copiar. Para cualquier cosa más allá de un volcado rápido, arranque del modelo de texto estructurado, que ya lleva bloques, líneas y una fuente de orden de lectura, como se describe en extracción de texto estructurado de PDF con orden de lectura

Lo que quien llama a las tablas sigue controlando

Quien llama a las tablas recibe la prueba del corredor, el filtro de palabras con líneas primero y el texto de celdas por solape sin tocar sus opciones. El umbral del corredor está fijo en la mitad de MinColumnGap, así que el único valor que usted ajusta para el ancho del canal gobierna ambos. La estrategia de espacios en blanco conserva un piso de dos filas incluso cuando MinRows se pone en 1; solo la estrategia de líneas acepta grillas de una fila, que ExtractDocumentTables usa para fragmentos de tabla continuados al tope de una página. El mismo record de opciones viaja hacia TPdfReflowOptions.TableOptions, así que la salida de reflow hereda las mismas decisiones cuando DetectTables está activado

La prueba del corredor decide sobre filas que tienen contenido a ambos lados de un límite, así que un candidato de dos filas se juzga sobre solo dos de esas filas. Para archivos que son mayormente prosa y rara vez contienen tablas genuinas de dos filas — contratos, correspondencia, ensayos — fije MinRows en 3 y acepte perder las tablas de dos filas. Para documentos financieros y con formularios mantenga el valor por defecto de 2, porque las tablas reales cortas son comunes ahí. El texto alineado a la izquierda conserva espacios simples ordinarios y rara vez produce un hueco cercano a los 12 puntos

El PDF no tiene un objeto de tabla general. ISO 32000-1 §14.8.4.3.4 define un elemento de estructura Table, pero solo el PDF etiquetado lo lleva, así que para todo lo demás la grilla es una inferencia desde la geometría, y la Confidence de una tabla de espacios en blanco nunca pasa de 0.88

Las opciones de detección, la serialización TPdfTable.ToCsv y ToJson, y la demo TableExtractionLab para Delphi, C++Builder y Lazarus están cubiertas en la página de producto de PDFium Component para Delphi