Artigo Técnico

Layout multi-coluna CSS em HTML para PDF com HotPDF Delphi

O HotPDF Component dispõe texto CSS multicoluna quando o THPDFHTMLImporter.RenderHTML5 converte HTML para PDF em Delphi. O engine de HTML paginado resolve column-count e column-width contra a caixa de conteúdo, equilibra o último fragmento de todo contêiner multicoluna com uma busca binária, preenche páginas anteriores coluna a coluna até o pé da página, e suporta column-fill, column-span: all e column-rule

O motivo habitual para querer isso é chato e real: um boletim de produto em duas colunas, um anexo de termos e condições composto em três colunas estreitas, ou um PDF de release notes que deveria se parecer com o jornal impresso que o marketing já desenhou em HTML. Sem suporte a colunas, o importador empilha todo parágrafo em uma medida larga única, o comprimento de linha dobra, e o documento lê-se como um arquivo de log. Com ele, a mesma stylesheet que comanda a pré-visualização no browser comanda o PDF, e a parte difícil vira a paginação, com que browsers quase nunca precisam se preocupar na tela

Como o HotPDF decide quantas colunas usar?

O HotPDF resolve a contagem de colunas com as mesmas regras do pseudo-algoritmo do CSS Multi-column Layout Module Level 1 §3.4, aplicado à largura da caixa de conteúdo W depois de removidos padding e bordas. Um column-count declarado é tomado como está. Quando só o column-width é dado, o engine computa N = Trunc((W + gap) / (column-width + gap)) com piso de uma coluna. Quando ambos estão presentes, o resultado é o menor dos dois, então a contagem declarada pode limitar a contagem derivada, mas nunca inflá-la

Um caso concreto: uma caixa de conteúdo de 483pt (A4 com margens laterais de 56pt, arredondadas) e column-width: 140pt; column-gap: 12pt dá Trunc(495 / 152) = 3 colunas, cada uma com (483 - 2 * 12) / 3 = 153pt de largura. Adicionar column-count: 2 à mesma regra derruba para duas colunas, porque o mínimo vence. A palavra-chave de gap normal resolve para 1em do tamanho de fonte do contêiner, e essa resolução acontece no momento do layout em vez do momento do parse, então um font-size declarado depois do column-gap na mesma regra ainda produz o gap certo

Resolução de contagem multicoluna CSS do HotPDF para HTML em PDF em Delphi: column-count é usado como declarado, column-width deriva N = Trunc((W + gap) / (column-width + gap)), o menor vence quando ambos estão definidos, e qualquer coluna abaixo de 12pt colapsa para uma única coluna
O engine aplica o pseudo-algoritmo CSS à caixa de conteúdo, limita a contagem derivada com uma declarada, e adiciona uma guarda de piso de 12pt para que templates não confiáveis não produzam milhares de colunas microscópicas

Uma guarda não está na especificação, e está ali de propósito. Depois que a largura da coluna é computada, qualquer valor abaixo de 12pt colapsa o contêiner de volta a uma única coluna. Uma stylesheet que diga column-count: 9999, seja por acidente seja porque veio de um template não confiável, produziria de outra forma milhares de colunas com larguras negativas ou microscópicas e um quebrador de linhas tentando encaixar um glifo por linha. Recuar para uma coluna mantém a saída legível e o custo de layout limitado. Contagens zero ou negativas nunca chegam tão longe: o validador aceita apenas auto ou um inteiro positivo para column-count, e column-width e column-gap recebem auto ou normal respectivamente, ou um comprimento em pontos ou em

Renderizando uma seção HTML multicoluna para PDF em Delphi

Saída multicoluna não precisa de nenhum switch no importador. O THPDFHTMLImporter é criado com o documento THotPDF em que desenha, o BeginDoc precisa já ter rodado, e as propriedades de coluna chegam pela cascata normal, na marcação ou na stylesheet de autor passada como segundo argumento do RenderHTML5

uses
  SysUtils, HPDFDoc, HPDFHTMLImport;

procedure BuildBulletin(const FileName: string);
var
  Pdf: THotPDF;
  Importer: THPDFHTMLImporter;
  Html, Css: WideString;
begin
  Pdf := THotPDF.Create(nil);
  Importer := THPDFHTMLImporter.Create(Pdf);
  try
    Pdf.FileName := FileName;
    Pdf.BeginDoc;
    Importer.Margin := 56;
    Importer.BaseFontName := 'Arial';
    Importer.BaseFontSize := 10;

    Css := 'section.news { column-width: 140pt; column-gap: 12pt; ' +
           '  column-rule: 0.5pt solid #999999 } ' +
           'section.news h2 { column-span: all }';

    Html := '<section class="news">' +
            '<p>Release 4.2 ships the new export pipeline...</p>' +
            '<p>Licensing changes take effect on 1 March...</p>' +
            '<h2>Known issues</h2>' +
            '<p>Import of legacy archives &amp; templates...</p>' +
            '</section>';

    if not Importer.RenderHTML5(Html, Css) then
      raise Exception.Create('Nothing was emitted');
    Pdf.EndDoc;
  finally
    Importer.Free;
    Pdf.Free;
  end;
end;

Note o escape duplicado no último parágrafo: &amp; dentro da string Pascal é a entidade HTML para um ampersand, que o importador decodifica de volta a um único caractere. O resto da cascata, dos orçamentos e dos callbacks de recursos se comporta exatamente como descrito na visão geral do import de paged-media HTML5 do HotPDF; colunas são só mais um modo de layout dentro do mesmo perfil

Equilibrando colunas com uma busca binária

O HotPDF equilibra colunas buscando a menor altura de coluna na qual um preenchimento guloso ainda posiciona toda linha restante. A passada gulosa é simples: percorra as linhas na coluna um até que a próxima linha excederia a altura candidata, então continue na coluna dois, e assim por diante. A busca começa com a maior linha única como limite inferior e a soma de todas as alturas de linha como limite superior, divide o intervalo pela metade a cada rodada, e para quando os limites estão dentro de 0,05pt

Balanceamento multicoluna do HotPDF em Delphi: uma busca binária entre a maior linha única e a soma de todas as alturas de linha acha a menor altura de coluna cujo preenchimento guloso posiciona toda linha, com limites convergindo dentro de 0,05pt
O predicado toda-linha-cabe é monótono na altura da coluna, então dividir o intervalo pela metade converge para a faixa equilibrada mais curta e o preenchimento guloso final consome toda linha sem órfãs

Uma busca binária só funciona se o predicado é monótono, e aqui ele é: uma coluna mais alta jamais consegue segurar menos linhas que uma mais baixa, então "toda linha cabe" vira de falso para verdadeiro exatamente uma vez conforme a altura cresce. Essa propriedade é o que garante a convergência e garante que o preenchimento guloso final na altura achada consuma toda linha, em vez de deixar uma linha órfã que dispararia uma página extra espúria. Ela também explica um detalhe do resultado: a faixa equilibrada é tão curta quanto as caixas de linha permitem, não necessariamente colunas iguais, porque linhas são indivisíveis e a última coluna pode terminar uma ou duas linhas acima das outras

O que acontece quando as colunas cruzam uma quebra de página?

Colunas que não cabem na página atual são preenchidas sequencialmente até o pé da página, e apenas o fragmento de página final é equilibrado. Esse é o comportamento que o CSS Multi-column Layout Level 1 §7.1 prescreve para column-fill: balance em contextos fragmentados, e é a cara de um jornal impresso: colunas cheias em cada página exceto a última, em que o texto restante é dividido uniformemente em vez de deixar uma coluna alta ao lado de uma vazia

Cada fragmento de página passa pelo mesmo fragmentador independentemente. Se as linhas restantes cabem em N colunas da altura disponível, o fragmento é equilibrado; caso contrário, toda coluna preenche até o pé da página e o loop começa uma página nova. A altura natural do contêiner inteiro, que o engine usa para decisões de break-inside: avoid e medição de extensão, é a altura da faixa equilibrada com capacidade ilimitada, então um contêiner que caberia em uma página nova como bloco equilibrado é movido para lá intacto em vez de fatiado. Quando uma única linha é mais alta que uma página inteira, o engine relaxa a restrição, posiciona a linha mesmo assim no topo de uma página, e reporta um diagnóstico hdkPaginationConstraint com o sujeito multi-column

Paginação multicoluna do HotPDF para HTML em PDF em Delphi: fragmentos antes da última página preenchem toda coluna até o pé da página, o fragmento final equilibra entre as colunas, e um column-span all divide um contêiner em faixas equilibradas separadamente ao redor de um elemento de largura total
Apenas o fragmento final de um contêiner multicoluna é equilibrado; páginas anteriores preenchem sequencialmente, e cada fragmento repete o balanceamento, as réguas e a paginação de forma independente

column-fill: auto e caixas de altura fixa

Com column-fill: auto e uma height definida, o HotPDF preenche as colunas em ordem, e cada altura de coluna é o menor entre o espaço de página restante e a altura do contêiner menos seu padding e bordas. O mínimo importa mais do que parece. Usar apenas o espaço de página restante deixaria a primeira coluna engolir toda linha sempre que a página tiver mais sobra que a caixa. Sem uma altura definida, o column-fill: auto recua para o balanceamento, como os browsers fazem, já que um contêiner multicoluna sem restrição não tem altura de coluna para preencher; a primeira implementação perdeu esse fallback, e uma das 27 checagens de regressão o pegou

column-span: all e parágrafos aninhados

O HotPDF suporta column-span: all em filhos diretos de um contêiner multicoluna, seguindo o CSS Multi-column Layout Level 1 §6.1. Um filho que estende divide o contêiner em segmentos: os filhos antes dele formam uma faixa de colunas, o elemento estendido renderiza pela largura total, e os filhos depois dele começam uma faixa nova. Cada faixa é equilibrada, preenchida, pautada e paginada por conta própria, então um título no meio de uma seção de três colunas produz duas faixas equilibradas com um título de largura total entre elas, que é o layout que as pessoas realmente desenham em mockups

Chegar lá exigiu consertar primeiro um problema menos visível. Um contêiner como <div style="column-count:2"> com vários filhos <p> costumava renderizar cada parágrafo em uma única coluna empilhada, porque cada parágrafo era disposto como seu próprio bloco de texto e a declaração de coluna no pai jamais se aplicava. O HotPDF agora coleta o texto de filhos de bloco aninhados em um único fluxo para o contêiner. O coletor precisa inserir uma quebra dura em cada fronteira de bloco enquanto faz isso; pulá-la concatena a última palavra de um parágrafo com a primeira palavra do seguinte na mesma linha, um bug fácil de perder quando o texto de teste por acaso termina em pontuação

Esse fluxo mesclado tem um escopo deliberado. Ele é usado apenas quando a subárvore inteira é fluxo de texto puro. Um contêiner que guarda controles de formulário, imagens, tabelas, listas ou <hr> em qualquer ponto dentro dele mantém a rota comum de blocos e renderiza em uma única coluna, porque o coletor de texto mesclado não consegue expressar a geometria deles. Tabelas em particular têm regras próprias de paginação, cobertas no artigo sobre tratamento de rowspan e cabeçalhos de tabela repetidos no HotPDF, e misturá-las em uma faixa de colunas quebraria essas garantias

Onde o column-rule fica, e como testá-lo

O HotPDF desenha o column-rule na linha central de cada gap, na borda direita de uma coluna mais metade do gap, do topo ao pé da faixa em cada fragmento de página, como o CSS Multi-column Layout Level 1 §4 descreve. A largura padrão é 1pt. A cor padrão é a cor de texto do elemento quando não declarada ou quando currentColor. O shorthand column-rule sem palavra-chave de estilo é tratado como solid, casando com a leitura comum do shorthand border, enquanto none e hidden suprimem a régua

A geometria das colunas é observável sem fazer parse do PDF. O evento OnLineLayout no THPDFHTMLImporter dispara uma vez por linha desenhada com um registro THPDFHTMLLineLayout, e como o engine desenha cada coluna separadamente, o campo X de cada linha é a borda esquerda de sua coluna. Colunas adjacentes diferem exatamente pela largura da coluna mais o gap, o que torna fácil distinguir um gap de 12pt de um de 20pt em um teste de regressão

type
  TColumnProbe = class
  public
    Lines: TStringList;
    constructor Create;
    destructor Destroy; override;
    procedure LineDrawn(Sender: TObject; const Line: THPDFHTMLLineLayout);
  end;

constructor TColumnProbe.Create;
begin
  inherited Create;
  Lines := TStringList.Create;
end;

destructor TColumnProbe.Destroy;
begin
  Lines.Free;
  inherited;
end;

procedure TColumnProbe.LineDrawn(Sender: TObject;
  const Line: THPDFHTMLLineLayout);
begin
  // PageIndex é base zero; X é a borda esquerda da coluna da linha
  Lines.Add(Format('page=%d x=%.1f y=%.1f %s',
    [Line.PageIndex, Line.X, Line.Y, string(Line.Text)]));
end;

// Ligação, antes do RenderHTML5:
//   Probe := TColumnProbe.Create;
//   Importer.OnLineLayout := Probe.LineDrawn;

Duas notas práticas de quem escreveu testes contra esse evento. O PageIndex é base zero, que é a primeira coisa que um assertion escrito à mão costuma errar. E o X inclui deslocamentos de alinhamento, então para texto centrado ou alinhado à direita compare posições de coluna em fixtures alinhados à esquerda. A régua em si não aparece no evento; para afirmar sua posição, varra o content stream descomprimido pelos operadores verticais m e l

Limites e armadilhas que valem conhecer

Alguns comportamentos são aproximações, e é melhor conhecê-los antes de uma revisão de layout do que depois. O espaçamento de parágrafos dentro de um fluxo de coluna mesclado é aproximado por uma linha da altura de linha do contêiner em cada fronteira de bloco, então margens por parágrafo dentro de uma faixa de colunas não são reproduzidas exatamente. Quando o column-span: all divide um contêiner, cada segmento repete a caixa de padding e fundo do contêiner, o que mantém os segmentos visualmente contínuos mas não é uma caixa única no sentido estrito do CSS. Todo valor de column-rule-style além de none e hidden passa na validação, mas a régua é traçada como uma linha sólida

Valores não suportados não falham em silêncio. Definir StrictCSS como True transforma todo diagnóstico em uma exceção EHPDFHTMLUnsupportedCSS, enquanto o OnDiagnostic deixa um job em lote logá-los e seguir em frente. Note que o modo estrito se aplica também ao diagnóstico de paginação, então um documento com uma linha mais alta que uma página vai levantar exceção sob StrictCSS em vez de relaxar a restrição silenciosamente

procedure TReportJob.HtmlDiagnostic(Sender: TObject;
  const Diagnostic: THPDFHTMLDiagnostic);
begin
  case Diagnostic.Kind of
    hdkUnsupportedProperty, hdkUnsupportedValue:
      FLog.Add('CSS ' + Diagnostic.Subject + ': ' + Diagnostic.MessageText);
    hdkPaginationConstraint:
      FLog.Add('Layout relaxed (' + Diagnostic.Subject + '): ' +
        Diagnostic.MessageText);
  else
    FLog.Add(Diagnostic.MessageText);
  end;
end;

// Autoria de template: falhe rápido em qualquer coisa que o engine ignoraria
Importer.StrictCSS := FValidatingTemplate;
Importer.OnDiagnostic := HtmlDiagnostic;

O modo estrito é a configuração certa enquanto um template está sendo escrito, já que um erro de digitação como column-span: both é rejeitado imediatamente em vez de deixar o título fluir silenciosamente dentro das colunas. Em produção, logar pelo OnDiagnostic costuma ser a troca melhor, porque uma declaração estranha em HTML fornecido por cliente não deveria custar o documento inteiro. Diagnósticos idênticos (mesmo kind, sujeito e valor) são reportados uma vez enquanto couberem na lista MaxDiagnostics, que tem padrão de 256 entradas, então uma declaração que a cascata visita para centenas de elementos não inunda o log

FAQ

O HotPDF suporta o shorthand columns?

Sim. O shorthand columns aceita no máximo uma contagem inteira positiva e um comprimento de coluna em qualquer ordem, e o auto reseta ambas as longhands. Os valores resolvidos seguem as mesmas regras de contagem e largura das propriedades individuais

Um contêiner multicoluna pode conter imagens ou tabelas?

Pode contê-las, mas o HotPDF então renderiza esse contêiner em uma única coluna. O fluxo de colunas é aplicado apenas quando todo descendente é fluxo de texto puro, porque imagens, tabelas, listas e controles de formulário precisam de sua própria geometria de caixa e regras de paginação

Por que a última página mostra colunas equilibradas enquanto as anteriores estão cheias?

Essa é a regra do CSS para column-fill: balance em mídia paginada: apenas o fragmento final é equilibrado. Páginas anteriores preenchem cada coluna até o pé da página para que nenhum espaço seja desperdiçado no meio do documento

Layout multicoluna, o perfil de paged-media HTML5 e o resto do pipeline de HTML para PDF fazem parte do HotPDF Component for Delphi and C++Builder, que também cobre desenho direto de PDF, formulários, criptografia e extração de texto