Artigo Técnico

Grids de rowspan e cabeçalhos de tabela repetidos no HotPDF

O HotPDF renderiza tabelas HTML pelo seu perfil HTML5 de paged media usando um grid de ocupação real para rowspan e colspan, alturas de linha medidas em vez de estimativas por contagem de caracteres, e linhas de cabeçalho repetidas em cada página de continuação. Duas situações o fazem recusar a repetição de um cabeçalho, e conhecê-las de antemão sai mais barato que debugar uma célula duplicada depois

A classe de documento que força isso é a que todo time de relatórios acaba entregando: uma fatura ou um relatório de compliance onde a fonte da verdade é HTML, a tabela se estende por quatro páginas, e o cabeçalho precisa estar legível em todas elas. Qualquer coisa menos que um layout de tabela de verdade produz as duas falhas que os leitores notam de imediato, um cabeçalho que aparece uma vez na página um e linhas cujas alturas foram chutadas pela contagem de caracteres

Por que a capacidade de tabelas migrou para o renderizador HTML?

Porque a alternativa perde rich text, e rich text é a razão de o conteúdo ser HTML em primeiro lugar. O plano óbvio parece reuso: o HotPDF já tem um objeto de tabela do layout DOM com um grid decente, então é só ligar o parser HTML a ele e ganhar o spanning de graça. O problema é com o que esse objeto de tabela desenha. As células dele carregam texto e um estilo, e o caminho de desenho emite saída de texto simples, então qualquer coisa que o HTML realmente continha além de uma fonte e uma cor, links, sobrescritos, mudanças de tamanho inline, cor por trecho, se perdeu antes de chegar à página

A direção que sobrevive ao contato com documentos reais é a inversa. Mover as capacidades do motor de tabelas, o grid de ocupação, a medição real, a repetição de cabeçalho e a ponderação de colunas, para dentro do renderizador HTML, e deixar a renderização de rich text onde já funciona. É uma mudança maior que a ponte, e é a mudança que mantém um hyperlink dentro de uma célula de tabela sendo um hyperlink

Rowspan sem union-find

Células com spanning criam grupos de linhas atômicos, mas o fechamento sobre esses grupos não precisa de uma estrutura disjoint-set geral, porque a ocupação é sempre um intervalo contíguo. Uma célula com rowspan="3" começando na linha K ocupa as linhas K até K+2 e nada mais, então a informação de grupo se reduz a um marcador de fim por linha

O algoritmo são duas linhas de intenção. Quando você posiciona uma célula com spanning que começa em K e termina em E, registre GroupEnd[K] := Max(GroupEnd[K], E). Depois percorra as linhas uma vez em ordem reversa e aplique G[R] := G[G[R]], que propaga cada fim de linha para trás pelos spans sobrepostos e produz o fechamento transitivo numa única passada. O que você obtém é, para cada linha, a última linha que precisa ficar na mesma página que ela, que é exatamente o que o passo de paginação precisa para decidir onde uma quebra pode cair

Distribuir a altura é a outra metade. Quando uma célula com spanning precisa de mais espaço vertical do que as linhas que ela cobre fornecem hoje, o excedente vai para a última linha do span, e não é espalhado igualmente por elas. Processe as células com spanning depois que as alturas ordinárias das linhas estiverem resolvidas, e então complete a linha final de cada span. Espalhar o excedente igualmente parece mais justo e produz saída visivelmente errada: linhas que contêm só células curtas de uma linha ficam infladas porque alguma célula sem relação três linhas acima era alta

Um grid de tabela HTML do HotPDF onde uma célula com rowspan 3 começando na linha 2 ocupa as linhas 2 a 4 como um único retângulo atômico, ao lado dos valores de fim de grupo por linha G de R produzidos por uma única caminhada reversa mostrando as linhas 2, 3 e 4 vinculadas à mesma página
A ocupação com spanning é sempre um intervalo contíguo, então marcadores de fim por linha e uma caminhada reversa substituem o union-find e dizem à paginação exatamente onde uma quebra pode cair
var
  Pdf: THotPDF;
  Importer: THPDFHTMLImporter;
  Stats: THPDFHTMLImportStatistics;
begin
  Pdf := THotPDF.Create(nil);
  try
    Pdf.FileName := 'audit-report.pdf';
    Pdf.BeginDoc;
    Importer := THPDFHTMLImporter.Create(Pdf);
    try
      Importer.Margin := 48;
      Importer.BaseFontName := 'Arial';
      Importer.BaseFontSize := 10;
      Importer.MaxDOMNodes := 200000;
      Importer.MaxLayoutOperations := 2000000;
      if Importer.RenderHTML5(SourceHtml, PrintStyleSheet) then
      begin
        Stats := Importer.Statistics;
        Writeln('tables ', Stats.TableCount,
                '  page breaks ', Stats.PageBreakCount);
      end;
    finally
      Importer.Free;
    end;
    Pdf.EndDoc;
  finally
    Pdf.Free;
  end;
end;

RenderHTML5 aceita uma folha de estilo de autoria opcional como segundo argumento, que é onde as regras de impressão pertencem. Deixe a folha de estilo de tela de fora. O perfil é versionado, e HTML5ProfileMilestones reporta quais grupos de capacidade o build atual implementa, himParserCascade, himPagedLayout, himTablesForms e himBoundedResources, para a aplicação degradar de forma deliberada em vez de descobrir uma lacuna em produção

A medição tem de bater com o desenho, exatamente

A altura da linha só está correta quando o código que mede as linhas quebradas as quebra pela mesma regra que o código que as desenha. Isso parece óbvio e é a fonte mais comum de tabelas cujas bordas não alinham com o conteúdo. O HotPDF mede com um contador de linhas greedy, e esse contador tem de casar com a semântica de quebra do caminho de saída de rich text em três aspectos específicos: quebra só em espaços, nunca divide uma palavra, e uma palavra mais larga que a coluna ganha uma linha só para ela

O segundo requisito é a fonte. A medição precisa rodar com a fonte da própria célula, definida via SetFont com o nome real, o estilo ajustado e o tamanho antes de chamar a função de largura, e não com a fonte que por acaso estivesse ativa. Texto em bold é rotineiramente mais de dez por cento mais largo que o regular no mesmo tamanho, o que basta para mudar uma célula de três linhas numa de quatro. Uma tabela em que as células do cabeçalho são bold e as do corpo não, medida com uma única fonte, sai errada exatamente nas linhas que os leitores olham primeiro

Acertar isso muda o que você pode afirmar num teste. O efeito observável de uma medição precisa é o espaçamento entre linhas, não contagens de glifos: uma linha de uma linha só tem cerca de 20 points de altura enquanto uma estimativa por contagem de caracteres do mesmo conteúdo prevê duas linhas e aproximadamente 35. Faça asserts sobre a distância vertical entre linhas. E lembre que o user space do PDF tem Y crescendo para cima, então um cabeçalho situado acima de uma linha do corpo significa que o valor Y do cabeçalho é o maior, o oposto do que o instinto de coordenadas de tela escreve

Quando o HotPDF se recusa a repetir um cabeçalho?

Em dois casos, ambos os quais produziriam saída visivelmente errada se seguisse em frente. O primeiro é um bloco de cabeçalho contendo uma célula com spanning que se estende para além do cabeçalho, entrando nas linhas do corpo. Repetir o cabeçalho desenharia esse conteúdo de célula uma segunda vez numa posição onde não pertence mais, então o cabeçalho é desenhado uma vez e a tabela continua sem ele. O segundo é um cabeçalho mais alto que 90 por cento da altura útil da página, onde a repetição deixaria quase nenhum espaço para dados e a tabela não avançaria

O fluxo de decisão do HotPDF para repetir cabeçalhos de tabelas HTML entre quebras de página: um cabeçalho cujo rowspan cruza para as linhas do corpo é desenhado uma vez, um cabeçalho mais alto que 90 por cento da altura útil da página é desenhado uma vez, e todo outro cabeçalho se repete em cada página de continuação
As duas recusas são deliberadas: repetir um cabeçalho que possui uma célula de corpo com spanning ou preenche quase toda a página desenharia conteúdo onde ele não pertence mais ou deixaria nenhum espaço para dados

As duas recusas são deliberadas e silenciosas por design, porque a alternativa é pior. Se o seu cabeçalho não se repete e você esperava que se repetisse, confira o markup atrás de um rowspan cruzando a fronteira do thead antes de suspeitar do engine. Esse único padrão de markup responde pela maior parte da surpresa

// Os pesos das colunas vêm do markup, então a folha de estilo de impressão
// é o lugar para controlá-los. Larguras são tratadas como pesos, não como pixels
const
  PrintStyleSheet =
    'table { width: 100%; }' +
    'thead th { font-weight: bold; background: #eee; }' +
    'td.amount { text-align: right; }';

// Uma linha de cabeçalho que carrega um rowspan cruzando para o corpo suprime
// a repetição do cabeçalho. Mantenha os spans dentro de uma seção:
//   <thead><tr><th rowspan="2">Item</th>...</tr></thead>  ok
//   <tr><th rowspan="3">Item</th>...  estendendo para o tbody, sem repetição

As larguras de coluna se comportam como pesos em vez de medidas absolutas, que é o comportamento que mantém uma tabela utilizável quando o conteúdo não bate com a estimativa do autor. Uma coluna declarada com 30 por cento recebe aproximadamente 30 por cento da largura disponível, mas a distribuição respeita a largura mínima que cada coluna realmente precisa, então uma coluna estreita segurando um token longo sem quebra não estoura a caixa da tabela em silêncio

Onde isso se encaixa numa pipeline de documentos

O trabalho de tabelas fica dentro do perfil mais amplo de paged media, e as regras de paginação, os orçamentos de recursos e o tratamento de CSS descritos em o caminho de import HTML5 de paged media valem sem mudança para documentos que contêm tabelas. Se os seus dados não começam como HTML, a rota de construção direta em construção de tabelas direto num PDF evita por completo a camada de parse e dá o mesmo comportamento de grid via API. E como a altura da linha no fim das contas depende de onde as linhas quebram, a discussão de medição em justificação de texto e quebra de linhas é a peça complementar para quem ajusta saída tabular densa

A lição reutilizável aqui não é sobre tabelas nem um pouco. Quando um subsistema novo precisa de uma capacidade que um subsistema antigo já tem, pergunte qual dos dois é o dono da coisa mais difícil de reimplementar. A aritmética do grid são algumas dezenas de linhas e migra fácil. A renderização de rich text com links inline, sobrescritos e estilos por trecho não é, então o grid se moveu e o texto ficou. O HotPDF entrega os dois caminhos como parte do componente PDF HotPDF para Delphi, então a escolha entre entrada HTML e construção direta é uma decisão de projeto, não de biblioteca