Artigo Técnico

Medindo Texto de PDF para Layout e Quebra de Palavras no Delphi

A chamada que coloca texto em uma página PDF é direta/straightforward. Você dá ao AddText uma string, uma fonte, um tamanho e uma posição, e os glifos aparecem. O que ele não faz é dizer a você quão larga essa string será uma vez que for desenhada, e ele não quebra uma string longa através de várias linhas. Uma chamada única pinta uma corrida/run de texto em uma posição. Se a corrida for mais larga do que a coluna na qual você teve a intenção de que ela coubesse, ela simplesmente corre além da borda, e nada na chamada de desenho o avisa. O momento em que você quer um parágrafo em vez de um rótulo único, a peça que falta é a largura de uma string na fonte e tamanho escolhidos, medida antes que você a confirme/commit para a página

Esse é o problema clássico de layout. Para quebrar/wrap um parágrafo em uma coluna você tem que saber, palavra por palavra, quanto espaço horizontal cada linha candidata tomará, e você tem que saber disso antes de desenhar qualquer coisa. A quebra de palavras é um loop de medição envolto em torno de uma chamada de desenho, e uma ligação que apenas desenha lhe dá a segunda metade. O suporte de medição de texto no componente PDFium fecha essa lacuna com duas funções, MeasureText e MeasureTextWidth, que relatam a extensão renderizada de uma string sem colocar uma marca em nenhuma página

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

O suporte de medição chega como um class helper do Delphi para o TPdf, vivendo/living na sua própria unidade, em vez de como novos métodos aparafusados/bolted na classe TPdf. Um class helper é um recurso da linguagem que o deixa anexar métodos a um tipo existente de fora da sua declaração. Uma vez que a unidade está no escopo, os novos métodos são chamados exatamente como se eles pertencessem à classe, então um método helper lê como Pdf.MeasureTextWidth(...) sem nenhum objeto separado para construir ou passar adiante

A razão para colocar isso em camadas dessa maneira é a separação. O tipo TPdf central permanece como está, com nenhum campo adicionado e nenhuma assinatura existente tocada, então um projeto que nunca precisa de layout nunca carrega o código de medição. Um projeto que precisa disso adiciona uma unidade a uma cláusula uses e os métodos se acendem/light up. A capacidade se torna opt-in na granularidade de uma unidade única, o que é a maneira mais limpa de estender um tipo que você não possui ou não quer perturbar

uses
  PDFium, FPdfView, FPdfEdit,
  FPdfMeasure;   // the helper unit; brings MeasureText into scope on TPdf

// With the unit in scope the methods read as members of TPdf:
var
  W, H: Double;
begin
  Pdf.MeasureText('Subtotal', 'Helvetica', 11, W, H);
  // W and H are now the rendered width and height in PDF user units
end;

Medindo sem tocar na página

A medição tem que ser livre de efeitos colaterais. Ela deve relatar uma largura sem deixar nada para trás, porque você a chama muitas vezes enquanto decide um layout e a página deve parecer exatamente como ela teria parecido se você nunca tivesse medido de forma alguma. A técnica que torna isso possível é construir um objeto de texto, pedir a ele pelo seu tamanho, e jogá-lo fora antes que ele seja anexado a uma página

A sequência é de quatro chamadas do PDFium. FPDFPageObj_NewTextObj cria um objeto de texto contra o documento, dado o nome da fonte e o tamanho. FPDFText_SetText define a string que aquele objeto carrega. FPDFPageObj_GetBounds lê de volta a caixa delimitadora/bounding box do objeto. FPDFPageObj_Destroy libera o objeto. Crucialmente, nada nessa sequência chama a API de inserção de página. O objeto é criado, consultado e destruído em isolamento, então o documento permanece inalterado quando a função retorna. Ele é uma sonda descartável/throwaway cuja única saída são os quatro números da sua caixa delimitadora

Essa é a maneira robusta de se fazer isso porque o PDFium não expõe uma conveniente largura de avanço por-glifo que você pudesse somar por si mesmo. As métricas do glifo dependem do programa da fonte, da codificação, e de como o PDFium carrega o rosto/face, e não há nenhuma chamada pública que lhe entregue o avanço de cada caractere em uma string. A caixa delimitadora de um objeto de texto real, por outro lado, é computada pela mesma maquinaria/machinery que layoutaria/lay out os glifos para desenho, então ela reflete a extensão renderizada real em vez de uma aproximação. Construir um objeto descartável e ler os seus limites é a medição mais confiável que a biblioteca pode dar

// The shape of MeasureText, expressed against the verified PDFium calls.
// A text object is built, measured, and destroyed; no page is involved.
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);   // probe discarded, page untouched
  end;
end;

Coordenadas e unidades do resultado

A caixa delimitadora volta como quatro bordas/edges, esquerda, inferior, direita e superior, e as duas dimensões caem/fall out por subtração. A largura é a direita menos a esquerda e a altura é a superior menos a inferior. Ambas são expressas em unidades de usuário do PDF, onde uma unidade é um setenta e dois avos de uma polegada/inch, o mesmo espaço de coordenadas em que você posiciona o texto na página. Não há nenhuma unidade de dispositivo oculta/hidden e nenhum pixel envolvido neste estágio. Uma largura de 36 significa meia polegada de página, qualquer que seja a resolução de renderização eventual

O eixo vertical corre/runs da maneira que o PDF o define, com o Y aumentando/increasing para cima/upward, o que é por que a altura é a superior menos a inferior em vez do reverso. Esse detalhe importa quando você avança um cursor para baixo em uma coluna. Você mede a altura de uma linha, então a subtrai da sua linha de base atual para encontrar a próxima, porque mover para baixo na página significa mover em direção a Y menores. Se o seu destino for uma tela em vez de papel, você converte as unidades de usuário para pixels de dispositivo com a resolução do display: um valor em unidades de usuário multiplicado pelo DPI e dividido por 72 dá pixels, então uma largura de coluna que você define em pontos pode ser correspondida/matched contra uma corrida medida antes que você decida onde a quebra vai

O que acontece com a entrada degenerada (degenerate input)

As funções são escritas para falhar silenciosamente. Se não houver nenhum documento aberto, ou se o objeto de texto não puder ser criado, o resultado é uma extensão zero em vez de uma exceção levantada/raised. A largura e a altura são inicializadas como zero no topo e apenas sobrescritas uma vez que uma caixa delimitadora tenha sido lida de volta com sucesso. Uma string vazia, um documento ausente, uma fonte que a biblioteca não consegue resolver em um objeto, cada um desses retorna zero em vez de lançar uma exceção/throwing

Essa escolha mantém um loop de medição simples, porque um loop que roda/runs sobre milhares de palavras não é o lugar para o tratamento de exceções em cada iteração. O custo é que o chamador carrega a verificação. Uma largura zero é uma sentinela, não um fato sobre o texto, então o código que divide por uma largura medida ou assume um valor positivo tem que se proteger contra o zero antes de confiar nele. Trate o zero como "não foi possível medir" e o contrato fica claro/clear; ignore-o e uma entrada degenerada silenciosamente se torna um layout com uma coluna de glifos sobrepostos/overlapping

Uma quebra de palavras gulosa (greedy) construída sobre a medição

Com uma função de largura em mãos, a quebra de palavras é um loop curto e guloso. Você divide o parágrafo em palavras, mantém uma linha atual, e para cada palavra você mede o que a linha seria se você anexasse essa palavra. Enquanto a linha de teste/trial ainda cabe na largura da coluna você continua adicionando; quando ela transbordasse/overflow você descarrega/flush a linha atual com o AddText e começa uma nova com a palavra que não coube. A acumulação é feita inteiramente com o MeasureTextWidth, e a única coisa que sequer alcança a página é uma linha que você já confirmou caber

procedure WrapParagraph(Pdf: TPdf; const Para, Font: WString;
  FontSize: Single; X, TopY, ColumnWidth, LineHeight: Double);
var
  Words: TArray<WideString>;
  Line, Trial: WideString;
  I: Integer;
  Y: Double;
begin
  Words := WideString(Para).Split([' ']);
  Line  := '';
  Y     := TopY;
  for I := 0 to High(Words) do
  begin
    if Line = '' then
      Trial := Words[I]
    else
      Trial := Line + ' ' + Words[I];
    // Measure the candidate line before drawing anything.
    if (Line <> '') and (Pdf.MeasureTextWidth(Trial, Font, FontSize) > ColumnWidth) then
    begin
      Pdf.AddText(X, Y, Font, FontSize, Line);   // flush the line that fit
      Y    := Y - LineHeight;                    // Y decreases going down
      Line := Words[I];                          // overflowing word starts next line
    end
    else
      Line := Trial;
  end;
  if Line <> '' then
    Pdf.AddText(X, Y, Font, FontSize, Line);      // flush the final line
end;

O loop 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 as palavras contribuem, e uma corrida medida captura isso diretamente. A regra gulosa, encaixar o máximo de palavras que a coluna permita e quebrar na última a caber, é a mesma regra que preenche/fills a lacuna entre um AddText bruto e um parágrafo real. A chamada de desenho nunca foi a parte difícil. A medição que tem que precedê-la é, e isso é exatamente o que o helper fornece

Onde isso se encaixa

A medição é a camada entre gerar o conteúdo e renderizá-lo, então ela se pareia/pairs naturalmente com o resto de um fluxo de trabalho de documento a partir do zero. Se você está montando páginas e colocando texto em primeiro lugar, o trabalho de base está em criando documentos PDF a partir do zero com o Componente PDFium no Delphi, onde o AddText e a configuração da página são cobertos na íntegra. Quando a fonte que você está medindo importa tanto quanto a string, porque as métricas dependem da face, analisando as propriedades da fonte do PDF com o Componente PDFium no Delphi mostra como a biblioteca relata a informação da fonte que direciona aquelas caixas delimitadoras. Ambos são construídos sobre o mesmo binding, o Componente PDFium para o Delphi e o Lazarus, onde o helper de medição é distribuído junto com as APIs de documento, página e texto descritas ao longo deste blog