Artículo técnico

Falsos positivos de tabla en PDFs con texto justificado

Una página de texto cuerpo justificado puede volver de un detector de tablas por whitespace como una cuadrícula de varias columnas, cada celda con media frase rota y la puntuación de confianza muy por encima de cualquier umbral que se le ocurriera fijar. No hay tabla en esa página. El detector encontró huecos entre palabras lo bastante anchos para pasar por separadores de columna y unas cuantas líneas consecutivas donde esos huecos caían 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 a fila y preguntarse si una franja de papel en blanco baja por la página entre las columnas

Por qué el texto justificado pasa una prueba de tablas por whitespace

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 fila concreta lo distingue de un canal. La estrategia de whitespace en PDFium Component funciona como la mayoría de detectores de whitespace. 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 son 3 puntos. Un ancla sobrevive solo si se repite en al menos MinRows filas, y cada fila de la candidata tiene que tocar al menos MinColumns anclas distintas. Para una tabla alineada genuina esa regla es correcta, y es la regla descrita en 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 cabía, 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 empiece a menos de 3 puntos de la de la primera línea, y el detector tiene una candidata de dos filas y dos columnas

Cómo se codifique el estiramiento da igual. ISO 32000-1 §9.3.3 aplica el word spacing (Tw) solo al código de carácter de un byte 32, así que los productores con fuentes multibyte suelen posicionar palabras con ajustes numéricos en un array TJ (§9.4.3); de cualquier manera las cajas de palabra salen idénticas. La última línea de cada párrafo se compone irregular y lleva espacios ordinarios, que es por lo que los falsos positivos tienden a cubrir el cuerpo de un párrafo y pararse una línea antes de su fin

Diagrama de PDFium Component de por qué la prosa justificada puntuaba como tabla: todas las líneas se estiran al mismo margen, así que huecos sueltos cruzan MinColumnGap en una X distinta en cada línea, y dos huecos consecutivos dentro de AlignmentTolerance construían los candidatos falsos que la prueba de corredor rechaza ahora
Una tabla real repite sus anclas de columna en todas las filas, mientras que un párrafo justificado estira un espacio distinto en cada línea, y por eso el ajuste a nivel de fila no podía separar las dos cosas por sí solo

Una forma rápida de comprobar su propio corpus es pasar el detector por documentos que usted sabe que son solo prosa, como cartas o notas de prensa, e imprimir cada tabla en modo whitespace que reporte. La salida esperada en esos casos es nada

uses
  SysUtils, PDFium;

// Lista cada tabla alineada por whitespace del documento cargado para que
// las páginas que usted sabe que son solo prosa se puedan comprobar 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 cambia una clase de documento por otra, y esa es la razón para no tocarlos con este problema. Subir MinColumnGap a 20 puntos tumba las columnas compactas de los estados financieros densos, donde el canal entre una columna de etiquetas y sus valores a menudo es 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, porque una página de prosa ofrece muchas ocasiones para que tres líneas se alineen. Apretar la AlignmentTolerance por debajo de 3 puntos empieza a partir columnas reales cuyos bordes izquierdos se desvían un poco, como pasa en páginas escaneadas con una capa de texto OCR

MinConfidence parece el filtro natural y es el más débil de los cuatro. Una tabla por whitespace en PDFium Component puntúa 0,58 más 0,30 por la proporción de celdas no vacías, con tope en 0,88, así que la puntuación mide lo llena que está la cuadrícula. Un fragmento de párrafo llena todas las celdas, lo que significa que un falso positivo de prosa puntúa cerca del techo 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 libre de texto en todas las filas 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 línea o dos. PDFium Component comprueba exactamente esto después de que los grupos de palabras de una candidata se hayan asignado a columnas de anclas. Para cada par de columnas adyacentes, cada fila con contenido en ambas 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 toda la candidata se rechaza cuando la intersección es más estrecha que la mitad de MinColumnGap — 6 puntos con el valor por defecto

Diagrama de PDFium Component de la prueba de corredor libre de texto que hay detrás de ExtractTables: cada fila aporta 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 se derrumba a nada en texto justificado
Un límite de columna genuino está vacío en todas las filas que separa, así que intersecar los huecos de cada fila deja una franja compartida en una tabla y ninguna franja en prosa estirada

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

La comprobació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 array de índices de palabra 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 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 de la pasada de whitespace

Una tabla con líneas se encontraría dos veces si la pasada de whitespace viera todas las palabras de la página, porque una tabla limpia con líneas es por construcción también una tabla por whitespace perfectamente alineada. Por sí sola, una regla de solapamiento de más de la mitad dejaría que una candidata que combine las filas inferiores de una tabla con líneas y unas cuantas líneas de texto alineado debajo se quedara bajo la proporción y saliera como una segunda tabla más grande que sangra hacia su vecina

Cuando DetectRuledTables y DetectWhitespaceTables están activos, que es el valor por defecto, ExtractTables corre primero la pasada de líneas y quita toda palabra cuyo punto central caiga dentro de los límites de una tabla con líneas antes de que empiece la pasada de whitespace. Se usa el centro en lugar de la contención completa para que una palabra que cabalga sobre un borde por una fracción de punto siga a la tabla a la que pertenece visualmente. La estrategia de whitespace ve entonces solo palabras libres, y una tabla pequeña sin líneas justo debajo de una cuadrícula con líneas se juzga por sus propias filas

Por qué «Purpose of Request:» puede salir como «of Purpose Request:»

Las palabras dentro de una celda salen en el orden equivocado cuando el código que decide «misma línea» compara centros verticales, porque las cajas de palabra construidas desde cajas de glifo no comparten centro ni siquiera cuando comparten línea base. FPDFText_GetCharBox devuelve la caja ajustada de la tinta de cada glifo en espacio de página, no una caja rellenada hasta el ascendente y el 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 corta 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 "of" en su propia línea encima de las otras dos y la emite primero

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 char boxes 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 dan al of sin descendente un centro más alto que una tolerancia de 1 pt en el centro Y ordena como línea propia, mientras que una regla de solapamiento vertical del 25 por ciento lo mantiene en la línea base y conserva el orden de las palabras
El centro Y se mueve según las ascendentes y descendentes que lleve la tinta, mientras que dos cajas en una misma línea base se solapan a lo ancho de la altura de x compartida hagan lo que hagan sus alturas

PDFium Component construye el texto de celda por solapamiento. Una palabra se une a una línea visual cuando su solapamiento vertical con los límites en curso de la línea es de al menos el 25 por ciento del menor de los dos altos, o cuando los dos centros distan menos de un punto; 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 ancho de toda la altura de la x, mucho más de un cuarto de la caja más corta, así que aterrizan en una línea y se leen en orden

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

La misma trampa espera en cualquier código de maquetación PDF que decida «misma línea» comparando centros contra una tolerancia fija. Los descendentes son el disparador más leve. Etiquetas en negrita junto a valores más pequeños, marcadores de notas al pie en superíndice y cajas OCR ruidosas mueven todos los centros más que cualquier tolerancia que todavía separe líneas adyacentes de texto de 10 puntos con interlineado de 12 puntos. La proporción de solapamiento no le importa el tamaño: dos cajas en una misma línea base se solapan a lo ancho de su altura de 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 toma un predicado y un bucle 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 en curso 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 de stream de contenido, que no tiene por qué coincidir con el visual
end;

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

Lo que los consumidores de tablas siguen controlando

Los consumidores de tablas obtienen la prueba del corredor, el filtro de palabras primero-con-líneas y el texto de celda por solapamiento sin tocar sus opciones. El umbral del corredor está fijado en la mitad de MinColumnGap, así que el único valor que usted ajusta para el ancho de canal gobierna ambos. La estrategia de whitespace mantiene un suelo de dos filas incluso con MinRows a 1; solo la estrategia de líneas acepta cuadrículas de una fila, que ExtractDocumentTables usa para fragmentos de tabla continuados al principio de una página. El mismo registro de opciones viaja a TPdfReflowOptions.TableOptions, así que la salida de reflow hereda las mismas decisiones cuando DetectTables está activo

La prueba del corredor decide con filas que tienen contenido a ambos lados de un límite, así que una candidata de dos filas se juzga con solo dos filas así. Para archivos que son sobre todo prosa y rara vez contienen tablas genuinas de dos filas — contratos, correspondencia, ensayos — ponga MinRows a 3 y acepte las tablas de dos filas perdidas. Para documentos financieros y con muchos formularios conserve el valor por defecto de 2, porque las tablas reales cortas son comunes allí. El texto alineado a la izquierda mantiene espacios individuales ordinarios y rara vez produce un hueco cercano a 12 puntos

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 cuadrícula es una inferencia de la geometría, y la Confidence de una tabla por whitespace 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