Artículo técnico

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

Una tabla exportada de Word o Google Docs puede llevar cientos de piezas de borde en una página y ni una sola línea trazada. Descomprima la página con qpdf --qdf y el flujo de contenido 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 a alinear palabras por el espacio en blanco entre ellas, y devuelve un reguero de fragmentos de dos y tres filas donde el autor dibujó una grilla limpia. Los bordes están todos ahí. Están pintados con un relleno, 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 grilla compartidas

Por qué una tabla 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

Si usted estuviera escribiendo su propio detector sobre la API cruda de PDFium, el primer borrador normalmente llama a FPDFPath_GetDrawMode, conserva el camino cuando el flag de trazo está puesto, y sigue de largo. Las dos cajas rellenas de arriba fallan esa prueba. Las palabras dentro de las celdas llegan a la detección por espacios en blanco, y en un export compacto las columnas quedan quizás a 6 puntos de distancia. En PDFium Component el MinColumnGap por defecto es de 12 puntos, así que esas columnas no se registran. Bajar MinColumnGap hasta que se registren hace que también se registre el espaciado estirado de palabras de los párrafos justificados, y la grilla 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 línea de tabla

PDFium Component lee la geometría de tablas de cada objeto de camino de la página, un subcamino por vez, y como TPdfTableExtractionOptions.DetectFilledRulings queda en True por defecto, un camino cuyo modo de relleno sea cualquier cosa distinta de none se examina en busca de líneas junto con los caminos trazados. El filtro que viene después es estricto a propósito. Un subcamino relleno califica 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 a 0.05 puntos de tolerancia. Un rectángulo dibujado con re pasa, y también uno dibujado como cinco puntos explícitos con una esquina repetida. Un triángulo, un chevron, un contorno en L o una pestaña redondeada falla la prueba y no aporta nada

Diagrama de cómo PDFium Component en Delphi clasifica un subcamino cerrado relleno: los contornos curvos y los polígonos con puntos fuera de las esquinas de la caja envolvente se descartan, una caja de un grosor no mayor que MaxRulingThickness da una línea a lo largo de su eje largo, una celda sombreada da cuatro líneas de borde y un cuadrado diminuto no da ninguna
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

El rectángulo que sobrevive se clasifica por su caja envolvente contra MaxRulingThickness, que queda en 3 puntos por defecto. Ancho igual o por debajo del umbral con altura por encima da una línea vertical en el centro horizontal, que recorre 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 el umbral o por debajo, la caja no aporta nada, así que una viñeta cuadrada de 2 puntos en una lista nunca se convierte en un muñón de grilla de tabla. El color de relleno no juega ningún papel en todo esto. Una caja blanca sobre página blanca produce las mismas líneas que una negra, que es por qué un panel de fondo del tamaño de la tabla puede aparecer como un marco exterior

Los caminos trazados conservan su propia ruta. Todo segmento recto cuyos extremos difieran a lo sumo RulingTolerance (1 punto por defecto) en un eje deviene 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 camino pintado con relleno y trazo a la vez 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 igual da líneas para sus lados rectos; solo los arcos de las esquinas se caen. La llamada de extracción en sí no necesita ningún acomodo 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;                     // con base 1

    Options := TPdfTableExtractionOptions.Default;
    // los campos de líneas rellenas, escritos con sus valores por defecto
    Options.DetectFilledRulings := True;
    Options.MaxRulingThickness := 3.0;       // puntos; debe ser mayor que 0
    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';
      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 en 0 no apaga la función. 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 obtener el comportamiento de solo trazos, ponga DetectFilledRulings en False. Déjelo activado para cualquier cosa que saliera 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 costo bajo, ya que los rellenos 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 se conecte en una sola grilla. 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 izquierdo de la siguiente quedan, digamos, a 2 puntos de distancia, y la prueba que decide si dos líneas pertenecen a la misma grilla solo permite RulingTolerance, 1 punto. Sin ajuste, cada celda es una isla de cuatro líneas que describe una grilla 1x1, nada llega a MinColumns o MinRows (ambos en 2 por defecto), y el pase de líneas no reporta tabla en una página que claramente tiene una

Diagrama de RulingSnapTolerance en PDFium Component para Delphi: las celdas sombreadas vecinas dejan un canal de 2 pt, sus líneas de borde en X 230.0 y 232.0 quedan más allá de la RulingTolerance de 1 pt, y el ajuste agrupa los dos valores en 231.0 para que ambas celdas compartan una línea de grilla
El ajuste corre antes de la fusión y antes del detector de líneas, así que los dos lados de un canal blanco se vuelven una sola línea y cada celda deja de ser una isla de cuatro líneas

El paso de ajuste reúne 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 grupos donde dos vecinos difieran más que la tolerancia; cada grupo se reemplaza 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 devienen ambos 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 fusionan en líneas simples. Solo entonces corre el detector de grilla, comparando 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 grilla trazada el paso no cambia nada, porque las coordenadas que ya coinciden se ajustan a sí mismas. La trampa es que el encadenamiento no tiene límite de ancho propio. Una corrida de valores separados 3 puntos entre sí, en 100, 103, 106 y 109, colapsa en un solo centro en 104.5 aunque los valores exteriores estén a 9 puntos. Con el default de 4 puntos eso solo muerde en columnas más angostas que un carácter, así que la salida de oficina puede quedarse con el default de 4 puntos. Bájelo hacia 1 o 2 cuando un formulario tenga columnas deliberadamente angostas (casillas de marcado, campos de número de cuenta con un dígito por celda) y póngalo en 0 para apagar el ajuste. Correr la estrategia de líneas sola bajo tres configuraciones muestra de qué mecanismo depende una página:

// Solo la estrategia de líneas, para que la detección por espacios en blanco no enmascare 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 trazos              -> 0  (sin geometría trazada para nada)
//   cajas rellenas, ajuste 4 -> 1  (los bordes del canal comparten una 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 hay que revisar a ojo antes de bajar la tolerancia

Líneas dentro de form XObjects

Las herramientas de maquetación de página a menudo envuelven 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 dice que la matriz del form se concatena con la matriz de transformación actual cuando el form se pinta, así que un rectángulo dentro del form se escribe en espacio del form y llega al espacio de página solo después de dos o más transformaciones. Con IncludeFormXObjects activado, que es el default, PDFium Component entra en cada objeto form, combina la matriz del objeto con la matriz de su padre, y le entrega la matriz combinada a cada hijo, así que un borde dibujado como 72 700 468 0.5 re f dentro de un form trasladado aterriza donde el visor lo muestra. La caminata se detiene a ocho niveles de forms anidados

Diagrama de líneas dentro de un form XObject en PDFium Component para 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 parte superior de la página después de que la matriz del form y la transformación del padre se combinan en el orden correcto
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

El orden de esa combinación importa. Un punto dentro del form pasa primero por la matriz del objeto y segundo por la matriz del padre; intercambie los operandos y el término de traslación se aplica en el espacio equivocado, así que una línea que pertenece a la parte superior de la página termina cerca del origen o fuera de la página. La misma trampa aparece siempre que usted construya matrices por su cuenta, y el artículo sobre anteponer frente a posponer matrices la recorre con un pivote de rotación

¿Por qué el presupuesto de líneas por defecto es 16384?

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 fusionar, y sombrear cada celda agrega 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 grilla parcial, y el pase de espacios en blanco para esa página tampoco corre. Un presupuesto ajustado es razonable para entradas que no son de confianza, donde una página maliciosa podría llevar millones de cajitas, 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 su propia rutina de logging, no forma parte de la librería
      LogWarning(Format('Page %d: %s', [Pdf.PageNumber, E.Message]));
      Tables := nil;
      Result := False;                     // presupuesto excedido u opciones inválidas
    end;
  end;
end;

// Un resultado False significa "no decidido", que es distinto de un arreglo vacío
if not TryExtractTables(Pdf, 4096, Tables) then
  QueueForReview(Pdf.FileName, Pdf.PageNumber);  // también rutina suya

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 las distingue en una cola de revisión

Dónde entrega el camino de líneas

La detección por líneas 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 grilla que el pase de líneas ya reportó se quitan antes de que arranque la detección por espacios en blanco, así que una tabla nunca se reporta dos veces. Un límite de columna por espacios en blanco además tiene que ser un corredor vertical libre de texto a lo largo de toda 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 mueve las tablas exportadas de oficina fuera del camino de espacios en blanco y hacia el de líneas

Como las líneas vienen de objetos de camino y el texto viene de la página de texto, una página sin capa de texto igual produce el esqueleto de la grilla con todas las celdas vacías. La confianza de una grilla de líneas es 0.88 más 0.12 por la fracción de celdas que tienen 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 OCR primero, como se describe en agregar 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 a la detección por espacios en blanco. Una tabla sin bordes ni sombreado pertenece a la estrategia de espacios en blanco descrita en el artículo de detección y extracción de tablas, y 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 escrito para una familia de documentos

La demo TableExtractionLab que viene con el componente tiene una casilla para DetectFilledRulings, así que puede cargar un export y ver la grilla aparecer y desaparecer sin escribir código. Opciones, tipos de record y el resto de la API de extracción están en la página de PDFium Component para Delphi