Artigo Técnico

Renderizar uma Tabela de Dados para PDF em Delphi com HotPDF

Um conjunto de dados é linhas e colunas; uma página PDF é uma grelha de coordenadas em branco sem noção de nenhuma das duas coisas. Fazer a ponte entre isso é todo o trabalho aqui. Não existe nenhuma chamada DrawTable no HotPDF que receba um dataset e entregue uma grelha formatada. O que se tem, em vez disso, são as primitivas de que uma grelha é feita: TextOut para colocar uma string num ponto, SetFont para escolher o seu tipo de letra, Rectangle e Fill para sombrear uma faixa, e MoveTo / LineTo / Stroke para desenhar réguas. Um exportador de tabelas que funcione é a disciplina de transformar o raciocínio em linhas e colunas em coordenadas x e y explícitas, e depois manter essas coordenadas corretas quando os dados ultrapassam o fundo da página

O exemplo que se segue reporta registos de clientes, mas nada no código de desenho sabe ou se importa de onde vêm as linhas. O original usava um TTable legado; uma consulta FireDAC, um dataset em memória, ou um simples array de registos alimenta as mesmas rotinas sem alterações. O que importa é conseguir percorrer os dados uma linha de cada vez e ler quatro campos de string de cada uma. Mantenha a renderização separada da fonte de dados e conseguirá alterar qualquer um dos lados sem perturbar o outro

A geometria das colunas vem primeiro

Antes de se desenhar um único caráter, decida onde vive cada coluna. Aqui a tabela tem quatro colunas, pelo que precisa de quatro margens esquerdas e de uma margem direita conhecida. Codificar um número mágico à mão em cada chamada TextOut, como os exemplos rápidos costumam fazer, é exatamente o que torna uma tabela penosa de alargar mais tarde. Dê nome às margens uma única vez, em pontos a partir da origem no canto inferior esquerdo, e todas as chamadas de desenho referem-se a elas pelo nome:

Geometria de colunas de tabela do HotPDF em Delphi: margens x nomeadas em 70, 110, 300 e 480 pontos entre fios de moldura em 50 e 570 pontos
Quatro margens esquerdas nomeadas e uma margem direita conhecida fixam toda a geometria da tabela antes da primeira chamada TextOut
const
  ColNo   = 70;    // margem esquerda da coluna "N.º"
  ColName = 110;   // nome da empresa
  ColAddr = 300;   // morada
  ColCity = 480;   // cidade
  RowLeft = 50;    // moldura da tabela: régua esquerda
  RowRight = 570;  // moldura da tabela: régua direita
  RowStep = 20;    // distância vertical entre linhas de base

procedure PrintRow(Page: THPDFPage; Y: Single;
  const ANo, AName, AAddr, ACity: string; Shaded: boolean);
begin
  if Shaded then
  begin
    // Uma faixa sombreada atrás da linha. Rectangle recebe X, Y, Width, Height.
    Page.SetRGBFillColor($00FFF3DD);
    Page.Rectangle(RowLeft, Y - 4, RowRight - RowLeft, RowStep);
    Page.Fill;
    Page.SetRGBFillColor(clBlack);
  end;
  Page.TextOut(ColNo,   Y, 0, ANo);
  Page.TextOut(ColName, Y, 0, AName);
  Page.TextOut(ColAddr, Y, 0, AAddr);
  Page.TextOut(ColCity, Y, 0, ACity);
end;

Dois pormenores merecem destaque aqui. A faixa sombreada é desenhada primeiro, depois o texto por cima, porque a ordem de pintura é a ordem-z em PDF: preencha o retângulo depois do texto e enterra a linha. E o sombreado alternado não é decoração pelo prazer de o ser. Num relatório denso, é a forma mais barata de impedir que o olhar deslize para a linha errada, razão pela qual o ciclo mais adiante inverte um booleano a cada linha e passa-o diretamente para Shaded

As posições de coluna acima são fixas, o que é honesto para um relatório cujo esquema se controla. Quando os dados são variáveis, meça em vez de adivinhar. O HotPDF expõe medição de largura de texto no objeto de página, pelo que a versão de produção de PrintRow pode receber o maior valor esperado em cada coluna, medi-lo uma vez no tamanho de letra escolhido, e derivar as margens esquerdas a partir dessas larguras mais uma calha. A forma da rotina não muda; só muda a origem das constantes

O cabeçalho, as réguas, e um único sítio que os possui

Uma tabela que desliza para fora de uma página e retoma na seguinte sem rótulos de coluna é ilegível. A correção é tratar o cabeçalho como algo que se redesenha, não como algo que se desenha uma única vez. Coloque os títulos das colunas e as réguas horizontais que os emolduram numa única rotina, e chame essa rotina tanto no início como sempre que se abre uma nova página. Como o cabeçalho e o corpo partilham as mesmas constantes de coluna, alinham-se por construção

O HotPDF redesenha os títulos e fios de DrawHeader na página um e de novo após cada AddPage, para que ambas as páginas PDF abram com um cabeçalho idêntico
A rotina de cabeçalho corre de novo em cada página nova, pelo que títulos e linhas aterram no mesmo lugar por construção
procedure DrawHeader(Page: THPDFPage; var Y: Single; PageNo: Integer);
begin
  // Esquerda: etiqueta da fonte e número de página. Direita: hora de geração.
  Page.SetFont('Arial', [fsItalic], 10);
  Page.TextOut(RowLeft, Y, 0, 'customer.db   Page ' + IntToStr(PageNo));
  Page.TextOut(ColCity, Y, 0, DateTimeToStr(Now));

  // Duas réguas horizontais que emolduram os títulos das colunas.
  Page.MoveTo(RowLeft, Y + 15);
  Page.LineTo(RowRight, Y + 15);
  Page.MoveTo(RowLeft, Y + 45);
  Page.LineTo(RowRight, Y + 45);
  Page.Stroke;

  // Os títulos das colunas, num tipo de letra mais pesado para se lerem como cabeçalhos.
  Page.SetFont('Times New Roman', [fsBold], 12);
  Page.SetRGBFillColor(clNavy);
  PrintRow(Page, Y + 25, 'No.', 'Company', 'Address', 'City', False);
  Page.SetRGBFillColor(clBlack);

  Y := Y + RowStep + 45;  // avança para além do cabeçalho emoldurado antes da primeira linha do corpo
end;

Note que DrawHeader recebe Y por referência e avança-o. Quem chama a rotina nunca precisa de saber quão alto é o cabeçalho; a rotina que o desenha é a rotina que sabe. Essa regra de posse única é o que impede o layout de derivar quando mais tarde se acrescenta um logótipo ou um resumo de filtros à faixa do cabeçalho. O ciclo do corpo mantém-se alheio a isso. Limita-se a continuar a desenhar linhas a partir de onde quer que Y esteja no momento

As próprias réguas são a diferença entre uma lista e uma tabela. Os separadores verticais de coluna são a mesma ideia aplicada ao eixo dos x: um MoveTo / LineTo / Stroke em cada margem de coluna, a correr desde a régua superior até ao fundo da última linha na página. O exemplo mantém-se apenas em réguas horizontais para ficar legível, mas o passo de produção é mecânico assim que as constantes de coluna existem

O ciclo do cursor é dono da quebra de página

Desenhar é a metade fácil. A metade que separa um brinquedo de um relatório é a paginação: saber, antes de desenhar uma linha, se ela ainda cabe, e começar uma página nova com um cabeçalho novo quando não cabe. Essa decisão pertence exatamente a um único sítio, o ciclo que percorre os dados, e a mais nenhum outro

Fluxograma de um ciclo de cursor Delphi em que um Y abaixo de 60 aciona AddPage, uma releitura de CurrentPage, uma reemissão de SetFont e um cabeçalho repetido antes da próxima linha da tabela
O ciclo de cursor é o único lugar que abre uma página nova e a reinicializa quando Y desce abaixo da margem inferior
var
  Pdf: THotPDF;
  Page: THPDFPage;
  Y: Single;
  PageNo: Integer;
  Shaded: boolean;
begin
  Pdf := THotPDF.Create(nil);
  try
    Pdf.FileName := 'CustomerReport.pdf';
    Pdf.BeginDoc;
    Page := Pdf.CurrentPage;

    // Título do relatório, uma vez, no topo da primeira página.
    Page.SetFont('Arial', [fsBold], 24);
    Page.TextOut(200, 800, 0, 'Customer Report');

    PageNo := 1;
    Y := 760;
    DrawHeader(Page, Y, PageNo);
    Shaded := False;

    CustomerTable.First;
    while not CustomerTable.Eof do
    begin
      // Sem mais espaço? Abre uma nova página e repete o cabeçalho aí.
      if Y < 60 then
      begin
        Pdf.AddPage;
        Page := Pdf.CurrentPage;   // AddPage avança o CurrentPage
        Inc(PageNo);
        Y := 760;
        DrawHeader(Page, Y, PageNo);
      end;

      Shaded := not Shaded;
      Page.SetFont('Arial', [], 10);   // SetFont tem de ser reemitido em cada nova página
      PrintRow(Page, Y,
        VarToStr(CustomerTable['CustNo']),
        VarToStr(CustomerTable['Company']),
        VarToStr(CustomerTable['Addr1']),
        VarToStr(CustomerTable['City']),
        Shaded);

      Y := Y - RowStep;
      CustomerTable.Next;
    end;

    Pdf.EndDoc;
  finally
    Pdf.Free;
  end;
end;

Dois factos de coordenadas conduzem todo o ciclo. O PDF mede o y para cima a partir do canto inferior esquerdo, pelo que as linhas avançam para baixo na página subtraindo RowStep a Y de cada vez, e o teste de página cheia dispara quando Y desce abaixo da margem inferior, e não acima de alguma margem superior. Inverta o sentido e a primeira linha imprime-se para fora do fundo da página enquanto o ciclo pensa que tem uma página inteira de espaço

O outro facto apanha quase toda a gente uma vez. AddPage cria uma página nova e reaponta CurrentPage para ela, mas não transporta nada: nem o tipo de letra, nem a cor de preenchimento, nem a posição. É por isso que Page é relido a partir de CurrentPage depois de cada AddPage, e por que SetFont é reemitido antes das linhas do corpo. Se se saltar a releitura, continua-se a desenhar na página que se acabou de deixar para trás; se se saltar o tipo de letra, a página nova é renderizada seja qual for a predefinição para a qual o visualizador recua

Os casos que partem um exportador de tabelas

A maioria dos bugs de tabelas não aparece no caminho feliz de umas dezenas de linhas arrumadas. Vivem nos limites, e os limites são baratos de testar assim que se sabe onde estão

  • Datasets vazios. Um ciclo sobre zero linhas produz uma página com um cabeçalho e nada por baixo, o que pelo menos parece intencional. Uma página em branco sem cabeçalho parece uma falha. Decida qual quer antes de publicar
  • A linha que cai exatamente na fronteira. Gere um relatório cuja última linha fica um passo acima da margem, depois outro cuja linha seguinte fica um passo abaixo dela. A paginação com erro de um-a-mais esconde-se até os dados terem exatamente o comprimento errado
  • Valores demasiado longos. Um nome de empresa mais largo do que a sua coluna vai invadir a seguinte. Meça o campo e decida uma política: quebrar para uma segunda linha, recortar, ou truncar com reticências. O silêncio não é uma política
  • Campos nulos. Ler um nulo diretamente para TextOut pode surgir como o texto literal Null ou como um espaço em branco, consoante a forma como se converte. Escolha a renderização deliberadamente em vez de deixar a conversão de variante escolher por si

Passe o resultado por mais do que um visualizador antes de o dar por concluído. A substituição de tipos de letra e o recorte comportam-se de forma diferente entre motores de renderização, e uma tabela que parece direita num leitor de PDF pode mostrar uma coluna desalinhada ou uma cidade cortada noutro. Confirme que o cabeçalho repetido, o sombreado das linhas e as margens sobrevivem à mudança, e que os números de página se mantêm contínuos depois de os dados atravessarem uma fronteira

Desenhar a grelha manualmente em vez de se apoiar num desenhador visual de relatórios é mais código, e vale a pena nomear a contrapartida com clareza: é o programador que controla cada coordenada, o que é exatamente o que se quer para tarefas em lote no lado do servidor, faturas, e exportações de auditoria que têm de ser renderizadas de forma idêntica em todas as máquinas, e exatamente o overhead que se preferiria evitar numa listagem interna pontual. No primeiro caso, o controlo paga-se a si próprio na primeira vez que um relatório tem de ter o mesmo aspeto em produção que teve na sua secretária

As réguas e as faixas sombreadas acima apoiam-se nas mesmas primitivas de vetores e cor abordadas no guia de desenho em canvas, caso se prefira ver as chamadas Rectangle, MoveTo e LineTo tratadas por si só primeiro. As primitivas de desenho aqui usadas fazem parte do HotPDF Delphi Component para Delphi e C++Builder