Artículo técnico

Falsos positivos de tabla en PDFs con texto justificado

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

Diagrama de PDFium Component de por qué la prosa justificada puntuaba como tabla: todas las líneas se estiran al mismo margen, así que huecos sueltos cruzan MinColumnGap en una X distinta en cada línea, y dos huecos consecutivos dentro de AlignmentTolerance construían los candidatos falsos que la prueba de corredor rechaza ahora
Una tabla real repite sus anclas de columna en todas las filas, mientras que un párrafo justificado estira un espacio distinto en cada línea, y por eso el ajuste a nivel de fila no podía separar las dos cosas por sí solo

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

Diagrama de PDFium Component de la prueba de corredor libre de texto que hay detrás de ExtractTables: cada fila aporta el intervalo desde el borde derecho de su celda izquierda hasta el borde izquierdo de su celda derecha, la intersección se mantiene más ancha que la mitad de MinColumnGap en una tabla real y se derrumba a nada en texto justificado
Un límite de columna genuino está vacío en todas las filas que separa, así que intersecar los huecos de cada fila deja una franja compartida en una tabla y ninguna franja en prosa estirada

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

Diagrama de PDFium Component del arreglo del reordenamiento de Purpose of Request: las cajas de glifo ajustadas de FPDFText_GetCharBox dan al «of» sin descendente un centro más alto que la antigua tolerancia de 1 pt en el centro Y ordenaba como su propia línea, mientras que una regla de solapamiento vertical del 25 por ciento lo mantiene en la línea base y restaura el orden de las palabras
El centro Y se mueve según las ascendentes y descendentes que lleve la tinta, mientras que dos cajas sobre una misma línea base se solapan en la altura de la x compartida hagan lo que hagan sus alturas

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