Artigo Técnico

PDFlibPas: entidades HTML decodificadas duas vezes no PDF

Versões do PDF Library for Delphi (PDFlibPas) anteriores à v3.539.47 podiam decodificar texto escapado duas vezes ao desenhar HTML ou Markdown num PDF. DrawHTMLText e DrawHTMLTextBox parseiam o HTML, normalizam de volta para HTML, e parseiam de novo, então texto escrito como <unsafe> chegava ao segundo parse como uma tag de verdade. Desde a v3.539.47 cada entidade é decodificada exatamente uma vez e o texto é re-escapado onde quer que volte a virar HTML

O cenário que expõe isso é banal. Um help desk exporta tickets para PDF, e o comentário do cliente entra num template HTML. O desenvolvedor fez a coisa certa e escapou o comentário, então <b> virou &lt;b&gt;. Dentro do renderer esse escape foi silenciosamente desfeito: o comentário saía em negrito, um nome de tag desconhecida simplesmente sumia da página, e uma âncora escapada virava uma annotation de link clicável. Nenhuma exceção, nenhum aviso, um PDF perfeitamente válido que diz algo diferente dos dados

Por que texto escapado vira uma tag de verdade no PDF?

Texto escapado virava markup porque o renderer roda duas passadas de parse, e o passo de normalização entre elas gravava texto já decodificado de volta em HTML sem escapar de novo. Cada decodificação que o primeiro parse fez ficava então disponível para o segundo parse como sintaxe viva

As duas passadas existem por um bom motivo. O primeiro parse constrói uma lista de elementos de tag e de palavra. O NormalizeParsedHTML então resolve a cascata de stylesheet: ele casa as regras dos blocos <style> com cada tag, faz merge com atributos style inline, guarda o resultado na tag, e serializa a lista inteira de elementos de volta numa string HTML. A passada de layout parseia essa string normalizada. É a mesma maquinaria que move o layout de flexbox, CSS grid e footnotes no HTML do PDFlibPas

A falha estava em como as palavras eram serializadas. Tags eram gravadas de volta a partir da forma original delas no fonte, enquanto palavras eram gravadas na forma decodificada. Uma palavra que o primeiro parse tinha decodificado de &lt;unsafe&gt; para <unsafe> pousava no HTML normalizado como angle brackets crus, e o segundo parse a lia como um elemento. Ao redor desse bug central havia três vazamentos menores apontando para o mesmo lugar:

  • &amp; não estava no conjunto de entidades suportadas, então R&amp;D imprimia literalmente e não havia como escrever uma grafia de entidade literal como &lt; como texto
  • O estágio de desenho substituía &nbsp; uma segunda vez, depois de o parse já ter terminado, então uma grafia de entidade literal ainda podia sumir no finzinho
  • O escape de código Markdown pulava o e comercial, e o exporter de dataset escapava só os angle brackets, então grafias de entidade dentro de código ou de valores de célula eram decodificadas como markup
Pipeline HTML do PDFlibPas para DrawHTMLText em que o parse um constrói elementos, o NormalizeParsedHTML os serializa de volta para HTML e o parse dois faz o layout do resultado; antes da v3.539.47 palavras decodificadas eram gravadas de volta sem escape e viravam tags vivas, desde a v3.539.47 toda palavra é re-escapada na fronteira
Palavras decodificadas reentram no parser como sintaxe quando o normalizador esquece que produz markup, e é assim que um comentário escapado ficava em negrito ou brotava um link
Input que chega ao rendererAntes da v3.539.47Desde a v3.539.47
&lt;unsafe&gt;Parseado como tag, o texto nunca chega à página<unsafe> desenhado como texto
&lt;b&gt;x&lt;/b&gt;x desenhado em negrito<b>x</b> desenhado como texto
R&amp;DR&amp;D impresso literalmenteR&D
&amp;lt;&amp;lt; impresso literalmente&lt;
Code span de Markdown contendo &nbsp;Virava um espaço não separável&nbsp; desenhado como texto
Valor de célula de dataset &lt;<&lt;

Como a v3.539.47 torna a decodificação de entidades HTML single-pass

O PDFlibPas v3.539.47 torna a decodificação de entidades single-pass com três mudanças coordenadas: o parser decodifica &amp; por último, o estágio de desenho não decodifica mais nada, e todo lugar que transforma palavras decodificadas de volta em HTML as escapa de novo primeiro

O conjunto de entidades suportadas para conteúdo de texto agora é &lt;, &gt;, &amp; e &nbsp;. Qualquer outra coisa, incluindo referências numéricas como &#65; e entidades nomeadas como &quot;, permanece texto literal. Essa fronteira importa para como você escapa o seu próprio input, como mostrado abaixo

A ordem dentro do decodificador é a primeira correção. Se &amp; fosse decodificado primeiro, o input &amp;lt; viraria &lt; e a substituição seguinte o transformaria em <, uma dupla decodificação que acontece dentro de uma passada só. O caminho de palavras ANSI portanto substitui &lt;, &gt; e &nbsp; primeiro e &amp; por último, então o e comercial que ele produz nunca mais é examinado. O caminho de palavras UTF-16 é um único scan da esquerda para a direita em passos de dois bytes que reescreve cada match no lugar e passa por cima dele, o que dá a mesma garantia estruturalmente

Ordem do decodificador do PDFlibPas para uma entidade encadeada como &amp;lt;: decodificar o e comercial primeiro o colapsa num angle bracket de verdade dentro de uma passada só, enquanto decodificar lt, gt e nbsp antes do e comercial mantém a grafia literal intacta para o texto chegar à página decodificado exatamente uma vez
O e comercial é o caractere de escape, então precisa ser decodificado por último e escapado primeiro, ou uma passada pode decodificar duas vezes

A segunda correção remove a substituição tardia de &nbsp; do estágio de desenho. Decodificação pertence ao parser e a mais ninguém, então uma palavra que chega ao line breaker é texto final

A terceira correção é a regra da fronteira. O NormalizeParsedHTML agora escapa &, < e > em toda palavra decodificada antes de anexá-la ao HTML normalizado. O segundo parse decodifica de volta para exatamente o mesmo texto, então o efeito líquido ao longo do pipeline inteiro é uma decodificação. A string de continuação segue a mesma regra: palavras que não couberam na caixa são escapadas antes de serem anexadas ao LeftOverText, e o resto do remanescente é copiado do HTML normalizado, que já está em forma escapada. O loop que coleta essas palavras sobrando também agora é limitado pela contagem de palavras, já que o antigo repeat loop podia passar do último word

Por que o escape UTF-16BE não pode usar um replace em nível de byte?

O escape UTF-16BE não pode usar um replace em nível de byte porque o padrão de dois bytes de um e comercial pode atravessar dois caracteres sem relação. A única unidade de trabalho correta é o code unit de 16 bits inteiro

O renderer guarda palavras Unicode como UTF-16 big-endian empacotado em byte strings, byte alto primeiro. Um e comercial é 00 26. Agora tome U+0100 (Latin capital A with macron, bytes 01 00) seguido de U+2603 (o boneco de neve, bytes 26 03). A sequência de bytes é 01 00 26 03, e os bytes dois e três leem 00 26. Uma busca por bytes de #0'&' acha um e comercial que não existe, emenda os bytes de &amp; no meio de dois caracteres, e cisga todo caractere seguinte em um byte

Perigo do escape UTF-16BE no PDFlibPas em que os bytes 01 00 26 03 de U+0100 e U+2603 contêm o padrão 00 26 atravessando dois caracteres, então uma busca em nível de byte pelo e comercial emenda uma entidade no meio de um code point; o scan por code units testa só offsets pares
Uma busca por bytes acha um e comercial que nenhum caractere jamais conteve; trabalhe sobre code units inteiros, nunca sobre buffers de bytes UTF-16 crus

Esse não é um canto exótico. Qualquer caractere cujo byte baixo é zero pode fornecer a primeira metade; U+4E00, um dos ideógrafos CJK mais frequentes, se qualifica. Os angle brackets têm a mesma exposição: 00 3C e 00 3E aparecem sempre que tal caractere é seguido por um de U+3C00 a U+3EFF no CJK Extension A. A correção no EscapeHTMLWord desempacota os bytes para um WideString, escapa caractere por caractere e empacota o resultado de novo. O lado do decodificador já era seguro porque só testa padrões em fronteiras pares de code units

A mesma regra vale para o seu próprio código. Se você alguma vez segura texto UTF-16 como TBytes, por exemplo depois de TEncoding.BigEndianUnicode.GetBytes, não o busque por padrões de bytes. Converta de volta para string e trabalhe sobre caracteres

Blocos de código Markdown e exports de dataset: escape o e comercial primeiro

Desde a v3.539.47 os dois produtores de HTML dentro do PDFlibPas, o conversor de Markdown e o exporter de dataset, escapam o e comercial antes dos angle brackets, então a decodificação única no renderer restaura exatamente o texto original

No MarkdownToHTML, code spans inline e code blocks cercados ou indentados agora mapeiam & para &amp;, < para &lt; e > para &gt;, enquanto espaços viram &nbsp; e um tab vira quatro deles para preservar a indentação. Prosa Markdown comum escapa só os angle brackets, então HTML cru em prosa não pode injetar tags enquanto um autor ainda pode escrever &amp; de propósito, bem como autores de Markdown esperam. DrawMarkdownText e DrawMarkdownTextBox usam a mesma conversão, então código aparece no PDF exatamente como digitado:

uses
  System.SysUtils, PDFlibrary;

procedure RenderCodeSample;
var
  Lib: TPDFlib;
  Md, Html: WideString;
begin
  Md := 'Comparison helper:' + sLineBreak + sLineBreak +
        '```' + sLineBreak +
        'if (A < B) and (Flags <> 0) then' + sLineBreak +
        '  WriteLn(''&lt;tag&gt; &amp; R&amp;D'');' + sLineBreak +
        '```';
  Lib := TPDFlib.Create;
  try
    // Inspecione o HTML: em código, '&' vira '&amp;' e '<' vira '&lt;'
    Html := Lib.MarkdownToHTML(Md);
    Lib.SetOrigin(1);            // origem no topo à esquerda, Y cresce para baixo
    Lib.SetMeasurementUnits(0);  // pontos
    // A página mostra o código exatamente como digitado, grafias de entidade incluídas
    Lib.DrawMarkdownText(50, 50, 495, Md);
    Lib.SaveToFile('code-sample.pdf');
  finally
    Lib.Free;
  end;
end;

O exporter de dataset é o caso instrutivo. Antes da v3.539.47 ele escapava só os angle brackets, e de propósito: o renderer não decodificava &amp;, então escapar o e comercial teria impresso &amp; em toda célula que contivesse um. O workaround era correto para o renderer antigo e errado em geral, porque um valor de célula que por acaso contivesse &lt; era decodificado para <. Com o renderer corrigido, o exporter escapa & primeiro, e um valor como R&D &lt; &amp; &nbsp; pousa no PDF verbatim. Se você constrói relatórios desse jeito, o passo a passo sobre exportar um TDataSet para um relatório PDF em Delphi cobre o resto do exporter

Por que o e comercial precisa ir primeiro vale ser dito uma vez. Escape < primeiro e você obtém &lt;; escape & segundo e aquilo vira &amp;lt;, que uma decodificação única correta exibe como &lt; em vez de <. Uma cadeia sequencial de replaces só é correta quando o próprio caractere de escape é tratado antes de qualquer coisa que o introduza

Como escapar texto não confiável para o DrawHTMLTextBox?

Para renderização HTML do PDFlibPas, escape conteúdo de texto não confiável substituindo &, depois <, depois >, exatamente uma vez, e mantenha dados não confiáveis completamente fora de valores de atributo

uses
  System.SysUtils, PDFlibrary;

// Escapa texto não confiável para conteúdo de texto HTML do PDFlibPas.
// '&' precisa ser substituído primeiro, senão o e comercial dentro
// de um '&lt;' já produzido seria escapado uma segunda vez
function EscapeHTMLText(const S: string): string;
begin
  Result := StringReplace(S, '&', '&amp;', [rfReplaceAll]);
  Result := StringReplace(Result, '<', '&lt;', [rfReplaceAll]);
  Result := StringReplace(Result, '>', '&gt;', [rfReplaceAll]);
end;

procedure RenderTicket(const CustomerComment: string);
var
  Lib: TPDFlib;
  Html: WideString;
begin
  Lib := TPDFlib.Create;
  try
    Lib.SetOrigin(1);
    Lib.SetMeasurementUnits(0);
    Html := '<p><b>Customer comment</b></p>' +
            '<p>' + EscapeHTMLText(CustomerComment) + '</p>';
    Lib.DrawHTMLText(50, 50, 495, Html);
    Lib.SaveToFile('ticket.pdf');
  finally
    Lib.Free;
  end;
end;

Na v3.539.47 um comentário como Try <a href="https://example.com">this</a> & &lt;b&gt; aparece na página caractere por caractere. Antes da v3.539.47 o mesmo input escapado podia produzir uma annotation de link viva, que é a parte que transforma um glitch de exibição num problema de segurança: um comentário de ticket nunca deveria poder plantar uma URL clicável num documento em que a sua equipe confia

Repare no que a função não escapa. Escapers de HTML de propósito geral também convertem " para &quot; e ' para &#39;, o que é certo para um browser. A decodificação de texto do PDFlibPas reconhece só as quatro entidades listadas antes, então essas duas imprimiriam literalmente como &quot; e &#39;. Aspas são inofensivas em conteúdo de texto; elas só importam dentro de valores de atributo, e o renderer não decodifica entidades em atributos de forma alguma. O design seguro portanto não é um escaper melhor, e sim uma regra: dado não confiável nunca entra em href, src ou style. Se um alvo de link realmente precisa vir de dados do usuário, valide você mesmo contra uma allow-list de schemes e caracteres e rejeite qualquer coisa contendo aspas ou angle brackets

Duas notas de upgrade decorrem direto da correção:

  • Se o seu código parou de escapar & porque versões antigas imprimiam &amp; literalmente, coloque de volta. Sem isso, texto de usuário contendo &lt; agora exibe como <, ainda texto inofensivo mas não mais o que o usuário digitou
  • Não escape duas vezes. Texto que passa por dois escapers renderiza < como a grafia visível &lt;, então encontre a única fronteira em que o seu dado entra no HTML e escape só ali

Paginando com LeftOverText sem quebrar os escapes

O DrawHTMLTextBox retorna o HTML que não coube, geralmente chamado de LeftOverText, e desde a v3.539.47 esse remanescente preserva grafias literais de entidades e angle brackets escapados quando você o passa para a próxima caixa. A regra para quem chama é simples: passe de volta sem alterar

const
  BoxLeft = 50;
  BoxTop = 50;
  BoxWidth = 495;    // dimensões para uma página A4 em pontos
  BoxHeight = 740;
  MaxPages = 500;

procedure RenderLongHTML(Lib: TPDFlib; const Html: WideString);
var
  Rest: WideString;
  Pages: Integer;
begin
  Lib.SetOrigin(1);
  Lib.SetMeasurementUnits(0);
  Rest := Lib.DrawHTMLTextBox(BoxLeft, BoxTop, BoxWidth, BoxHeight, Html);
  Pages := 1;
  while (Rest <> '') and (Pages < MaxPages) do
  begin
    Lib.NewPage;
    Inc(Pages);
    // LeftOverText já é HTML escapado da engine: nunca escape ou desescape
    Rest := Lib.DrawHTMLTextBox(BoxLeft, BoxTop, BoxWidth, BoxHeight, Rest);
  end;
  if Rest <> '' then
    raise Exception.CreateFmt('Content still left after %d pages', [MaxPages]);
end;

Trate o remanescente como opaco. É o HTML normalizado da engine, com styles já resolvidos, então não o passe pelo seu próprio escaper, não o decodifique, e não emende texto de usuário nele. O teto de páginas é seguro barato: se algum elemento nunca couber na caixa, um loop sem teto não tem saída natural

Markdown tem continuação própria. O DrawMarkdownTextBox retorna um token que começa com um marcador interno para que a próxima chamada possa pular a conversão; entregue-o de volta ao DrawMarkdownTextBox ou ao DrawMarkdownText, não aos pontos de entrada HTML, que desenhariam o marcador como texto

A lição geral: decodifique uma vez, re-codifique em toda fronteira

Qualquer pipeline que parseia texto, serializa o resultado de volta na mesma sintaxe e parseia de novo precisa tratar decodificação como uma operação que acontece em exatamente um lugar, e precisa re-codificar em toda fronteira em que texto decodificado volta a ser sintaxe. Template engines, sanitizers de HTML e cadeias Markdown-para-HTML-para-PDF compartilham essa forma e falham do mesmo jeito quando um serializador esquece que produz markup

Os sintomas são previsíveis quando você conhece a forma. Re-codificação de menos transforma dado em sintaxe, que é a direção da injeção. Codificação demais, ou um decodificador que roda duas vezes, mostra grafias de entidade ao leitor ou as devora, que é a direção da exibição. Consertar uma direção sozinho geralmente quebra a outra, e é por isso que a correção do PDFlibPas precisou adicionar a decodificação de &amp;, reordená-la, remover a decodificação tardia e acrescentar re-escape na mesma release. O mesmo princípio corre no sentido oposto quando conteúdo de PDF é exportado como texto estruturado, como no export semântico de PDF para Markdown e DOCX a partir do Delphi, em que todo caractere literal precisa ser escapado para a sintaxe alvo exatamente uma vez

Checklist de referência rápida

  • Faça upgrade para o PDFlibPas v3.539.47 ou posterior se você renderiza HTML ou Markdown que contém dados de usuário
  • Escape conteúdo de texto com & primeiro, depois < e >; não converta aspas para texto do PDFlibPas
  • Escape uma vez, no único ponto em que o dado entra na string HTML
  • Mantenha valores não confiáveis fora de href, src e style, ou valide-os contra uma allow-list
  • Espere que só &lt;, &gt;, &amp; e &nbsp; sejam decodificados em texto; outras entidades permanecem literais
  • Passe o LeftOverText de volta ao DrawHTMLTextBox sem alterar e imponha teto ao loop de páginas
  • Passe tokens de continuação de Markdown só ao DrawMarkdownTextBox ou ao DrawMarkdownText
  • Nunca busque buffers de bytes UTF-16 por padrões de bytes; trabalhe sobre code units inteiros

Renderização de HTML e de Markdown, export de relatórios de dataset e o resto da engine de layout saem no fonte Pascal nativo do PDF Library for Delphi, para Delphi e Free Pascal. Veja a página de produto do PDFlibPas para edições, suporte de plataforma e download de teste