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 <b>. 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 <unsafe> 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:
&não estava no conjunto de entidades suportadas, entãoR&Dimprimia literalmente e não havia como escrever uma grafia de entidade literal como<como texto- O estágio de desenho substituía
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
| Input que chega ao renderer | Antes da v3.539.47 | Desde a v3.539.47 |
|---|---|---|
<unsafe> | Parseado como tag, o texto nunca chega à página | <unsafe> desenhado como texto |
<b>x</b> | x desenhado em negrito | <b>x</b> desenhado como texto |
R&D | R&D impresso literalmente | R&D |
&lt; | &lt; impresso literalmente | < |
Code span de Markdown contendo | Virava um espaço não separável | desenhado como texto |
Valor de célula de dataset < | < | < |
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 & 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 é <, >, & e . Qualquer outra coisa, incluindo referências numéricas como A e entidades nomeadas como ", 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 & fosse decodificado primeiro, o input &lt; viraria < 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 <, > e primeiro e & 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
A segunda correção remove a substituição tardia de 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 & no meio de dois caracteres, e cisga todo caractere seguinte em um byte
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 &, < para < e > para >, enquanto espaços viram 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 & 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(''<tag> & R&D'');' + sLineBreak +
'```';
Lib := TPDFlib.Create;
try
// Inspecione o HTML: em código, '&' vira '&' e '<' vira '<'
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 &, então escapar o e comercial teria impresso & 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 < era decodificado para <. Com o renderer corrigido, o exporter escapa & primeiro, e um valor como R&D < & 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 <; escape & segundo e aquilo vira &lt;, que uma decodificação única correta exibe como < 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 '<' já produzido seria escapado uma segunda vez
function EscapeHTMLText(const S: string): string;
begin
Result := StringReplace(S, '&', '&', [rfReplaceAll]);
Result := StringReplace(Result, '<', '<', [rfReplaceAll]);
Result := StringReplace(Result, '>', '>', [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> & <b> 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 " e ' para ', 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 " e '. 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&literalmente, coloque de volta. Sem isso, texto de usuário contendo<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<, 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 &, 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,srcestyle, ou valide-os contra uma allow-list - Espere que só
<,>,&e sejam decodificados em texto; outras entidades permanecem literais - Passe o
LeftOverTextde volta aoDrawHTMLTextBoxsem alterar e imponha teto ao loop de páginas - Passe tokens de continuação de Markdown só ao
DrawMarkdownTextBoxou aoDrawMarkdownText - 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