PDFium Component versión 3.117.0 deja de informar de párrafos justificados como tablas alineadas por espacios en blanco, exigiendo que cada límite de columna sea un corredor vertical sin texto en ninguna fila que separe, saltando las palabras que ya ha reclamado una rejilla y montando el texto de celda por solapamiento vertical en vez de por distancia entre centros de caja de glifo. Los tres cambios viven dentro de ExtractTables y ExtractDocumentTables y no necesitan ninguna opción
El informe que dio origen a esto no tenía nada de glamuroso. Una página de nota de prensa sin ninguna tabla volvía de ExtractTables con una tabla de espacios en blanco de 5x4, una confianza holgadamente por encima del MinConfidence por defecto de 0,5, y las celdas contenían fragmentos de texto de cuerpo corriente. Un formulario de admisión hizo lo mismo con sus párrafos de redacción y produjo una 3x4 y una 5x3. Ambos documentos estaban justificados. La respuesta obvia es ajustar los umbrales, y la lección útil de esta versión es que el ajuste no puede arreglarlo, porque la regla que se ajustaba estaba haciendo la pregunta equivocada
uses
PDFium;
// Comprobación de regresión: lista cada tabla por espacios en blanco de un documento
// para confirmar que una página que sabes que es solo prosa queda 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é parece una tabla el texto justificado?
Un párrafo justificado parece una tabla 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 alcanza MinColumnGap el detector no tiene forma, a nivel de fila, de distinguirlo de un separador de columna. La estrategia de espacios en blanco de PDFium Component agrupa las cajas de palabra en filas visuales, parte cada fila en grupos de palabras allí donde la distancia horizontal a la palabra anterior es 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 que describe el panorama de detección de tablas, y para una tabla alineada de verdad es exactamente la correcta
Ahora aplícalo a veinte líneas de prosa justificada de 10 puntos. Todas las líneas se estiran hasta el mismo margen derecho, así que una línea que termina con una palabra larga abre sus espacios interiores, y en un párrafo con unas cuantas líneas cortas algunos de esos espacios pasan de 12 puntos. A dos líneas consecutivas les basta con tener cada una un hueco estirado que caiga dentro de 3 puntos de la misma posición X para formar un candidato de dos filas y dos columnas. Con suficientes líneas esto no es mala suerte; es una probabilidad que tiende a la certeza, y la 5x4 de la nota de prensa fue simplemente la racha en la que cuatro de esos huecos se alinearon en cinco líneas
Cada umbral cambia una clase de documento por otra. Subir MinColumnGap a 20 puntos pierde las columnas compactas de los informes financieros densos, que es justo el caso por el que ya se bajó el valor por defecto. Subir MinRows a 3 descarta tablas reales de dos filas y solo baja las probabilidades en párrafos largos. Apretar AlignmentTolerance por debajo de 3 puntos rompe las cajas de palabra derivadas de OCR, cuyos bordes izquierdos tiemblan más que eso. La señal a nivel de fila es genuinamente ambigua, así que el arreglo tiene que venir de una señal que las filas no llevan consigo
¿Qué hace que un límite de columna sea real?
Un límite de columna real es una franja vertical de la página que se mantiene 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 de palabra 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 comprueba ahora exactamente eso: después de asignar los grupos de palabras del candidato a columnas ancla, para cada par de columnas adyacentes toma, en cada fila que tiene contenido en ambas 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, intersecta esos intervalos entre filas, y rechaza todo el candidato si la intersección es más estrecha 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, sigue pasando. Y la anchura del corredor se deriva de MinColumnGap en vez de exponerse como opción aparte, porque las dos describen lo mismo físicamente: el hueco que un diseñador deja entre columnas. La lógica es lo bastante pequeña para reproducirla si trabajas sobre cajas de palabra crudas en vez de con 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 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;
¿Por qué se extraían dos veces las tablas de rejilla?
Las tablas de rejilla se extraían dos veces porque la pasada de espacios en blanco veía antes todas las palabras de la página, incluidas las que la pasada de rejilla ya había colocado en una cuadrícula, y una tabla de rejilla limpia es por construcción también una tabla de espacios en blanco perfectamente alineada. Una comprobación de solapamiento 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 bajas de la tabla con unas cuantas líneas alineadas de texto debajo podía caer por debajo de ese ratio y sobrevivir como una segunda tabla algo mayor que se metía en la de al lado. ExtractTables elimina ahora esas palabras antes de que corra la pasada de espacios en blanco. Una palabra se descarta cuando su punto central cae dentro de los límites de cualquier tabla que produjo la pasada de rejilla; se usa el centro y no la contención completa para que una palabra a caballo de un borde por una fracción de punto siga a la tabla a la que visualmente pertenece. 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 justo debajo de una de rejilla se detecta por sus propios méritos en vez de fusionarse con la cuadrícula de arriba
¿Por qué salía «of Purpose Request:» en vez de «Purpose of Request:»?
Las palabras salían reordenadas porque las cajas de palabra que construye PDFium Component son uniones de cajas envolventes de glifo, y «of» no tiene descendente mientras que «Purpose» y «Request:» sí. FPDFText_GetCharBox devuelve la caja ajustada de la tinta del glifo en espacio de página, no una caja rellenada hasta la ascendente y la descendente de la fuente, y la caja de palabra es la unión de las cajas de sus caracteres. Una palabra sin descendentes es por tanto más corta y su centro vertical queda más alto, entre 2 y 3 puntos en el formulario en cuestión. La antigua rutina de texto de celda ordenaba las palabras primero por centro Y, con una tolerancia de 1 punto para «misma línea», y luego por borde izquierdo; «of» superaba la tolerancia, se ordenaba como su propia línea por encima de las demás y se emitía la primera
Esto no es una rareza de PDFium sino una consecuencia de cómo coloca PDF 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 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 de §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 de glifo ajustadas que hacen que el resaltado de selección se vea bien, como se describe en selección de líneas de texto con cajas de carácter de PDFium, son la entrada equivocada para una comparación de distancias entre centros
El arreglo de la versión 3.117.0 cambia la pregunta: de «cuánto distan los centros» a «cuánto se solapan verticalmente las cajas». El texto de celda se monta agrupando primero las palabras de la celda en líneas visuales, donde una palabra se une a una línea cuando su solapamiento vertical con los límites acumulados de la línea es al menos el 25 por ciento de la menor de las dos alturas, luego ordenando por inserción cada línea por borde izquierdo, y luego uniendo las líneas con un salto de línea. «Purpose» y «of» se solapan en toda la altura de la x, que es mucho más del 25 por ciento de la caja más corta, así que caen en la misma línea y se ordenan por X como se pretendía
Agrupa las líneas de texto por solapamiento, no por distancia entre centros
La regla que merece llevarse de este bug es general: cualquier código de maquetación de texto PDF que decida «misma línea» comparando centros verticales contra una tolerancia fija 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 detonante más leve. Una etiqueta en negrita de 12 puntos junto a valores de 10, un marcador de nota al pie en superíndice, un símbolo de moneda dibujado con una fuente de reserva y cajas de palabra de OCR con ruido de altura por palabra desplazan los centros más que cualquier tolerancia que aún separe líneas adyacentes de texto de 10 puntos con interlineado de 12. La proporción de solapamiento es invariante al tamaño: dos cajas sobre una misma línea base se solapan en su altura de la 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 todas las palabras de la página activa con su rectángulo en 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;
// ordena cada línea por Rect.Left antes de leerla; PageWordBoxes devuelve
// las palabras en orden del flujo de contenido, que no garantiza ser visual
end;
Qué cambia para quien ya lo usa y dónde están los límites
La gracia de ese fragmento es el predicado, no el bucle; para algo más que un volcado rápido, parte 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 PDF estructurado con orden de lectura. Quien ya llama a la extracción de tablas recibe las tres correcciones sin tocar sus opciones. El umbral del corredor está fijado en la mitad de MinColumnGap, la estrategia de espacios en blanco conserva su suelo de dos filas aunque MinRows se ponga a 1 (que es lo que la estrategia de rejilla acepta ahora), y el filtrado de palabras previo que hace la rejilla es incondicional siempre que ambas estrategias estén activadas. En el conjunto de 13 documentos de muestra usado para la versión, la pasada de espacios en blanco devolvía antes 34 fragmentos y falsos positivos junto a 9 tablas de rejilla; después de la versión no devuelve ninguno, y el recuento de tablas de rejilla subió a 41, aunque la mayor parte de esa subida viene de que la misma versión le enseñó al detector de rejillas 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 ambos lados de un límite para rechazar algo, así que un candidato de dos filas cuyos dos huecos estirados caigan a menos de 6 puntos entre sí sigue pasando. Es una coincidencia estrecha y no la casi certeza que era antes, pero los documentos con mucha prosa y ninguna tabla real de dos filas pueden cerrarla poniendo MinRows a 3. El texto irregular alineado a la izquierda nunca fue el problema y no se ve afectado. Y PDF sigue sin tener objeto de tabla; ISO 32000-1 §14.8.4.3 define un elemento de estructura Table, pero solo lo lleva el PDF etiquetado, así que para todo lo demás la rejilla sigue siendo una inferencia a partir de la geometría, y el valor de confianza de cada TPdfTable está ahí porque una inferencia merece una puntuación
La extracción de tablas, el texto estructurado y las cajas de palabra leen todos del mismo modelo de página en Delphi, C++Builder y Lazarus; la API completa, incluida TPdfTableExtractionOptions y la demo TableExtractionLab que se distribuye con ella, se describe en la página de PDFium Component para Delphi