Artigo Técnico

Medir Texto em PDF para Disposição e Quebra de Linha em Delphi

A chamada que coloca texto numa página PDF é direta: fornece-se a AddText uma cadeia de carateres, um tipo de letra, um tamanho e uma posição, e os glifos aparecem. O que essa chamada não faz é indicar qual será a largura dessa cadeia depois de desenhada, nem quebra uma cadeia longa por várias linhas. Uma única chamada pinta um trecho de texto numa posição. Se o trecho for mais largo do que a coluna onde deveria caber, ultrapassa simplesmente o limite, e nada na chamada de desenho avisa disso. No momento em que se pretende um parágrafo em vez de uma única etiqueta, a peça em falta é a largura de uma cadeia no tipo de letra e tamanho escolhidos, medida antes de a colocar na página

Este é o problema clássico de composição de texto. Para quebrar um parágrafo dentro de uma coluna é necessário saber, palavra a palavra, quanto espaço horizontal cada linha candidata vai ocupar, e é preciso sabê-lo antes de desenhar seja o que for. A quebra de linha (word wrap) é um ciclo de medição envolvido à volta de uma chamada de desenho, e uma ligação (binding) que apenas desenha fornece só metade da solução. O suporte de medição de texto no PDFium Component fecha essa lacuna com duas funções, MeasureText e MeasureTextWidth, que reportam a extensão visual de uma cadeia sem deixar qualquer marca em nenhuma página

Por que a medição é um class helper, e não um novo método em TPdf

O suporte de medição chega sob a forma de um class helper de Delphi para TPdf, alojado na sua própria unit, em vez de novos métodos acrescentados diretamente à classe TPdf. Um class helper é uma funcionalidade da linguagem que permite associar métodos a um tipo já existente a partir de fora da sua declaração. Assim que a unit está no âmbito (scope), os novos métodos são chamados exatamente como se pertencessem à classe, pelo que um método do helper se lê como Pdf.MeasureTextWidth(...), sem qualquer objeto separado para construir ou transportar

A razão para o organizar desta forma é a separação. O tipo central TPdf permanece como está, sem qualquer campo acrescentado e sem qualquer assinatura existente tocada, pelo que um projeto que nunca precise de composição de texto nunca transporta o código de medição. Um projeto que precise dela acrescenta uma unit a uma cláusula uses e os métodos ficam disponíveis. A funcionalidade passa a ser opcional ao nível de uma única unit, que é a forma mais limpa de estender um tipo que não se possui ou que não se quer perturbar

uses
  PDFium, FPdfView, FPdfEdit,
  FPdfMeasure;   // a unit do helper; traz MeasureText para o âmbito de TPdf

// Com a unit no âmbito, os métodos leem-se como membros de TPdf:
var
  W, H: Double;
begin
  Pdf.MeasureText('Subtotal', 'Helvetica', 11, W, H);
  // W e H são agora a largura e a altura visuais em unidades de utilizador do PDF
end;

Medir sem tocar na página

A medição tem de estar livre de efeitos secundários. Tem de reportar uma largura sem deixar nada para trás, porque é chamada muitas vezes enquanto se decide uma composição, e a página tem de ficar exatamente como ficaria se nunca tivesse havido qualquer medição. A técnica que torna isto possível é construir um objeto de texto, perguntar-lhe o seu tamanho e descartá-lo antes de alguma vez ser associado a uma página

A sequência é composta por quatro chamadas PDFium. FPDFPageObj_NewTextObj cria um objeto de texto associado ao documento, dado o nome e o tamanho do tipo de letra. FPDFText_SetText define a cadeia que esse objeto transporta. FPDFPageObj_GetBounds lê de volta a caixa delimitadora do objeto. FPDFPageObj_Destroy liberta o objeto. Fundamentalmente, nada nesta sequência chama a API de inserção na página. O objeto é criado, consultado e destruído de forma isolada, pelo que o documento fica inalterado quando a função termina. É uma sonda descartável cuja única saída são os quatro números da sua caixa delimitadora

Esta é a forma robusta de o fazer porque o PDFium não expõe uma largura de avanço por glifo conveniente que se pudesse somar diretamente. As métricas dos glifos dependem do programa do tipo de letra, da codificação e da forma como o PDFium carrega a face tipográfica, e não existe nenhuma chamada pública que forneça o avanço de cada caráter numa cadeia. A caixa delimitadora de um objeto de texto real, por outro lado, é calculada pelo mesmo mecanismo que dispõe os glifos para o desenho, pelo que reflete a extensão visual real e não uma aproximação. Construir um objeto descartável e ler os seus limites é a medição mais fiável que a biblioteca consegue dar

Diagrama das quatro chamadas PDFium por trás do MeasureText em Delphi, sondando um objeto de texto descartável sem tocar na página
MeasureText constrói um objeto de texto descartável, lê a sua caixa delimitadora e destrói-o, pelo que a medição deixa o documento PDF intocado
// A forma de MeasureText, expressa em função das chamadas PDFium verificadas.
// Um objeto de texto é construído, medido e destruído; nenhuma página está envolvida.
procedure TPdfMeasureHelper.MeasureText(const Text, Font: WString;
  FontSize: Single; out Width, Height: Double);
var
  TextObject: FPDF_PAGEOBJECT;
  L, B, R, T: Single;
begin
  Width  := 0;
  Height := 0;
  if Self.Document = nil then
    Exit;
  TextObject := FPDFPageObj_NewTextObj(Self.Document,
    FPDF_BYTESTRING(AnsiString(Font)), FontSize);
  if TextObject = nil then
    Exit;
  try
    if FPDFText_SetText(TextObject, FPDF_WIDESTRING(WideString(Text))) = 0 then
      Exit;
    if FPDFPageObj_GetBounds(TextObject, L, B, R, T) <> 0 then
    begin
      Width  := R - L;
      Height := T - B;
    end;
  finally
    FPDFPageObj_Destroy(TextObject);   // sonda descartada, página intocada
  end;
end;

Coordenadas e unidades do resultado

A caixa delimitadora é devolvida como quatro limites, esquerdo, inferior, direito e superior, e as duas dimensões resultam de uma subtração. A largura é o direito menos o esquerdo, e a altura é o superior menos o inferior. Ambas são expressas em unidades de utilizador do PDF, em que uma unidade equivale a um septuagésimo segundo de uma polegada, o mesmo espaço de coordenadas em que se posiciona o texto na página. Não há qualquer unidade de dispositivo escondida nem qualquer pixel envolvido nesta fase. Uma largura de 36 significa meia polegada de página, seja qual for a resolução de renderização final

O eixo vertical segue a definição do PDF, com o Y a aumentar para cima, razão pela qual a altura é o superior menos o inferior e não o contrário. Esse pormenor importa quando se avança um cursor ao longo de uma coluna, linha após linha. Mede-se a altura de uma linha e depois subtrai-se essa altura à linha de base atual para encontrar a seguinte, porque descer a página significa mover-se para valores de Y mais pequenos. Se o destino for um ecrã em vez de papel, convertem-se as unidades de utilizador em pixels de dispositivo com a resolução do ecrã: um valor em unidades de utilizador multiplicado pelos DPI e dividido por 72 dá pixels, pelo que uma largura de coluna definida em pontos pode ser comparada com um trecho medido antes de se decidir onde colocar a quebra

O que acontece com entradas degeneradas

As funções estão escritas para falhar em silêncio. Se não houver nenhum documento aberto, ou se o objeto de texto não puder ser criado, o resultado é uma extensão nula em vez de uma exceção. A largura e a altura são inicializadas a zero no topo e só são substituídas depois de uma caixa delimitadora ter sido lida com sucesso. Uma cadeia vazia, um documento em falta, um tipo de letra que a biblioteca não consegue resolver num objeto: cada um destes casos devolve zero em vez de gerar uma exceção

Essa escolha mantém simples um ciclo de medição, porque um ciclo que percorre milhares de palavras não é o lugar para tratamento de exceções em cada iteração. O custo é que a verificação fica a cargo de quem chama a função. Uma largura zero é uma sentinela, não um facto sobre o texto, pelo que código que divide por uma largura medida ou assume um valor positivo tem de se proteger contra o zero antes de confiar nele. Tratando o zero como "não foi possível medir", o contrato fica claro; ignorando-o, uma entrada degenerada transforma-se silenciosamente numa composição com uma coluna de glifos sobrepostos

Uma quebra de linha greedy construída sobre a medição

Com uma função de largura disponível, a quebra de linha resume-se a um pequeno ciclo greedy. Divide-se o parágrafo em palavras, mantém-se uma linha atual e, para cada palavra, mede-se como ficaria a linha se essa palavra fosse acrescentada. Enquanto a linha de teste ainda couber na largura da coluna, continua-se a acrescentar; quando ultrapassaria o limite, esvazia-se a linha atual com AddText e começa-se uma nova com a palavra que não coube. A acumulação é feita inteiramente com MeasureTextWidth, e a única coisa que chega alguma vez à página é uma linha já confirmada como cabendo

Diagrama de um ciclo de quebra de palavras guloso em Delphi que mede linhas de experiência com MeasureTextWidth e quebra na última palavra que cabe
O ciclo de quebra guloso mede cada linha de tentativa contra a largura da coluna e só liberta linhas confirmadas como cabendo
procedure WrapParagraph(Pdf: TPdf; const Para, Font: WString;
  FontSize: Single; X, TopY, ColumnWidth, LineHeight: Double);
var
  Words: TArray<string>;
  Line, Trial: WideString;
  I: Integer;
  Y: Double;
begin
  Words := string(Para).Split([' ']);
  Line  := '';
  Y     := TopY;
  for I := 0 to High(Words) do
  begin
    if Line = '' then
      Trial := Words[I]
    else
      Trial := Line + ' ' + Words[I];
    // Mede a linha candidata antes de desenhar seja o que for.
    if (Line <> '') and (Pdf.MeasureTextWidth(Trial, Font, FontSize) > ColumnWidth) then
    begin
      Pdf.AddText(Line, Font, FontSize, X, Y);   // esvazia a linha que coube
      Y    := Y - LineHeight;                    // Y diminui à medida que desce
      Line := Words[I];                          // a palavra que ultrapassou inicia a linha seguinte
    end
    else
      Line := Trial;
  end;
  if Line <> '' then
    Pdf.AddText(Line, Font, FontSize, X, Y);      // esvazia a linha final
end;

O ciclo mede a linha de teste em vez de medir cada palavra e somar, porque a largura de uma linha não é a soma das larguras das suas palavras. Os espaços entre palavras contribuem para essa largura, e um trecho medido capta isso diretamente. A regra greedy, encaixar tantas palavras quantas a coluna permitir e quebrar na última que ainda coube, é a mesma regra que preenche a lacuna entre um AddText em bruto e um parágrafo real. A chamada de desenho nunca foi a parte difícil. A medição que tem de a preceder é que o é, e é exatamente isso que o helper fornece

Onde isto se encaixa

A medição é a camada entre gerar conteúdo e renderizá-lo, pelo que se combina naturalmente com o resto de um fluxo de trabalho de documentos criados de raiz. Para quem está a montar páginas e a colocar texto em primeiro lugar, a base está descrita em criar documentos PDF de raiz com o PDFium Component em Delphi, onde AddText e a configuração de páginas são abordados na íntegra. Quando o tipo de letra que se está a medir importa tanto quanto a cadeia, porque as métricas dependem da face tipográfica, analisar propriedades de tipos de letra em PDF com o PDFium Component em Delphi mostra como a biblioteca reporta a informação de tipo de letra que determina essas caixas delimitadoras. Ambos assentam na mesma ligação (binding), o PDFium Component para Delphi e Lazarus, onde o helper de medição é fornecido juntamente com as APIs de documento, página e texto descritas ao longo deste blogue