Technical Article

Renderizar uma Tabela de Dados para PDF em Delphi com o HotPDF

Um conjunto de dados (dataset) é composto por linhas e colunas; uma página PDF é uma grelha de coordenadas em branco sem qualquer noção de nenhum dos dois. Fazer a ponte para essa lacuna é todo o trabalho necessário aqui. Não existe nenhuma chamada DrawTable no HotPDF que receba um dataset e lhe entregue uma grelha formatada. O que obtém, 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 linhas de grelha. Um exportador de tabelas funcional exige a disciplina de transformar o raciocínio de linhas e colunas em coordenadas x e y explícitas, e depois manter essas coordenadas corretas quando os dados ultrapassam o fim da página.

O exemplo que se segue apresenta registos de clientes, mas nada no código de desenho sabe ou quer saber de onde vêm as linhas. O original utilizava um TTable antigo; uma consulta FireDAC, um dataset em memória ou um simples array de registos alimentam as mesmas rotinas sem qualquer alteração. O que importa é que consiga percorrer os dados uma linha de cada vez e ler quatro campos de texto (string) de cada uma. Mantenha a renderização separada da origem de dados e poderá alterar qualquer um dos lados sem perturbar o outro.

A geometria das colunas vem primeiro

Antes de desenhar um único carácter, decida onde se situa cada coluna. A tabela aqui tem quatro colunas, pelo que necessita de quatro limites esquerdos e de uma margem direita conhecida. Codificar diretamente um número mágico em cada chamada a TextOut, como os exemplos rápidos costumam fazer, é exatamente o que torna uma tabela difícil de alargar mais tarde. Defina os limites uma vez, em pontos a partir da origem no canto inferior esquerdo, e cada chamada de desenho referir-se-á a eles 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-se aqui. A faixa sombreada é desenhada primeiro, e depois o texto por cima, porque a ordem de pintura é a ordem Z no PDF: preencha o retângulo após o texto e acabará por ocultar a linha. E a alternância de sombreado não é decoração por capricho. Num relatório denso, é a forma mais simples de evitar que a vista se desvie para a linha errada, razão pela qual o ciclo inverte um valor booleano em cada linha e o passa diretamente para Shaded.

As posições das colunas acima são fixas, o que é adequado para um relatório cujo esquema controla. Quando os dados são variáveis, meça em vez de adivinhar. O HotPDF disponibiliza a medição da largura do texto no objeto de página, pelo que a versão de produção do PrintRow pode obter o valor mais longo esperado em cada coluna, medi-lo uma vez no tamanho de letra escolhido e deduzir os limites esquerdos a partir dessas larguras mais um espaçamento (gutter). A estrutura da rotina não se altera; apenas muda a origem das constantes.

O cabeçalho, as linhas e um único local que os gere

Uma tabela que transborda de uma página e continua na seguinte sem cabeçalhos de coluna torna-se ilegível. A solução consiste em tratar o cabeçalho como algo que se redesenha, e não como algo que se desenha uma única vez. Coloque os títulos das colunas e as linhas horizontais que os enquadram numa única rotina e chame essa rotina tanto no início como sempre que abrir uma nova página. Como o cabeçalho e o corpo partilham as mesmas constantes de coluna, o alinhamento é garantido.

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;

Note que o DrawHeader recebe Y por referência e avança-o. O chamador nunca precisa de recordar a altura do cabeçalho; a rotina que o desenha é a mesma que conhece essa medida. Essa regra de gestão única é o que evita que o layout se desvie quando adicionar mais tarde um logótipo ou um resumo de filtro à faixa do cabeçalho. O ciclo do corpo permanece alheio a isso. Limita-se a desenhar linhas a partir de onde quer que Y aponte no momento.

As próprias linhas definem a diferença entre uma lista e uma tabela. Os separadores verticais de colunas seguem a mesma lógica aplicada ao eixo x: um MoveTo / LineTo / Stroke em cada limite de coluna, executado a partir da linha superior até ao final da última linha na página. O exemplo mantém-se apenas com linhas horizontais para facilitar a leitura, mas o passo para produção é mecânico assim que as constantes de coluna existirem.

O ciclo de cursor controla a quebra de página

Desenhar é a parte fácil. A parte que distingue uma ferramenta simples de um relatório real é a paginação: saber, antes de desenhar uma linha, se esta ainda cabe, e iniciar uma nova página com um novo cabeçalho quando já não couber. Essa decisão pertence a exatamente um local, o ciclo que percorre os dados, e a mais lado nenhum.

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 factos sobre coordenadas controlam todo o ciclo. O PDF mede Y para cima a partir do canto inferior esquerdo, pelo que as linhas avançam para baixo na página ao subtrair RowStep a Y de cada vez, e o teste de página cheia é acionado quando Y desce abaixo da margem inferior, em vez de subir acima de um determinado limite superior. Se inverter a direção, a primeira linha será impressa fora da margem inferior enquanto o ciclo assume que tem uma página inteira de espaço livre.

O outro facto surpreende quase toda a gente pelo menos uma vez. O AddPage cria uma nova página e aponta 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 o Page é lido novamente a partir do CurrentPage após cada AddPage, e porque o SetFont é reemitido antes das linhas do corpo. Se ignorar a nova leitura, continuará a desenhar na página que acabou de deixar para trás; se omitir o tipo de letra, a nova página será renderizada com o tipo de letra padrão do visualizador.

Os casos que quebram um exportador de tabelas

A maioria dos problemas com tabelas não surge no cenário ideal de algumas dezenas de linhas organizadas. Manifestam-se nos limites extremos, e esses limites são fáceis de testar quando sabe onde se encontram.

  • Conjuntos de dados vazios. Um ciclo sobre zero linhas gera 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 assemelha-se a uma falha. Decida qual das opções pretende antes de disponibilizar a aplicação.
  • A linha que fica exatamente no limite. Gere um relatório cuja última linha fique um passo acima da margem, e depois outro cuja linha seguinte fique um passo abaixo. Os erros de paginação por uma unidade (off-by-one) permanecem ocultos até que os dados tenham exatamente o comprimento problemático.
  • Valores excessivamente longos. Um nome de empresa mais largo do que a sua coluna invadirá a coluna seguinte. Meça o campo e decida uma política: quebrar para uma segunda linha, cortar (clip) ou truncar com reticências. A ausência de resposta não é uma política.
  • Campos nulos. A leitura de um nulo diretamente para o TextOut pode manifestar-se como o texto literal Null ou como um espaço em branco, dependendo de como o converte. Escolha a renderização de forma deliberada, em vez de deixar que a conversão da variante decida por si.

Submeta o resultado a mais do que um visualizador antes de dar o trabalho por concluído. A substituição de tipos de letra e o recorte (clipping) comportam-se de forma diferente entre renderizadores, e uma tabela que parece perfeita num leitor de PDF pode mostrar uma coluna desalinhada ou uma cidade cortada noutro. Confirme se o cabeçalho repetido, o sombreado das linhas e as margens persistem após a transição, e se a numeração das páginas se mantém contínua depois de os dados ultrapassarem o limite da página.

Desenhar a grelha por si próprio, em vez de recorrer a um designer visual de relatórios, exige mais código, e a compensação deve ser expressa claramente: é proprietário de cada coordenada, o que é exatamente o que procura para processos em lote no servidor, faturas e exportações de auditoria que têm de ser renderizados de forma idêntica em qualquer máquina, sendo também precisamente a sobrecarga que preferiria evitar para uma listagem interna ocasional. No primeiro caso, o controlo compensa na primeira vez que um relatório tem de apresentar o mesmo aspeto em produção que apresentava no seu computador.

As linhas e as faixas sombreadas acima baseiam-se nas mesmas primitivas de vetor e cor abordadas no guia de desenho em tela (canvas), caso prefira analisar primeiro as chamadas Rectangle, MoveTo e LineTo de forma isolada. As primitivas de desenho aqui utilizadas fazem parte do HotPDF Component para Delphi e C++Builder.