PDFium Component versión 3.117.0 deja de reportar los párrafos justificados como tablas alineadas por espacios en blanco, porque exige que cada límite de columna sea un corredor vertical sin texto en ninguna de las filas que separa, se salta las palabras que ya reclamó una grilla de líneas y arma el texto de cada celda por solape vertical en vez de por distancia entre los centros de las cajas de glifos. Los tres cambios viven dentro de ExtractTables y ExtractDocumentTables y no necesitan ninguna opción
El reporte que originó todo esto era de lo más mundano. Una página de comunicado de prensa que no tenía ninguna tabla volvía de ExtractTables con una tabla de espacios en blanco de 5x4, una confianza cómodamente por encima del MinConfidence por defecto de 0.5, y las celdas contenían fragmentos de texto de cuerpo común. Un formulario de admisión hizo lo mismo con sus párrafos de ensayo y produjo una de 3x4 y otra de 5x3. Los dos documentos estaban justificados. La respuesta obvia es ajustar los umbrales, y la lección útil de esta versión es que ajustar no puede arreglarlo, porque la regla que se estaba ajustando hacía la pregunta equivocada
uses
PDFium;
// Chequeo de regresión: listar cada tabla de espacios en blanco de un documento para
// poder confirmar que una página que usted sabe que es solo prosa está limpia
procedure ReportWhitespaceTables(Pdf: TPdf);
var
Options: TPdfTableExtractionOptions;
Tables: TPdfTables;
I: Integer;
begin
Options := TPdfTableExtractionOptions.Default; // MinColumnGap 12pt
Tables := Pdf.ExtractDocumentTables(Options);
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;
¿Por qué el texto justificado parece una tabla?
Un párrafo justificado parece una tabla porque una línea justificada es una fila de palabras separadas por espacios que el motor de maquetación estiró, y en cuanto un espacio estirado alcanza MinColumnGap, el detector no tiene forma local a la fila de distinguirlo de un separador de columna. La estrategia de espacios en blanco de PDFium Component agrupa las cajas de palabras en filas visuales, parte cada fila en grupos de palabras donde la distancia horizontal a la palabra anterior sea al menos MinColumnGap (12 puntos por defecto) y acepta una tabla cuando al menos dos filas consecutivas repiten al menos MinColumns anclas de grupo alineadas a la izquierda dentro de AlignmentTolerance, que son 3 puntos. Esa es la regla descrita en la introducción a la detección de tablas, y para una tabla alineada de verdad es exactamente correcta
Ahora aplique eso a veinte líneas de prosa justificada de 10 puntos. Cada línea se estira al mismo margen derecho, así que una línea que termina en una palabra larga abre sus espacios internos, y en un párrafo con unas cuantas líneas cortas algunos de esos espacios pasan de 12 puntos. Dos líneas consecutivas solo necesitan un espacio estirado cada una, que caiga dentro de 3 puntos de la misma posición X, para formar un candidato de dos filas por dos columnas. Con suficientes líneas esto no es mala suerte; es una probabilidad que tiende a la certeza, y la de 5x4 del comunicado de prensa fue simplemente la corrida en la que cuatro de esos espacios se alinearon en cinco líneas
Todo umbral cambia una clase de documento por otra. Subir MinColumnGap a 20 puntos pierde las columnas compactas de los reportes financieros densos, que es justo el caso por el que el valor por defecto ya se había bajado. Subir MinRows a 3 descarta tablas reales de dos filas y solo reduce las probabilidades en párrafos largos. Apretar AlignmentTolerance por debajo de 3 puntos rompe las cajas de palabras derivadas de OCR, cuyos bordes izquierdos tiemblan más que eso. La señal a nivel de fila es genuinamente ambigua, así que el fix tiene que venir de una señal que las filas no llevan por sí solas
¿Qué vuelve real a un límite de columna?
Un límite de columna real es una franja vertical de la página que queda vacía en todas las filas que separa. Una tabla tiene una entre cada par de columnas por construcción, porque las celdas se maquetaron contra posiciones X compartidas. Un párrafo justificado estira sus espacios entre palabras en posiciones horizontales distintas en cada línea, así que ninguna franja sobrevive a la intersección de más de una o dos líneas. PDFium Component ahora prueba exactamente eso: después de que los grupos de palabras del candidato se hayan asignado a columnas ancla, para cada par de columnas adyacentes toma, en cada fila que tenga contenido en las dos celdas, 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, interseca esos intervalos entre filas, y rechaza el candidato completo si la intersección es más angosta que MinColumnGap por 0.5, que con el valor por defecto son 6 puntos
Dos detalles importan. Las filas en las que alguna de las dos celdas está vacía no votan, así que una tabla con una celda en blanco, o con un encabezado que abarca menos columnas que el cuerpo, igual pasa. Y el ancho del corredor se deriva de MinColumnGap en vez de exponerse como una opción aparte, porque los dos describen la misma cosa física: el espacio que un diseñador deja entre columnas. La lógica es lo bastante corta para reproducirla si usted trabaja sobre cajas de palabras crudas en lugar de la API de tablas, y el ejemplo de abajo refleja la comprobación que hay dentro del componente:
uses
Math, PDFium;
type
TIndexList = array of Integer;
TCellIndexes = array of TIndexList; // Row * ColumnCount + Column
// Devuelve False cuando algún par de columnas adyacentes carece de un corredor
// vertical libre de texto de al menos MinColumnGap / 2 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;
¿Por qué se extraían dos veces las tablas con líneas?
Las tablas con líneas se extraían dos veces porque el pase de espacios en blanco veía todas las palabras de la página, incluidas las que el pase de líneas ya había puesto en una grilla, y una tabla con líneas limpia es por construcción también una tabla de espacios en blanco perfectamente alineada. Una comprobación de solape ya rechazaba un candidato de espacios en blanco cuyos límites cubrieran más de la mitad de una tabla existente, pero un candidato que combinara las filas inferiores de la tabla con unas cuantas líneas de texto alineadas debajo podía caer por debajo de esa proporción y sobrevivir como una segunda tabla un poco más grande que se derramaba sobre su vecina. ExtractTables ahora quita esas palabras antes de que corra el pase de espacios en blanco. Una palabra se descarta cuando su punto central cae dentro de los límites de cualquier tabla que haya producido el pase de líneas; se usa el centro y no la contención total para que una palabra que se monta un poco sobre un borde siga a la tabla a la que pertenece visualmente. La estrategia de espacios en blanco trabaja entonces solo con las palabras libres, lo que además significa que una tabla pequeña sin líneas que está justo debajo de una con líneas se detecta por sus propios méritos en lugar de fusionarse con la grilla de arriba
¿Por qué "Purpose of Request:" salía como "of Purpose Request:"?
Las palabras salían reordenadas porque las cajas de palabras que arma PDFium Component son uniones de cajas envolventes de glifos, y "of" no tiene descendente mientras que "Purpose" y "Request:" sí. FPDFText_GetCharBox devuelve la caja ajustada de la tinta del glifo en el espacio de página, no una caja rellenada hasta la ascendente y la descendente de la fuente, y la caja de la palabra es la unión de las cajas de sus caracteres. Una palabra sin descendentes es por lo tanto más baja y su centro vertical queda más arriba, entre 2 y 3 puntos en el formulario en cuestión. La rutina vieja de texto de celda ordenaba las palabras primero por el centro en Y, con una tolerancia de 1 punto para "misma línea", y después por el borde izquierdo; "of" pasaba la tolerancia, se ordenaba como su propia línea por encima de las demás y se emitía primero
Esto no es tanto una rareza de PDFium como una consecuencia de cómo el PDF posiciona el texto. ISO 32000-1 §9.2.2 y §9.4.4 definen la colocación de glifos como desplazamiento horizontal a lo largo de la línea base en el espacio de texto, y las únicas métricas verticales que lleva el archivo son por fuente: las entradas Ascent, Descent y FontBBox del descriptor de fuente en §9.8.1. Nada en el archivo dice que dos glifos compartan línea; eso hay que inferirlo de la geometría, y las cajas ajustadas de glifo que hacen que el resaltado de selección se vea bien, como se describe en selección de líneas de texto con las cajas de caracteres de PDFium, son la entrada equivocada para una comparación por distancia entre centros
El fix de la versión 3.117.0 cambia la pregunta de "qué tan lejos están los centros" a "cuánto se solapan las cajas en vertical". El texto de la celda se arma agrupando primero las palabras de la celda en líneas visuales, donde una palabra se une a una línea cuando su solape vertical con los límites acumulados de esa línea es al menos el 25 por ciento de la menor de las dos alturas, después ordenando cada línea por inserción según el borde izquierdo, y por último uniendo las líneas con un salto de línea. "Purpose" y "of" se solapan en toda la altura x, que es mucho más que el 25 por ciento de la caja más baja, así que caen en la misma línea y se ordenan por X como se pretende
Agrupar líneas de texto por solape, no por distancia entre centros
La regla que vale la pena llevarse de este bug es general: cualquier código de maquetación de texto en PDF que decida "misma línea" comparando centros verticales contra una tolerancia fija va a fallar con fuentes reales, y el fallo es silencioso: nada da error, las palabras simplemente salen en el orden equivocado. Las descendentes mezcladas son el disparador más leve. Una etiqueta en negrita de 12 puntos junto a valores de 10 puntos, un marcador de nota al pie en superíndice, un símbolo de moneda dibujado con una fuente de reserva y cajas de palabras de OCR con ruido de altura por palabra 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 es invariante al tamaño: dos cajas sobre una misma línea base se solapan en su altura x compartida hagan lo que hagan sus ascendentes y descendentes, y dos cajas en líneas adyacentes no se solapan en nada
La misma regla es fácil de aplicar fuera de la extracción de tablas. TPdf.PageWordBoxes devuelve cada palabra de la página activa con su rectángulo en el espacio de página, así que agrupar una página en líneas visuales es 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 acumulada por línea
I, J, Found: Integer;
begin
Words := Pdf.PageWordBoxes;
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;
// ordenar cada línea por Rect.Left antes de leerla; PageWordBoxes devuelve
// las palabras en orden del content stream, que no garantiza ser el visual
end;
Qué cambia para quien ya llama a la API y dónde están los límites
Lo importante de ese fragmento es el predicado, no el bucle; 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 cubre en extracción de texto estructurado de PDF con orden de lectura. Quienes ya llaman a la API de tablas reciben las tres correcciones sin tocar sus opciones. El umbral del corredor está fijo en la mitad de MinColumnGap, la estrategia de espacios en blanco conserva su piso de dos filas incluso cuando MinRows se pone en 1 (que la estrategia de líneas ahora sí acepta), y el filtrado previo de palabras de las tablas con líneas es incondicional siempre que las dos estrategias estén habilitadas. En el conjunto de 13 documentos de muestra usado para la versión, el pase de espacios en blanco antes devolvía 34 fragmentos y falsos positivos junto a 9 tablas con líneas; después de la versión no devuelve ninguno, y el conteo de tablas con líneas subió a 41, aunque la mayor parte de esa subida viene de que la misma versión le enseñó al detector de líneas a leer bordes dibujados como rectángulos rellenos, que es otra historia aparte
Los límites honestos: la prueba de corredor necesita al menos una fila con contenido a los dos lados de un límite para rechazar algo, así que un candidato de dos filas cuyos dos espacios estirados caigan dentro de 6 puntos entre sí igual pasa. Es una coincidencia estrecha y no la casi certeza que era antes, pero los documentos cargados de prosa que no tengan tablas genuinas de dos filas pueden cerrarla poniendo MinRows en 3. El texto alineado a la izquierda con bordes irregulares nunca fue el problema y no se ve afectado. Y el PDF todavía no tiene objeto de tabla; ISO 32000-1 §14.8.4.3 define un elemento de estructura Table, pero solo el PDF etiquetado lo lleva, así que para todo lo demás la grilla sigue siendo una inferencia a partir de la geometría, y el valor de confianza en cada TPdfTable está ahí porque una inferencia merece un puntaje
La extracción de tablas, el texto estructurado y las cajas de palabras leen todos del mismo modelo de página en Delphi, C++Builder y Lazarus; la API completa, incluidos TPdfTableExtractionOptions y la demo TableExtractionLab que viene junto a ella, está descrita en la página de PDFium Component para Delphi