Artículo técnico

HTML a PDF en Delphi: la ruta de medios paginados de HTML5

HotPDF convierte HTML en páginas PDF mediante THPDFHTMLImporter.RenderHTML5, un perfil de medios paginados versionado con un DOM real, una cascada CSS real, tablas, saltos de página y generación opcional de AcroForm a partir de controles de formulario HTML. Se ejecuta enteramente dentro del proceso: sin motor de navegador, sin Chromium, sin conversor externo, y por defecto sin ningún acceso a archivos ni a la red

La razón de que exista un punto de entrada aparte en lugar de una mejora de RenderHTML es la compatibilidad. El método antiguo lleva años renderizando facturas y cartas en aplicaciones de producción, y un DOM permisivo con una cascada completa cambia la salida de documentos que se ajustaron al comportamiento anterior. Así que el método antiguo conserva su semántica exacta y todo lo nuevo vive detrás de RenderHTML5, cuya versión de perfil se reporta mediante HTML5ProfileVersion y cuyos hitos soportados se listan mediante HTML5ProfileMilestones

¿Qué añade realmente el perfil HTML5?

En la práctica importan cuatro capacidades. Un DOM estructural donde html y body son contenedores y no nodos de texto. Una cascada CSS con la precedencia correcta. Maquetación de tablas. Y control explícito de saltos de página, que es lo que significa «medios paginados»: HTML se diseñó para un viewport con desplazamiento, y convertirlo en páginas exige decidir dónde se permite que el contenido se corte

El asunto de los contenedores suena a detalle de implementación, pero en realidad es la diferencia entre que algo funcione o no. Si los elementos raíz se tratan como contenedores de texto, todo el DOM se colapsa en un único bloque de texto durante el análisis, y a partir de ahí cada regla de hoja de estilos, cada salto de página, cada tabla y cada subárbol de formulario se aplica sobre nada. Cualquier renderizador de HTML que produzca un único muro de texto indiferenciado a partir de un documento estructurado ha cometido exactamente este error

El orden de la cascada, explicado paso a paso

La precedencia de CSS no es una única ordenación. HotPDF aplica las declaraciones normales de las reglas coincidentes por orden de especificidad y luego por orden de origen, aplica encima las declaraciones normales en línea, después las declaraciones !important de las reglas coincidentes, y por último las !important en línea. Cuatro pasadas, en ese orden

HotPDF resuelve cada propiedad CSS en Delphi mediante cuatro pasadas de cascada ordenadas, desde la coincidencia de reglas normales hasta las declaraciones !important en línea
Las pasadas posteriores ganan los empates de forma deliberada: las declaraciones en línea superan a las hojas de estilos, y !important supera a todo lo declarado normalmente

Las implementaciones que reducen esto a «gana el último» aciertan en los casos comunes y fallan en los interesantes, lo cual es peor que fallar de forma uniforme, porque el fallo solo aparece en las plantillas que usan !important para sobrescribir una hoja de estilos base — precisamente las plantillas escritas por personas que esperan que la cascada funcione:

uses
  HPDFDoc, HPDFHTMLImport;

var
  Pdf: THotPDF;
  Importer: THPDFHTMLImporter;
  Html, Css: WideString;
begin
  Pdf := THotPDF.Create(nil);
  Importer := THPDFHTMLImporter.Create(Pdf);
  try
    Pdf.FileName := 'statement.pdf';
    Pdf.BeginDoc;

    Importer.Margin := 56;              // puntos
    Importer.BaseFontName := 'Arial';
    Importer.BaseFontSize := 10;
    Importer.CreateAcroForms := False;  // renderiza los controles, no genera campos

    Css := '@page { margin: 56pt } ' +
           'table { border-collapse: collapse } ' +
           'tr.total { page-break-inside: avoid; font-weight: bold }';
    Html := LoadTemplate('statement.html');

    if not Importer.RenderHTML5(Html, Css) then
      raise Exception.Create('nothing was emitted - check the markup');

    Pdf.EndDoc;

    Writeln(Format('profile=%d nodes=%d rules=%d layout ops=%d',
      [Importer.Statistics.ProfileVersion,
       Importer.Statistics.ParsedNodeCount,
       Importer.Statistics.CSSRuleCount,
       Importer.Statistics.LayoutOperationCount]));
  finally
    Importer.Free;
    Pdf.Free;
  end;
end;

El segundo argumento es una hoja de estilos de autor que se aplica junto con lo que el documento ya lleva, lo cual es la forma limpia de imponer normas propias, como márgenes de página y control de viudas, sin editar cada plantilla

¿Por qué el importador nunca hace peticiones de ningún tipo?

Un conversor de HTML a PDF que resuelve referencias externas es, en el fondo, un motor de server-side request forgery disfrazado de generador de documentos. Apúntalo a marcado suministrado por el usuario y solicitará sin dudarlo http://169.254.169.254/ o una URL de archivo de tu servidor de aplicaciones, e incrustará el resultado en un PDF que alguien descargará

Por eso HotPDF no lee archivos ni la red por defecto. Las imágenes y demás recursos llegan exactamente por dos vías: como URI data: dentro del marcado, o mediante el callback OnResource, donde tu código decide qué significa una URI y suministra los bytes:

El importador de HTML de HotPDF en Delphi resuelve cada URI de recurso de forma local, aceptando URI data y callbacks OnResource mientras rechaza peticiones a archivos y a la red, limitado por techos de recuento y de bytes
Solo los bytes que llegan de forma local alcanzan la página, las referencias rechazadas incrementan el contador, y ninguna petición sale jamás del proceso
procedure TReportForm.SupplyResource(Sender: TObject;
  const URI: WideString; ResourceKind: THPDFHTMLResourceKind;
  var Data: TBytes; var ContentType: string; var Handled: boolean);
begin
  Handled := False;
  // Sirve únicamente desde un directorio local de assets en lista blanca.
  // Nunca pases la URI a un cliente HTTP, y nunca la combines con una
  // ruta sin validarla primero
  if StartsText('asset:', URI) then
  begin
    Data := LoadAssetBytes(Copy(URI, 7, MaxInt));
    ContentType := 'image/png';
    Handled := Length(Data) > 0;
  end;
end;

Cada recurso que sí llega se sigue contabilizando contra tres límites independientes: cuántos recursos, cuántos bytes tiene cada uno, y cuántos bytes en total. Un callback que devuelve fielmente lo que se le pide no puede ser inducido a agotar la memoria por un documento que referencia la misma imagen de 50 MB doscientas veces

Cuatro presupuestos, un único modo de fallo

Los nodos del analizador, las reglas CSS, las operaciones de maquetación y los recursos se miden de forma independiente, porque fallan de forma independiente. Un documento puede ser pequeño y patológicamente costoso de maquetar; otro puede ser superficial pero con una hoja de estilos diseñada para hacer explotar la coincidencia de selectores. Agotar cualquiera de ellos lanza EHPDFHTMLImportBudget, una clase de excepción dedicada para que el agotamiento del presupuesto pueda capturarse por separado de una entrada malformada

Las estadísticas se reinician al comienzo de cada llamada a RenderHTML5, de modo que los números describen una importación y no la vida útil del objeto importador. En un servicio que renderiza plantillas no confiables, registra ParsedNodeCount, LayoutOperationCount y ResourceRejectCount por documento: un recuento de rechazos que aumenta suele significar que el autor de la plantilla está referenciando assets que tu callback no reconoce, y un recuento de operaciones de maquetación cercano al límite significa que el siguiente documento, apenas un poco más grande, fallará

Elegir entre la importación de HTML y la API de maquetación

La importación de HTML gana cuando el contenido lo redactan como HTML personas que no son desarrolladores de Delphi, cuando las plantillas cambian con más frecuencia que el código, o cuando el mismo contenido también debe enviarse como correo electrónico. El árbol de maquetación declarativa descrito en maquetación PDF declarativa con salida etiquetada gana cuando el documento lo genera tu propio código a partir de datos, cuando necesitas que la estructura PDF/UA se emita automáticamente, o cuando el control preciso sobre la paginación importa más que la comodidad de redacción

No son excluyentes. Una combinación habitual es usar importación de HTML para el cuerpo de un informe, con los bloques de encabezado, pie y firma dibujados directamente, ya que esos tienen una geometría fija que ninguna plantilla debería controlar. Las tablas son el único terreno donde vale la pena medir ambas opciones: las tablas financieras complejas a veces paginan mejor mediante la ruta dedicada de tablas descrita en generar tablas en PDF

Define CreateAcroForms de forma deliberada. Desactivado, los controles de formulario HTML se renderizan como arte estático, que es lo que quiere un extracto impreso. Activado, se convierten en campos AcroForm interactivos reales, que es lo que quiere un formulario rellenable, y el comportamiento de los campos que se deriva de ello se trata en añadir campos de formulario a un PDF cargado

La importación de HTML, la maquetación declarativa y la API de dibujo directo actúan todas sobre el mismo objeto documento en Delphi y C++Builder; la lista completa de funciones está en la página del componente HotPDF Delphi PDF