Artigo Técnico

PDFlibPas HTML para PDF: entidades descodificadas duas vezes

As versões da PDF Library for Delphi (PDFlibPas) anteriores à v3.539.47 podiam descodificar texto escapado duas vezes ao desenhar HTML ou Markdown num PDF. O DrawHTMLText e o DrawHTMLTextBox analisam o HTML, normalizam-no de volta para HTML e voltam a analisá-lo, por isso texto escrito como <unsafe> chegava à segunda análise como uma tag a sério. Desde a v3.539.47 cada entidade é descodificada exatamente uma vez e o texto volta a ser escapado sempre que regressa a HTML

O cenário que expõe isto é banal. Um help desk exporta tickets para PDF, e o comentário do cliente entra num template HTML. O programador fez a coisa certa e escapou o comentário, por isso <b> tornou-se &lt;b&gt;. Dentro do renderer, esse escaping era desfeito em silêncio: o comentário saía a negrito, um nome de tag desconhecido simplesmente desaparecia da página, e uma anchor escapada tornava-se uma anotação de link clicável. Nenhuma exceção, nenhum aviso, um PDF perfeitamente válido que diz algo diferente dos dados

Porque é que texto escapado se torna uma tag a sério no PDF?

O texto escapado tornava-se markup porque o renderer corre duas passagens de análise, e o passo de normalização entre elas escrevia texto já descodificado de volta para HTML sem o voltar a escapar. Cada descodificação que a primeira análise fez ficava então disponível para a segunda como sintaxe viva

As duas passagens existem por uma boa razão. A primeira análise constrói uma lista de elementos de tag e de palavra. O NormalizeParsedHTML resolve depois a cascata da stylesheet: cruza as regras dos blocos <style> com cada tag, funde-as com atributos style inline, guarda o resultado na tag e serializa a lista inteira de elementos de volta para uma string HTML. A passagem de layout analisa essa string normalizada. É a mesma maquinaria que move o layout de flexbox, CSS grid e notas de rodapé na renderização HTML do PDFlibPas

A falha estava na forma como as palavras eram serializadas. As tags eram reescritas a partir da sua forma original no código-fonte, enquanto as palavras eram reescritas na forma descodificada. Uma palavra que a primeira análise tinha descodificado de &lt;unsafe&gt; para <unsafe> caía no HTML normalizado como parênteses angulares crus, e a segunda análise lia-a como um elemento. Em volta desse bug central havia três fugas menores que apontavam todas no mesmo sentido:

  • O &amp; não estava no conjunto de entidades suportadas, por isso R&amp;D imprimia-se literalmente e não havia forma de escrever uma grafia literal de entidade como &lt; como texto
  • A fase de desenho substituía &nbsp; uma segunda vez, depois de a análise já ter acabado, por isso uma grafia literal de entidade ainda podia desaparecer no último momento
  • O escaping de código Markdown saltava o ampersand, e o exportador de datasets escapava só os parênteses angulares, por isso grafias de entidades dentro de código ou valores de células eram descodificadas como markup
Pipeline HTML do PDFlibPas para o DrawHTMLText em que a primeira análise constrói elementos, o NormalizeParsedHTML serializa-os de volta para HTML e a segunda análise dá layout ao resultado; antes da v3.539.47 as palavras descodificadas eram reescritas sem escaping e tornavam-se tags vivas, desde a v3.539.47 cada palavra volta a ser escapada na fronteira
Palavras descodificadas reentram no parser como sintaxe quando o normalizador se esquece de que produz markup, e foi assim que um comentário escapado ficou a negrito ou ganhou um link
Input que chega ao rendererAntes da v3.539.47Desde a v3.539.47
&lt;unsafe&gt;Analisado como tag, o texto nunca chega à página<unsafe> desenhado como texto
&lt;b&gt;x&lt;/b&gt;x desenhado a negrito<b>x</b> desenhado como texto
R&amp;DR&amp;D impresso literalmenteR&D
&amp;lt;&amp;lt; impresso literalmente&lt;
Code span Markdown contendo &nbsp;Tornava-se 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 descodificação de entidades HTML numa só passagem

O PDFlibPas v3.539.47 torna a descodificação de entidades numa só passagem com três alterações coordenadas: o parser descodifica &amp; em último lugar, a fase de desenho já não descodifica nada, e todos os sítios que transformam palavras descodificadas de volta em HTML voltam a escapá-las primeiro

O conjunto de entidades suportadas em 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;, mantém-se texto literal. Essa fronteira interessa-lhe para saber como escapar o seu próprio input, como se mostra abaixo

A ordem dentro do descodificador é a primeira correção. Se o &amp; fosse descodificado primeiro, o input &amp;lt; tornar-se-ia &lt; e a substituição seguinte transformá-lo-ia em <, uma dupla descodificação que acontece dentro de uma única passagem. O caminho de palavras ANSI substitui por isso &lt;, &gt; e &nbsp; primeiro e &amp; em último, por isso o ampersand que produz nunca é novamente examinado. O caminho de palavras UTF-16 é uma única varredura da esquerda para a direita em passos de dois bytes que reescreve cada correspondência no sítio e passa em frente, o que dá a mesma garantia por construção

Ordem do descodificador PDFlibPas para uma entidade encadeada como &amp;lt;: descodificar o ampersand primeiro colapsa-o num parêntese angular real dentro de uma única passagem, enquanto descodificar lt, gt e nbsp antes do ampersand mantém a grafia literal intacta para o texto chegar à página descodificado exatamente uma vez
O ampersand é o carácter de escape, por isso tem de ser descodificado em último e escapado em primeiro, ou uma passagem pode descodificar duas vezes

A segunda correção remove a substituição tardia de &nbsp; da fase de desenho. Descodificar pertence ao parser e a mais ninguém, por isso uma palavra que chega ao line breaker é texto final

A terceira correção é a regra da fronteira. O NormalizeParsedHTML escapa agora &, < e > em cada palavra descodificada antes de a acrescentar ao HTML normalizado. A segunda análise descodifica-a de volta para exatamente o mesmo texto, por isso o efeito líquido ao longo de todo o pipeline é uma descodificação. A string de continuação segue a mesma regra: as palavras que não couberam na caixa são escapadas antes de serem acrescentadas ao LeftOverText, e o resto do remanescente é copiado do HTML normalizado, que já está em forma escapada. O loop que recolhe essas palavras excedentárias também é agora limitado pela contagem de palavras, onde o antigo repeat loop podia ultrapassar a última palavra

Porque é que o escaping UTF-16BE não pode usar uma substituição ao nível de bytes?

O escaping UTF-16BE não pode usar uma substituição ao nível de bytes porque o padrão de dois bytes de um ampersand pode atravessar dois caracteres sem relação nenhuma. A única unidade de trabalho correta é a unidade de código de 16 bits inteira

O renderer guarda palavras Unicode como UTF-16 big-endian empacotado em strings de bytes, byte alto primeiro. Um ampersand é 00 26. Tome agora U+0100 (A maiúsculo latino com 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-se 00 26. Uma procura por bytes de #0'&' encontra um ampersand que não existe, enxerta os bytes de &amp; no meio de dois caracteres, e corta um byte a todos os caracteres seguintes

Perigo do escaping 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 através de dois caracteres, por isso uma procura ao nível de bytes pelo ampersand enxerta uma entidade no meio de um code point; a varredura por unidades de código testa só offsets pares
Uma procura por bytes encontra um ampersand que nenhum carácter alguma vez contiveu; trabalhe sobre unidades de código inteiras, nunca sobre buffers de bytes UTF-16 crus

Não é um caso exótico de canto. Qualquer carácter cujo byte baixo é zero pode fornecer a primeira metade; o U+4E00, um dos ideogramas CJK mais frequentes, qualifica. Os parênteses angulares têm a mesma exposição: 00 3C e 00 3E aparecem sempre que tal carácter é 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 carácter a carácter e volta a empacotar o resultado. O lado do descodificador já era seguro porque só testa padrões em fronteiras pares de unidades de código

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

Blocos de código Markdown e exportações de datasets: escapar o ampersand primeiro

Desde a v3.539.47 ambos os produtores de HTML dentro do PDFlibPas, o conversor Markdown e o exportador de datasets, escapam o ampersand antes dos parênteses angulares, por isso a única descodificação no renderer restaura exatamente o texto original

No MarkdownToHTML, code spans inline e blocos de código cercados ou indentados mapeiam agora & para &amp;, < para &lt; e > para &gt;, enquanto os espaços se tornam &nbsp; e um tab se torna quatro deles para manter a indentação. A prosa Markdown comum escapa só os parênteses angulares, por isso HTML cru na prosa não pode injetar tags enquanto um autor ainda pode escrever &amp; de propósito, muito ao gosto do que os autores de Markdown esperam. O DrawMarkdownText e o DrawMarkdownTextBox usam a mesma conversão, por isso o código aparece no PDF exatamente como foi 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, '&' torna-se '&amp;' e '<' torna-se '&lt;'
    Html := Lib.MarkdownToHTML(Md);
    Lib.SetOrigin(1);            // origem no topo esquerdo, Y cresce para baixo
    Lib.SetMeasurementUnits(0);  // pontos
    // A página mostra o código exatamente como digitado, grafias de entidades incluídas
    Lib.DrawMarkdownText(50, 50, 495, Md);
    Lib.SaveToFile('code-sample.pdf');
  finally
    Lib.Free;
  end;
end;

O exportador de datasets é o caso instrutivo. Antes da v3.539.47 escapava só os parênteses angulares, e de propósito: o renderer não descodificava &amp;, por isso escapar o ampersand teria imprimido &amp; em todas as células que o contivessem. O workaround estava correto para o renderer antigo e errado em geral, porque um valor de célula que por acaso contivesse &lt; era descodificado para <. Com o renderer corrigido, o exportador escapa & primeiro, e um valor como R&D &lt; &amp; &nbsp; chega ao PDF verbatim. Se constrói relatórios desta maneira, o passo a passo sobre exportar um TDataSet para um relatório PDF em Delphi cobre o resto do exportador

Vale a pena explicar uma vez porque é que o ampersand tem de ir primeiro. Escape < primeiro e obtém &lt;; escape & em segundo e isso torna-se &amp;lt;, que uma descodificação única correta mostra como &lt; em vez de <. Uma cadeia de substituições sequenciais só está correta quando o próprio carácter de escape é tratado antes de tudo o que o introduz

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

Para a 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 fora dos valores de atributos por completo

uses
  System.SysUtils, PDFlibrary;

// Escapa texto não confiável para conteúdo de texto HTML do PDFlibPas.
// O '&' tem de ser substituído primeiro, senão o ampersand 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 carácter a carácter. Antes da v3.539.47 o mesmo input escapado podia produzir uma anotação de link viva, e é essa a parte que transforma um defeito de visualização num problema de segurança: um comentário de ticket nunca devia poder plantar um URL clicável num documento em que a sua equipa confia

Note o que a função não escapa. Escapers HTML de uso geral também convertem " para &quot; e ' para &#39;, o que é correto num browser. A descodificação de texto do PDFlibPas reconhece só as quatro entidades listadas atrás, por isso essas duas imprimir-se-iam literalmente como &quot; e &#39;. Aspas são inofensivas em conteúdo de texto; só interessam dentro de valores de atributos, e o renderer não descodifica entidades em atributos de todo. O desenho seguro é por isso não um escaper melhor mas uma regra: dados não confiáveis nunca entram em href, src ou style. Se um destino de link realmente tem de vir de dados do utilizador, valide-o você próprio contra uma allow-list de esquemas e caracteres e rejeite tudo o que contenha aspas ou parênteses angulares

Duas notas de upgrade seguem-se diretamente da correção:

  • Se o seu código deixou de escapar & porque versões antigas imprimiam &amp; literalmente, volte a acrescentá-lo. Sem ele, texto de utilizador contendo &lt; agora mostra-se como <, ainda texto inofensivo mas já não o que o utilizador digitou
  • Não escape duas vezes. Texto que passa por dois escapers renderiza < como a grafia visível &lt;, por isso encontre a única fronteira onde os seus dados entram no HTML e escape aí apenas

Paginar com LeftOverText sem partir os escapings

O DrawHTMLTextBox devolve o HTML que não coube, habitualmente chamado LeftOverText, e desde a v3.539.47 esse remanescente preserva grafias literais de entidades e parênteses angulares escapados quando o passa para a caixa seguinte. A regra para quem chama é simples: devolva-o inalterado

const
  BoxLeft = 50;
  BoxTop = 50;
  BoxWidth = 495;    // dimensionado 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);
    // O LeftOverText já é HTML do engine escapado: nunca escape ou desfaça o escaping
    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 do engine, com os estilos já resolvidos, por isso não o passe pelo seu próprio escaper, não o descodifique, e não enxerte texto de utilizador nele. O limite de páginas é um seguro barato: se algum elemento nunca couber na caixa, um loop sem limite não tem saída natural

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

A lição geral: descodificar uma vez, recodificar em cada fronteira

Qualquer pipeline que analise texto, serialize o resultado de volta para a mesma sintaxe e o analise de novo tem de tratar a descodificação como uma operação que acontece num único sítio, e tem de recodificar em cada fronteira onde o texto descodificado volta a ser sintaxe. Template engines, sanitizers HTML e cadeias Markdown-para-HTML-para-PDF partilham esta forma e falham da mesma maneira quando um serializador se esquece de que produz markup

Os sintomas são previsíveis assim que conhece a forma. Recodificar de menos transforma dados em sintaxe, que é a direção da injeção. Codificar de mais, ou um descodificador que corre duas vezes, mostra grafias de entidades ao leitor ou come-as, que é a direção da visualização. Corrigir uma direção sozinho normalmente parte a outra, e é por isso que a correção do PDFlibPas teve de acrescentar a descodificação de &amp;, reordená-la, remover a descodificação tardia e acrescentar re-escaping na mesma versão. O mesmo princípio corre no sentido inverso quando o conteúdo PDF é exportado como texto estruturado, como na exportação semântica de PDF para Markdown e DOCX a partir de Delphi, onde cada carácter literal tem de 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 renderiza HTML ou Markdown que contém dados de utilizador
  • Escape conteúdo de texto com & primeiro, depois < e >; não converta aspas para texto do PDFlibPas
  • Escape uma vez, no único ponto onde os dados entram 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 descodificados em texto; as outras entidades mantêm-se literais
  • Devolva o LeftOverText ao DrawHTMLTextBox inalterado e limite o loop de páginas
  • Entregue tokens de continuação Markdown apenas ao DrawMarkdownTextBox ou ao DrawMarkdownText
  • Nunca procure padrões de bytes em buffers de bytes UTF-16; trabalhe sobre unidades de código inteiras

A renderização HTML e Markdown, a exportação de relatórios de datasets e o resto do layout engine vêm no código-fonte Pascal nativo da PDF Library for Delphi, para Delphi e Free Pascal. Veja a página de produto do PDFlibPas para edições, suporte de plataformas e download de trial