Artículo técnico

Rectángulos rellenos como líneas de tabla en PDFium Delphi

La extracción de tablas de PDFium Component, desde la versión 3.117.0, trata un rectángulo relleno delgado como una línea de tabla. Con DetectFilledRulings activado, que es lo predeterminado, una caja rellena alineada a los ejes y no más gruesa que MaxRulingThickness (3 puntos) se vuelve una línea a lo largo de su eje largo, una caja rellena más grande aporta sus cuatro bordes, y cada coordenada de línea se ajusta dentro de RulingSnapTolerance (4 puntos) antes de armar la grilla. Las tablas exportadas desde Word, Google Docs y navegadores llegan por lo tanto al detector de líneas como grillas completas en vez de caer a la detección por espacios en blanco como fragmentos

El artículo anterior sobre detección y extracción de tablas decía que la detección de líneas usa las líneas dibujadas y que cada segmento de trazo se transforma a coordenadas de página. Esa frase era verdadera e incompleta. Contar los objetos de camino en un conjunto de 13 documentos de muestra del mundo real mostró que 9 de ellos no contienen ningún trazo, y aun así cada una de sus páginas lleva cientos de rectángulos rellenos de 0.5 a 1 punto de grosor. El detector solo de trazos no veía nada, cada página caía a la detección por espacios en blanco y la salida era un reguero de fragmentos pequeños en lugar de tablas. El preset de columnas compactas añadido en la 3.116.4 suavizaba eso a nivel de fragmento; la causa raíz era que el detector estaba leyendo el operador de pintado equivocado

¿Por qué una tabla exportada de Word no tiene líneas trazadas?

Un procesador de textos no piensa en un borde como una línea; piensa en él como una caja con un ancho, y pinta esa caja con un relleno. ISO 32000-1 §8.5.2.1 define el operador re como el que agrega un subcamino rectangular, y §8.5.3 separa los operadores de pintado: S traza el camino con el ancho de línea actual, f rellena su interior. Un borde de celda de 0.5 puntos sale como x y w 0.5 re f, y la maquinaria de trazo, con ancho de línea, uniones y patrón de guiones incluidos, nunca se ejecuta. El sombreado de celda es la misma construcción con una caja más grande. Una grilla trazada con m, l y S es lo que esperaba el detector original, y es lo que casi nada exportado desde una aplicación de oficina produce:

% 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 grilla trazada para la que se escribió el detector original
72 700 m 540 700 l S

Para un detector que solo le pregunta a FPDFPath_GetDrawMode si el flag de trazo está puesto, las dos cajas rellenas son invisibles. Las palabras dentro de las celdas llegan entonces a la detección por espacios en blanco, donde columnas separadas por un espacio de 6 puntos quedan por debajo del MinColumnGap predeterminado de 12 puntos, y lo que vuelve es el subconjunto de filas que dé la casualidad de alinearse lo suficiente para pasar MinRows. Ese es el comportamiento de fragmento, y ninguna cantidad de ajuste de parámetros lo convierte en la grilla que dibujó el autor

¿Cómo convierte PDFium Component una caja rellena en una línea de tabla?

TableCollectObjectRulings inspecciona cada objeto de camino subcamino por subcamino. El modo de pintado viene de FPDFPath_GetDrawMode; un camino cuenta como relleno cuando DetectFilledRulings está activado y el modo de relleno no es none. Cada punto se transforma por la matriz del objeto y se recolecta, hasta MaxSubpathPoints (8) por subcamino, y cualquier segmento de curva marca el subcamino como curvo. Cuando el subcamino cierra o empieza un nuevo MoveTo, FlushSubpath decide qué era: un subcamino curvo se descarta, y también cualquier polígono cerrado cuyos puntos no estén todos dentro de PointTolerance (0.05 puntos) de los bordes de la caja envolvente en al menos un eje. Un triángulo, un chevron o una pestaña redondeada nunca se vuelven una línea, que es lo que mantiene el arte decorativo fuera de la grilla

Diagrama de PDFium Component de cómo TableCollectObjectRulings convierte subcaminos cerrados en líneas de tabla en Delphi: FlushSubpath descarta contornos curvos y polígonos que no caen sobre los bordes de la caja envolvente, MaxRulingThickness parte las cajas delgadas en una línea por eje largo, las celdas sombreadas dan cuatro líneas de borde y DetectFilledRulings deja fuera los cuadrados diminutos
Un subcamino cerrado sobrevive solo cuando está alineado a los ejes, y la caja envolvente decide entonces si es una línea, los cuatro bordes de una celda sombreada o nada en absoluto

Lo que sobrevive es un rectángulo alineado a los ejes, clasificado por su caja envolvente. Un ancho igual o menor que MaxRulingThickness con una altura mayor produce una línea vertical en el centro horizontal, que abarca la caja de abajo arriba; el caso espejo produce una línea horizontal. Ambas dimensiones por encima del umbral significan una celda sombreada, y la caja aporta cuatro líneas, una por borde. Ambas dimensiones iguales o menores que el umbral no aportan nada, así que una viñeta cuadrada de 2 puntos no se confunde con una línea. Un camino trazado toma la ruta anterior por AddLine, una línea por segmento alineado a los ejes, así que una grilla dibujada con S se maneja exactamente como antes, y un camino pintado con relleno y trazo a la vez produce piezas solapadas que el pase de fusión colapsa:

uses
  PDFium;

var
  Pdf: TPdf;
  Options: TPdfTableExtractionOptions;
  Tables: TPdfTables;
  Mode: string;
  I: Integer;
begin
  Pdf := TPdf.Create(nil);
  try
    Pdf.FileName := 'itinerary-from-word.pdf';
    Pdf.LoadDocument;
    Pdf.PageNumber := 1;                     // con base 1

    Options := TPdfTableExtractionOptions.Default;
    // estos son los valores por defecto de la 3.117.0, escritos para mayor claridad
    Options.DetectFilledRulings := True;     // las cajas rellenas delgadas se vuelven líneas
    Options.MaxRulingThickness := 3.0;       // puntos; las cajas más gruesas cuentan como sombreado
    Options.RulingSnapTolerance := 4.0;      // puntos; 0 desactiva 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';
      Writeln(Format('%dx%d %s, confidence %.2f',
        [Tables[I].RowCount, Tables[I].ColumnCount, Mode,
         Tables[I].Confidence]));
    end;
  finally
    Pdf.Free;
  end;
end;

¿Qué hace RulingSnapTolerance por las tablas de celdas sombreadas?

RulingSnapTolerance es lo que hace que una tabla construida solo con sombreado se conecte en una sola grilla. Algunos exports no dibujan borde alguno: cada celda es una caja rellena con su propio color, y las cajas vecinas quedan separadas por un espacio blanco de 1 a 3 puntos. Cada caja produce cuatro líneas de borde, pero el borde derecho de una celda y el izquierdo de la siguiente quedan a 2 puntos de distancia, y la prueba de conectividad usa RulingTolerance, que por defecto es 1 punto. Sin el ajuste, cada celda forma su propio componente conexo de cuatro líneas, ningún componente llega a MinRows y la página no reporta nada. TableSnapRulings reúne cada coordenada X en juego (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 forma, ordena cada lista, la agrupa encadenando valores cuyo vecino no difiera más que la tolerancia, reemplaza cada grupo por su media y después mueve cada posición, inicio y fin al centro de grupo más cercano. Los dos lados de un espacio blanco se vuelven la misma línea, y la conectividad se sostiene

Diagrama de PDFium Component de RulingSnapTolerance conectando una tabla de celdas sombreadas en Delphi: las celdas vecinas dejan un espacio de 2 pt, sus líneas de borde quedan más allá de la RulingTolerance de 1 pt, y TableSnapRulings encadena los dos valores X en la media de un solo grupo para que la prueba de conectividad vea por fin una línea de grilla compartida
El ajuste corre antes de la fusión y antes del detector de líneas, así que los dos lados de un espacio blanco se vuelven una sola línea y cada celda deja de ser una isla de cuatro líneas

El ajuste corre antes de TableMergeRulings, que ordena las líneas y une las piezas colineales que se tocan o se solapan dentro de RulingTolerance, y ambos corren antes de que TableDetectRuled llegue a ver los datos, así que la prueba de conectividad por pares es proporcional al número de líneas de grilla y no al número de fragmentos por celda. En una grilla trazada los pases son inofensivos, porque las coordenadas que ya eran idénticas se ajustan a sí mismas. Lo único que hay que tener en cuenta es que el agrupamiento por encadenamiento no tiene límite de ancho propio: una serie de coordenadas separadas 3 puntos entre sí colapsa en un solo centro. Con el valor por defecto de 4 puntos eso solo afecta a columnas más angostas que un carácter, pero si un documento tiene espacios reales de 3 puntos que deben quedar separados, baje la tolerancia o póngala en 0 para desactivar el ajuste:

// Aislar la estrategia de líneas y comparar qué ve cada ajuste en una página
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;

// Un export de Word suele reportar 0, N y después menos que N:
// solo trazos no ve nada, el ajuste conecta las celdas sombreadas,
// y desactivar el ajuste deja cada celda sombreada como su propia isla
Writeln(CountRuledTables(Pdf, False, 4.0));
Writeln(CountRuledTables(Pdf, True, 4.0));
Writeln(CountRuledTables(Pdf, True, 0.0));

Líneas dentro de form XObjects

Las herramientas de maquetación de página envuelven con frecuencia una tabla, o todo el cuerpo de la página, en un form XObject y lo pintan con Do. ISO 32000-1 §8.10.1 especifica que la matriz del form se concatena con la matriz de transformación actual cuando se pinta el form, así que un rectángulo dentro del form vive en el espacio del form y solo aterriza en la página después de dos o más transformaciones. TableCollectObjectRulings entra recursivamente en los form objects cuando IncludeFormXObjects está activado: lee la matriz del objeto, la combina con la matriz del padre mediante TableMultiplyMatrix, cuyo orden de argumentos significa "mapear por la primera matriz y luego por la segunda", y enumera los hijos con FPDFFormObj_CountObjects y FPDFFormObj_GetObject, pasando hacia abajo la matriz combinada. Un anidamiento más profundo que MaxFormDepth (8) se salta en silencio, lo que es una guarda contra archivos patológicos más que un límite al que algún export real se acerque. El motivo por el que el orden de multiplicación importa es el mismo que se discute en anteponer frente a posponer matrices: intercambiar los operandos mueve el término de traslación, y una línea que debería caer en la parte superior de la página cae en el origen

Diagrama de PDFium Component de las líneas dentro de un form XObject en Delphi: un rectángulo delgado escrito como 72 700 468 0.5 re f vive en el espacio del form y solo aterriza en la página después de que TableMultiplyMatrix combine la CTM del padre con la matriz del form, recursando por FPDFFormObj_CountObjects hasta MaxFormDepth
El rectángulo está escrito en el espacio del form y llega a la parte superior de la página solo después de multiplicar las matrices en un orden que deja el término de traslación donde corresponde

¿Por qué se cuadruplicó el presupuesto de líneas?

El MaxRulingSegments por defecto subió de 4096 a 16384 en la 3.117.0 porque los bordes por celda llegan en cantidades mucho mayores que las líneas de grilla trazadas. Una tabla trazada de 30 filas y 6 columnas son 38 segmentos de línea. La misma tabla exportada como cajas rellenas son hasta cuatro bordes por celda, 720 piezas antes de fusionar, y un formulario con celdas sombreadas duplica eso. Dos tablas así en una página habrían agotado el presupuesto viejo. El presupuesto se hace cumplir en TableAppendRuling a través de Check, que lanza EPdfError con el mensaje "Table ruling-segment budget exceeded"; no hay resultado degradado, ni grilla parcial, y el pase de espacios en blanco tampoco corre. Si usted fija un presupuesto más ajustado por su cuenta para entradas que no son de confianza, atrape la excepción y decida, en vez de leer un resultado vacío como "no hay tablas":

Options := TPdfTableExtractionOptions.Default;
Options.MaxRulingSegments := 2048;        // deliberadamente ajustado para entradas no confiables
try
  Tables := Pdf.ExtractTables(Options);
except
  on E: EPdfError do
  begin
    Log(E.Message);                       // 'Table ruling-segment budget exceeded'
    Options.MaxRulingSegments := 16384;   // el valor por defecto de la 3.117.0
    Tables := Pdf.ExtractTables(Options);
  end;
end;

Resultados medidos y dónde se detiene el enfoque

En los mismos 13 documentos de muestra, la extracción pasó de 43 tablas, 9 de ellas con líneas y 34 fragmentos de espacios en blanco o falsos positivos, a 41 tablas con líneas y ningún falso positivo de espacios en blanco. Parte de esa limpieza se debe a dos cambios acompañantes de la 3.117.0: las palabras ya reclamadas por una grilla de líneas se quitan antes de que corra la detección por espacios en blanco, así que una tabla nunca se reporta dos veces, y un límite de columna por espacios en blanco debe ser ahora un corredor libre de texto a lo largo de cada fila que separa, que es lo que evitó que los párrafos justificados puntuaran como tablas de 5x4. El lector de rectángulos rellenos es lo que movió a las tablas mismas de la columna de fragmentos a la de líneas

Los límites vale la pena enunciarlos sin rodeos. Una página sin capa de texto igual produce el esqueleto de la grilla, con todas las celdas vacías, porque las líneas vienen de la geometría y el texto viene de la página de texto; las páginas escaneadas necesitan OCR primero. Las formas rellenas con curvas, esquinas redondeadas o contornos no rectangulares se descartan por completo, así que una tabla cuyos bordes estén dibujados como contornos de rectángulo redondeado necesita la detección por espacios en blanco como antes. Una tabla sin bordes ni sombreado no cambia por nada de esto y sigue siendo territorio de la estrategia de espacios en blanco descrita en el artículo de extracción de tablas; cuando ni eso alcanza, las cajas de palabras y los bloques de texto estructurado y orden de lectura son la materia prima para un lector propio del dominio. La demo TableExtractionLab que viene con el componente expone DetectFilledRulings en su panel de opciones, que es la forma más rápida de ver cómo queda un export dado con y sin ella; la API completa está descrita en la página de PDFium Component para Delphi