Artículo técnico

Continuación de tablas PDF según el contenido en Delphi

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

Por qué la continuación de tablas en PDFium Component necesita dos pruebas: con los márgenes de una pulgada de Word, la prueba de márgenes exige bordes de fragmento dentro de ventanas de 36 pt que la maquetación nunca alcanza, mientras que la prueba consciente del contenido compara cajas de palabras y enlaza cuando la tabla es el último contenido de cuerpo de la página N y el primero de la N+1, ignorando las bandas de encabezado y pie repetidos
Cualquiera de las dos pruebas abre la puerta, y solo entonces corren las comprobaciones restantes: páginas adyacentes, ninguna fila de título a todo el ancho en el fragmento posterior, y límites de columna que coincidan dentro del doble de AlignmentTolerance

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 puerta de la fila de título en PDFium Component: una continuación genuina abre con celdas de datos y se une al mismo ContinuationGroup, mientras que un fragmento posterior cuya fila cero tiene una sola celda combinada con RowIndex 0, ColumnIndex 0 y ColumnSpan igual a ColumnCount se rechaza como continuación y se reporta como tabla nueva
La inspección toca solo el fragmento posterior, así que una tabla cuyo propio título esté en su primera página no se ve afectada, y la puerta corre solo después de que una de las dos pruebas de borde haya pasado

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

Cómo conserva PDFium Component una fila con líneas que se desborda por un salto de página: el pase de grillas por página corre con el piso de filas en uno cuando DetectContinuations y DetectRuledTables están activados, las continuaciones se marcan sobre el resultado completo, y solo se eliminan los fragmentos más cortos que MinRows que quedan fuera de toda cadena
La fila desbordada sobrevive porque su cadena la enlaza, mientras que una grilla de una sola fila e independiente en una página común se filtra como siempre, y las tablas detectadas por espacios en blanco conservan su piso de dos filas sin ese alivio

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