Exportación y render en servidor
Qué formatos alcanza cada modelo, por qué un SVG se puede generar en el servidor sin navegador y un lienzo no, y cómo el requisito de mandar un PNG por correo decide toda tu arquitectura.
La exportación parece un requisito menor que se resuelve al final con un botón de descarga, y no lo es: decide si tu gráfico tiene que poder ejecutarse fuera del navegador, y ese requisito se propaga hacia atrás hasta la arquitectura. Un SVG es una cadena de texto que cualquier proceso puede escribir; un lienzo es una API del navegador. Esa asimetría es el eje de decisión que más gente descubre tarde.
- Enumerar los formatos de salida que alcanza cada modelo y con qué pérdidas.
- Generar un SVG en el servidor sin DOM y sin navegador.
- Evaluar el coste de rasterizar un lienzo fuera del navegador.
- Anticipar cuándo un requisito de exportación obliga a cambiar de modelo.
Los formatos que alcanza cada uno
| Salida | Desde SVG | Desde lienzo |
|---|---|---|
| Fichero vectorial | Directo, es su formato | No existe |
| PNG o WebP | Rasterizando, eligiendo la resolución | toBlob, a la resolución del búfer |
| PDF vectorial | Sí, con texto seleccionable | Solo como imagen incrustada |
| Editable en herramienta de diseño | Sí, con las capas y los nombres | No |
| Datos de píxel | Rasterizando primero | getImageData, directo |
| Cadena en el HTML inicial | Sí, sin JavaScript | No |
La fila que más decisiones cambia es la última. Un gráfico SVG generado en el servidor viaja dentro del HTML: se ve antes de que cargue ningún script, se ve con JavaScript desactivado, aparece en una captura de un rastreador y no produce ningún salto de diseño porque sus dimensiones están en el marcado. Un gráfico en lienzo es un rectángulo vacío hasta que el script se descarga, se ejecuta y dibuja.
Para un panel interno detrás de un inicio de sesión, da igual. Para una página pública, un informe que se comparte por enlace o un correo, es la diferencia entre funcionar y no funcionar.
La conversión de SVG a PNG en el cliente ya se trata, con sus limitaciones, en serializar y descargar. Lo que interesa aquí es el otro lado.
SVG en el servidor sin navegador
Este es el argumento decisivo y el menos conocido: generar SVG no requiere un DOM. Es texto.
// Node, cero dependencias, cero navegador
function graficoLineas(datos, { ancho = 640, alto = 240,
m = { arriba: 12, dcha: 12, abajo: 24, izda: 40 } } = {}) {
const w = ancho - m.izda - m.dcha;
const h = alto - m.arriba - m.abajo;
const xs = datos.map(d => d.t);
const ys = datos.map(d => d.v);
const [x0, x1] = [Math.min(...xs), Math.max(...xs)];
const y1max = Math.max(...ys);
const px = t => ((t - x0) / (x1 - x0)) * w;
const py = v => h - (v / y1max) * h;
const r1 = n => Math.round(n * 10) / 10;
const d = datos.map((p, i) =>
`${i ? 'L' : 'M'}${r1(px(p.t))} ${r1(py(p.v))}`).join('');
const marcas = [0, 0.5, 1].map(f => {
const v = f * y1max, y = r1(py(v));
return `<line x1="0" y1="${y}" x2="${w}" y2="${y}" stroke="#313244"/>` +
`<text x="-8" y="${y}" dy="0.32em" text-anchor="end" ` +
`font-size="11" fill="#a6adc8">${Math.round(v)}</text>`;
}).join('');
return `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${ancho} ${alto}" ` +
`width="${ancho}" height="${alto}" role="img" aria-label="Evolucion de la metrica">` +
`<g transform="translate(${m.izda},${m.arriba})">${marcas}` +
`<path d="${d}" fill="none" stroke="#89b4fa" stroke-width="2" ` +
`stroke-linejoin="round" stroke-linecap="round"/></g></svg>`;
}
Eso corre en cualquier proceso de Node, en un worker de borde, en un trabajo por lotes o en una función serverless. Sin navegador, sin DOM simulado, sin dependencias, en microsegundos. Y el resultado se puede meter tal cual en un HTML, guardar en un fichero, o mandar a una herramienta de conversión.
El xmlns es obligatorio en cuanto el SVG sale del HTML, y las razones están en el artículo de serialización. Y fíjate en que las escalas y el generador de ruta son los mismos del nivel 27 y del 28: la capa de cálculo no sabe dónde se ejecuta, que es exactamente lo que prometía la arquitectura de especificación separada del render.
Un lienzo, en cambio, es una API del navegador. Fuera de él hay dos caminos y los dos son pesados:
Una implementación nativa del contexto 2D. Existen paquetes que exponen la misma API sobre una biblioteca gráfica nativa. Funcionan bien y traen binarios compilados por plataforma, lo que complica el despliegue en entornos con arquitecturas mixtas o sin compilador.
Un navegador sin interfaz. Levantar un navegador de verdad, cargar la página, dejar que dibuje y capturar. Es la ruta más fiel, porque el resultado es idéntico al del cliente, y cuesta cientos de megabytes de imagen de contenedor y del orden de segundos por gráfico.
La diferencia de coste entre generar una cadena y arrancar un navegador es de varios órdenes de magnitud, y esa es la razón por la que casi todos los servicios que generan miniaturas de gráficos por lotes lo hacen en SVG.
Cuando el requisito llega tarde
Los tres requisitos que llegan al final del proyecto y que fuerzan una ruta de renderizado fuera del navegador:
«Queremos que el informe semanal llegue por correo con los gráficos.» El correo no ejecuta JavaScript y su soporte de SVG es irregular, así que los gráficos tienen que ser PNG generados en el servidor. No hay alternativa.
«Queremos un PDF descargable con el panel entero.» Si el PDF tiene que ser vectorial y con texto seleccionable, la ruta es SVG. Si vale un PDF con imágenes, sirve cualquier cosa, y hay que decidir la resolución de antemano.
«Queremos que la vista previa del enlace muestre el gráfico.» Las tarjetas de vista previa de las redes y de los mensajeros piden una imagen por URL. Eso es un endpoint que devuelve un PNG, generado en el servidor, sin sesión.
Los tres tienen la misma forma: el gráfico tiene que poder dibujarse sin usuario, sin sesión y sin navegador. Si la única implementación que tienes es un lienzo con manejadores de ratón, ese requisito es un proyecto entero. Si tienes una función pura que produce una especificación y dos renderizadores, es un fin de semana.
El escenario es este: tienes el gráfico interactivo en lienzo para el panel, y añades una ruta en el servidor que genera SVG para los correos. Funcionan las dos. Y a partir de ese día empiezan a separarse.
Alguien cambia el dominio del eje vertical para que empiece en cero, en el cliente. Nadie toca el servidor. Durante meses, el gráfico del panel y el del correo semanal muestran los mismos datos con escalas distintas, y son los dos correctos por separado, así que ninguna prueba falla y nadie lo nota. Hasta que un cliente pone las dos imágenes al lado y pregunta cuál es la buena.
Las variantes de la misma historia: un formateador de números que se actualiza en un sitio y no en otro, un filtro de datos que se aplica antes en uno y después en otro, una paleta que cambia con el tema de la marca, un redondeo de fechas a la zona horaria del usuario en el cliente y a UTC en el servidor. Esta última es la peor, porque produce datos distintos, no solo un aspecto distinto.
La defensa no es disciplina, es arquitectura, y son tres piezas:
Uno: una sola función de especificación, compartida. El paquete que calcula escalas, marcas, rutas y etiquetas es el mismo módulo importado por el cliente y por el servidor. Si tu servidor no es JavaScript, esto no es posible, y entonces el problema es real y hay que aceptarlo explícitamente: escribe la especificación en un formato de datos y genera desde ahí en los dos lenguajes, o resígnate a mantener dos implementaciones y ponlo en el registro de riesgos.
Dos: dos renderizadores tontos. Cada uno traduce la especificación a su medio y no toma ninguna decisión. En cuanto un renderizador calcula algo (un margen, un formato, un color condicional), acabas de crear el segundo origen de verdad.
Tres: una prueba que compare las dos salidas. No hace falta comparar imágenes: basta con comparar la especificación que produce cada camino con los mismos datos de entrada. Si las dos rutas producen la misma estructura de coordenadas y etiquetas, no pueden divergir en lo que importa. Es una prueba de igualdad de objetos, corre en milisegundos, y detecta exactamente la clase de fallo que ninguna otra detecta.
Y el corolario que cierra el asunto: si al añadir la segunda ruta de renderizado tienes que duplicar cualquier cálculo, la arquitectura estaba mal antes de empezar. Ese es el momento de extraer la capa de especificación, no después. El requisito del correo semanal no es una molestia: es la prueba de fuego que revela si la separación entre cálculo y dibujo era real o era una historia que te contabas.
Coge la función de cálculo de un gráfico tuyo y ejecútala en Node, fuera del navegador, con un renderizador que produzca una cadena de SVG. Si falla, apunta cada línea que falla y por qué: cada una es una decisión de dibujo que se había colado en la capa de cálculo. La lista suele tener entre tres y seis entradas, y arreglarlas es exactamente el trabajo que hace posible todo lo demás de este artículo.