Una tabla que corre por tres páginas de un export de Word nunca llega a menos de 72 puntos del borde inferior del papel. Los márgenes de una pulgada son 72 puntos, y el procesador de textos deja de colocar filas en el margen. Un linker que une fragmentos solo cuando se sientan a 36 puntos de los bordes de página ve tres tablas sin relación con columnas idénticas. La pregunta que funciona es distinta: ¿hay algo más que un encabezado o pie de página corriendo entre el final de un fragmento y el arranque del siguiente?
Esa pregunta trae dos trampas propias. Un formulario puede terminar una tabla al fondo de la página 1 y abrir una tabla distinta con los mismos anchos de columna arriba de la página 2. Y una grilla con líneas puede empujar una sola fila más allá del corte, demasiado corta para un detector que quiere dos filas antes de llamar tabla a algo
Dos pruebas de borde en ExtractDocumentTables
PDFium Component marca continuaciones en TPdf.ExtractDocumentTables, el contraparte a nivel de documento del ExtractTables por página cubierto en el artículo de detección y extracción de tablas. Con DetectContinuations fijado, como está en TPdfTableExtractionOptions.Default, el método extrae cada página, ordena las tablas por número de página y luego de arriba hacia abajo dentro de cada página, y chequea cada par de vecinos en esa lista. Solo se comparan vecinos. Un candidato a continuación es por lo tanto siempre la tabla más baja de la página N emparejada con la tabla más alta de la página N+1, y una tabla pequeña colocada debajo de una larga en la página N toma el lugar de la larga en ese emparejamiento. Cuando una tabla de notas corta se sienta debajo de una larga, enlace esos fragmentos en su propio código a partir de números de página y ColumnCount
La primera prueba es puramente geométrica. El borde inferior del fragmento anterior debe estar dentro de ContinuationMargin puntos del borde inferior de la página, y el borde superior del fragmento posterior dentro de la misma distancia del tope de la página siguiente. El default es 36 puntos, media pulgada. Los reportes de motores de layout que llenan una caja de contenido fija pasan esto fácilmente. Un export de Word con márgenes de una pulgada falla por al menos 36 puntos de cada lado, y agrandar el margen hasta que pase va mal, porque el mismo número fija las bandas de encabezado y pie de la segunda prueba
Esa segunda prueba, la consciente del contenido, es la que maneja la salida de procesadores de textos. Las dos se combinan con OR: cualquiera abre la puerta
¿Qué cuenta como texto entre dos fragmentos?
Cada palabra de una página cuenta como texto de cuerpo salvo que esté por completo dentro de la banda de encabezado corriendo o de la banda de pie, y ambas bandas tienen ContinuationMargin puntos de profundidad. Por página, ExtractDocumentTables registra el borde inferior más bajo entre las palabras cuyo tope está arriba de la banda de pie y el borde superior más alto entre las palabras cuyo fondo está debajo de la banda de encabezado, incluido el texto de celda de la propia tabla. Cuando la tabla es el último contenido de cuerpo de la página N, su borde inferior se sienta en o debajo de la palabra más baja, y la prueba pasa dentro de AlignmentTolerance, que por default es 3 puntos. El fragmento posterior tiene que cumplir la condición espejo en el tope de la página N+1
Ignorar las bandas tiene base en la especificación. ISO 32000-1 §14.8.2.2 trata encabezados corriendo, pies y números de página como artefactos de paginación, contenido que existe solo porque el documento fue cortado en páginas. Un archivo etiquetado puede declararlos con /Artifact, como explica el artículo de contenido marcado, pero los exports que más necesitan coser tablas rara vez cargan tags, así que la posición hace de tag. Con la banda default, un número de página cuyos glifos terminan 30 puntos arriba del borde inferior se ignora. Una línea de disclaimer que llega a 40 puntos es texto de cuerpo, y bloquea el enlace
En una página sin capa de texto, los extremos guardados caen a los límites de la página, la prueba consciente del contenido pasa de forma trivial, y las demás compuertas deciden. Los pies dibujados como imágenes o contornos vectoriales son invisibles por la misma razón y nunca bloquean un enlace
Pasar una prueba de borde solo abre la puerta. Las páginas deben ser consecutivas, el fragmento posterior no debe abrir con una fila de caption, y los límites de columna deben alinearse dentro del doble de AlignmentTolerance, 6 puntos con los defaults. Cuando todo eso se sostiene, el fragmento anterior se vuelve ptcStart con un número de grupo fresco, o pasa de ptcEnd a ptcMiddle si ya cerró una cadena, y el fragmento posterior se vuelve ptcEnd en el mismo grupo. Una tabla repartida en tres páginas se lee start, middle, end. Los números de grupo arrancan en 1; grupo 0 con ptcNone significa que la tabla está sola. ToJson escribe los mismos datos como "continuationGroup" y "continuation" (none, start, middle o end), así que un servicio que recibe JSON puede coser por su cuenta
El listado de abajo asume un export de Word con un pie de dos líneas de unos 50 puntos de profundidad, así que el margen sube a 54 para mantener ambas líneas dentro de la banda
uses
System.SysUtils, 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; // DetectContinuations = True
Options.ContinuationMargin := 54; // pie asumido de ~50 pt de profundidad
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 x %d',
[Tables[I].PageNumber, Tables[I].RowCount,
Tables[I].ColumnCount]));
end;
finally
Pdf.Free;
end;
end.
Cómo una primera fila a todo el ancho mantiene separadas dos tablas
Un fragmento posterior cuya primera fila es una sola celda que abarca todas las columnas se reporta como tabla nueva, incluso cuando una prueba de borde pasa y las columnas coinciden. Los formularios son donde la prueba consciente del contenido sobrepasaría: una tabla termina cerca del fondo de la página 1, la siguiente sección abre en la página 2 con anchos de columna idénticos, y todos los chequeos geométricos pasan. La diferencia es la fila de apertura de la segunda tabla, un caption de sección como RECIPIENT INFORMATION maquetado como una celda combinada a todo el ancho. Una continuación genuina no abre así, porque el caption de una tabla se queda en la página donde la tabla arranca
La regla lee solo las celdas del fragmento posterior. Ese fragmento necesita al menos dos columnas y una celda con RowIndex = 0, ColumnIndex = 0 y ColumnSpan = ColumnCount. El fragmento anterior nunca se inspecciona, así que una tabla cuyo propio caption se sienta arriba de la página N enlaza normalmente con su continuación en la página N+1
La comparación de columnas que sigue es más estricta que un conteo de columnas igual. Las posiciones de frontera de cada fragmento se reconstruyen desde sus rectángulos de celda; una frontera escondida bajo una celda combinada se rellena espaciándola uniformemente entre las fronteras conocidas más cercanas a cada lado. Luego cada par de fronteras se compara, y una diferencia arriba de 6 puntos rechaza el enlace. Dos tablas de cuatro columnas con proporciones distintas se quedan separadas. También lo hace una continuación real cuyo generador movió una columna 8 puntos entre páginas; subir AlignmentTolerance para aceptarla también afloja el clustering usado durante la detección
Una fila con líneas sobre un salto de página
Una grilla con líneas que empuja una sola fila a la página siguiente conserva esa fila cuando se enlaza a una cadena y la pierde cuando no. El MinRows default de 2 evita que un par de líneas sueltas se reporte como tabla. Una fila empujada más allá del corte es distinta: es dato real, y soltarla dejaría la tabla cosida una fila corta sin ningún fragmento que explique el hueco
El escaneo del documento lo maneja en tres pasos. Cuando DetectContinuations y DetectRuledTables están ambos encendidos, el detector con líneas corre en cada página con su piso de filas bajado a 1. Las continuaciones se marcan después sobre el resultado completo. Finalmente, toda tabla con menos filas que el MinRows del caller que no terminó dentro de una cadena se elimina. Una grilla de una fila en medio de una página ordinaria pasa por ese filtro y desaparece, que es exactamente para lo que está MinRows
Deje MinRows en 2. Fijarlo en 1 usted mismo apaga el filtro final, y toda grilla con líneas de una fila en cada página vuelve
La excepción aplica solo a grillas con líneas. La estrategia de espacios en blanco necesita al menos dos filas alineadas para reconocer una tabla, así que mantiene un piso interno de 2 diga lo que diga MinRows; ExtractTables acepta MinRows = 1, pero solo la detección con líneas lo honra. Para una tabla sin líneas que derrama una fila, las cajas de palabras de la página o los bloques descritos en bloques de texto estructurado y orden de lectura le dan las posiciones para recuperar esa fila usted mismo
Coser una cadena de vuelta en una tabla
Las cadenas vuelven contiguas. Los enlaces solo se hacen entre vecinos del arreglo ordenado, así que un fragmento ptcStart va seguido directamente de sus fragmentos ptcMiddle y ptcEnd, y un bucle que se resetea en ptcNone o ptcStart y escribe en ptcNone o ptcEnd nunca tiene que buscar nada por número de grupo
La fila de encabezado repetida es la parte delicada: Word puede repetirla en cada página, otros generadores no. El flag IsHeader de TPdfTableCell no puede resolverlo, porque se asigna por posición, RowIndex < HeaderRowCount, por separado para cada fragmento. La fila 0 de una continuación se marca como encabezado ya sea que repita el encabezado o cargue datos, y el derrame de una fila de la sección anterior también se marca. La rutina de abajo compara el texto de la primera fila de cada continuación con la primera fila de la cadena y la suelta solo en un match exacto
También construye las filas desde Cells en lugar de partir la salida de ToCsv en líneas. Una celda cuyo texto se envuelve dentro de la celda vuelve con un CR LF entre sus líneas visuales, ToCsv mantiene ese salto dentro del campo entrecomillado, y borrar la primera línea del texto CSV cortaría por la mitad una celda de encabezado de dos líneas
// Helpers del lado lector (no parte de la biblioteca): reconstruir cada cadena
// como un archivo separado por tabulaciones, soltando encabezados repetidos en páginas posteriores
function RowText(const T: TPdfTable; Row: Integer): string;
var
Parts: array of string;
I: Integer;
begin
SetLength(Parts, T.ColumnCount);
for I := 0 to High(T.Cells) do
if (T.Cells[I].RowIndex = Row) and (T.Cells[I].ColumnIndex >= 0) and
(T.Cells[I].ColumnIndex < T.ColumnCount) then
Parts[T.Cells[I].ColumnIndex] :=
StringReplace(string(T.Cells[I].Text), #13#10, ' ', [rfReplaceAll]);
Result := '';
for I := 0 to High(Parts) do
begin
if I > 0 then
Result := Result + #9;
Result := Result + Parts[I];
end;
end;
procedure ExportChains(const Tables: TPdfTables; const Folder: string);
var
I, R, FirstRow: Integer;
Header: string;
Lines: TStringList;
begin
Lines := TStringList.Create;
try
for I := 0 to High(Tables) do
begin
FirstRow := 0;
if Tables[I].Continuation in [ptcNone, ptcStart] then
begin
Lines.Clear;
Header := RowText(Tables[I], 0);
end
else if (Trim(Header) <> '') and (RowText(Tables[I], 0) = Header) then
FirstRow := 1; // encabezado repetido en esta página
for R := FirstRow to Tables[I].RowCount - 1 do
Lines.Add(RowText(Tables[I], R));
if Tables[I].Continuation in [ptcNone, ptcEnd] then
Lines.SaveToFile(Format('%s\table%d-page%d.tsv',
[Folder, I, Tables[I].PageNumber]));
end;
finally
Lines.Free;
end;
end;
La comparación exacta es la decisión correcta. Un encabezado repetido por el procesador de textos coincide carácter por carácter, mientras que un match difuso eventualmente se tragaría una fila de datos que se parece al encabezado. Un encabezado extra suelto es fácil de ver en la salida; una fila de datos faltante no
Medir la banda de pie en lugar de adivinar
ContinuationMargin hace tres trabajos con un número: holgura de bordes de página para la primera prueba, profundidad de banda de pie y profundidad de banda de encabezado para la segunda. Un pie más profundo que la banda deja su línea superior en la zona de cuerpo, el fragmento anterior parece seguido de texto, y la cadena se corta en esa página. Una banda fijada demasiado profunda empieza a tragarse contenido real: una línea de cierre corta justo arriba del margen inferior cae dentro de ella, y la tabla arriba de esa línea puede entonces enlazar con lo que abra la página siguiente
El propio documento puede suministrar el número. TPdf.PageWordBoxes devuelve cada palabra de la página actual con un Rect en espacio de página cuyo Top es mayor que su Bottom, así que el Top más alto entre las palabras debajo del margen de cuerpo es la profundidad del pie. La sonda de abajo toma un valor justo debajo del margen inferior del layout, 70 para una página de una pulgada, y mantiene 36 puntos como piso para documentos sin pie
// Helper del lado lector (no parte de la biblioteca): el tope de palabra más alto
// encontrado debajo de BodyMargin en cualquier página, o sea la profundidad de la zona de pie
function MeasureFooterDepth(Pdf: TPdf; BodyMargin: Double): Double;
var
P, I: Integer;
Words: TPdfWordBoxes;
begin
Result := 0;
for P := 1 to Pdf.PageCount do
begin
Pdf.PageNumber := P;
Words := Pdf.PageWordBoxes;
for I := 0 to High(Words) do
if (Words[I].Rect.Bottom < BodyMargin) and
(Words[I].Rect.Top > Result) then
Result := Words[I].Rect.Top;
end;
end;
// usa System.Math para Max
Options := TPdfTableExtractionOptions.Default;
Options.ContinuationMargin := Max(36.0, MeasureFooterDepth(Pdf, 70) + 2);
Tables := Pdf.ExtractDocumentTables(Options);
La banda tiene la misma profundidad en ambos bordes, así que mida un encabezado corriendo profundo contra el tope de la página también y use el valor mayor. Mantenga la holgura en un par de puntos; cada punto extra es un punto de texto de cuerpo que la prueba consciente del contenido deja de ver
Un layout vence la regla del caption por construcción: un generador que escribe un banner combinado de "continued" como primera fila de cada fragmento de continuación. Cada tal fragmento se reporta como tabla nueva, así que enlácelos en su propio código a partir de números de página consecutivos, ColumnCount igual y el texto del banner en la fila 0
El marcado de continuaciones, la regla del caption y la excepción de una fila corren todos dentro de ExtractDocumentTables, que se comporta igual en las compilaciones Delphi, C++Builder y Lazarus. El set completo de opciones de extracción de tablas está listado en la página de PDFium Component for Delphi