sábado, 30 de mayo de 2009

Superando el desafío de llevar el generador de diagramas a AppEngine


El parseador y generador de diagramas del que hablábamos por acá ahora tiene nombre y está publicado como aplicación web en Google AppEngine: NS-Matic .
Como se sabe, desde hace poco AppEngine soporta Java, y eso significa que soporta varios lenguajes más: todos aquellos que compilan a bytecode para la JVM. Lo que yo no sabía hasta luego del primer deploy, era que las limitaciones impuestas por Google iban a frustrar el plan. Resulta que hay una lista blanca de clases estándar disponibles, entre las cuales no están las de AWT. En AppEngine no se puede instanciar ninguna clase de AWT. Google provee un API alternativo de manipulación de imágenes pero no incluye el tipo de operaciones que mi aplicación necesitaba.
Al no poder generar imágenes en el servidor, consideré usar el viejo generador de HTML. Pero no me quise conformar, e investigué la posibilidad de pasarle el fardo al cliente: ¿se puede dibujar en el navegador?

Dojo al rescate

No estaba al tanto pero resulta que sí, todo navegador que se precie hoy en día tiene capacidad de gráficos vectoriales. La mayoría con SVG, Internet Explorer con VML (cuándo no, cortándose solo). Cómo será la cosa: se está hablando de que esto le vaya ganando terreno a Flash. Para resolver las diferencias entre navegadores, podemos usar una librería cross-browser como Dojo Toolkit , y su módulo Gfx que sirve precisamente para graficar. En mis diagramas, el resultado se ve mejor que los GIF que generaba antes con AWT. Lo que se pierde es la posibilidad de guardar el diagrama como un archivo de imagen, lo que sería más práctico que guardar una página con javascript.

Antes dijimos que estábamos preparados para agregar nuevas implementaciones de generadores de diagramas porque teníamos un diseño extensible. El generador de AWT recorría el AST y daba como resultado un BufferedImage; el nuevo debería recorrer el AST y retornar un String con el script a ser ejecutado por el navegador.

Pero esto nos lleva a un nuevo replanteo: los generadores de AWT y Dojo Gfx, si bien apuntan a tecnologías diferentes (el cómo), tienen un núcleo común: el qué. Antes estábamos mezclando el qué y el cómo, ahora estamos obligados a extraer el qué, si queremos hacer las cosas bien.

El graficador abstracto

La idea sería poder definir en abstracto qué líneas hay que trazar y qué texto hay que desplegar, y en qué posición. Para esto necesitamos un modelo sencillo para generadores de tipo gráfico, que tengan la capacidad de tomar esa especificación y ejecutarla en alguna tecnología.

trait Alignment
case class AlignLeft() extends Alignment
case class AlignCenter() extends Alignment

case class Pos(x:Int, y:Int) {
  def shift(dx:Int, dy:Int) = Pos(x + dx, y + dy)
}
case class Line(from:Pos, to:Pos)
case class TextLine(text:String, anchor:Pos, align:Alignment)

Pos define una posición (x,y), y con el método shift obtenemos una nueva posición con un delta horizontal y otro vertical. El concepto de posición, y la posibilidad de obtener una a partir de otra preexistente, serán de uso intensivo en esta solución. Line y TextLine definen los dos elementos básicos que sirven para armar todo.

class GraphicElement(
    val pos:Pos, 
    val width:Int, 
    val elemHeight:Int,
    val textLines: List[TextLine],
    val lines: List[Line],
    val children: List[GraphicElement]) 
{
  lazy val height: Int = maxY - minY 
  lazy val nextPos = pos.shift(0, height)
  
  private def minY = children match {
    case Nil => pos.y
    case _ => (children map (_.pos.y)) reduceLeft (_ min _) min pos.y
  } 
    
  private def maxY:Int = children match {
    case Nil => pos.y + elemHeight
    case _ =>   ((children map (_.maxY)) reduceLeft (_ max _)) max (pos.y + elemHeight)
  }
}

GraphicElement define un nodo gráfico como contenedor de objetos Line, TextLine, y nodos hijos del mismo tipo. El resultado del proceso será un árbol de nodos de éste único tipo. Para calcular en qué posición vertical se ubica cada elemento, es necesario establecer claramente cuánto espacio vertical ocupa cada uno. Al instanciar un GraphicElement, en elemHeight indicamos la altura "neta" del elemento, es decir, cuánto ocupa en vertical sin tomar en cuenta los nodos hijos. Podría ser cero en el caso de un simple contenedor de nodos. Lo que no indicamos nosotros es la altura "bruta", que incluye la de los hijos. Esa la calcula el campo height. Con el método nextPos vemos que cualquier elemento puede determinar la posición "siguiente" que se puede usar como origen del elemento que le sigue, cuando hay varios al mismo nivel. Y lo hace en términos de la posición propia, desplazada en vertical la altura propia.

Acá vemos una versión recortada del pre-generador para gráficos. El método generate retorna el GraphicElement raíz del árbol.

class GraphicGenerator(totalWidth: Int) 
{
  // ... codigo omitido 
  
  private def layoutText(s:String, p:Pos, w:Int, 
                         align:Alignment):List[TextLine] = // ...etc

  private def makeChildren(nodes:List[Node], prevElem:GraphicElement, 
                           w:Int):List[GraphicElement] = // ...etc
  
  def generate(n:Node):GraphicElement = generate(n, Pos(0,0), totalWidth)
  
  private def generate(n:Node, p:Pos, w:Int): GraphicElement = n match
  {
    case s:Stmt =>       
      val textLines = layoutText(s.text, p.shift(5, STMT_HEIGHT - 5), w, AlignLeft())
      val height = (STMT_HEIGHT - TEXT_HEIGHT) + (TEXT_HEIGHT * textLines.size)
      new GraphicElement(p, w, height, textLines, Nil, Nil)
    case f:Loop =>
      val blockWidth = w - STMT_HEIGHT
      val textLines = layoutText(f.text, p.shift(5, STMT_HEIGHT - 5), w, AlignLeft())
      val height = (STMT_HEIGHT - TEXT_HEIGHT) + (TEXT_HEIGHT * textLines.size)
      val blockPos = p.shift(STMT_HEIGHT, height)
      val children = makeChildren(f.body, GraphicElement.nullObject(blockPos), blockWidth)
      val childrenHeight = GraphicElement.sumHeights(children)
      val blockLines = List(Line(p.shift(0,childrenHeight+height), p.shift(w, childrenHeight+height)),
                            Line(p, p.shift(w,0)),
                            Line(blockPos, blockPos.shift(blockWidth,0)),
                            Line(blockPos, blockPos.shift(0,GraphicElement.sumHeights(children))))
      new GraphicElement(p, w, height, textLines, blockLines, children)

    // ... y el resto de los nodos del AST
  }
}

Como ejemplo vemos la resolución de los nodos Stmt (statement) y Loop. No importan mucho los detalles, pero señalaremos un par de cosas. Usamos el método layoutText cada vez que tenemos que generar texto; no podemos directamente instanciar un TextLine a partir de un String porque puede ocurrir que no nos dé el ancho disponible y haya que wrapear ese String. El método resuelve el wrapping y devuelve una lista de TextLine.
El método makeChildren procesa una lista de Node, invocando generate por cada uno, y determinando la posición de cada uno con nextPos del elemento anterior. Le pasamos como argumento un GraphicElement.nullObject, que es un GraphicElement vacío que sirve sólo para definir la posición inicial de esa secuencia de nodos.

Una vez que nos sacamos de encima todo el trabajo pesado, definamos un marco para las implementaciones gráficas. Estas ya no necesitan conocer el modelo del AST (Node y sus subtipos: Stmt, Loop, etc.) porque sólo trabajarán en términos de objetos GraphicElement.

abstract class GraphicBasedGenerator[T](width:Int) {
  def generate(n:Node):T = {
    val s = new GraphicGenerator(width).generate(n)
    generate(s)
  }
  def generate(ge:GraphicElement):T
}

Esta clase ya resuelve el uso del graficador abstracto que a partir de un Node raíz obtiene un GraphicElement raíz. Queda por definir la generación de un T (tipo que depende del renderizado a usar) a partir de un GraphicElement. Entonces, implementé un graficador para Dojo Gfx como subclase de GraphicBasedGenerator, que es bastante sencillo y no incluyo en el post para no alargarlo más.

No hay comentarios:

Publicar un comentario