wandres.dev
SVG GENERADO · Construir gráficos por código

Serializar, descargar y convertir a PNG

Sacar el SVG del DOM a un fichero: el xmlns que hay que añadir, los estilos que no viajan, el flujo de descarga, y la conversión a mapa de bits con sus tres limitaciones.

⏱ 18 min

Un gráfico generado en el navegador que el usuario puede descargar es una función que se pide en todos los productos y que casi nunca funciona a la primera. El SVG serializado sale sin estilos, sin espacio de nombres, y con las fuentes equivocadas. La conversión a PNG añade tres limitaciones más. Todas tienen solución y todas hay que aplicarlas explícitamente.

🎯 Al terminar esta lección sabrás
  • Serializar un elemento SVG del DOM a una cadena válida como fichero.
  • Incorporar los estilos que vienen de hojas externas.
  • Disparar una descarga y liberar los recursos correctamente.
  • Convertir a PNG y enumerar las tres limitaciones del proceso.

Serializar

function serializar(svgEl) {
  const clon = svgEl.cloneNode(true);
  clon.setAttribute('xmlns', 'http://www.w3.org/2000/svg');
  clon.setAttribute('xmlns:xlink', 'http://www.w3.org/1999/xlink');
  return new XMLSerializer().serializeToString(clon);
}

Tres decisiones.

Se clona. Serializar el elemento vivo funciona, pero vas a modificarlo (añadir atributos, incrustar estilos) y no quieres tocar el que está en pantalla.

Se añade xmlns. Dentro de un documento HTML el espacio de nombres es implícito y el atributo no está en el marcado. En un fichero independiente, sin xmlns el documento no es SVG y no se abre.

Se añade xmlns:xlink solo si hace falta. Si el SVG usa xlink:href en alguna parte, sin la declaración el XML es inválido. Si no lo usa, sobra; en la práctica no molesta.

Los estilos que no viajan

XMLSerializer copia el marcado, con sus atributos. No copia nada que venga de una hoja de estilos externa. Un gráfico cuyos colores están en CSS se serializa en negro.

Hay dos soluciones y una es claramente mejor.

La mala: recorrer y aplicar los estilos computados. Para cada elemento, leer getComputedStyle y escribir las propiedades relevantes como atributos.

const PROPS = ['fill', 'fill-opacity', 'stroke', 'stroke-width', 'stroke-linecap',
               'stroke-linejoin', 'stroke-dasharray', 'opacity', 'font-family',
               'font-size', 'font-weight', 'text-anchor'];

function fijarEstilos(origen, destino) {
  const nodosO = [origen, ...origen.querySelectorAll('*')];
  const nodosD = [destino, ...destino.querySelectorAll('*')];
  nodosO.forEach((n, i) => {
    const cs = getComputedStyle(n);
    for (const p of PROPS) {
      const v = cs.getPropertyValue(p);
      if (v) nodosD[i].setAttribute(p, v);
    }
  });
}

Funciona, es frágil (la lista de propiedades hay que mantenerla), es lento (un getComputedStyle por nodo fuerza cálculos de estilo) y produce un fichero enorme, con todas las propiedades repetidas en cada elemento.

La buena: incrustar un bloque de estilo. Se escribe una vez el CSS que el gráfico necesita, y se inyecta en el clon.

const CSS_GRAFICO = `
  .eje line { stroke: #313244; }
  .eje text { fill: #a6adc8; font: 11px system-ui, sans-serif; }
  .barra { fill: #89b4fa; }
  .linea { fill: none; stroke: #a6e3a1; stroke-width: 2; }
`;

function serializarConEstilos(svgEl, css) {
  const clon = svgEl.cloneNode(true);
  clon.setAttribute('xmlns', 'http://www.w3.org/2000/svg');
  const est = document.createElementNS('http://www.w3.org/2000/svg', 'style');
  est.textContent = css;
  clon.insertBefore(est, clon.firstChild);
  return new XMLSerializer().serializeToString(clon);
}

Un solo bloque, legible, mantenible junto al componente. El coste es que el CSS del gráfico tiene que estar disponible como cadena, lo que en un proyecto con módulos de CSS exige un poco de fontanería. Merece la pena.

Descargar

function descargar(cadena, nombre, tipo = 'image/svg+xml;charset=utf-8') {
  const blob = new Blob([cadena], { type: tipo });
  const url = URL.createObjectURL(blob);
  const a = document.createElement('a');
  a.href = url;
  a.download = nombre;
  document.body.appendChild(a);
  a.click();
  a.remove();
  // Liberar en el siguiente turno, no antes de que empiece la descarga
  setTimeout(() => URL.revokeObjectURL(url), 0);
}

descargar(serializarConEstilos(svg, CSS_GRAFICO), 'ventas-2025.svg');

El revokeObjectURL no es opcional: cada URL de objeto mantiene el blob vivo en memoria hasta que se libera o hasta que el documento se descarga. Un panel que permite exportar cien veces sin liberar acumula cien blobs.

El setTimeout es porque revocar inmediatamente después del clic puede cancelar la descarga en algunos navegadores. Un turno de la cola de tareas basta.

Convertir a PNG

El camino es: cadena SVG, blob, URL de objeto, Image, drawImage en un lienzo, toBlob.

async function aPng(svgEl, css, escala = 2) {
  const cadena = serializarConEstilos(svgEl, css);
  const vb = svgEl.viewBox.baseVal;
  const w = vb.width  || svgEl.clientWidth;
  const h = vb.height || svgEl.clientHeight;

  const blob = new Blob([cadena], { type: 'image/svg+xml;charset=utf-8' });
  const url = URL.createObjectURL(blob);

  try {
    const img = new Image();
    img.decoding = 'sync';
    const cargada = new Promise((ok, err) => {
      img.onload = () => ok();
      img.onerror = () => err(new Error('el SVG no se ha podido cargar como imagen'));
    });
    img.src = url;
    await cargada;

    const lienzo = document.createElement('canvas');
    lienzo.width = Math.round(w * escala);
    lienzo.height = Math.round(h * escala);
    const ctx = lienzo.getContext('2d');
    ctx.drawImage(img, 0, 0, lienzo.width, lienzo.height);

    return await new Promise(r => lienzo.toBlob(r, 'image/png'));
  } finally {
    URL.revokeObjectURL(url);
  }
}

El escala a 2 produce un PNG del doble de resolución, que es lo que hace falta para que no se vea borroso en una pantalla de alta densidad o al imprimir.

Las tres limitaciones

Uno: el SVG se carga en modo seguro. Como se explicó en el nivel 23, un SVG cargado a través de Image no carga recursos externos: ni fuentes, ni imágenes referenciadas, ni hojas de estilo. El PNG saldrá con la fuente de reserva y sin las imágenes. La solución es incrustar todo como data URI antes de serializar, o usar fuentes del sistema.

Dos: hace falta un tamaño intrínseco. Un SVG sin width y height explícitos puede no tener dimensiones intrínsecas al cargarse como imagen, y el resultado del drawImage es impredecible o vacío. La defensa: añadir width y height al clon antes de serializar, calculados del viewBox.

clon.setAttribute('width', w);
clon.setAttribute('height', h);

Tres: foreignObject no se renderiza. Cualquier HTML embebido desaparece. Si tu gráfico usa foreignObject para el texto ajustado, el PNG saldrá sin ese texto.

Y una cuarta que ya no es un problema pero conviene conocer: durante años, dibujar un SVG en un lienzo contaminaba el lienzo en algunos motores, lo que impedía leer los píxeles con toBlob o getImageData. Hoy, un SVG del mismo origen y sin recursos externos no contamina. Si tu SVG referencia una imagen de otro origen, sí puede contaminar, y toBlob lanzará una excepción de seguridad.

El SVG serializado no es reproducible entre navegadores, y para un informe eso importa

Hay una diferencia entre «el usuario descarga el gráfico que está viendo» y «el sistema genera el gráfico del informe», y confundirlas produce un problema que aparece meses después.

El SVG serializado desde el navegador depende de el estado del DOM en ese momento, que a su vez depende del navegador, de la versión, de las fuentes instaladas, del tema del sistema operativo, del nivel de zoom y de la densidad de píxeles. Dos usuarios que exporten el mismo gráfico con los mismos datos obtienen ficheros distintos: las anchuras de texto difieren, las posiciones calculadas a partir de mediciones difieren, y los colores pueden diferir si algún valor viene de una consulta de preferencia de color.

Para una descarga puntual eso es irrelevante. Para un informe que se archiva, para un documento que se compara entre periodos, o para una imagen que se incrusta en un correo enviado a un cliente, es un problema real: el mismo informe generado dos veces no es idéntico, y eso rompe cualquier proceso que compare o verifique.

La solución para el caso reproducible es generar el SVG en el servidor, con un renderizador determinista, fuentes fijadas y sin depender de mediciones del navegador. El mismo código de la capa de especificación del artículo anterior sirve: es una función pura de datos a geometría. Lo único que cambia es que la capa de render escribe una cadena en lugar de tocar el DOM, y que las mediciones de texto se hacen con las métricas de la fuente en lugar de con getComputedTextLength.

Ese es el argumento más fuerte a favor de la separación entre especificación y render: te permite tener las dos rutas, la interactiva del navegador y la reproducible del servidor, compartiendo el cálculo. Sin la separación, el gráfico del informe es una reimplementación, y las dos derivan.

⚔️ Reto práctico

Implementa la exportación completa de un gráfico a SVG y a PNG, y verifica las tres limitaciones a propósito: exporta uno con una fuente web y comprueba que el PNG sale con otra; exporta uno sin width y height y comprueba qué pasa; y mete un foreignObject y comprueba que desaparece. Después arregla las tres y compara el resultado con una captura de pantalla del gráfico real.