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
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
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
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