HotPDF Component maqueta texto multicol de CSS cuando THPDFHTMLImporter.RenderHTML5 convierte HTML a PDF en Delphi. El motor HTML paginado resuelve column-count y column-width contra el content box, equilibra el último fragmento de cada contenedor multicol con una búsqueda binaria, llena las páginas anteriores columna a columna hasta el fondo de la página, y soporta column-fill, column-span: all y column-rule
El motivo habitual para querer esto es aburrido y real: un boletín de producto a dos columnas, un anexo de términos y condiciones compuesto en tres columnas estrechas, o un PDF de release notes que debería parecerse al boletín impreso que ya diseñó el equipo de marketing en HTML. Sin soporte de columnas el importador apila cada párrafo en una medida ancha única, la longitud de línea se duplica, y el documento se lee como un fichero de log. Con él, la misma hoja de estilos que dirige la vista previa del navegador dirige el PDF, y lo difícil pasa a ser la paginación, cosa de la que los navegadores casi nunca tienen que ocuparse en pantalla
Cómo decide HotPDF cuántas columnas usar
HotPDF resuelve el recuento de columnas con las mismas reglas que el pseudoalgoritmo de CSS Multi-column Layout Module Level 1 §3.4, aplicado a la anchura del content box W después de quitar padding y bordes. Un column-count declarado se toma tal cual. Cuando solo se da column-width, el motor calcula N = Trunc((W + gap) / (column-width + gap)) con un suelo de una columna. Cuando ambos están presentes, el resultado es el menor de los dos, así que el recuento declarado puede limitar el derivado pero nunca inflarlo
Un caso concreto: un content box de 483pt (A4 con márgenes laterales de 56pt, redondeados) y column-width: 140pt; column-gap: 12pt da Trunc(495 / 152) = 3 columnas, cada una de (483 - 2 * 12) / 3 = 153pt de ancho. Añadir column-count: 2 a la misma regla lo baja a dos columnas, porque gana el mínimo. La palabra clave de gap normal se resuelve a 1em del tamaño de fuente del contenedor, y esa resolución ocurre en tiempo de layout en vez de en tiempo de parseo, así que un font-size declarado después de column-gap en la misma regla produce igualmente el gap correcto
Un guarda no está en la especificación, y está ahí a propósito. Después de calcular la anchura de columna, cualquier valor por debajo de 12pt colapsa el contenedor de vuelta a una sola columna. Una hoja de estilos que dice column-count: 9999, sea por accidente o porque vino de una plantilla no fiable, produciría si no miles de columnas con anchuras negativas o microscópicas y un partir de líneas que intenta meter un glifo por línea. Volver a una columna mantiene la salida legible y el coste de layout acotado. Los recuentos cero o negativos nunca llegan tan lejos: el validador solo acepta auto o un entero positivo para column-count, y column-width y column-gap toman auto o normal respectivamente, o una longitud en puntos o em
Renderizar una sección HTML multicol a PDF en Delphi
La salida multicol no necesita ningún interruptor en el importador. THPDFHTMLImporter se crea con el documento THotPDF en el que dibuja, BeginDoc debe haber corrido ya, y las propiedades de columna llegan por la cascada normal, ya sea en el markup o en la hoja de estilos del autor pasada como segundo argumento de RenderHTML5
uses
SysUtils, HPDFDoc, HPDFHTMLImport;
procedure BuildBulletin(const FileName: string);
var
Pdf: THotPDF;
Importer: THPDFHTMLImporter;
Html, Css: WideString;
begin
Pdf := THotPDF.Create(nil);
Importer := THPDFHTMLImporter.Create(Pdf);
try
Pdf.FileName := FileName;
Pdf.BeginDoc;
Importer.Margin := 56;
Importer.BaseFontName := 'Arial';
Importer.BaseFontSize := 10;
Css := 'section.news { column-width: 140pt; column-gap: 12pt; ' +
' column-rule: 0.5pt solid #999999 } ' +
'section.news h2 { column-span: all }';
Html := '<section class="news">' +
'<p>Release 4.2 ships the new export pipeline...</p>' +
'<p>Licensing changes take effect on 1 March...</p>' +
'<h2>Known issues</h2>' +
'<p>Import of legacy archives & templates...</p>' +
'</section>';
if not Importer.RenderHTML5(Html, Css) then
raise Exception.Create('Nothing was emitted');
Pdf.EndDoc;
finally
Importer.Free;
Pdf.Free;
end;
end;
Fijaos en el doble escape del último párrafo: & dentro del string Pascal es la entidad HTML de un ampersand, que el importador decodifica de vuelta a un solo carácter. El resto de la cascada, los presupuestos y los callbacks de recursos se comportan exactamente como se describe en la visión general de la importación paged-media HTML5 de HotPDF; las columnas son solo un modo de layout más dentro del mismo perfil
Equilibrar columnas con una búsqueda binaria
HotPDF equilibra columnas buscando la menor altura de columna a la que un relleno avaro aún coloca cada línea restante. La pasada avara es simple: las líneas entran en la columna uno hasta que la siguiente excedería la altura candidata, luego siguen en la columna dos, y así. La búsqueda empieza con la línea más alta como cota inferior y la suma de todas las alturas de línea como cota superior, divide el intervalo a la mitad cada ronda, y se para cuando las cotas están a menos de 0,05pt
Una búsqueda binaria solo funciona si el predicado es monótono, y aquí lo es: una columna más alta nunca puede contener menos líneas que una más baja, así que «cada línea cabe» pasa de falso a verdadero exactamente una vez mientras la altura crece. Esa propiedad es la que garantiza la convergencia y garantiza que el relleno avaro final a la altura encontrada consume todas las líneas, en lugar de dejar una línea huérfana que dispararía una página extra espuria. También explica un detalle del resultado: la banda equilibrada es tan corta como lo permiten las cajas de línea, no necesariamente columnas iguales, porque las líneas son indivisibles y la última columna puede acabar una o dos líneas más arriba que las demás
Qué pasa cuando las columnas cruzan un salto de página
Las columnas que no caben en la página actual se llenan secuencialmente hasta el fondo de la página, y solo el fragmento final de página se equilibra. Este es el comportamiento que CSS Multi-column Layout Level 1 §7.1 prescribe para column-fill: balance en contextos fragmentados, y es lo que parece un boletín impreso: columnas llenas en cada página salvo la última, donde el texto restante se reparte de forma pareja en lugar de dejar una columna alta junto a una vacía
Cada fragmento de página pasa por el mismo fragmentador de forma independiente. Si las líneas restantes caben en N columnas de la altura disponible, el fragmento se equilibra; si no, cada columna se llena hasta el fondo de la página y el bucle empieza una página nueva. La altura natural del contenedor completo, que el motor usa para decisiones de break-inside: avoid y medición de extensión, es la altura de banda equilibrada con capacidad ilimitada, así que un contenedor que cabería en una página nueva como bloque equilibrado se mueve allí intacto en lugar de rebanarse. Cuando una sola línea es más alta que una página entera, el motor relaja la restricción, coloca la línea igualmente arriba en una página, e informa un diagnóstico hdkPaginationConstraint con el subject multi-column
column-fill: auto y cajas de altura fija
Con column-fill: auto y una height definida, HotPDF llena las columnas en orden, y cada altura de columna es la menor entre el espacio de página restante y la altura del contenedor menos su padding y bordes. El mínimo importa más de lo que parece. Usar solo el espacio de página restante dejaría que la primera columna se tragara todas las líneas siempre que a la página le quede más sitio que el de la caja. Sin altura definida, column-fill: auto vuelve al equilibrio, como hacen los navegadores, ya que un contenedor multicol sin restricciones no tiene altura de columna que llenar; la primera implementación se perdió ese fallback, y una de las 27 comprobaciones de regresión lo cazó
column-span: all y párrafos anidados
HotPDF soporta column-span: all en hijos directos de un contenedor multicol, siguiendo CSS Multi-column Layout Level 1 §6.1. Un hijo que abarca divide el contenedor en segmentos: los hijos anteriores forman una banda de columnas, el elemento que abarca se renderiza a ancho completo, y los hijos posteriores empiezan una banda nueva. Cada banda se equilibra, llena, rula y pagina por su cuenta, así que un encabezado en medio de una sección de tres columnas produce dos bandas equilibradas con un encabezado a ancho completo entre ellas, que es el layout que la gente dibuja de verdad en los mockups
Llegar ahí exigió arreglar primero un problema menos visible. Un contenedor como <div style="column-count:2"> con varios hijos <p> renderizaba antes cada párrafo en una única columna apilada, porque cada párrafo se maquetaba como su propio bloque de texto y la declaración de columnas del padre nunca se aplicaba. HotPDF ahora recoge el texto de los hijos de bloque anidados en un único flujo para el contenedor. El recolector tiene que insertar un salto duro en cada frontera de bloque mientras lo hace; saltárselo concatena la última palabra de un párrafo con la primera palabra del siguiente en la misma línea, un bug fácil de perder cuando el texto de prueba acaba en puntuación por casualidad
Ese flujo fusionado tiene un alcance deliberado. Solo se usa cuando todo el subárbol es flujo de texto plano. Un contenedor que sostiene controles de formulario, imágenes, tablas, listas o <hr> en cualquier parte de su interior se queda en la ruta ordinaria de bloques y se renderiza en una sola columna, porque el recolector de texto fusionado no puede expresar su geometría. Las tablas en particular tienen sus propias reglas de paginación, cubiertas en el artículo sobre el manejo de rowspan y los encabezados de tabla repetidos en HotPDF, y mezclarlas en una banda de columnas rompería esas garantías
Dónde va la columna de reglas, y cómo probarla
HotPDF dibuja column-rule en la mediana de cada gap, en el borde derecho de una columna más la mitad del gap, desde arriba hasta abajo de la banda en cada fragmento de página, como describe CSS Multi-column Layout Level 1 §4. La anchura por defecto es 1pt. El color por defecto es el color de texto del elemento cuando no está declarado o es currentColor. La forma abreviada column-rule sin palabra clave de estilo se trata como solid, igual que se suele leer la forma abreviada border, mientras que none y hidden suprimen la regla
La geometría de columnas es observable sin parsear el PDF. El evento OnLineLayout de THPDFHTMLImporter se dispara una vez por línea dibujada con un registro THPDFHTMLLineLayout, y como el motor dibuja cada columna por separado, el campo X de cada línea es el borde izquierdo de su columna. Las columnas adyacentes difieren exactamente en la anchura de columna más el gap, lo que hace fácil distinguir un gap de 12pt de uno de 20pt en una prueba de regresión
type
TColumnProbe = class
public
Lines: TStringList;
constructor Create;
destructor Destroy; override;
procedure LineDrawn(Sender: TObject; const Line: THPDFHTMLLineLayout);
end;
constructor TColumnProbe.Create;
begin
inherited Create;
Lines := TStringList.Create;
end;
destructor TColumnProbe.Destroy;
begin
Lines.Free;
inherited;
end;
procedure TColumnProbe.LineDrawn(Sender: TObject;
const Line: THPDFHTMLLineLayout);
begin
// PageIndex es zero-based; X es el borde izquierdo de la columna de la línea
Lines.Add(Format('page=%d x=%.1f y=%.1f %s',
[Line.PageIndex, Line.X, Line.Y, string(Line.Text)]));
end;
// Cableado, antes de RenderHTML5:
// Probe := TColumnProbe.Create;
// Importer.OnLineLayout := Probe.LineDrawn;
Dos notas prácticas de escribir pruebas contra este evento. PageIndex es zero-based, que es lo primero que una aserción escrita a mano suele torcer. Y X incluye desplazamientos de alineación, así que para texto centrado o alineado a la derecha comparad posiciones de columna en fixtures alineadas a la izquierda. La regla en sí no aparece en el evento; para afirmar su posición, escanead el content stream sin comprimir buscando los operadores verticales m y l
Límites y trampas que conviene conocer
Algunos comportamientos son aproximaciones, y es mejor conocerlos antes de una revisión de layout que después. El espaciado de párrafos dentro de un flujo de columnas fusionado se aproxima con una línea de la altura de línea del contenedor en cada frontera de bloque, así que los márgenes por párrafo dentro de una banda de columnas no se reproducen exactamente. Cuando column-span: all divide un contenedor, cada segmento repite la caja de padding y fondo del contenedor, lo que mantiene los segmentos visualmente continuos pero no es una única caja en el sentido estricto de CSS. Todo valor de column-rule-style distinto de none y hidden pasa la validación, pero la regla se traza como una línea sólida
Los valores no soportados no fallan en silencio. Poner StrictCSS a True convierte cada diagnóstico en una excepción EHPDFHTMLUnsupportedCSS, mientras que OnDiagnostic deja que un trabajo por lotes los registre y siga. Fijaos en que el modo estricto también se aplica al diagnóstico de paginación, así que un documento con una línea más alta que una página lanzará bajo StrictCSS en lugar de relajar la restricción en silencio
procedure TReportJob.HtmlDiagnostic(Sender: TObject;
const Diagnostic: THPDFHTMLDiagnostic);
begin
case Diagnostic.Kind of
hdkUnsupportedProperty, hdkUnsupportedValue:
FLog.Add('CSS ' + Diagnostic.Subject + ': ' + Diagnostic.MessageText);
hdkPaginationConstraint:
FLog.Add('Layout relaxed (' + Diagnostic.Subject + '): ' +
Diagnostic.MessageText);
else
FLog.Add(Diagnostic.MessageText);
end;
end;
// Autoría de plantillas: fallar rápido en cualquier cosa que el motor ignoraría
Importer.StrictCSS := FValidatingTemplate;
Importer.OnDiagnostic := HtmlDiagnostic;
El modo estricto es el ajuste correcto mientras se escribe una plantilla, ya que una errata como column-span: both se rechaza de inmediato en lugar de dejar en silencio que el encabezado fluya dentro de las columnas. En producción, registrar a través de OnDiagnostic suele ser el mejor intercambio, porque una declaración rara en HTML suministrado por el cliente no debería costar el documento entero. Los diagnósticos idénticos (mismo kind, subject y valor) se informan una vez mientras quepan en la lista MaxDiagnostics, que por defecto tiene 256 entradas, así que una declaración que la cascada visita para cientos de elementos no inunda el log
FAQ
¿Soporta HotPDF la forma abreviada columns?
Sí. La forma abreviada columns acepta como máximo un recuento entero positivo y una longitud de columna en cualquier orden, y auto reinicia ambos longhands. Los valores resueltos siguen las mismas reglas de recuento y anchura que las propiedades individuales
¿Puede un contenedor multicol sostener imágenes o tablas?
Puede sostenerlas, pero HotPDF renderiza entonces ese contenedor en una sola columna. El flujo de columnas solo se aplica cuando cada descendiente es flujo de texto plano, porque las imágenes, tablas, listas y controles de formulario necesitan su propia geometría de caja y reglas de paginación
¿Por qué la última página muestra columnas equilibradas mientras las anteriores van llenas?
Es la regla de CSS para column-fill: balance en medios paginados: solo el fragmento final se equilibra. Las páginas anteriores llenan cada columna hasta el fondo de la página para no desperdiciar espacio a mitad de documento
El layout multicol, el perfil paged-media HTML5 y el resto del pipeline HTML a PDF son parte de HotPDF Component para Delphi y C++Builder, que también cubre dibujo directo de PDF, formularios, cifrado y extracción de texto