PDF Library for Delphi (PDFlibPas), en versiones anteriores a v3.539.47, podía decodificar dos veces el texto escapado al dibujar HTML o Markdown en un PDF. DrawHTMLText y DrawHTMLTextBox parsean el HTML, lo normalizan de vuelta a HTML y lo vuelven a parsear, de modo que un texto escrito como <unsafe> llegaba al segundo parseo como una etiqueta real. Desde v3.539.47 cada entidad se decodifica exactamente una vez y el texto se re-escapa en todos los puntos donde vuelve a convertirse en HTML
El escenario que destapa esto es de lo más corriente. Un help desk exporta tickets a PDF y el comentario del cliente entra en una plantilla HTML. El desarrollador hizo lo correcto y escapó el comentario, así que <b> se convirtió en <b>. Dentro del renderer ese escape se deshacía en silencio: el comentario salía en negrita, un nombre de etiqueta desconocido simplemente desaparecía de la página y un anchor escapado se convertía en una anotación de enlace pulsable. Sin excepción, sin aviso, un PDF perfectamente válido que dice algo distinto de lo que dicen los datos
¿Por qué un texto escapado se convierte en etiqueta real dentro del PDF?
El texto escapado se convertía en markup porque el renderer ejecuta dos pasadas de parseo, y el paso de normalización entre ambas escribía texto ya decodificado de vuelta a HTML sin volver a escaparlo. Cada decodificación que el primer parseo había hecho quedaba entonces a disposición del segundo como sintaxis viva
Las dos pasadas existen por una buena razón. El primer parseo construye una lista de elementos de etiqueta y palabra. NormalizeParsedHTML resuelve después la cascada de la hoja de estilos: cruza las reglas de los bloques <style> con cada etiqueta, las fusiona con los atributos style en línea, guarda el resultado en la etiqueta y serializa toda la lista de elementos de vuelta a una cadena HTML. La pasada de layout parsea esa cadena normalizada. Es la misma maquinaria que mueve flexbox, CSS grid y maquetación de footnotes en el renderizado HTML de PDFlibPas
El fallo estaba en cómo se serializaban las palabras. Las etiquetas se escribían de vuelta en su forma original del fuente, mientras que las palabras se escribían en su forma decodificada. Una palabra que el primer parseo había decodificado de <unsafe> a <unsafe> aterrizaba en el HTML normalizado como ángulos en crudo, y el segundo parseo la leía como un elemento. Alrededor de ese bug central había tres fugas menores que apuntaban al mismo sitio:
&no estaba en el conjunto de entidades soportadas, así queR&Dse imprimía literal y no había forma de escribir como texto una grafía de entidad literal como<- La etapa de dibujo sustituía
una segunda vez, cuando el parseo ya había terminado, de modo que una grafía de entidad literal aún podía desaparecer al final del todo - El escape de código Markdown se saltaba el ampersand, y el exportador de datasets solo escapaba los ángulos, así que las grafías de entidad dentro de código o de valores de celda se decodificaban como markup
| Entrada que llega al renderer | Antes de v3.539.47 | Desde v3.539.47 |
|---|---|---|
<unsafe> | Se parsea como etiqueta, el texto nunca llega a la página | <unsafe> dibujado como texto |
<b>x</b> | x dibujado en negrita | <b>x</b> dibujado como texto |
R&D | R&D impreso literal | R&D |
&lt; | &lt; impreso literal | < |
Fragmento de código Markdown con | Se convertía en un espacio de no separación | dibujado como texto |
Valor de celda de dataset < | < | < |
Cómo v3.539.47 deja la decodificación de entidades HTML en una sola pasada
PDFlibPas v3.539.47 deja la decodificación de entidades en una sola pasada con tres cambios coordinados: el parser decodifica & al final, la etapa de dibujo ya no decodifica nada y cada sitio que convierte palabras decodificadas de vuelta a HTML las escapa antes otra vez
El conjunto de entidades soportadas para contenido de texto es ahora <, >, & y . Cualquier otra cosa, incluidas las referencias numéricas como A y las entidades con nombre como ", se queda como texto literal. Esa frontera importa para cómo escape usted su propia entrada, como se ve abajo
El orden dentro del decodificador es la primera corrección. Si & se decodificara primero, la entrada &lt; se convertiría en < y la siguiente sustitución la convertiría en <, una doble decodificación dentro de una misma pasada. El camino de palabras ANSI sustituye por eso <, > y primero y & al final, de modo que el ampersand que produce no vuelve a examinarse nunca. El camino de palabras UTF-16 es un único barrido de izquierda a derecha en pasos de dos bytes que reescribe cada coincidencia en su sitio y salta más allá, lo que da la misma garantía por construcción
La segunda corrección elimina la sustitución tardía de de la etapa de dibujo. La decodificación pertenece al parser y a nadie más, así que una palabra que llega al que parte las líneas es texto final
La tercera corrección es la regla de la frontera. NormalizeParsedHTML escapa ahora &, < y > en cada palabra decodificada antes de añadirla al HTML normalizado. El segundo parseo la decodifica de vuelta a exactamente el mismo texto, así que el efecto neto sobre todo el pipeline es una decodificación. La cadena de continuación sigue la misma regla: las palabras que no caben en la caja se escapan antes de añadirse a LeftOverText, y el resto de lo sobrante se copia del HTML normalizado, que ya está en forma escapada. El bucle que recoge esas palabras sobrantes también está acotado por el recuento de palabras ahora, donde el antiguo bucle repeat podía pisar más allá de la última palabra
¿Por qué el escape UTF-16BE no puede usar una sustitución a nivel de byte?
El escape UTF-16BE no puede usar una sustitución a nivel de byte porque el patrón de dos bytes de un ampersand puede cabalgar entre dos caracteres sin relación. La única unidad de trabajo correcta es la unidad de código completa de 16 bits
El renderer guarda las palabras Unicode como UTF-16 big-endian empaquetado en cadenas de bytes, byte alto primero. Un ampersand es 00 26. Tome ahora U+0100 (A latina mayúscula con macrón, bytes 01 00) seguido de U+2603 (el muñeco de nieve, bytes 26 03). La secuencia de bytes es 01 00 26 03, y los bytes dos y tres se leen 00 26. Una búsqueda por byte de #0'&' encuentra un ampersand que no existe, empalma los bytes de & en medio de dos caracteres y cizalla en un byte todos los caracteres siguientes
No es un caso raro de precisos. Cualquier carácter cuyo byte bajo sea cero puede aportar la primera mitad; U+4E00, uno de los ideogramas CJK más frecuentes, cumple. Los ángulos tienen la misma exposición: 00 3C y 00 3E aparecen cada vez que un carácter así va seguido de uno del rango U+3C00 a U+3EFF en el anexo CJK A. La corrección en EscapeHTMLWord desempaqueta los bytes a un WideString, escapa carácter a carácter y vuelve a empaquetar el resultado. El lado del decodificador ya era seguro porque solo prueba patrones en fronteras pares de unidad de código
La misma regla vale para su propio código. Si alguna vez guarda texto UTF-16 como TBytes, por ejemplo tras TEncoding.BigEndianUnicode.GetBytes, no lo busque por patrones de bytes. Conviértalo de vuelta a string y trabaje sobre caracteres
Bloques de código Markdown y exportaciones de dataset: escape primero el ampersand
Desde v3.539.47 los dos productores HTML dentro de PDFlibPas, el conversor de Markdown y el exportador de datasets, escapan el ampersand antes de los ángulos, de modo que la única decodificación del renderer restaura exactamente el texto original
En MarkdownToHTML, los fragmentos de código en línea y los bloques de código delimitados o indentados mapean ahora & a &, < a < y > a >, mientras que los espacios se convierten en y un tabulador en cuatro de ellos para conservar la indentación. La prosa Markdown corriente solo escapa los ángulos, así que el HTML en crudo de la prosa no puede inyectar etiquetas mientras un autor puede seguir escribiendo & a propósito, bastante como los autores de Markdown esperan. DrawMarkdownText y DrawMarkdownTextBox usan la misma conversión, así que el código aparece en el PDF exactamente como se tecleó:
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
// Inspeccione el HTML: en código, '&' pasa a '&' y '<' pasa a '<'
Html := Lib.MarkdownToHTML(Md);
Lib.SetOrigin(1); // origen arriba a la izquierda, Y crece hacia abajo
Lib.SetMeasurementUnits(0); // puntos
// La página muestra el código tal cual se tecleó, grafías de entidad incluidas
Lib.DrawMarkdownText(50, 50, 495, Md);
Lib.SaveToFile('code-sample.pdf');
finally
Lib.Free;
end;
end;
El exportador de datasets es el caso instructivo. Antes de v3.539.47 solo escapaba los ángulos, y a propósito: el renderer no decodificaba &, de modo que escapar el ampersand habría impreso & en cada celda que contuviera uno. El workaround era correcto para el renderer antiguo y erróneo en general, porque un valor de celda que contuviera por casualidad < se decodificaba a <. Con el renderer arreglado, el exportador escapa & primero, y un valor como R&D < & aterriza en el PDF literal. Si monta informes así, el recorrido de exportar un TDataSet a un informe PDF en Delphi cubre el resto del exportador
Merece la pena explicar una vez por qué el ampersand debe ir primero. Escape < primero y obtiene <; escape & después y eso se convierte en &lt;, que una única decodificación correcta muestra como < en lugar de <. Una cadena de sustituciones secuenciales solo es correcta cuando el carácter de escape se trata antes que nada que lo introduzca
¿Cómo se escapa texto no fiable para DrawHTMLTextBox?
Para el renderizado HTML de PDFlibPas, escape el contenido de texto no fiable sustituyendo &, luego < y luego >, exactamente una vez, y mantenga los datos no fiables fuera de los valores de atributo por completo
uses
System.SysUtils, PDFlibrary;
// Escapa texto no fiable para el contenido de texto HTML de PDFlibPas.
// '&' debe sustituirse primero; si no, el ampersand dentro de
// un '<' ya producido quedaría escapado una 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;
En v3.539.47 un comentario como Try <a href="https://example.com">this</a> & <b> aparece en la página carácter a carácter. Antes de v3.539.47 la misma entrada escapada podía producir una anotación de enlace viva, que es la parte que convierte un fallo de visualización en un problema de seguridad: el comentario de un ticket no debería poder plantar una URL pulsable en un documento en el que su equipo confía
Fíjese en lo que la función no escapa. Los escapadores HTML de propósito general convierten además " a " y ' a ', que es lo correcto para un navegador. La decodificación de texto de PDFlibPas solo reconoce las cuatro entidades listadas antes, así que esas dos se imprimirían literalmente como " y '. Las comillas son inofensivas en contenido de texto; solo importan dentro de valores de atributo, y el renderer no decodifica entidades en atributos en absoluto. El diseño seguro no es por tanto un escapador mejor sino una regla: los datos no fiables nunca entran en href, src ni style. Si un destino de enlace tiene que salir sí o sí de datos de usuario, valídelo usted mismo contra una allow-list de esquemas y caracteres y rechace todo lo que lleve comillas o ángulos
Del arreglo se deducen directamente dos notas de actualización:
- Si su código dejó de escapar
&porque las versiones antiguas imprimían&literalmente, devuélvalo. Sin él, el texto de usuario que contenga<se muestra ahora como<, sigue siendo texto inofensivo pero ya no es lo que el usuario tecleó - No escape dos veces. Un texto que pasa por dos escapadores renderiza
<como la grafía visible<, así que localice la única frontera donde sus datos entran en HTML y escape solo ahí
Paginar con LeftOverText sin romper los escapes
DrawHTMLTextBox devuelve el HTML que no cupo, llamado normalmente LeftOverText, y desde v3.539.47 ese resto conserva las grafías de entidad literales y los ángulos escapados cuando usted se lo pasa a la siguiente caja. La regla para quien llama es simple: páseselo de vuelta sin tocar
const
BoxLeft = 50;
BoxTop = 50;
BoxWidth = 495; // dimensionado para una página A4 en puntos
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 ya es HTML escapado del motor: no lo escape ni lo desescape nunca
Rest := Lib.DrawHTMLTextBox(BoxLeft, BoxTop, BoxWidth, BoxHeight, Rest);
end;
if Rest <> '' then
raise Exception.CreateFmt('Content still left after %d pages', [MaxPages]);
end;
Trate el resto como opaco. Es el HTML normalizado del motor, con los estilos ya resueltos, así que no lo pase por su propio escapador, no lo decodifique y no le empalme texto de usuario. El tope de páginas es un seguro barato: si algún elemento no cabe nunca en la caja, un bucle sin tope no tiene salida natural
Markdown tiene su propia continuación. DrawMarkdownTextBox devuelve un token que empieza con un marcador interno para que la siguiente llamada pueda saltarse la conversión; devuélvaselo a DrawMarkdownTextBox o a DrawMarkdownText, no a los puntos de entrada HTML, que dibujarían el marcador como texto
La lección general: decodificar una vez, re-codificar en cada frontera
Cualquier pipeline que parsea texto, serializa el resultado de vuelta a la misma sintaxis y lo vuelve a parsear debe tratar la decodificación como una operación que ocurre en exactamente un sitio, y debe re-codificar en cada frontera donde el texto decodificado vuelve a ser sintaxis. Los motores de plantillas, los sanitizadores HTML y las cadenas Markdown-a-HTML-a-PDF comparten esta forma y fallan igual cuando un serializador olvida que produce markup
Los síntomas son predecibles una vez conocida la forma. Re-codificar de menos convierte datos en sintaxis, que es la dirección de la inyección. Codificar de más, o un decodificador que corre dos veces, muestra grafías de entidad al lector o se las come, que es la dirección de la visualización. Arreglar una sola dirección suele romper la otra, que es por lo que el arreglo de PDFlibPas tuvo que añadir la decodificación de &, reordenarla, retirar la decodificación tardía y añadir el re-escape en la misma versión. El mismo principio corre al revés cuando el contenido PDF se exporta como texto estructurado, como en la exportación semántica de PDF a Markdown y DOCX desde Delphi, donde cada carácter literal debe escaparse para la sintaxis destino exactamente una vez
Lista de referencia rápida
- Actualice a PDFlibPas v3.539.47 o posterior si renderiza HTML o Markdown que contenga datos de usuario
- Escape el contenido de texto con
&primero y luego<y>; no convierta comillas para el texto de PDFlibPas - Escape una vez, en el único punto donde los datos entran en la cadena HTML
- Mantenga los valores no fiables fuera de
href,srcystyle, o valídelos contra una allow-list - Espere que solo
<,>,&y se decodifiquen en texto; las demás entidades se quedan literales - Pase
LeftOverTextde vuelta aDrawHTMLTextBoxsin tocar y ponga tope al bucle de páginas - Pase los tokens de continuación de Markdown solo a
DrawMarkdownTextBoxo aDrawMarkdownText - Nunca busque patrones de bytes en buffers de bytes UTF-16; trabaje sobre unidades de código completas
El renderizado HTML y Markdown, la exportación de informes de dataset y el resto del motor de layout vienen en el fuente Pascal nativo de PDF Library for Delphi, para Delphi y Free Pascal. Vea la página de producto de PDFlibPas para ediciones, plataformas soportadas y una descarga de prueba