Tablas rápidas para Grails

Si hay algo odioso cuando hay que hacer una interfaz de administración, es armar la lista para cada modelo del dominio para los AMBs. Esta semana me tocó mejorar el diseño para una aplicación grails, por lo que decidí ver como se podían generar “helpers” o TagLibs en nomenclatura grails.

El siguiente código genera usando bootstrap3 una tabla con paginado, botón de editar y eliminar de forma genérica:

package test

class ResourceTableTagLib {
    /**
     * Creates a bootstrap3 compatible datatable.
* * @emptyTag * * @attr list REQUIRED List of domain models to show * @attr count REQUIRED Total registers (for pagination) * @attr labels REQUIRED Comma-separated values for the columns headers * @attr columns REQUIRED Comma-separated of model attributes to show */ def table_for = { attrs -> def writer = out def list = attrs.list def count = attrs.count def labels = attrs.labels.split(/,/).collect { x -> x.trim() } def columns = attrs.columns.split(/,/).collect { x -> x.trim() } writer << '
' writer << '' writer << '' writer << '' labels.eachWithIndex { a, i -> writer << "" } writer << '' writer << '' writer << '' writer << '' list.eachWithIndex { a, i -> writer << " " writer << " " columns.eachWithIndex { c, j -> if (j != 0) { writer << "" } } writer << ' ' writer << '' } writer << '' writer << '
${a}
" writer << g.link(action: "show", id: a.id, a."${columns[0]}") writer << " ${a."${columns[j]}"}' writer << g.form(url: [resource:a, action:'delete'], method: "DELETE") { writer << g.link(class: "btn btn-default btn-sm", action: "edit", resource: a, "Editar") writer << " " writer << g.actionSubmit(class: "btn btn-danger btn-sm", action: "delete", value: "Eliminar", onclick: "return confirm('¿Eliminar curso?');") } writer << '
' writer << '
' writer << '
' def offset = params.offset as Integer ?: 0 def page_start = offset + 1 def page_end = Math.min(offset+10, count) writer << "
Mostrando ${page_start} a ${page_end} de ${pluralize(count: count, singular: "registro")}
" writer << '
' writer << '
' writer << g.paginate(total: count) writer << '
' writer << '
' writer << '
' } def pluralize = { attrs, body -> def plural = attrs['plural'] ?: attrs['singular'] + "s" out < 1 ? plural : attrs['singular'] ) } }

PD : Seguramente exista forma de sacar el nombre de columna desde el nombre del atributo del modelo, pero todavía no lo encontré.

Para usarlo simplemente basta con agregar en nuestro template :

Volviendo a PHP

Hace fácil 5 años que no hago nada serio en PHP, empezando desde cero. Mayormente me ha tocado retocar cosas andando o parchar bugs boludos. Sin embargo el otro día tuve que hacer un demo chiquitito en PHP. El principal limitante era el tiempo, por lo que tenía que hacerlo rápido.

Al principio intenté el viejo HTML+PHP all-in-one con ifs $_GET, $_POST, etc. Una locura, no podía avanzar. Mi mente me decía “esto va en el controlador, esto en el view” :D. Borre todo y empecé de nuevo. Pensé dos segundos y dije “tiene que haber algo”. No, CakePHP no es ese “algo”. Era mucha config al pedo, bloated y cada vez que tuve en mi poder un site de Cake para poner en producción terminé a los cabezazos.

Googleando me topé con SlimFramework un micro framework para hacer apps (el Sinatra de CakePHP 😀 ?) y la verdad que dije “ah, mirá, en php también se puede programar como al gente”. No tiene ORM, solo un modo fácil de declarar rutas, con arguments y verbos REST (post, delete, put, etc) y un body para ejecutar la lógica.

Los templates son modulares, podemos llamar a otro .php que genera HTML o usar HAML, Smarty (dios que alguien de de baja este proyecto :P) o armar el propio. Un hola mundo :

get('/hello/:name', function ($name) {
    echo "Hello, $name";
  });
  $app->run();
?>

Simple, no?. La documentación está bastante completa y no tuve mayores drama en sacar andando el demo. Lo único que no me gustaba era que los “views” no podían tener un main template (al menos en la version básica de usar php como template language). Es decir, tenía que poner <? require “header.php”; ?> <? require “footer.php” ?> por todos lados, por lo que decidí implementar me propia “view” :

setTemplate($template);
        extract($this->data);
        ob_start();

        $this->setTemplate("template_pre.php");
        require $this->templatePath;

        $this->setTemplate($template);
        require $this->templatePath;

        $this->setTemplate("template_post.php");
        require $this->templatePath;

        return ob_get_clean();
    }
}
?>

Simplemente hago los 3 render y así tengo view con layout a’la Rails :P.

El segundo punto a resolver era el acceso a la base de datos. Hoy en día laburar con mysql_query, mysql_fetch es inpensable, al menos para prototipado ;). Otra vez, googleando me topé con Idiorm, “A lightweight nearly-zero-configuration object-relational mapper and fluent query builder for PHP5”, o sea, un Arel para PHP, super limiteado pero suficiente por el momento.

Este ORM nos pemite encadenar condiciones y luego obtener objetos, manipularlos y guardarlos de nuevo :

$user = ORM::for_table('user')
    ->where_equal('username', 'j4mie')
    ->find_one();

$user->first_name = 'Jamie';
$user->save();

Si queremos más abstracción todavía el mismo autor implementó Paris, una implementación de ActiveRecord sobre Idiorm. Yo en mi caso no la usé así que lo dejo a criterio del lector probarla.

A modo de ilustrar como quedó mi demo, les comparti el método del index, que obtiene la lista de registros y hace un render del view acorde :

$app->get('/', function () use ($app) {
  $contacts = ORM::for_table('contact')->find_many();
  $app->render('home.php', array('contacts' => $contacts));
});

Y la vista (recortada) :

Contactos

... data['contacts'] as $contact) { ?> ...
nombre ?> <form action="/contacts/id ?>" method="POST" class="form-inline"> <a href="/contacts/id ?>/edit" class="btn">Modificar
Agregar

Mejorando el “Doble panel” en Android

Siguendo con la serie de casos en mejorar las interfaces en Android, esta vez le toca al doble panel que solemos ver en las aplicaciones cuando las usamos en una tablet. No voy a entrar en detalle de cómo implementar un doble panel con Fragments ya que en la web hay más que suficiente información para hacerlo.

De lo que si vamos a hablar es de cómo mejorar el aspecto y darle al usuario una mejor experiencia. Para lograr esto lo que vamos a hacer es copiar lo que hacen Google con sus aplicaciones, com Gmail o la misma aplicación de Contactos que viene en IceCream Sandwich y que se ve algo así :

ics_effects

El simple agregado de un gradiente a modo de “dropshadow” entre los paneles y el indicador de selección cambian radicalmente la experiencia de usuario, por más simple que parezca el cambio, pueden verlo al final del post donde dejo el antes y el después. Ahora bien, lo simple no siempre es fácil :), por lo que para lograr este efecto tuve que trabajar un poco. Empecemos por la sombra.

Existen varias formas de lograrla, desde las más simples como usar un FrameLayour con un bitmap como overlay hasta backgrounds complicados. Sin embargo se pueden obtener efectos raros en versiones nuevas de Android, ya que la ActionBar también tiene un dropshadow. Estas técnicas además pueden hacer complicado la segunda parte que es mostrar el selector.

La forma más prolija que encontré es meterse en el ciclo de render y aprovechar el método “dispatchDraw(Canvas canvas)”. Este método es el encargado de dibujar un View, por lo que si lo sobreescribimos podemos dibujar cosas antes o despues del render original (que lo llamaremos con “super.dispatchDraw(canvas)”). Como es un método protegido, lo que debemos hacer entonces es nuestra propia View, en este caso basta con hacer nuestra propia ListView :

;
public class ShadowListView extends ListView {
  public ShadowListView(Context context) {
    super(context);
  }

  private void prepareShadow() {
    // Load drawables and initialize stuff
  }

  @Override
  protected void dispatchDraw(Canvas canvas) {
    // Draw the children
    super.dispatchDraw(canvas);

    if (!initializes)
      prepareShadow();

    drawShadow(canvas);
  }

  private void drawShadow(Canvas canvas) {
    canvas.save();
    canvas.translate(getWidth() - 20, 0);
    drawable.setBounds(0, 0, 20, getHeight());
    drawable.draw(canvas);
    canvas.restore();
  }
}

Lo siguientes es utilizarla en nuestro layout directamente en lugar de usar <ListView> usar <ShadowListView>. Con esto lo que hacemos es dibujar en el borde derecho, desde arriba hasta abajo con un ancho de “20” la sombra, haciendo parecer que el panel derecho está por arriba y que le hace “sombra” a la lista, cool :), no?.

Lo siguiente a lograr es dibujar el triángulo y queremos tener en cuanta varias cosas : debe estar dibujado por encima de la sombra (así el efecto queda bien) y debe coincidir en la posición del elemento seleccionado en la lista, si hay alguno.

El primer problema con el que me encontré es que quise utilizar “getSelectedView” para obtener la View seleccionada. Lamentablemente no pude entender por qué no funciona como parecería indicar el nombre :D. Por lo que fui por un camino diferente : como ya tengo mi clase de ListView, puedo sobreescribir otros métodos, como por ejemplo “performItemClick” para saber cuando un item fue “clickeado” (ya sea via touch o via teclado). Cuando eso pasa me guardo esa posición para futuro :

@Override
public boolean performItemClick(View view, int position, long id) {
  selectedPosition = position;
  return super.performItemClick(view, position, id);
}

Y luego puedo definir el método que dibuja la flecha :

private void drawArrow(Canvas canvas) {
  View selectedView = getChildAt(selectedPosition - getFirstVisiblePosition());
  if (selectedView != null) {
    selectedView.getDrawingRect(rect);

    offsetDescendantRectToMyCoords(selectedView, rect);

    int h = selectedView.getHeight();

    canvas.save();
    canvas.translate(getWidth() - h, rect.top);
    arrow.setBounds(0, 0, h, h);
    arrow.draw(canvas);
    canvas.restore();
  }
}

Lo primero que hago es ver que vista está seleccionada. Para eso, como ListView puede reutilizar un View para optimizar el render, debo tener en cuenta el View que se está mostrando. Si tengo una vista obtengo su recta de de dibujado. Esta recta está en coordenadas de la vista, por lo que tengo que trasladar esas coordenadas a mi espacio de coordenadas, y eso android me permite hacerlo fácil llamando a “offsetDescendantRectToMyCoords”.

Por último dibujo la flecha que es un PNG cuadrado que tiene la flecha con su propio dropshadow. El paso final es llamar a drawArrow despues de drawShadow en el dispatchDraw, de manera que si dibujo la flecha, esta se dibuja por arriba.

El resultado final, antes y después :

table_sin_efectos tablet_conf_efectos

Mejorando las UI en Android

En los últimos días me puse a ver varios de los videos de Google I/O del año pasado referidos al diseño de interfaces y diseño gráfico de android. La verdad es que con muy poco se puede cambiar el aspecto radicalmente, y la experiencia termina siendo muy superior (y si tenemos suerte podemos tener una mayor user base lo que significa más ingresos :D).

Para probar diferentes consejos decidí mejorar la interfaz de mi lector del diario Río Negro, el cual era bastante feo y se puede ver al final del post lado a lado con la imagen luego de mejorar el diseño.
La idea era resaltar un poco más los títulos y acomodar el layout. Lo primero que hice fue agregar “paddings”, de manera de descomprimir los diferentes elementos de la lista. Luego de un par de pruebas y error el valor de “10dp” fue el ganador.Una vez hecho eso tire los LinearLayout que usaba y converti a RelativeLayout lo que además hizo que quede más natural al rotar o usar la aplicación en otra resolución, ya que los “floats” se adapan mucho mejor una vez que uno entiende bien que opción va en cada caso :D.Lo último fue mejorar el render de la imagen. Como primer paso decidí cropear las imágenes a un tamaño único, para que tenga todo más sentido : da la sensación de uniformidad y distrae mucho menos si queremos ignorar las imágenes.El último punto fue hacer mas “fancy” la imagen poniéndole bordes redondeados. Para eso hice un custom View que cache la imagen usando una versión modificada de esta técnica.La imagen original y el resultado final a continuación :

Todavía no está subida a Google Play esta modificación pero lo estará en los próximos días. Mientras si pueden bajar la versión actual desde el market ;).


Get it on Google Play

Participando en Facebook Hacker Cup

Este año me acordé a tiempo de este evento (y la verdad que este año estuve usando por primera vez mi cuenta de Facebook :P) así que decidí probar a ver que onda.

La primer impresión : se complica rendir bien teniendo familia (o una vida de por si :P); no se puede salir apenas aparecen los problemas a lo loco a cualquier hora a resolverlos.

Desde el punto de vista técnico, los dos primeros problemas fueron muy fáciles. “Beautiful strings” fue trivial realmente, con pensar 2 o 3 min salía. El problema “Balanced Smileys” donde se buscaba determinar balanceo de () con al dificultad de que había caritas felices y tristes fue un poco mas arduo de probar, más que nada por pensar en posibilidades locas para estar seguro de tener todos los casos. En ambos problemas el primer envío de mi solución paso bien, así que realmente para estar fuera de training nada mal :).

El problema real llegó en el tercer problema, el que más puntos da. “Find the Min” es un problema conceptualmente fácil. Me complico un poco al principio entender por el idioma (“which is *not* contained in the previous *k* values of m.” no fue una frase trivial de entender para mi :D). El desafío del problema era principalmente la optimización, ya que se trabajaba mucho con arrays y en algunos casos se podía ir el tiempo al demonio.

En mi implementación (que veré de postear en algún momento cuando cierre el concurso) pude resolver los ejemplos que dan y algunos casos más que invente grandes en unos 2 a 3 min. Sin embargo no conté con casos tan complicados como los que venían cuando uno se bajaba el archivo posta para participar y lamentablemente llegué unos 5 o 6 minutos tarde, por lo que no pude subir mi solución. Facebook nos da una ventana de 6 minutos desde que bajamos el archivo de prueba hasta subir la solución. Yo pase de los 10 y ahí me quede.

Calculo que eso me elimina para la siguiente face, pero uno nunca sabe :P, tal vez me vaya mejor el año que viene :D. O en el Google Code Jam que viene dentro de poco ;).

Oregano tiene nuevo lider

Luego ya de muchisimos años (parece eterno) alguien con pilas apareció Marc Lorber, quien va a ser el nuevo lider y main developer de Oregano, proyecto que salio de este grupo alguna vez. Marc estaba completando el port a Gtk3 y al nuevo canvas además de tener pilas para arreglar los bugs históricos que arrastramos hace tiempo :).

El main git fue movido a https://github.com/marc-lorber/oregano por lo que es la nueva fuente oficial.

Saludos y Feliz 2012 para todos!

Filtro de Contenido para T!

Hoy estuve leyendo este post donde los dueños de Taringa declara que “no podemos investigar el destino final de cada uno de los 20.000 post diarios”. Lejos de ser algo “colectivamente inteligente” como declaración, es una terrible boludez :). Con aplicar algunos filtros automáticos, permitir que cualquiera de forma rápida y fácil reporte los contenidos ilegales y actuando más o menos rápido en su eliminación uno se cubre el 90% del orto. El otro 10% hay que pelearlo por carta documento porque hay gente muy al pedo ahí afuera, pero ni en pedo llegamos a un juicio oral teniendo un poco de criterio :).

El problema es que filtrar a mano es caro, y además filtrar hace que nuestros usuarios bajen pues no tiene ya toda la piratería al alcance de la mano ;). Pero veamos como hacer un filtro para determinar por heurística los posts que posiblemente sean ilegales, de manera de poder revisar a mano solo los que tal ve son malos, y así bajar el trabajo necesario y por ahí bajar el costo y aumentar el profit :).

No lo voy a explicar muy en detalle, pero básicamente el programa siguiente hace un scraping de la home de T! y agarra los ‘Ultimos posts’. Para cada uno de ellos, lee el contenido y aplica una heurística muy básica para ver si es potencialmente ilegal :

require 'rubygems'
require 'net/http'
require 'hpricot'

AGENT = "Mozilla/5.0 (X11; U; Linux x86_64; en-US; rv:1.9.2.10) Gecko/20100915 Ubuntu/10.04 (lucid) Firefox/3.6.10"

url = URI.parse("http://www.taringa.net/")
http = Net::HTTP.new("www.taringa.net")
html = http.request_get('/', {'User-Agent' => AGENT})

doc = Hpricot(html.body)
(doc/"html/body/div[2]/div[4]/div[4]/div/div[2]/ul/li/a").each do |link|
  text = http.request_get(link.attributes['href'], {'User-Agent' => AGENT})
  doc1 = Hpricot(text.body)
  content = (doc1/"html/body/div[2]/div[4]/div[4]/div/div[2]/div[2]").first.to_s

  if (content =~ /megaupload/i) || (content =~ /free/i) || (content =~ /mediafire/i) || (content =~ /fileserve/i)
    puts "#{link.inner_html.strip} posible post ilegal"
  else
    if (content =~ /Este post es privado/i)
        puts "#{link.inner_html.strip} es privado. Al menos si es ilegal no lo ve todo el mundo :)"
    else
      puts "#{link.inner_html.strip} parece legal."
    end
  end
end

Con esto podemos saber :

  • Cuantos posts parecen legales
  • Cuantos parecen ilegales
  • Cuantos no son públicos, por lo que podemos revisarlos o no, no es tan terrible

Con un poco de bash sacamos las stats :

gazer@Max:~$ ruby tfilter.rb > post.txt
54 post.txt
gazer@Max:~$ grep 'posible post ilegal' post.txt | wc -l
2

Solo el 3.7% de los posts analizados parecería ser ilegales, por lo que en lugar de tener que revisar 20.000 posts por día solo deberia revisar 740, el número es otro. Si de esos ademas sumás el report del user, seguro no te quedan mucho más y hasta por ahí lo podés moderar mientras desayunas :).

Obviamente mi filtro se basa solamente en linksharing (que es el 90% del problema de T! y del uso que sus usuarios le dan al site). Seguramente haya servicios de upload que no puse, pero para demostrar que filtrar el contenido es una terrible pelotudez y que Taringa! no lo hace porque no le conviene, alcanza y sobra :).

Posts analizados

Procesando archivos en background con Rails

Procesar archivos en paneles de administración es un bajón, sobre todo cuando son cada vez más grande y cada vez más. En uno de nuestros proyectos tenemos ya unos cinco archivos CSV diferentes a procesar donde cada uno cumple alguna función específica. Algunos de estos archivos superan los 5 Mb por upload, por lo que se hizo necesario desacoplar el procesamiento del archivo del request que realiza el upload.

Para manejar procesos en background en rails hay muchas opciones buenas, algunas más complejas con colas de trabajo, workers distribuidos y otras cosas que nosotros no necesitamos. Lo importante para nosotros era encontrar alguno que permita :

  • Procesar en background (obvio :D)
  • Poder saber en que estado está el trabajo (% completado)
  • Al menos un proceso en background a la vez (si hay más que espere su turno, los uploads son semanales o mensuales por lo que no hay tanto solapamiento entre tareas como para justificar múltiples workers)

Y nada más. Buscando y buscando caí con BackgroundFu que cumplía con lo necesario.

Procesando los archivos

Como todos los archivos son de texto con algún separador (algunos ‘;’, otros tabs o comas) empezamos por lo básico y crear una clase base que maneje algunos parámetros comunes.

En BackgroundFu uno puede encolar cualquier clase, por lo que nuestro worker es simplemente una clase con un método process (que también es arbitrario el nombre, podría ser cualquier otro).

Como el archivo que sube el usuario está en un directorio temporal, antes de mandar al proceso en background en necesario guardarlo en algún lugar seguro, para poder saber que al momento de procesarlo va a seguir estando, para ello encapsulamos este comportamiento en un método especial para encolar.

Nuestra completa termina siendo algo como (más adelante veremos en detalle algunas cosas) :

class FileWorker
  include BackgroundFu::WorkerMonitoring

  attr_reader :total_lines

  def self.enqueue(file, *args)
    filename = "#{RAILS_ROOT}/tmp/#{self.name}-#{Time.now.to_i}"
    File.open(filename, "wb") { |f| f.write(file.read) }
    Job.enqueue!(self.name, :'process', filename, *args).id
  end

  def process(filename, *args)
    @lines = 0
    @total_lines = `wc -l #{filename}`.to_i
    record_progress(0)

    result = real_process(filename, *args)

    record_progress(100)
    File.unlink(filename)

    result
  end

  protected
  def line_processed!
    @lines += 1

    update(@lines)
  end

  def update(lines)
    record_progress(lines, @total_lines)
  end
end

Veamos un poco más en detalle algunas partes.

Encolado

  def self.enqueue(file, *args)
    filename = "#{RAILS_ROOT}/tmp/#{self.name}-#{Time.now.to_i}"
    File.open(filename, "wb") { |f| f.write(file.read) }
    Job.enqueue!(self.name, :'process', filename, *args).id
  end

Este método es quien guarda en un lugar seguro el archivo. Utilizando el nombre de la clase actual (recuerden que este método está en una clase base, de donde luego vamos a heredar) y un timestamp por las dudas que se lleguen a hacer 2 uploads (cosa que en la práctica no pasa, pero por las dudas está).

Luego de guardar el archivo se encola el trabajo pasándole el archivo (el path completo) y cualquier otro argumento que se quiera.

Esto hará que luego el daemon de background instancia la clase ‘self.name’ y llame al método process pasandole nuestros parámetros.

Procesamiento

El método genérico de procesamiento hace uso de un método que la clase base no tiene ‘real_process’ que es el método que las clases hijas definarán para realizar el trabajo final :

  def process(filename, *args)
    @lines = 0
    @total_lines = `wc -l #{filename}`.to_i
    record_progress(0)

    result = real_process(filename, *args)

    record_progress(100)
    File.unlink(filename)

    result
  end

En este caso el método se encarga de saber cual es el total de lineas (de una manera tal vez no muy linda, pero que anda :P) y borrar el archivo al terminar el proceso.

Un worker real

Para que esto tenga sentido, debemos crear un worker que haga realmente algo, entonces por ejemplo podríamos tener un worker que sume puntos a los usuarios desde un archivo :

class UserPointProcessor  "t").each do |row|
         # Hacer algo con row

         line_processed!
      end
    end

    "Podemos retornar algun valor o status de exito"
  end
end

De esta forma podemos ahora en algún controller hacer el encolado del trabajo :

  def add_points
    if request.post?
      redirect_to admin_job_path(UserPointProcessor.enqueue(params[:file])) and return
    end

    render 'file_upload'
  end

En este caso el template file_upload es un form con un file field para hacer el upload, sin ningún otro campo. La ruta admin_job_path nos la provee directamente BackgroundFu, generándonos también las vistas con AJAX para actualizar el estado, una linda barra de progreso que nos muestra el tiempo estimado para terminar entre otros datos útiles (o no tanto :D).

Filtrando datos con jQuery

Filtrar elementos dentro de una lista o una tabla para realizar una búsqueda inline con jQuery es bastante simple, solo basta entender un poco donde va cada cosa. La función en cuestión que nos permite hacer esto es filter() que junto con un poco de trabajo para hacer un deep-search dentro del DOM nos da esta funcionalidad.

Por ejemplo si queremos mostrar los elementos de una lista que coinciden con una búsqueda deberíamos hacer algo como :

  v = 'some word';
  /* Ocultamos todos los LI */
  $('#list ul li').hide();
  /* Filtramos y mostramos los que coinciden */
  $('#list ul').contents().filter(function () {
    return innerSearch(this, v);
  }).show();

La función innerSearch la pueden ver en el ejemplo, y básicamente hace un recorrido por el DOM y devolviendo true en aquellos tags donde su contenido de texto contiene el patrón buscado.

Eso nos da muchas posibilidades, ya que por ejemplo en lugar de un simple show se podría aplicar alguna clase CSS para resaltar, modificar el color o lo que se les pueda ocurrir.

Por razones de performance no es muy útil para un DOM muy complejo, pero para el caso donde yo lo necesité funciona aceptablemente bien.

Dejo un ejemplo completo con filtrado en listas y tablas.

HTML5 : Guardar el contenido de en el servidor

Hoy tenía ganas de hacer algo distinto (vamos, no quería trabajar :P) así que me puse a jugar con HTML5, entre otras cosas con el tag <canvas> para poder dibujar. Luego de bajar varios ejemplos y tener un ‘paint’ andando lo que quería era guardar la imagen, y llegué a este post donde el autor deja guardar en PNG, JPG hasta incluso en BMP el contenido del canvas.

Pero que el usuario se pueda guardar la imagen no era mi idea, era más bien guardarle en el servidor. Por ejemplo para hacer una firma digital y usarla después en el sitio para firmar los posts como si fueran documentos :P.

Al principio pensé que iba a ser super complicado, pero me encontré que todo fue mucho más fácil de lo que imaginé. Así que vamos por partes.

Vamos a empezar por el server. Como era muy poco código para este ejemplo, hice una aplicación en Sinatra que me permite mostrar un canvas y luego hacer un POST con los datos. Es ideal en este caso, ya que el server queda de tan solo unas 20 líneas de código :

require 'sinatra'
require 'RMagick'

get '/' do
   erb :index
end

post '/' do
  # Ya completaremos ...
end

Como se puede ver tenemos una vista en el root y el POST lo manejamos también en la raiz. Para procesar la imagen decidí usar la única gema que conozco, RMagick, que buscando en la documentación de ImageMagick me encontré con grata sorpresa que sale leer ‘Base64-encoded inline image’ , que resulta que es justo el formato en que se obtienen los datos desde el canvas :). La única diferencia es que hay que preceder el string con ‘inline:’ y ya estamos, nuestro método POST queda así :

post '/' do
  data = 'inline:' + request.body.read

  begin
    image = Magick::Image.read(data).first

    image.write('image.png')
    "alert('Image saved successfully')"
  rescue
    "alert('Image format not recognized')"
  end
end

Habría que revisar la documentación y bugs de ImageMagick para ver que tipos de implicancia puede llegar a tener en la seguridad del sitio o del sistema, pero son cosas que no vienen al caso :).

La vista (reducida para el ejemplo) puede resumirse en el siguiente HTML :

  
    This text is displayed if your browser does not support HTML5 Canvas.
  

  

Y un poco de javascript :


  $(function () {
    $("input").click(function () {
      // jQuery todavía no habla bien con HTML5, hack para
      // no usar un plugin.
      var c = $("#example").get(0);
      $.post('/', c.toDataURL(), null, "script");
    });

    // Un dibujito estático para probar
    var example = document.getElementById('example');
    var context = example.getContext('2d');
    context.fillStyle = "rgb(255,0,0)";
    context.fillRect(30, 30, 50, 50);
  })

Y ya estamos, al presionar el botón se envían los datos de la imagen dibujada, si está todo bien ImageMagick creará el archivo en el filesystem y por último devolverá algo de javascript para informar al usuario como salió todo. Obviamente si tuviéramos un sistema de usuarios en el server podríamos guardar la imagen para cada uno, pero de nuevo, no viene al caso.

El resultado final :

$ identify -verbose image.png 
Image: image.png
  Format: PNG (Portable Network Graphics)
  Class: DirectClass
  Geometry: 200x200+0+0
  Resolution: 72x72
  Print size: 2.77778x2.77778
  Colorspace: RGB
  Depth: 8/1-bit
  Histogram:
     37500: (  0,  0,  0,  0) #00000000 none
      2500: (255,  0,  0,255) #FF0000 red
  Page geometry: 200x200+0+0
  Compression: Zip
  Properties:
    create-date: 2010-04-13T00:20:15-03:00
    modify-date: 2010-04-13T00:20:15-03:00
    signature: 0a42f3c85d6364e38f14dac554d26c62a90b84b06ac7f22264c9d964657ba8d6
  Tainted: False
  Filesize: 503b
  Number pixels: 39.1kb
  Version: ImageMagick 6.5.1-0 2009-08-27 Q16 OpenMP http://www.imagemagick.org

Probado en :

  • Chromium 5.0.372.0 (44042) Ubuntu
  • Mozilla/5.0 (X11; U; Linux i686; en-US; rv:1.9.1.9) Gecko/20100401 Ubuntu/9.10 (karmic) Firefox/3.5.9

Código completo : html5_canvas_submit