Las versiones de PDF Library for Delphi (PDFlibPas) anteriores a v3.539.47 podían decodificar el texto escapado dos veces al dibujar HTML o Markdown en un PDF. DrawHTMLText y DrawHTMLTextBox parsean el HTML, lo normalizan de vuelta a HTML y luego lo parsean otra vez, así que un texto escrito como <unsafe> llegaba al segundo parse como un tag real. Desde v3.539.47 cada entidad se decodifica exactamente una vez y el texto se re-escapa dondequiera que vuelva a ser HTML
El escenario que lo expone es de lo más común. 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 volvió <b>. Dentro del renderer ese escape se deshizo en silencio: el comentario salió en negrita, un nombre de tag desconocido simplemente desapareció de la página, y un anchor escapado se convirtió en una anotación de link clicable. Sin excepción, sin warning, un PDF perfectamente válido que dice algo distinto a los datos
¿Por qué el texto escapado se vuelve un tag real en el PDF?
El texto escapado se volvió markup porque el renderer corre dos pasadas de parse, y el paso de normalización entre ellas escribía el texto ya decodificado de vuelta a HTML sin escaparlo otra vez. Cada decode que hizo el primer parse quedaba entonces disponible para el segundo parse como sintaxis viva
Las dos pasadas existen por una buena razón. El primer parse construye una lista de elementos tag y word. NormalizeParsedHTML resuelve entonces la cascada del stylesheet: cruza las reglas de los bloques <style> contra cada tag, las mezcla con los atributos style inline, guarda el resultado en el tag, y serializa toda la lista de elementos de vuelta a un string HTML. La pasada de layout parsea ese string normalizado. Es la misma maquinaria que impulsa el flexbox, el CSS grid y el layout de footnotes en el render HTML de PDFlibPas
El defecto estaba en cómo se serializaban los words. Los tags se escribían de vuelta desde su forma original del source, mientras que los words se escribían en su forma decodificada. Un word que el primer parse había decodificado de <unsafe> a <unsafe> aterrizaba en el HTML normalizado como brackets angulares crudos, y el segundo parse lo leía como un elemento. Alrededor de ese bug central había tres fugas menores que apuntaban en la misma dirección:
&no estaba en el conjunto de entidades soportadas, así queR&Dse imprimía literal y no había forma de escribir una grafía de entidad literal como<como texto- La etapa de dibujo reemplazaba
una segunda vez, después de que el parse ya había terminado, así que una grafía de entidad literal podía desaparecer al final mismo - El escape de código Markdown se saltaba el ampersand, y el exportador de datasets escapaba solo los brackets angulares, así que las grafías de entidad dentro de código o 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 tag, 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 literalmente | R&D |
&lt; | &lt; impreso literalmente | < |
Code span de Markdown que contiene | Se volvía un espacio de no separación | dibujado como texto |
Valor de celda de dataset < | < | < |
Cómo v3.539.47 hace que el decode de entidades HTML sea de una sola pasada
PDFlibPas v3.539.47 hace que el decode de entidades sea de una sola pasada con tres cambios coordinados: el parser decodifica & al final, la etapa de dibujo ya no decodifica nada, y todo lugar que convierte words decodificados de vuelta a HTML los escapa primero otra vez
El conjunto de entidades soportadas para contenido de texto ahora es <, >, & y . Cualquier otra cosa, incluidas las referencias numéricas como A y las entidades nombradas como ", queda como texto literal. Esa frontera importa para cómo escape su propio input, como se muestra abajo
El orden dentro del decoder es la primera corrección. Si & se decodificara primero, el input &lt; se volvería < y el siguiente reemplazo lo convertiría en <, un doble decode que ocurre dentro de una sola pasada. El camino de words ANSI reemplaza entonces <, > y primero y & al final, así que el ampersand que produce nunca vuelve a examinarse. El camino de words UTF-16 es un único barrido de izquierda a derecha en pasos de dos bytes que reescribe cada coincidencia en su lugar y avanza más allá, lo que da la misma garantía por construcción
La segunda corrección elimina el reemplazo tardío de de la etapa de dibujo. El decode es asunto del parser y de nadie más, así que un word que llega al line breaker es texto final
La tercera corrección es la regla de la frontera. NormalizeParsedHTML ahora escapa &, < y > en cada word decodificado antes de agregárselo al HTML normalizado. El segundo parse lo decodifica de vuelta a exactamente el mismo texto, así que el efecto neto sobre todo el pipeline es un solo decode. El string de continuación sigue la misma regla: los words que no cupieron en la caja se escapan antes de agregarse a LeftOverText, y el resto del remanente se copia del HTML normalizado, que ya está en forma escapada. El loop que recolecta esos words sobrantes ahora también está acotado por el conteo de words, donde el viejo loop repeat podía pisar más allá del último word
¿Por qué el escape UTF-16BE no puede usar un replace a nivel de byte?
El escape UTF-16BE no puede usar un replace a nivel de byte porque el patrón de dos bytes de un ampersand puede quedar a caballo entre dos caracteres sin relación. La única unidad de trabajo correcta es el code unit de 16 bits completo
El renderer guarda los words Unicode como UTF-16 big-endian empaquetado en byte strings, byte alto primero. Un ampersand es 00 26. Tome ahora U+0100 (la A latina mayúscula con macrón, bytes 01 00) seguida 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 bytes de #0'&' encuentra un ampersand que no existe, empalma los bytes de & en medio de dos caracteres, y cizalla cada carácter posterior un byte
No es un caso de esquina exótico. Cualquier carácter cuyo byte bajo sea cero puede aportar la primera mitad; U+4E00, uno de los ideogramas CJK más frecuentes, califica. Los brackets angulares tienen la misma exposición: 00 3C y 00 3E aparecen siempre que ese carácter vaya seguido de uno del rango U+3C00 a U+3EFF en CJK Extension A. La corrección en EscapeHTMLWord desempaqueta los bytes a un WideString, escapa carácter por carácter y vuelve a empaquetar el resultado. El lado del decoder ya era seguro porque solo prueba patrones en fronteras pares de code units
La misma regla aplica a su propio código. Si alguna vez guarda texto UTF-16 como TBytes, por ejemplo después de 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 exportes de dataset: escape el ampersand primero
Desde v3.539.47 ambos productores de HTML dentro de PDFlibPas, el conversor de Markdown y el exportador de datasets, escapan el ampersand antes de los brackets angulares, así que el decode único del renderer restaura exactamente el texto original
En MarkdownToHTML, los code spans inline y los bloques de código fenced o indentados ahora mapean & a &, < a < y > a >, mientras que los espacios se vuelven y un tab se vuelve cuatro de ellos para conservar la indentación. La prosa Markdown corriente escapa solo los brackets angulares, así que el HTML crudo en prosa no puede inyectar tags mientras un autor todavía puede escribir & a propósito, tal como los autores de Markdown esperan. DrawMarkdownText y DrawMarkdownTextBox usan la misma conversión, así que el código aparece en el PDF tal como se tipeó:
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, '&' se vuelve '&' y '<' se vuelve '<'
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 como se tipeó, 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 escapaba solo los brackets angulares, y a propósito: el renderer no decodificaba &, así que escapar el ampersand habría impreso & en cada celda que contuviera uno. El workaround era correcto para el renderer viejo y equivocado en general, porque un valor de celda que casualmente contuviera < se decodificaba a <. Con el renderer corregido, el exportador escapa & primero, y un valor como R&D < & aterriza en el PDF textual. Si arma reportes de esa forma, el recorrido sobre cómo exportar un TDataSet a un reporte PDF en Delphi cubre el resto del exportador
Por qué el ampersand debe ir primero merece explicarse una vez. Escape < primero y obtiene <; escape & segundo y eso se vuelve &lt;, que un decode único correcto muestra como < en vez de <. Una cadena de reemplazos secuencial solo es correcta cuando el carácter de escape se procesa antes que cualquier cosa que lo introduzca
¿Cómo debe escapar texto no confiable para DrawHTMLTextBox?
Para el render HTML de PDFlibPas, escape el contenido de texto no confiable reemplazando &, luego <, luego >, exactamente una vez, y mantenga los datos no confiables fuera de los valores de atributos por completo
uses
System.SysUtils, PDFlibrary;
// Escapa texto no confiable para el contenido de texto HTML de PDFlibPas.
// '&' debe reemplazarse primero, de lo contrario 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 por carácter. Antes de v3.539.47 el mismo input escapado podía producir una anotación de link viva, que es la parte que convierte un glitch de visualización en un problema de seguridad: el comentario de un ticket jamás debería poder plantar una URL clicable 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 también convierten " a " y ' a ', lo que es correcto para un navegador. El decode 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 atributos, y el renderer no decodifica entidades en atributos para nada. El diseño seguro no es entonces un escapador mejor sino una regla: los datos no confiables nunca entran en href, src ni style. Si un target de link realmente tiene que venir de datos del usuario, valídelo usted mismo contra una allow-list de esquemas y caracteres, y rechace cualquier cosa que contenga comillas o brackets angulares
Dos notas de actualización se desprenden directo de la corrección:
- Si su código dejó de escapar
&porque las versiones viejas imprimían&literalmente, vuelva a agregarlo. Sin él, el texto de usuario que contenga<ahora se muestra como<, sigue siendo texto inofensivo pero ya no es lo que el usuario tipeó - No escape dos veces. Un texto que pasa por dos escapadores muestra
<como la grafía visible<, así que encuentre la única frontera donde sus datos entran al HTML y escape solo allí
Paginar con LeftOverText sin romper los escapes
DrawHTMLTextBox devuelve el HTML que no cupo, normalmente llamado LeftOverText, y desde v3.539.47 ese remanente preserva las grafías de entidad literales y los brackets angulares escapados cuando se lo pasa a la siguiente caja. La regla para quien llama es simple: páselo de vuelta sin cambios
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 del engine escapado: nunca lo escape ni lo 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 el remanente como opaco. Es el HTML normalizado del engine, con los estilos ya resueltos, así que no lo pase por su propio escapador, no lo decodifique, y no empalme texto de usuario dentro de él. El tope de páginas es un seguro barato: si algún elemento nunca puede caber en la caja, un loop sin tope no tiene salida natural
Markdown tiene su propia continuación. DrawMarkdownTextBox devuelve un token que arranca con un marcador interno para que la siguiente llamada se salte la conversión; entréguelo de vuelta a DrawMarkdownTextBox o DrawMarkdownText, no a los puntos de entrada HTML, que dibujarían el marcador como texto
La lección general: decode una vez, re-escape en cada frontera
Cualquier pipeline que parsea texto, serializa el resultado de vuelta a la misma sintaxis y lo parsea otra vez debe tratar el decode como una operación que ocurre en exactamente un lugar, y debe re-encodear en cada frontera donde el texto decodificado vuelve a ser sintaxis. Los template engines, los sanitizers de 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 que conoce la forma. Re-encodear de menos convierte datos en sintaxis, que es la dirección de la inyección. Encodear de más, o un decoder que corre dos veces, le muestra grafías de entidad al lector o se las come, que es la dirección de la visualización. Corregir una sola dirección normalmente rompe la otra, y por eso la corrección de PDFlibPas tuvo que agregar el decode de &, reordenarlo, eliminar el decode tardío y agregar re-escape en la misma versión. El mismo principio corre al revés cuando el contenido de un 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
Checklist 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, luego<y>; no convierta comillas para el texto de PDFlibPas - Escape una vez, en el único punto donde los datos entran al string HTML
- Mantenga los valores no confiables fuera de
href,srcystyle, o valídelos contra una allow-list - Espere que solo
<,>,&y se decodifiquen en texto; las demás entidades quedan literales - Pase
LeftOverTextde vuelta aDrawHTMLTextBoxsin cambios y ponga un tope al loop de páginas - Pase los tokens de continuación de Markdown solo a
DrawMarkdownTextBoxoDrawMarkdownText - Jamás busque patrones de bytes en buffers de bytes UTF-16; trabaje sobre code units completos
El render de HTML y Markdown, el exporte de reportes de datasets y el resto del layout engine vienen en el código fuente Pascal nativo de PDF Library for Delphi, para Delphi y Free Pascal. Vea la página del producto PDFlibPas para ediciones, soporte de plataformas y una descarga de prueba