PDFium Component en su versión 3.117.0 enlaza una tabla que se parte en un salto de página cuando ambos fragmentos tocan los bordes de la página o cuando no hay texto de cuerpo por debajo del primer fragmento y por encima del segundo, ignorando encabezados y pies de página corridos. ExtractDocumentTables aplica esa prueba basada en contenido como alternativa a la antigua prueba de márgenes, rechaza un fragmento de la página siguiente cuya primera fila sea una única celda de rótulo a todo el ancho, y conserva una fila suelta que se desborda a la página siguiente como parte de su cadena de continuación
El artículo sobre detección y extracción de tablas presentaba la continuación como cuatro puertas estrictas y trataba «toca el borde de la página» como una de ellas. Esa descripción era exacta para la versión que cubría, y también era equivocada para la mayoría de tablas que la gente le pasa de verdad al componente. Este artículo es la corrección: qué documentos no sabe manejar la prueba de márgenes, qué la sustituyó y los dos casos laterales que el arreglo arrastró consigo
¿Por qué falla la prueba de márgenes con exports de Word?
La prueba de márgenes falla porque un procesador de textos deja de maquetar filas en el margen inferior, no en el borde del papel. Con el ContinuationMargin por defecto de 36 puntos, la regla original exigía que el borde inferior del fragmento anterior quedara a menos de 36 puntos del fondo de la página y que el borde superior del posterior quedara a menos de 36 puntos de la parte alta. Un documento exportado desde Word con sus márgenes por defecto de una pulgada deja la última fila como mínimo 72 puntos por encima del fondo de la página, y más si hay un pie, así que la condición nunca se cumplía. Todas las tablas largas de un documento así volvían como fragmentos independientes con ContinuationGroup a cero, y quien llamaba estaba otra vez cosiendo a mano. La prueba sigue teniendo sentido para aquello alrededor de lo que se diseñó: informes generados por motores de maquetación que llenan la página hasta una caja de contenido fija y empiezan la siguiente pegada arriba del todo. No es una regla mala, es una regla incompleta, y por eso la versión 3.117.0 la mantuvo y añadió un segundo camino en vez de reemplazarla
¿Qué comprueba en su lugar la prueba basada en contenido?
La prueba basada en contenido comprueba si algo que no es la tabla ocupa el espacio entre los dos fragmentos, usando las cajas de palabra de cada página y no la geometría de página. Mientras ExtractDocumentTables recorre el documento, registra por página el borde inferior más bajo de cualquier palabra cuya parte superior quede por encima de la banda de pie y el borde superior más alto de cualquier palabra cuya parte inferior quede por debajo de la banda de encabezado. Ambas bandas miden ContinuationMargin puntos de alto, así que la misma opción hace ahora doble trabajo como holgura de borde de página y como altura de las zonas de encabezado y pie corridos. Un par de fragmentos pasa cuando el borde inferior del anterior está a la altura del texto de cuerpo más bajo de su página o por debajo, y el borde superior del posterior está a la altura del texto de cuerpo más alto de la página siguiente o por encima, cada uno dentro de AlignmentTolerance. Dicho llano: la tabla era lo último de la página N y lo primero de la página N+1, y un número de página o un título de documento en la banda de margen no cuenta. Esa exclusión no es arbitraria. ISO 32000-1 §14.8.2.2 clasifica los encabezados y pies corridos como artefactos de paginación, contenido que existe por el salto de página y no a pesar de él, y la misma idea que permite a un lector de etiquetas saltárselos es la que permite a una tabla continuar más allá de ellos. El artículo sobre contenido marcado cubre cómo los archivos etiquetados declaran esos artefactos de forma explícita; aquí la clasificación se infiere de la posición, porque la mayoría de las tablas exportadas no llevan etiqueta ninguna
Las dos pruebas se combinan con OR. Un informe de motor de maquetación cuyas tablas llegan al borde del papel pasa la primera; un export de Word cuyas tablas se detienen en el margen pasa la segunda; un documento que hace ambas cosas pasa dos veces. Solo después de que una tenga éxito se ejecutan las demás puertas, y lo hacen en un orden fijo: los números de página deben ser adyacentes, el fragmento posterior no debe abrir con una fila de rótulo, y los límites de columna deben coincidir dentro del doble de AlignmentTolerance, que con los valores por defecto son 6 puntos. La enumeración es TPdfTableContinuation con los valores ptcNone, ptcStart, ptcMiddle y ptcEnd. Un fragmento marcado como ptcEnd que luego enlaza con otra página más se promueve a ptcMiddle, así que una tabla de tres páginas se lee como inicio, medio y final en orden de página. Los números de grupo empiezan en 1 y 0 significa sin enlazar, y ToJson emite la misma información en los miembros continuation y continuationGroup, que es la forma que conviene preferir si el cosido lo hace un servicio posterior
uses
PDFium;
var
Pdf: TPdf;
Options: TPdfTableExtractionOptions;
Tables: TPdfTables;
I: Integer;
begin
Pdf := TPdf.Create(nil);
try
Pdf.FileName := 'itinerary-from-word.pdf';
Pdf.LoadDocument;
Options := TPdfTableExtractionOptions.Default;
Options.DetectContinuations := True; // por defecto; se muestra por claridad
Options.ContinuationMargin := 54; // pie de dos líneas, ~50 pt de alto
Tables := Pdf.ExtractDocumentTables(Options);
for I := 0 to High(Tables) do
case Tables[I].Continuation of
ptcStart:
Writeln(Format('group %d starts on page %d (%d rows)',
[Tables[I].ContinuationGroup, Tables[I].PageNumber,
Tables[I].RowCount]));
ptcMiddle, ptcEnd:
Writeln(Format('group %d continues on page %d (%d rows)',
[Tables[I].ContinuationGroup, Tables[I].PageNumber,
Tables[I].RowCount]));
else
Writeln(Format('standalone table on page %d (%d rows)',
[Tables[I].PageNumber, Tables[I].RowCount]));
end;
finally
Pdf.Free;
end;
end;
¿Cómo impide una fila de rótulo que dos tablas se fusionen?
Un fragmento de la página siguiente cuya primera fila es una celda que abarca todas las columnas se trata como tabla nueva, nunca como el resto de la anterior. Esta regla existe porque la prueba basada en contenido, por sí sola, enlaza con demasiada alegría. El caso que la sacó a la luz era un formulario tipo expediente: una tabla termina cerca del fondo de la página 1, una segunda tabla con anchos de columna idénticos empieza cerca de la parte alta de la página 2, entre ambas no hay más que el pie, y las columnas coinciden al milímetro. Con la prueba de márgenes las dos nunca se encontraban porque ninguna tocaba un borde; con la prueba de contenido se enlazaron al instante, y un formulario con secciones se convirtió en una sola rejilla incoherente. Lo que las separa se ve en la estructura de celdas. La segunda tabla abre con un rótulo de sección como «RECIPIENT INFORMATION» maquetado como una única celda combinada a todo el ancho, y una continuación genuina nunca hace eso, porque el rótulo pertenece a la tabla que ya empezó en la página anterior. TableStartsWithCaptionRow codifica exactamente eso: que el fragmento tenga al menos dos columnas y contenga una celda con RowIndex = 0, ColumnIndex = 0 y ColumnSpan = ColumnCount. La comprobación se ejecuta solo sobre el fragmento posterior, así que una tabla cuyo propio rótulo esté en su primera página no se ve afectada; el rótulo está en la página N, y solo se inspecciona el fragmento de la página N+1
La comparación de columnas que viene después, TablesHaveMatchingColumns, es más estricta que «mismo número de columnas». Reconstruye las posiciones de los límites de cada fragmento a partir de los rectángulos de celda, interpola los límites que ocultan las celdas combinadas y rechaza el par cuando algún límite se desvía más que la tolerancia. Dos tablas de cuatro columnas con proporciones distintas se quedan por tanto separadas aunque todo lo demás encaje
¿Qué pasa con una fila suelta que se desborda a la página siguiente?
Una rejilla con líneas que lleva una fila a la página siguiente ahora se detecta y se enlaza, siempre que acabe dentro de una cadena de continuación; por sí sola se descarta. El MinRows por defecto de 2 existe para evitar que un par de líneas sueltas se informe como tabla, pero una última fila empujada al otro lado del salto es una fila real que un suelo duro de 2 se comía en silencio, y el resto de la tabla parecía completa cuando no lo estaba. El barrido a nivel de documento lo resuelve en tres pasos. Cuando DetectContinuations y DetectRuledTables están ambos activados, la pasada por página ejecuta el detector de rejillas con el suelo de filas bajado temporalmente a 1, y por eso ExtractTables acepta ahora MinRows de 1 en rejillas con líneas mientras que la detección por espacios en blanco mantiene un suelo interno de 2. Las continuaciones se marcan sobre el resultado completo. Después se elimina toda tabla más corta que el MinRows de quien llama y que no forme parte de ninguna cadena. El fragmento de una sola fila sobrevive solo porque quedó enlazado, y una rejilla de una fila en medio de una página por lo demás normal se filtra exactamente igual que antes
// Reconstruye cada cadena como un CSV, descartando las filas de encabezado
// repetidas en los fragmentos de continuación
procedure ExportChains(const Tables: TPdfTables; const Folder: string);
var
I, R: Integer;
Lines: TStringList;
Csv: TStringList;
begin
Csv := TStringList.Create;
Lines := TStringList.Create;
try
for I := 0 to High(Tables) do
begin
if Tables[I].Continuation in [ptcNone, ptcStart] then
Csv.Clear;
Lines.Text := string(Tables[I].ToCsv);
if (Tables[I].Continuation in [ptcMiddle, ptcEnd]) and
(Lines.Count > 1) and (Tables[I].RowCount > 1) then
Lines.Delete(0); // encabezado repetido por el procesador de textos
for R := 0 to Lines.Count - 1 do
Csv.Add(Lines[R]);
if Tables[I].Continuation in [ptcNone, ptcEnd] then
Csv.SaveToFile(Format('%s\page%d-group%d.csv',
[Folder, Tables[I].PageNumber, Tables[I].ContinuationGroup]));
end;
finally
Lines.Free;
Csv.Free;
end;
end;
Dos detalles de esa rutina son deliberados. La fila desbordada nunca se elimina, porque el guardián sobre RowCount la conserva, y un procesador de textos que repite la fila de encabezado en cada página produce un fragmento cuya primera línea vuelve a ser el encabezado, así que descartar la línea cero en los fragmentos intermedios y finales es correcto para ese caso y erróneo para un generador que no repite encabezados. Comprueba un documento antes de soltar la rutina sobre una carpeta entera
Dónde siguen parándose las reglas
La prueba basada en contenido vale tanto como la capa de texto que lee. En una página escaneada sin nada de texto, los extremos de texto de cuerpo registrados caen a los límites de la página, la condición de «nada en medio» se cumple de forma vacía, y solo quedan las puertas de rótulo y de columnas; una rejilla con líneas en una página así se sigue encontrando como esqueleto vacío, de modo que la cadena puede enlazar bien, pero no se verificó de verdad nada del texto que la rodea. Añade antes una capa de texto si eso importa. Los pies renderizados como imágenes en lugar de texto son invisibles para la lógica de bandas e inofensivos por el mismo motivo
Las bandas son un solo número. Un pie más alto que ContinuationMargin deja sus líneas bajas dentro de la zona de cuerpo, lo que hace que el fragmento anterior parezca seguido de texto y bloquea el enlace; sube la opción a la profundidad real de la banda, como hace el primer ejemplo. Súbela demasiado y un párrafo corto de cierre cerca del fondo de la página se cuela en la banda y se ignora, lo que enlaza una tabla con lo que venga después. La regla del rótulo tiene un fallo especular: un generador que escribe un banner combinado de «continuación» como primera fila de cada fragmento de continuación hará que esos fragmentos se rechacen como tablas nuevas, y el único remedio hoy es que coses tú mismo por ContinuationGroup sin aflojar nada, porque la regla no tiene interruptor
Las tablas detectadas por espacios en blanco no reciben nada de este alivio de una fila. La estrategia de espacios en blanco necesita dos filas alineadas para ver siquiera una tabla, así que una tabla sin líneas a la que se le desborda una fila sigue informándose corta por esa fila. Cuando te topes con eso, las cajas de palabra que hay detrás de los bloques de texto estructurado y el orden de lectura te dan las posiciones crudas para recuperarla. En el conjunto de muestras que movió este trabajo, trece exports de procesadores de textos y navegadores, los cinco documentos con tablas multipágina genuinas se enlazaron todos en cadenas únicas y el formulario tipo expediente que antes se fusionaba se quedó aparte, que es el listón contra el que se midió la versión, no una promesa sobre cualquier maquetación
El marcado de continuaciones, la regla del rótulo y la pasada de una sola fila viven todos en el camino a nivel de documento que comparten las builds de Delphi, C++Builder y Lazarus; la API completa de extracción de tablas se describe en la página de PDFium Component para Delphi