Artículo técnico

Reajustar contenido PDF a HTML adaptable en Delphi

PDFium Component convierte un PDF de disposición fija en un modelo semántico que se puede reajustar, mediante BuildReflowDocument, y exporta ese modelo como HTML autocontenido a través de ToHtml. Los encabezados siguen siendo encabezados, los elementos de lista siguen siendo elementos de lista, y las tablas detectadas en la página salen como marcado de tabla real con celdas de cabecera y combinaciones preservadas. Nada en la salida hace referencia a un script o una hoja de estilos externos

El motivo para querer esto es que una página PDF es un conjunto de glifos posicionados, que es exactamente lo incorrecto para una pantalla de teléfono, un lector de pantalla o un índice de búsqueda. Todo intento de resolverlo extrayendo texto plano pierde la estructura que hacía legible el documento, y todo intento de resolverlo convirtiendo páginas en imágenes pierde el texto por completo. Un modelo de reajuste conserva ambas cosas: las palabras y las relaciones entre ellas

¿De dónde procede la información semántica?

Todo empieza en GetStructuredText, la única fuente de texto y semántica del componente. Cuando el PDF lleva un árbol de estructura, PDF etiquetado según se define en la cláusula 14.7 de ISO 32000-1, el modelo sigue la jerarquía lógica que registró el productor. Cuando no lo lleva, y la mayoría de los PDF que circulan no lo llevan, el modelo recurre al orden de disposición física ya calculado con fines de orden de lectura

Esa decisión mantiene un límite estricto: no se introduce ningún segundo analizador de PDF ni ningún segundo motor de renderizado para responder preguntas que el existente ya puede responder. La maquinaria de orden de lectura subyacente se describe en bloques de texto estructurado y orden de lectura, y el modelo de reajuste es una capa semántica encima de ella, no un reemplazo

Cada nodo registra de dónde procede su información, de modo que un consumidor puede distinguir un encabezado que el documento declaró de un encabezado que infirió la heurística de disposición. Las canalizaciones sensibles a la confianza deberían leer ese campo en lugar de tratar todos los nodos como igualmente fiables

Un árbol plano, y por qué no es un árbol de objetos

El modelo es un árbol aplanado en preorden: un array de nodos donde cada nodo lleva un ParentIndex y una Depth, en lugar de un registro recursivo o un grafo de objetos con propiedad. Las páginas, los encabezados, los párrafos, las listas, los elementos de lista, las figuras, los pies de figura, las tablas, las filas y las celdas residen todos en ese único array lineal

De ahí se derivan dos beneficios. Los consumidores pueden recorrer el array en orden en flujo sin recursión, lo que convierte la emisión de HTML, Markdown o una vista de árbol en un simple bucle. Y la disposición se mantiene portable entre Delphi, C++Builder y Free Pascal, que difieren en cómo gestionan los tipos administrados recursivos a través de un límite ABI. Un registro recursivo de arrays dinámicos es exactamente el tipo de construcción que compila en todas partes y se comporta de forma sutilmente distinta en cada una

uses
  PDFium;

var
  Pdf: TPdf;
  Options: TPdfReflowOptions;
  Doc: TPdfReflowDocument;
  I: Integer;
begin
  Pdf := TPdf.Create(nil);
  try
    Pdf.FileName := 'report.pdf';
    Pdf.LoadDocument;

    Options := TPdfReflowOptions.Default;
    Options.FullDocument := True;
    Options.DetectTables := True;
    Options.IncludeCss := True;          // bloque de estilo en línea, sin archivo externo
    Options.MaxNodes := 200000;          // presupuesto que falla de forma segura
    Options.MaxCharacters := 4000000;

    Doc := Pdf.BuildReflowDocument(Options);

    for I := 0 to High(Doc.Nodes) do
      case Doc.Nodes[I].Kind of
        prnkHeading:
          Writeln(Format('%sH%d: %s', [StringOfChar(' ', Doc.Nodes[I].Depth),
            Doc.Nodes[I].HeadingLevel, Doc.Nodes[I].Text]));
        prnkParagraph:
          Writeln(Format('%sp: %s', [StringOfChar(' ', Doc.Nodes[I].Depth),
            Copy(Doc.Nodes[I].Text, 1, 60)]));
        prnkTable:
          Writeln(Format('table on page %d', [Doc.Nodes[I].PageNumber]));
      end;

    Writeln(Format('%d node(s), %d table(s), %d character(s)',
      [Length(Doc.Nodes), Doc.TableCount, Doc.CharacterCount]));
  finally
    Pdf.Free;
  end;
end;

¿Cómo se evita que las tablas aparezcan dos veces?

La detección de tablas se ejecuta después de recopilar el texto estructurado de una página, lo cual crea un peligro evidente: el mismo contenido de celda existe tanto en los bloques de texto como en la tabla detectada. Emitir ambos produce HTML donde cada tabla va seguida de su propio contenido otra vez como párrafos sueltos

La regla que lo resuelve es geométrica. Cuando una tabla detectada cubre más de la mitad del área de un bloque de texto, el nodo de tabla reemplaza a ese bloque en lugar de unirse a él. La indexación de celdas dentro de una fila se construye contando en cubetas, así que la construcción del modelo se mantiene lineal en celdas más filas en lugar de volver a escanear cada celda por cada fila, lo cual importa en documentos financieros donde una sola página puede llevar cientos de celdas

La estructura detectada es honesta respecto a ser una detección. Una tabla con líneas de trazado se reconoce de forma más fiable que una alineada puramente por espacios en blanco, y la confianza del nodo lo refleja. Para contenido donde una tabla equivocada es mejor que ninguna tabla, mantenga la detección activada; para conversión de archivo donde una tabla equivocada es peor, filtre por confianza

Exportar HTML que se mantiene autocontenido

ToHtml recorre el modelo ya construido y nunca vuelve a consultar PDFium, así que exportar dos veces no cuesta nada extra y no puede producir un resultado distinto a partir del mismo modelo. El texto y los valores de atributo se escapan de forma uniforme, los niveles de encabezado se acotan al rango h1 a h6 que HTML realmente define, y las celdas de cabecera, RowSpan y ColumnSpan pasan tal cual se escribieron

El CSS opcional es un simple bloque de estilo en línea. No hay script, ni fuente web, ni recurso externo de ningún tipo, que es lo que hace segura la incrustación de la salida en un correo electrónico, un visor de ayuda o un control de navegador en sandbox:

var
  Html: WideString;
  Stream: TFileStream;
  Bytes: TBytes;
begin
  Options := TPdfReflowOptions.Default;
  Options.FullDocument := True;
  Options.IncludeCss := True;
  Options.IncludePageSections := True;   // mantener visibles los límites de página
  Options.PreserveLineBreaks := False;   // dejar que el navegador ajuste los párrafos

  Html := Pdf.BuildReflowDocument(Options).ToHtml;

  Bytes := TEncoding.UTF8.GetBytes(string(Html));
  Stream := TFileStream.Create('report.html', fmCreate);
  try
    if Length(Bytes) > 0 then
      Stream.WriteBuffer(Bytes[0], Length(Bytes));
  finally
    Stream.Free;
  end;
end;

PreserveLineBreaks es la opción que más merece la pena considerar. Un salto de línea en PDF es una decisión de composición tipográfica tomada para un ancho de página fijo, así que preservarlo en una pantalla estrecha reproduce exactamente el problema que el reajuste existe para resolver. Preserve los saltos para poesía, listados de código y direcciones; descártelos para prosa

Presupuestos, cancelación y estado de página

Los caracteres, los nodos, las tablas y las celdas tienen cada uno un límite, y cada uno se comprueba antes de la reserva y no después, así que un documento malformado u hostil falla de forma controlada en lugar de consumir memoria hasta que algo más lo haga. El token de cancelación se comprueba en los límites de página, bloque, tabla, fila y celda, lo que mantiene la capacidad de respuesta ante el escaneado cancelado de un documento de mil páginas

Un comportamiento importa específicamente para las aplicaciones con interfaz gráfica: todo el escaneado del documento se ejecuta dentro de un ámbito que restaura la página activa, así que el éxito, el fallo de presupuesto y la cancelación dejan intacta la página actual de quien llama. Un visor que permite al usuario exportar mientras mira la página 340 se encuentra todavía en la página 340 después

Para qué sirve el reajuste, y para qué no

La salida de reajuste es una excelente entrada para la indexación de búsqueda, las vistas de lectura accesibles, la visualización móvil y la migración de contenido. No es un conversor que preserve la fidelidad: las posiciones absolutas, las fuentes exactas, el material gráfico vectorial y la geometría de página precisa quedan fuera de su propósito por diseño. Cuando un trabajo necesita que la página tenga el mismo aspecto, renderícela; cuando necesita que la página sea legible en otro sitio, reajústela

Para la tecnología de asistencia en concreto, el modelo de reajuste se combina con las funciones de lectura descritas en construir un lector accesible, y los documentos que llevan un árbol de estructura genuino producen modelos notablemente mejores, lo cual es un buen argumento para validar el etiquetado en origen como se describe en la validación del árbol de estructura PDF/UA

El reajuste, el texto estructurado, la validación de etiquetado y el renderizado comparten un único objeto de documento en Delphi, C++Builder y Lazarus; la API completa se describe en la página de PDFium Component para Delphi