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 <b>. 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 <unsafe> 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
&não estava no conjunto de entidades suportadas, por issoR&Dimprimia-se literalmente e não havia forma de escrever uma grafia literal de entidade como<como texto - A fase de desenho substituía
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
| Input que chega ao renderer | Antes da v3.539.47 | Desde a v3.539.47 |
|---|---|---|
<unsafe> | Analisado como tag, o texto nunca chega à página | <unsafe> desenhado como texto |
<b>x</b> | x desenhado a negrito | <b>x</b> desenhado como texto |
R&D | R&D impresso literalmente | R&D |
&lt; | &lt; impresso literalmente | < |
Code span Markdown contendo | Tornava-se um espaço não separável | desenhado como texto |
Valor de célula de dataset < | < | < |
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 & 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 <, >, & e . Qualquer outra coisa, incluindo referências numéricas como A e entidades nomeadas como ", 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 & fosse descodificado primeiro, o input &lt; tornar-se-ia < 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 <, > e primeiro e & 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
A segunda correção remove a substituição tardia de 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 & no meio de dois caracteres, e corta um byte a todos os caracteres seguintes
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 &, < para < e > para >, enquanto os espaços se tornam 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 & 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(''<tag> & R&D'');' + sLineBreak +
'```';
Lib := TPDFlib.Create;
try
// Inspecione o HTML: em código, '&' torna-se '&' e '<' torna-se '<'
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 &, por isso escapar o ampersand teria imprimido & 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 < era descodificado para <. Com o renderer corrigido, o exportador escapa & primeiro, e um valor como R&D < & 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 <; escape & em segundo e isso torna-se &lt;, que uma descodificação única correta mostra como < 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 '<' 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 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 " e ' para ', 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 " e '. 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&literalmente, volte a acrescentá-lo. Sem ele, texto de utilizador contendo<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<, 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 &, 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,srcestyle, ou valide-os contra uma allow-list - Espere que só
<,>,&e sejam descodificados em texto; as outras entidades mantêm-se literais - Devolva o
LeftOverTextaoDrawHTMLTextBoxinalterado e limite o loop de páginas - Entregue tokens de continuação Markdown apenas ao
DrawMarkdownTextBoxou aoDrawMarkdownText - 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