Artigo Técnico

Renderizar uma Tabela de Dados em PDF no Delphi com HotPDF

Um dataset é composto por linhas e colunas; uma página PDF é uma grade de coordenadas em branco sem noção de nenhuma das duas coisas. Preencher essa lacuna é todo o trabalho aqui. Não há nenhuma chamada DrawTable no HotPDF que receba um dataset e entregue uma grade formatada. O que você obtém em vez disso são as primitivas das quais uma grade é feita: TextOut para colocar uma string em um ponto, SetFont para escolher sua fonte, Rectangle e Fill para sombrear uma faixa e MoveTo / LineTo / Stroke para desenhar réguas. Um exportador de tabelas funcional é a disciplina de transformar o pensamento de linhas e colunas em coordenadas x e y explícitas, e depois manter essas coordenadas corretas quando os dados ultrapassarem a parte inferior da página

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

A geometria da coluna vem primeiro

Antes que um único caractere seja desenhado, decida onde cada coluna ficará. Uma tabela tem quatro colunas aqui, portanto, precisa de quatro bordas esquerdas e uma margem direita conhecida. Codificar um número mágico fixo em cada chamada TextOut, da maneira que amostras rápidas costumam fazer, é exatamente o que torna doloroso alargar uma tabela mais tarde. Dê nome às bordas uma vez, em pontos a partir da origem inferior esquerda, e todas as chamadas de desenho farão referência a elas pelo nome:

const
  ColNo   = 70;    // left edge of the "No." column
  ColName = 110;   // company name
  ColAddr = 300;   // street address
  ColCity = 480;   // city
  RowLeft = 50;    // table frame: left rule
  RowRight = 570;  // table frame: right rule
  RowStep = 20;    // vertical distance between baselines

procedure PrintRow(Page: THPDFPage; Y: Single;
  const ANo, AName, AAddr, ACity: string; Shaded: boolean);
begin
  if Shaded then
  begin
    // A shaded band behind the row. Rectangle takes 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 detalhes justificam sua presença aqui. A faixa sombreada é desenhada primeiro, e em seguida o texto por cima, porque a ordem de pintura é a ordem z no PDF: preencha o retângulo depois do texto e você ocultará a linha. E o sombreado alternado não é mera decoração. Em um relatório denso, é a maneira mais econômica de evitar que os olhos deslizem para a linha errada, razão pela qual o loop depois inverte um booleano a cada linha e o passa diretamente para Shaded

As posições das colunas acima são fixas, o que é razoável para um relatório cujo esquema você controla. Quando os dados forem variáveis, meça em vez de adivinhar. O HotPDF expõe a medição da largura do texto no objeto da página, então a versão de produção do PrintRow pode obter o maior valor esperado em cada coluna, medi-lo uma vez no tamanho de fonte escolhido e derivar as bordas esquerdas dessas larguras mais uma calha. O formato da rotina não muda; apenas a fonte das constantes muda

O cabeçalho, as réguas e o único local que as controla

Uma tabela que passa da página e continua na próxima sem rótulos de coluna é ilegível. A solução é tratar o cabeçalho como algo que você redesenha, não algo que você desenha apenas uma vez. Coloque os títulos das colunas e as réguas horizontais que as emolduram em uma única rotina e chame essa rotina tanto no início quanto cada vez que abrir uma nova página. Como o cabeçalho e o corpo compartilham as mesmas constantes de coluna, eles se alinham por construção

procedure DrawHeader(Page: THPDFPage; var Y: Single; PageNo: Integer);
begin
  // Left: source label and page number. Right: generation time.
  Page.SetFont('Arial', [fsItalic], 10);
  Page.TextOut(RowLeft, Y, 0, 'customer.db   Page ' + IntToStr(PageNo));
  Page.TextOut(ColCity, Y, 0, DateTimeToStr(Now));

  // Two horizontal rules that box the column titles.
  Page.MoveTo(RowLeft, Y + 15);
  Page.LineTo(RowRight, Y + 15);
  Page.MoveTo(RowLeft, Y + 45);
  Page.LineTo(RowRight, Y + 45);
  Page.Stroke;

  // The column titles, in a heavier face so they read as headings.
  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;  // advance past the boxed header before the first body row
end;

Observe que DrawHeader recebe Y por referência e o move para frente. Quem chama nunca precisa lembrar qual é a altura do cabeçalho; a rotina que o desenha é a rotina que sabe. Essa regra de propriedade única é o que impede que o layout se desloque quando você posteriormente adicionar um logotipo ou um resumo de filtro à faixa do cabeçalho. O loop do corpo permanece alheio. Ele apenas continua desenhando linhas a partir de onde Y aponta no momento

As próprias réguas fazem a diferença entre uma lista e uma tabela. Os separadores de coluna verticais são a mesma ideia aplicada ao eixo x: um MoveTo / LineTo / Stroke em cada borda de coluna, executado da régua superior até o final da última linha na página. A amostra se restringe às réguas horizontais para se manter legível, mas o passo para a produção é mecânico assim que as constantes da coluna existirem

O loop do cursor controla a quebra de página

Desenhar é a parte fácil. A parte 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 couber. Essa decisão pertence a exatamente um lugar, o loop que percorre os dados, e a nenhum outro

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;

    // Report title, once, at the top of the first page.
    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
      // Out of room? Open a new page and repeat the header there.
      if Y < 60 then
      begin
        Pdf.AddPage;
        Page := Pdf.CurrentPage;   // AddPage moves CurrentPage forward
        Inc(PageNo);
        Y := 760;
        DrawHeader(Page, Y, PageNo);
      end;

      Shaded := not Shaded;
      Page.SetFont('Arial', [], 10);   // SetFont must be reissued on every new page
      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 fatos sobre coordenadas guiam todo o loop. O PDF mede o y para cima a partir do canto inferior esquerdo, então as linhas avançam para baixo na página subtraindo RowStep de Y a cada vez, e o teste de página cheia é acionado quando Y cai abaixo da margem inferior em vez de ultrapassar algum topo. Inverta a direção e a sua primeira linha será impressa fora da borda inferior enquanto o loop acha que tem uma página inteira de espaço

O outro fato pega quase todo mundo alguma vez. O AddPage cria uma nova página e redireciona CurrentPage para ela, mas não transporta nada: nem a fonte, nem a cor de preenchimento, nem a posição. É por isso que Page é relido a partir de CurrentPage após cada AddPage e por isso o SetFont é reemitido antes das linhas do corpo. Se você omitir a releitura, continuará desenhando na página que acabou de deixar para trás; se omitir a fonte, a nova página será renderizada em qualquer padrão que o visualizador utilizar

Os casos que quebram um exportador de tabelas

A maioria dos bugs em tabelas não aparece no caminho feliz de algumas dezenas de linhas organizadas. Eles vivem nas bordas, e as bordas são baratas para testar depois que você sabe onde estão

  • Datasets vazios. Um loop sobre zero linhas produz uma página com um cabeçalho e nada abaixo dele, o que pelo menos parece intencional. Uma página em branco sem cabeçalho parece uma falha. Decida o que você prefere antes de lançar o produto
  • A linha que cai exatamente no limite. Gere um relatório cuja última linha fique a um passo acima da margem e, em seguida, um em que a próxima linha fique um passo abaixo dela. Erros de paginação por um ocultam-se até que os dados tenham exatamente o comprimento errado
  • Valores excessivamente longos. Um nome de empresa mais largo que sua coluna irá invadir a próxima. Meça o campo e decida uma política: quebrar para uma segunda linha, cortar ou truncar com reticências. Ficar em silêncio não é uma política
  • Campos nulos. Ler um nulo diretamente em TextOut pode virar o texto literal Null ou ficar em branco, dependendo de como você o converte. Escolha a renderização de forma deliberada, em vez de permitir que a conversão de variant escolha por você

Execute o resultado em mais de um visualizador antes de considerá-lo pronto. A substituição e o recorte de fontes se comportam de maneira diferente entre os renderizadores, e uma tabela que parece certa em um leitor de PDF pode exibir uma coluna desalinhada ou uma cidade cortada em outro. Confirme se o cabeçalho repetido, o sombreamento das linhas e as margens sobrevivem à mudança, e se os números de página permanecem contínuos após os dados cruzarem um limite

Desenhar a grade você mesmo, em vez de depender de um designer de relatórios visual, envolve mais código e vale a pena nomear claramente a troca: você tem controle de cada coordenada, o que é exatamente o que você deseja para tarefas em lote do lado do servidor, faturas e exportações de auditoria que devem ser renderizadas de forma idêntica em todas as máquinas, e exatamente a sobrecarga que você preferiria evitar em uma listagem interna esporádica. Para o primeiro caso, o controle se paga na primeira vez que um relatório tiver que ficar igual na produção e na sua mesa

As réguas e faixas sombreadas acima baseiam-se nas mesmas primitivas de vetores e de cor abordadas no guia de desenho no canvas, caso você queira as chamadas de Rectangle, MoveTo e LineTo explicadas sozinhas primeiro. As primitivas de desenho usadas aqui são parte do Componente HotPDF para Delphi e C++Builder