Una tabla exportada de Word o de Google Docs puede llevar cientos de piezas de borde en una página y ni una sola línea trazada. Descomprime la página con qpdf --qdf y el content stream se lee como una columna de pares re f, cada uno describiendo una caja de medio punto de alto. Un detector de tablas que solo busca segmentos trazados no encuentra nada, cae en alinear palabras por los espacios en blanco entre ellas, y devuelve un dispersión de fragmentos de dos y tres filas donde el autor dibujó una rejilla limpia. Los bordes están todos ahí. Están pintados con un fill, así que el detector tiene que leer rectángulos rellenos como líneas y luego llevar los bordes casi tocándose a líneas de rejilla compartidas
Por qué una tabla de Word no tiene líneas trazadas
Un procesador de textos trata el borde de una celda como una caja con una anchura, y pinta esa caja con el operador de fill. ISO 32000-1 §8.5.2.1 define re como añadir un subcamino de rectángulo cerrado, y §8.5.3 reparte los operadores de pintado: S traza el path con la anchura de línea actual, f rellena su interior. Un borde de 0.5 puntos sale como x y w 0.5 re f, y la maquinaria de trazado (anchura de línea, joins, patrón de dash) nunca corre. El sombreado de celdas es la misma construcción con una caja más grande. La forma trazada que un detector orientado a líneas espera, m, l, S, es la que los exports de oficina rara vez usan:
% un borde de celda de un export de procesador de textos: una caja rellena de 0.5 pt de alto
72 700 468 0.5 re f
% sombreado de celda: una caja rellena del tamaño de la celda
72 676 117 24 re f
% la línea de rejilla trazada para la que se escribió el detector original
72 700 m 540 700 l S
Si estuvieras escribiendo tu propio detector sobre la API cruda de PDFium, el primer borrador normalmente llama a FPDFPath_GetDrawMode, conserva el path cuando el flag de trazado está activo, y pasa a lo siguiente. Ambas cajas rellenas de arriba suspenden ese test. Las palabras dentro de las celdas llegan a la detección por espacios en blanco, y en un export compacto las columnas se sientan quizá a 6 puntos de distancia. En PDFium Component el MinColumnGap por defecto son 12 puntos, así que esas columnas no registran. Bajar MinColumnGap hasta que registren deja también que registre el espaciado de palabras estirado de los párrafos justificados, y la rejilla que dibujó el autor nunca vuelve, porque la información que la define vive en las cajas rellenas
Cómo lee PDFium Component una caja rellena como una línea
PDFium Component lee la geometría de tablas de cada objeto de path en la página, un subcamino cada vez, y como TPdfTableExtractionOptions.DetectFilledRulings hace default a True, un path cuyo modo de fill sea cualquier cosa distinta de none se examina en busca de líneas junto a los paths trazados. El filtro que viene después es estricto a propósito. Un subcamino relleno cualifica solo cuando tiene entre cuatro y ocho puntos, no contiene ningún segmento de curva, y cada punto se sienta en una esquina de la caja envolvente del subcamino con un margen de 0.05 puntos. Un rectángulo dibujado con re pasa, y también lo hace uno dibujado como cinco puntos explícitos con una esquina repetida. Un triángulo, una cheurón, un contorno en L o una pestaña redondeada suspende el test y no aporta nada
El rectángulo superviviente se clasifica por su caja envolvente contra MaxRulingThickness, que hace default a 3 puntos. Anchura en o bajo el umbral con altura por encima da una línea vertical en el centro horizontal, corriendo toda la altura de la caja; el caso espejo da una línea horizontal. Cuando ambas dimensiones exceden el umbral la caja se trata como celda sombreada y aporta cuatro líneas, una por borde. Cuando ambas dimensiones están en o bajo el umbral, la caja no aporta nada, así que una viñeta cuadrada de 2 puntos en una lista nunca se convierte en un tocón de rejilla de tabla. El color del fill no juega papel alguno en todo esto. Una caja blanca sobre una página blanca produce las mismas líneas que una negra, que es por lo que un panel de fondo del tamaño de la tabla puede aparecer como un marco exterior
Los paths trazados conservan su propia ruta. Cada segmento recto cuyos extremos difieren en no más de RulingTolerance (1 punto por defecto) en un eje se convierte en una línea a lo largo del otro, los segmentos de curva se saltan, y el segmento de cierre de un subcamino cerrado cuenta también. Un path pintado con fill y trazo aporta por ambas rutas, y las piezas que se solapan se colapsan después en el paso de fusión. Un rectángulo redondeado trazado sigue dando líneas para sus lados rectos; solo los arcos de las esquinas se caen. La llamada de extracción en sí no necesita configuración especial:
uses
System.SysUtils, PDFium;
var
Pdf: TPdf;
Options: TPdfTableExtractionOptions;
Tables: TPdfTables;
Mode: string;
I, J, WithText: Integer;
begin
Pdf := TPdf.Create(nil);
try
Pdf.FileName := 'itinerary-from-word.pdf';
Pdf.LoadDocument;
Pdf.PageNumber := 1; // 1-based
Options := TPdfTableExtractionOptions.Default;
// los campos de líneas rellenas, escritos con sus valores por defecto
Options.DetectFilledRulings := True;
Options.MaxRulingThickness := 3.0; // puntos; tiene que ser mayor que 0
Options.RulingSnapTolerance := 4.0; // puntos; 0 apaga el ajuste
Options.IncludeFormXObjects := True;
Tables := Pdf.ExtractTables(Options);
for I := 0 to High(Tables) do
begin
if Tables[I].DetectionMode = ptdmRuled then
Mode := 'ruled'
else
Mode := 'whitespace';
WithText := 0;
for J := 0 to High(Tables[I].Cells) do
if Tables[I].Cells[J].Text <> '' then
Inc(WithText);
Writeln(Format('%dx%d %s, confidence %.2f, %d of %d cells hold text',
[Tables[I].RowCount, Tables[I].ColumnCount, Mode,
Tables[I].Confidence, WithText, Length(Tables[I].Cells)]));
end;
finally
Pdf.Free;
end;
end;
Poner MaxRulingThickness a 0 no apaga la característica. ExtractTables valida las opciones de geometría por adelantado y lanza EPdfError con "Table extraction geometry options are invalid" para un umbral de cero o menos. Para conseguir el comportamiento solo-trazado, pon DetectFilledRulings a False. Déjalo activado para cualquier cosa que haya salido de una suite de oficina o de un diálogo de impresión de navegador; el filtro estricto de rectángulos es lo que mantiene el coste bajo, porque los fills decorativos casi nunca lo pasan
¿Qué hace RulingSnapTolerance por las tablas de celdas sombreadas?
RulingSnapTolerance es lo que deja que una tabla construida solo con sombreado conecte en una rejilla. Algunos exports no dibujan borde alguno: cada celda es una caja rellena separada de sus vecinas por un canal blanco de 1 a 3 puntos. Cada caja produce sus cuatro líneas de borde, pero el borde derecho de una celda y el borde izquierdo de la siguiente se sientan, digamos, a 2 puntos de distancia, y el test que decide si dos líneas pertenecen a la misma rejilla solo permite RulingTolerance, 1 punto. Sin ajuste, cada celda es una isla de cuatro líneas describiendo una rejilla 1x1, nada llega a MinColumns ni a MinRows (ambos 2 por defecto), y la pasada de rejilla no reporta ninguna tabla en una página que claramente tiene una
El paso de ajuste recoge cada coordenada X en juego, es decir la posición de cada línea vertical más el inicio y el fin de cada horizontal, y cada coordenada Y de la misma manera. Cada lista se ordena y se parte en clusters dondequiera que dos vecinos difieran en más de la tolerancia; cada cluster se sustituye por su media, y cada posición, inicio y fin se mueve entonces al centro más cercano. Los bordes en 230.0 y 232.0 se convierten ambos en 231.0, las dos celdas comparten una línea, y las líneas horizontales se estiran con ellas para que las esquinas se encuentren
Las piezas colineales que se tocan o se solapan dentro de RulingTolerance se funden en líneas únicas. Solo entonces corre el detector de rejillas, comparando las líneas por pares; una tabla de 30 filas que llegó como 720 piezas de borde llega a esa comparación como unas pocas docenas de líneas
Sobre una rejilla trazada el paso no cambia nada, porque las coordenadas que ya coinciden se ajustan a sí mismas. La pega es que el encadenamiento no tiene límite de anchura propio. Una carrera de valores cada uno a 3 puntos de distancia, en 100, 103, 106 y 109, colapsa en un centro en 104.5 aunque los valores exteriores estén a 9 puntos. Con el default de 4 puntos esto solo muerde en columnas más estrechas que un carácter, así que la salida de oficina puede quedarse el default de 4 puntos. Bájalo hacia 1 o 2 cuando un formulario tenga columnas deliberadamente estrechas (casillas, campos de número de cuenta con un dígito por celda) y ponlo a 0 para apagar el ajuste. Correr la estrategia de rejilla sola bajo tres configuraciones muestra de qué mecanismo depende una página:
// Solo la estrategia de rejilla, así que la detección por espacios no puede enmascarar el resultado
function CountRuledTables(Pdf: TPdf; FilledRulings: Boolean;
SnapTolerance: Double): Integer;
var
Options: TPdfTableExtractionOptions;
begin
Options := TPdfTableExtractionOptions.Default;
Options.DetectWhitespaceTables := False;
Options.DetectFilledRulings := FilledRulings;
Options.RulingSnapTolerance := SnapTolerance;
Result := Length(Pdf.ExtractTables(Options));
end;
// Esperado para una tabla dibujada solo como celdas sombreadas con canales de 2 pt:
// solo trazado -> 0 (sin geometría trazada en absoluto)
// cajas rellenas, ajuste 4 -> 1 (los bordes del canal comparten línea)
// cajas rellenas, ajuste 0 -> 0 (cada celda queda como una isla 1x1)
Writeln('stroke only: ', CountRuledTables(Pdf, False, 4.0));
Writeln('filled boxes, snap 4: ', CountRuledTables(Pdf, True, 4.0));
Writeln('filled boxes, snap 0: ', CountRuledTables(Pdf, True, 0.0));
Cuando el tercer conteo cae por debajo del segundo, la página depende del ajuste, y esa es la página que conviene comprobar a ojo antes de bajar la tolerancia
Líneas dentro de form XObjects
Las herramientas de maquetación envuelven a menudo una tabla, o el cuerpo entero de la página, en un form XObject y lo pintan con Do. ISO 32000-1 §8.10.1 dice que la matriz del formulario se concatena con la matriz de transformación actual cuando el formulario se pinta, así que un rectángulo dentro del formulario está escrito en espacio de formulario y llega a espacio de página solo después de dos o más transformaciones. Con IncludeFormXObjects activo, que es el default, PDFium Component entra en cada objeto de formulario, combina la matriz del objeto con la matriz de su padre, y entrega la matriz combinada a cada hijo, así que un borde dibujado como 72 700 468 0.5 re f dentro de un formulario trasladado aterriza donde el visor lo muestra. El recorrido se para a ocho niveles de formularios anidados
El orden de esa combinación importa. Un punto dentro del formulario pasa primero por la matriz del objeto y segundo por la matriz del padre; intercambia los operandos y el término de traslación se aplica en el espacio equivocado, así que una línea que pertenece a lo alto de la página acaba cerca del origen o fuera de la página. La misma trampa aparece siempre que construyes matrices tú mismo, y el artículo sobre prepend contra append de matrices la recorre con un pivote de rotación
¿Por qué se cuadruplicó el presupuesto de líneas?
El MaxRulingSegments por defecto de 16384 refleja cuántas piezas produce un export de bordes por celda. Una tabla trazada de 30 filas y 6 columnas necesita 38 segmentos de línea: 31 horizontales y 7 verticales. La misma tabla exportada como cajas rellenas puede llevar cuatro piezas de borde por celda, 720 antes de la fusión, y sombrear cada celda añade otras cuatro líneas de borde por celda, 1440 en total. Dos tablas así en una página ya necesitan cerca de 3000
Exceder el presupuesto es un error, y se manifiesta como tal. ExtractTables lanza EPdfError con el mensaje "Table ruling-segment budget exceeded", no devuelve ninguna rejilla parcial, y la pasada por espacios en blanco de esa página tampoco corre. Un presupuesto ajustado es razonable para entrada no confiable, donde una página maliciosa podría llevar millones de cajas diminutas, pero entonces el llamador tiene que distinguir "demasiado complejo para este presupuesto" de "no hay tablas en esta página":
function TryExtractTables(Pdf: TPdf; Budget: Integer;
out Tables: TPdfTables): Boolean;
var
Options: TPdfTableExtractionOptions;
begin
Options := TPdfTableExtractionOptions.Default;
Options.MaxRulingSegments := Budget; // 16384 por defecto
try
Tables := Pdf.ExtractTables(Options);
Result := True;
except
on E: EPdfError do
begin
// LogWarning es tu rutina de logging propia, no forma parte de la librería
LogWarning(Format('Page %d: %s', [Pdf.PageNumber, E.Message]));
Tables := nil;
Result := False; // sobre presupuesto u opciones inválidas
end;
end;
end;
// Un resultado False significa "sin decidir", que es distinto de un array vacío
if not TryExtractTables(Pdf, 4096, Tables) then
QueueForReview(Pdf.FileName, Pdf.PageNumber); // también rutina tuya
El mismo tipo de excepción cubre también el presupuesto de palabras y las opciones inválidas, así que el mensaje registrado es lo que los distingue en una cola de revisión
Dónde entrega el testigo la ruta de rejilla
La detección de rejilla corre primero, la detección por espacios en blanco segundo, y dos reglas evitan que se pisen. Las palabras cuyos centros caen dentro de una rejilla que la pasada de rejilla ya reportó se eliminan antes de que arranque la detección por espacios, así que una tabla nunca se reporta dos veces. Un límite de columna por espacios también tiene que ser un corredor vertical libre de texto a través de cada fila que separa, lo que evita que los párrafos justificados puntúen como tablas; el artículo sobre falsos positivos de tablas por espacios en blanco cubre esa regla. Leer rectángulos rellenos saca las tablas exportadas de oficina del camino por espacios y las mete en el de rejilla
Como las líneas vienen de objetos de path y el texto viene de la página de texto, una página sin capa de texto sigue produciendo el esqueleto de la rejilla con cada celda vacía. La confianza de una rejilla es 0.88 más 0.12 veces la fracción de celdas que llevan texto, así que un esqueleto vacío reporta exactamente 0.88 y el conteo de celdas del primer listado muestra 0 celdas con texto. Esa combinación es la señal de correr primero OCR, como se describe en añadir una capa de texto buscable con OCR. Las formas rellenas con curvas o contornos no rectangulares no aportan nada, así que una tabla cuyas celdas son rectángulos redondeados rellenos cae en la detección por espacios. Una tabla sin bordes ni sombreado pertenece a la estrategia por espacios descrita en el artículo de detección y extracción de tablas, y cuando ni eso basta, las cajas de palabras y los bloques del texto estructurado y orden de lectura son la materia prima para un lector escrito para una familia de documentos
El demo TableExtractionLab que viaja con el componente tiene una casilla para DetectFilledRulings, así que puedes cargar un export y ver la rejilla aparecer y desaparecer sin escribir código. Las opciones, los tipos record y el resto de la API de extracción están listados en la página del producto PDFium Component para Delphi