Artigo Técnico

Refluir Conteúdo PDF para HTML Responsivo em Delphi

O PDFium Component transforma um PDF de layout fixo num modelo semântico que pode ser refluído, usando BuildReflowDocument, e exporta esse modelo como HTML autónomo através de ToHtml. Os títulos continuam a ser títulos, os itens de lista continuam a ser itens de lista, e as tabelas detetadas na página saem como marcação de tabela real, com células de cabeçalho e abrangências preservadas. Nada no resultado faz referência a um script ou a uma folha de estilos externa

A razão para se querer isto é que uma página PDF é um conjunto de glifos posicionados, o que é exatamente errado para um ecrã de telemóvel, um leitor de ecrã ou um índice de pesquisa. Toda a tentativa de resolver o problema extraindo texto simples perde a estrutura que tornava o documento legível, e toda a tentativa de o resolver convertendo páginas em imagens perde o texto por completo. Um modelo de refluxo mantém ambos: as palavras e as relações entre elas

Diagrama de pipeline do reflow do PDFium Component em Delphi, em que GetStructuredText alimenta quer uma árvore de estrutura etiquetada quer uma ordem de leitura de disposição no BuildReflowDocument e numa exportação ToHtml autónoma
Cada nó do modelo de reflow remonta à mesma camada de texto estruturada, quer o seu título tenha sido declarado numa árvore de estrutura, quer inferido do layout

De onde vem a informação semântica?

Tudo começa em GetStructuredText, a única fonte de texto e semântica no componente. Quando o PDF transporta uma árvore de estrutura, PDF etiquetado conforme definido na cláusula 14.7 da ISO 32000-1, o modelo segue a hierarquia lógica que o produtor registou. Quando não a transporta, e a maioria dos PDF encontrados na prática não a transporta, o modelo recua para a ordem de layout físico já calculada para efeitos de ordem de leitura

Essa escolha mantém um limite rígido: não se introduz um segundo analisador de PDF nem um segundo motor de renderização para responder a perguntas que o existente já consegue responder. A maquinaria de ordem de leitura subjacente está descrita em blocos de texto estruturado e ordem de leitura, e o modelo de refluxo é uma camada semântica por cima dela, não uma substituição

Cada nó regista de onde veio a sua informação, para que um consumidor possa distinguir um título que o documento declarou de um título que as heurísticas de layout inferiram. Os pipelines sensíveis à confiança devem ler esse campo em vez de tratar todos os nós como igualmente autoritativos

Uma árvore plana, e por que razão não é uma árvore de objetos

O modelo é uma árvore achatada em pré-ordem: um array de nós em que cada nó transporta um ParentIndex e uma Depth, em vez de um registo recursivo ou um grafo de objetos com posse. Páginas, títulos, parágrafos, listas, itens de lista, figuras, legendas, tabelas, linhas e células residem todos nesse único array linear

Daí decorrem dois benefícios. Os consumidores podem percorrer o array em fluxo, por ordem, sem recursão, o que torna a emissão de HTML, Markdown ou uma vista em árvore um simples ciclo. E o layout mantém-se portável entre Delphi, C++Builder e Free Pascal, que diferem na forma como tratam tipos geridos recursivos através de uma fronteira de ABI. Um registo recursivo de arrays dinâmicos é exatamente o tipo de construção que compila em todo o lado e se comporta de forma subtilmente diferente em cada um:

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;          // bloco de estilo em linha, sem ficheiro externo
    Options.MaxNodes := 200000;          // orçamento com falha fechada
    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;

Como é que as tabelas não acabam por aparecer duas vezes?

A deteção de tabelas corre depois de o texto estruturado ter sido recolhido para uma página, o que cria um risco óbvio: o mesmo conteúdo de célula existe tanto nos blocos de texto como na tabela detetada. Emitir ambos produz HTML em que cada tabela é seguida do seu próprio conteúdo outra vez, como parágrafos soltos

A regra que resolve isto é geométrica. Quando uma tabela detetada cobre mais de metade da área de um bloco de texto, o nó de tabela substitui esse bloco em vez de se juntar a ele. A indexação de células dentro de uma linha é construída contando para dentro de compartimentos, pelo que a construção do modelo se mantém linear em células mais linhas, em vez de voltar a analisar cada célula por cada linha, o que importa em documentos financeiros onde uma única página pode transportar centenas de células

A estrutura detetada é honesta quanto a ser deteção. Uma tabela com linhas de régua é reconhecida de forma mais fiável do que uma alinhada apenas por espaços em branco, e a confiança do nó reflete isso. Para conteúdo em que uma tabela errada é melhor do que nenhuma tabela, mantenha a deteção ligada; para conversão de arquivo em que uma tabela errada é pior, condicione pela confiança

Diagrama da regra geométrica na deteção de tabelas do PDFium Component para o reflow Delphi, em que uma tabela que cobre mais de metade de um bloco de texto o substitui, para que o texto das células nunca apareça duas vezes
Quando uma tabela detetada cobre mais de metade de um bloco de texto, o nó da tabela substitui o bloco, para que o texto das células apareça exatamente uma vez

Exportar HTML que se mantém autónomo

ToHtml percorre o modelo já construído e nunca volta a consultar o PDFium, pelo que exportar duas vezes não custa nada extra e não pode produzir um resultado diferente a partir do mesmo modelo. O texto e os valores de atributo são escapados de forma uniforme, os níveis de título são limitados ao intervalo de h1 a h6 que o HTML realmente define, e as células de cabeçalho, RowSpan e ColumnSpan passam tal como foram escritos

O CSS opcional é um simples bloco de estilo em linha. Não há script, não há tipo de letra web, nem qualquer recurso externo de qualquer tipo, o que é o que torna o resultado seguro para incorporar num email, num visualizador de ajuda ou num controlo de navegador em sandbox:

var
  Html: WideString;
  Stream: TFileStream;
  Bytes: TBytes;
begin
  Options := TPdfReflowOptions.Default;
  Options.FullDocument := True;
  Options.IncludeCss := True;
  Options.IncludePageSections := True;   // manter os limites de página visíveis
  Options.PreserveLineBreaks := False;   // deixar o navegador quebrar os parágrafos

  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 é a opção que mais vale a pena ponderar. Uma quebra de linha em PDF é uma decisão de composição tomada para uma largura de página fixa, pelo que preservá-la num ecrã estreito reproduz exatamente o problema que o refluxo existe para resolver. Preserve as quebras para poesia, listagens de código e moradas; descarte-as para prosa

Orçamentos, cancelamento e estado da página

Carateres, nós, tabelas e células têm cada um o seu teto, e cada um é verificado antes da alocação, não depois, pelo que um documento malformado ou hostil falha de forma limpa em vez de consumir memória até que outra coisa o faça. O token de cancelamento é verificado nos limites de página, bloco, tabela, linha e célula, o que mantém responsiva a análise cancelada de um documento com mil páginas

Um comportamento importa especificamente para aplicações com interface gráfica: toda a análise do documento corre dentro de um âmbito que restaura a página ativa, pelo que o sucesso, a falha de orçamento e o cancelamento deixam todos a página atual do chamador intocada. Um visualizador que permita ao utilizador exportar enquanto está a ver a página 340 encontra-se, depois, ainda na página 340

Para que serve o refluxo, e para que não serve

O resultado do refluxo é uma excelente entrada para indexação de pesquisa, vistas de leitura acessíveis, apresentação em telemóvel e migração de conteúdo. Não é um conversor que preserva fidelidade: posições absolutas, fontes exatas, ilustrações vetoriais e geometria de página precisa estão fora do seu propósito por design. Quando um trabalho precisa que a página tenha o mesmo aspeto, renderize-a; quando precisa que a página seja legível noutro sítio, reflua-a

Para tecnologia de apoio especificamente, o modelo de refluxo combina com as funcionalidades de leitura descritas em construir um leitor acessível, e os documentos que transportam uma árvore de estrutura genuína produzem modelos visivelmente melhores, o que é um bom argumento para validar a etiquetagem a montante, conforme descrito em validação da árvore de estrutura PDF/UA

O refluxo, o texto estruturado, a validação de etiquetagem e a renderização partilham um único objeto de documento em Delphi, C++Builder e Lazarus; a API completa está descrita na página do PDFium Component para Delphi