11 diciembre 2012

Convirtiendo Gráficos Vectoriales (SVG) en PNG programáticamente

Trabajando en unas cosas de la oficina me vi en la necesidad de desplegar la información de tendencias de precios en una página web; Sinceramente, no soy nada amigo de Flash, necesitaba algo estándar, que tuviera la posiblidad de agregar interactividad y que al mismo tiempo me permitiera trabajar directamente con los datos en vez de tener que hacer todo a mano. Buscando un poquito en google me topé con d3js - Data-Driven Documents.

d3js es una biblioteca de Javascript que permite crear visualizaciones de datos de una manera un poco fuera de lo común usando Gráficos Vectoriales Redimensionables(SVG). En esta entrada no pretendo explicar como usar d3js sino cómo resolver la siguiente pregunta que me hizo un usuario luego de ver las gráficas que había generado con esta herramienta:

¿Se puede descargar la gráfica?


Como sabrán en programación nunca hay una respuesta definitiva, sin embargo comencemos por intentar responder rápidamente a la pregunta:

Respuesta corta: No.

Respuesta larga: Lástimosamente el navegador considera las gráficas generadas dinámicamente como parte de la página web por lo que no se pueden descargar de manera individual, podemos descargar o toda la página o nada, si optamos por descargar toda la página y los datos dependen de una fuente dinámica externa esta será de poquisima utilidad para el usuario que solo quiere una "fotografía" de la gráfica actual. En otras palabras con d3js vayanse olvidando del "click derecho" y "guardar como" para gráficos en SVG.

Por suerte siempre tenemos una respuesta alternativa:

Sí, pero requiere de un par de trucos y procesamiento del lado del servidor.

Como en mi trabajo no me pagan por decir que algo no se puede hacer, voy a explicar en esta entrada como convertir nuestros gráficos generados con d3js en algo que el usuario pueda descargar como por ejemplo: Una imagen PNG.

Comencemos por el principio: Brevemente, ¿Qué es SVG?


Dicho un poco mal y pronto, el SVG es un formato "estándar" para intercambio de gráficos vectoriales. Con "vectoriales" me refiero a que no son un conjunto de pixeles los que se guardan sino un conjunto de "instrucciones" que le dicen a la computadora que es lo tiene que dibujar en pantalla.

El SVG utiliza un lenguaje de marcado similar al HTML solo que con sus propias etiquetas, un gráfico SVG no es más que un archivo de texto con diferentes etiquetas que definen si lo que se dibuja es una línea, un texto, un área, colores, anchos de línea, etc, etc.

La mayoría de navegadores modernos tienen soporte nativo para SVG y los nuevos estándares impulsan el uso de formatos de gráficos vectoriales que nos da una ventaja extra sobre todo cuando se trabaja con múltiples dispositivos de diversas resoluciones de pantalla.

Conociendo lo anterior decidí que SVG sería el camino a seguir.

¿Qué es y cómo funciona d3js?

Ejemplo de d3js
d3js es una biblioteca que nos proporciona una capa de abstracción para el procesamiento de datos y su visualización en el navegador. Por ejemplo para hacer una gráfica de tendencias en vez de perder nuestro tiempo dibujando punto por punto, únicamente le indicamos a la biblioteca cuales seran los datos que queremos que despliegue y luego especificamos como queremos que estos se dibujen.

A bajo nivel, el trabajo que realiza d3js es generar etiquetas "SVG" que se incrustan en la página web, esto permite que nos concentremos en como desplegar nuestras series de datos en vez de estar revisando si hemos colocado bien o mal alguna etiqueta de SVG.

El plan de acción:

La solución para descargar el gráfico generado realmente me pareció bastante simple. Primero tengo que obtener el código SVG generado por d3js y luego mediante alguna aplicación en el servidor puedo convertir este archivo vectorial en algún formato de imágen más común como PNG.

En pocas palabras el proceso se realiza en dos pasos:
  1. Extraer el código SVG incrustado en la página.
  2. Convertir el código SVG en imágen.

Obteniendo el código SVG generado dinámicamente


La primera opción que me paso por la cabeza fué simplemente extraer el contenido de las etiquetas SVG que incrustaba d3js con Javascript. Lastimosamente la propiedad "innerHTML" no está disponible para la etiqueta SVG por razones obvias, así que tuve que buscar otra solución.

Por suerte por medio de DOM podemos acceder a todas las propiedades y etiquetas que tiene acceso al navegador. La siguiente es una función recursiva que genera una cadena de texto con las etiquetas y sus atributos. La función recibe como parámetro un elemento DOM y devuelve una cadena con el código correspondiente XML, es equivalente a outerHTML pero a diferencia de esta última funcion esta se puede llamar en cualquier etiqueta que haya sido cargada en el navegador.

Para no arruinar la funcionalidad existente vamos a definir una nueva funcion toXML() para todos los elementos DOM y vamos a dejar la función "toXML(element)" por separado.

/*
 * Author: Mario Gomez (mxgxw.alpha_at_gmail.com)
 * Date: 11/12/2011
 * Este codigo está protegido bajo la licencia
 * GNU/GPL 2.0 o posterior.
 */ 
Element.prototype.toXML = function() {
  return toXML(this);
}

function toXML(element) {
  var xmlOut = "<";
  xmlOut += element.tagName;
  for(var i = 0; i < element.attributes.length ; i ++) {
    xmlOut += " ";
    xmlOut += element.attributes[i].name+'="';
    xmlOut += element.attributes[i].value+'"';
  }
  if(element.childNodes.length>0) {
    xmlOut += ">";
    for(var i = 0; i < element.childNodes.length ; i ++) {
      if(element.childNodes[i].nodeType==3) {
        xmlOut += element.childNodes[i].textContent;
      } else {
        xmlOut += toXML(element.childNodes[i]);
      }
    }
    xmlOut += '</'+element.tagName+'>';
  } else {
    xmlOut += '/>';
  }
  return xmlOut;
}

+Agregado 13/12/2012:

Gracias a la sugerencia de vlad tenemos una implementación alternativa compatible con casi todos los navegadores (Exceptuando IE<9). En este caso podemos utilizar la clase XMLSerializer para extraer el código SVG. En nuestro caso tendríamos algo como lo siguiente:

Element.prototype.toXML = function() {
  return (new XMLSerializer).serializeToString(this);
}

Luego necesitamos enviar el código de alguna manera al servidor, para ello crearemos un pequeñísimo formulario en el cual almacenaremos nuestro código SVG antes de enviarlo a la página que se encargará de procesarlo:

<form action="downloadSvg.php" id="frmDownload" method="post">
<input id="svgData" name="svgData" type="hidden">
<input id="btnDownload" type="button" value="Descargar">
</form>

El campo svgData es donde almacenaremos el gráfico una vez esté listo. Noten que utilizo un campo "button" y no un "submit". Podría utilizar un submit pero no quisiera enviar datos si por ejemplo el Javascript no estuviera habilidado. Usando un campo tipo "button" puedo asociar luego el evento con jQuery

Por último utilizaremos jQuery para asociar el evento y enviar los datos SVG a un script PHP del lado del servidor que se encargará de generar nuestro PNG para el usuario final:

$(function () {
  $('#btnDownload').bind('click', function () {
    $('#svgData').val(document.getElementById('svg').toXML());
    $('#frmDownload').submit();
  });
});

Convirtiendo la imágen en el servidor.

Para convertir la imágen a PNG vamos a utilizar la herramienta CairoSVG, esta es una sencilla herramienta escrita en python para línea de comandos que nos permitirá convertir el código SVG que recibamos a una imágen PNG.

La sintaxis del comando es bastante sencilla:

$ cairosvg vector.svg > imagen.png

También podemos llamarlo desde la linea de comandos de esta manera:

$ echo "<svg>....</svg>" | cairosvg -f png > imagen.png

De la forma en que llamemos al comando dependerá la implementación de la herramienta del lado del servidor.

Considerando los comandos anteriores nos quedan entonces dos opciones:

Opción A:
  1. Crear un archivo temporal con el código SVG enviado por el usuario
  2. Convertir el archivo temporal en PNG con CairoSVG
  3. Enviar los daros al usuario y borrar los archivos temporales
Opción B:
  1. Ejecutar el comando CairoSVG directamente en una sola linea de comandos y enviar el resultado al usuario.
La opción B si bien parece más simple tiene algunas limitaciones: Primero, si nuestro PNG es demasiado grande o complejo, podríamos superar el tamaño máximo de argumentos para la consola, lo que podría generar algún tipo de error. La  segunda razón es por seguridad. Ejecutar datos del usuario en un argumento de consola no es algo demasiado seguro y abre la puerta a vulnerabilidades. Si bien pueden "limpiarse" los caracteres maliciosos de los argumentos de comandos la Opción A es "defensivamente" más segura. Aunque el usuario puede incluir un código malicioso este nunca es ejecutado directamente y es borrado inmediatamente luego de la ejecución del script.

Sin embargo debo advertir: La opción A implica cargar un archivo con datos enviados por el usuario a nuestro servidor, esto significa que una vulnerabilidad en CairoSVG podría permitir a un atacante subir un archivo malicioso que podría darle algún tipo de acceso. Revisen siempre los boletines de seguridad cuando utilicen herramientas de terceros, siempre como medida preventiva revisen que los archivos temporales creados por PHP no tengan permisos de ejecución, no sean accesibles desde una carpeta pública en el servidor y que el dueño del proceso en que corre el Apache/PHP no sea root.

Una vez aclarado esto les dejo el código utilizado para convertir el archivo SVG a PNG, dejo el código y luego explico en detalle:
<?php
// Creación de archivo temporal
$tmpfname = tempnam("/tmp", "svgTmp");
$temp = fopen($tmpfname,"w");
fwrite($temp,$_POST['svgData']);
fflush($temp);
fclose($temp);

// Conversión del archivo
exec("/usr/bin/cairosvg $tmpfname -f png > $tmpfname.png");

// Envío al usuario
header("Content-type: image/png");
readfile("$tmpfname.png");

// Limpieza de archivos temporales
unlink($tmpfname);
unlink("$tmpfname.png");
exit(); 

¿Cómo funciona?
  1. Primero creamos un archivo temporal, la función tempnamp nos ayuda a generar nombres temporales en la carpeta especificada. Luego abrimos el archivo de manera normal y escribimos los datos recibidos del usuario. En este paso es importante cerrar el archivo y asegurarnos de que todos los datos han sido escritos antes de continuar.
  2. El segundo paso es el más sencillo es donde llamamos a cairosvg para que realice el proceso de conversión para mi caso lo llamo utilizando la ruta completa al ejecutable.
  3. El tercer paso simplemente envía convertido de regreso al usuario con la función readfile de PHP, se utiliza también la función header para indicar al navegador que enviaremos de regreso una imágen PNG.
  4. El último paso realiza la limpieza de los archivos temporales. 
Nota: El código de ejemplo tiene una desventaja ya que cualquiera podría "abusar" de este "convertidor" para realizar conversiones en el aire de archivos SVG. Pueden disminuir el "abuso" si utilizan alguna variable de sesión o alguna técnica similar para establecer si el código SVG se origina de una de sus páginas y no de una fuente externa.

Conclusiones

Si todo ha salido bien, nuestro PNG generado vía CairoSVG debería poder abrirse directamente del navegador. Es posible que se vean algunas diferencias en las tipografías ya que CairoSVG intentará utilizar las que estén disponibles en el servidor, pero los gráficos deberían de mostrarse igual.

Si quieren probar como funciona instalen CairoSVG y descarguen el código. Solo asegurense de modificar el archivo downloadSvg.php con la ubicación correcta de cairosvg en su computadora.

+Agregado 13/12/2012:

El siguiente demo es una pequeña modificación al ejemplo de d3js "multiseries chart". Pueden abrirlo haciendo click aquí.

2 comentarios:

Sam V dijo...

Veo que este proyecto se trata de convertir un formato de archivo de imagen vectorial a un formato de imagen de mapa de bits, básicamente como se explica aquí además del manejo de DOM para extraer el gráfico en el contexto de una página web:

Capítulo 7 — Conversión de Formatos

Básicamente se trata de renderizar de alguna forma el SVG y guardar el resultado como mapa de bits PNG (o GIF, JPG, o BMP).
___________________________
___________________________
___________________________

Yo estoy trabajando en un proyecto relacionado desde el punto de vista de procesamiento de imágenes. Se trata de un "Explicador de GIFs", totalmente escrito en JavaScript:

Discusión: Explicador de GIFs — De Binario a Código Fuente

Aplicación Pre-Alpha: Explicador de GIFs — De Binario a Código Fuente

La intención es leer un archivo GIF cualquiera y convertir el binario en código fuente (un archivo de texto plano) para una comprensión completa de la estructura del archivo.

Por ahora estoy en la etapa de normalizar el código para que las variables tengan una estructura idéntica a la de las estructuras de datos definidas para GIF (y después para otros tipos de archivos), según la explicación más básica:

Formato de Archivo — GIF

Geo dijo...

para poder visualizar un arbol binario en svg como se hace? tengo las funciones y algo de codigo svg pero no se como implementarlo me podrias ayudar?