viernes, 30 de agosto de 2013

Sólo para ti

Para ti y sólo para ti

Y, para que nadie diga que me escabullo de mis promesas con la facilidad de un pez, comencemos ya con los métodos privados. Lo primero es una línea que marque claramente el territorio. Ponemos:

private

... y a partir de aquí, lo que pongamos será privado. Sólo accesible por el propio objeto.

Comencemos con el método “tagRange”, que nos proporciona el rango de los nodos que representan el objeto:

def tagRange
  if typeOf == :deleted
   raise "Deleted"
  end
  return (@starts_at .. @ends_at)
end

Recuerda que con “..” creábamos rangos. Así:

5..9

… se corresponde con el rango de números enteros formado por 5, 6, 7, 8 y 9.

Éste ha sido muy sencillete. Veamos ahora cómo nos las ingeniamos con “nodesHTLM”, que recibe como parámetro un rango y nos genera una cadena de texto con el contenido HTML de los nodos comprendidos en ese rango:

def nodesHTML(range)
  return range.collect{|x| @nodes[x].to_s}.join
end

Tampoco es que haya sido muy complicado. Resolvamos otro problema. Sabemos que una etiqueta HTML puede contener su propio “cierre” y no tener contenido interno, como en:

<div id="prueba" />

La barra “/” que precede al “>” indica que no tenemos que andar buscando una etiqueta de cierre del tipo “</div>”. Pero es que hay algunas etiquetas, como “<img ...>” o “<br>” que no necesitan etiqueta de cierre. Se cierran ellas solas, incluso si no les ponemos la barra al final. Para darles un tratamiento “personalizado”, vamos a crear un método que nos detecte si el objeto actual representa una de estas etiquetas o si incluye de forma explícita la barra al final para autocerrarse:

def isClosed?(start=@starts_at)
  if @nodes[start].class.name != "HTMLTag"
   raise 'Not a tag'
  end

  return (@nodes[start].autoclosed or
   ['meta','link','img','br','hr','wbr','input','source','param','frame',
   'keygen','area','base','batsefon','col','embed','bgsound'].include?

      (@nodes[start].name.downcase))
end

El método crea un array con los nombres de las etiquetas que se cierran ellas solitas incluso sin barra y comprueba si el nombre de la etiqueta representada por el objeto está en dicho array. Sencillo a la par que elegante. Y por si queremos comprobar otros nodos, podemos pasarle un parámetro.

Eso sí, tened en cuenta que la lista de nombres de etiquetas no es exhaustiva. Seguro que alguna se me olivda.

Pasemos ahora al que quizá sea el método más complejo de la clase: “deleteTags”.

Nosotros le pasamos un rango y él elimina los nodos correspondientes a ese rango. Vaya primero el código

def deleteTags(range)
  if range.count == 0
   return
  end

  doc_range = (0..(@nodes.length - 1))
  if not doc_range.include?(range.first) or not doc_range.include?(range.last)
   raise 'Out of range'

  end

  # Delete range
  @nodes.slice!(range)

  # Fix top document count
  document = @type == :document ? self : @document
  document.ends_at = @nodes.length - 1

  # Fix nodes created from top document
  document.nodes_created.each do |n|

   if n.starts_at >= range.first
    if n.starts_at <= range.last
     # Object is inside deleted range. Clear it
     n.clear
    else
     # Object is outside deleted range. Keep it, but...
     # Compute new start and end nodes
      n.starts_at -= range.count
     n.ends_at -= range.count
    end
   

   elsif n.ends_at >= range.first
    if n.ends_at <= range.last
     # "Non well formed" HTML: trim elements
     n.ends_at = range.first - 1
     if n.ends_at < 0
      n.clear
     end
    else
     # The deleted range is inside the node
     # Compute new end node
     n.ends_at -= range.count
    end
   end
  end
end
  

La primera parte del método hace unas cuantas comprobaciones y elimina los nodos:

...
...
if not doc_range.include?(range.first) or not doc_range.include?(range.last)
  raise 'Out of range'
end

# Delete range
@nodes.slice!(range)

El método “slice!” elimina un rango de elementos de un array. Y, por si necesitamos saber qué había en los elementos eliminados, retorna un array con lo que se ha cargado. Existe un método parecido llamado “slice” que funciona de forma similar, pero sin borrar nada del array original.

Hasta aquí todo bien... excepto por los “efectos laterales”. Vamos a ver: imaginemos que hemos ido creando elementos a partir del documento con llamadas, por ejemplo, a “getElementById”. Al quitar código HTML de nuestro documento puede que algunos de estos elementos desaparezcan. O que varíen sus posiciones de inicio o final, puesto que hemos quitado cosas.

Así que ahora tenemos que arreglar el desaguisado. Comencemos por el documento padre, el original.

El objeto que invoca el método puede ser o no el documento original, así que primero lo comprobamos:

document = @type == :document ? self : @document

… y corregimos su atributo @ends_at

document.ends_at = @nodes.length - 1

Recuerda que podemos hacer esto porque el accessor a @ends_at es de tipo “protegido”.

Y ahora le damos un repaso a todos los nodos creados a partir del documento. “document”, el objeto que lo representa los almacena en un atributo llamado “nodes_create”. Así que se hace un bucle sobre ellos con:

document.nodes_created.each do |n|
...
...
...

end

Dentro de este bucle, para cada nodo “n” realizamos las siguientes comprobaciones:

  1. Si el inicio del nodo está dentro del rango del nodo, eliminamos dicho nodo mediante una llamada a “clear”. Y a otra cosa, mariposa.
  2. Si el objeto comienza después del final del rango eliminado, tenemos que restar de sus índice de inicio y finalización el número de nodos que hemos borrado.
  3. Si el objeto comienza antes del inicio del rango borrado y acaba dentro de él, habrá que corregir su índice de finalización y decir que acaba justo antes del rango que acabamos de eliminar.
  4. Si el objeto comienza antes del inicio del rango eliminado y acaba fuera de él, o sea si el objeto contiene dentro el rango eliminado, habrá que corregir sólo su índice de finalización, decrementándolo en el número de nodos eliminados.
O, dicho en Rubyense:

if n.starts_at >= range.first
  if n.starts_at <= range.last
    # Object is inside deleted range. Clear it
   n.clear
  else
   # Object is outside deleted range. Keep it, but...
   # Compute new start and end nodes
   n.starts_at -= range.count
   n.ends_at -= range.count
  end
elsif n.ends_at >= range.first
  if n.ends_at <= range.last
   # "Non well formed" HTML: trim elements
   n.ends_at = range.first - 1
   if n.ends_at < 0
    n.clear
   end
  else
   # The deleted range is inside the node
   # Compute new end node
   n.ends_at -= range.count
  end
end

Añadir nodos es mucho más sencillo que borrarlos. La única corrección necesaria consiste en incrementar los inicios y finales de nodos que se produzcan después del punto en que insertamos el nuevo contenido. Eso es lo que hace “inserTags”, que añade en una posición dada un conjunto de nodos o etiquetas:

def insertTags(position,tags)

  # Add the new elements
  if position >= @nodes.length
   # Append
   @nodes.concat(tags)
  elsif position >= 0
   @nodes.insert(position, tags).flatten!
  else
   raise 'Invalid position'
  end

  # Fix top document indexes
  document = typeOf == :document ? self : @document
  document.ends_at += tags.length

  # Fix nodes created from top document
  document.nodes_created.each do |n|
   if n.starts_at >=position
    n.starts_at += tags.length
   end

   if n.ends_at >=position
    n.ends_at += tags.length
   end
  end
end
  

 

lunes, 26 de agosto de 2013

Lo que el documento esconde

Lo que el documento esconde


¡Bonito título! ¿Verdad? Y lo mismo sirve para un artículo sobre los METADATOS que para un post sobre Ruby.

Hasta aquí hemos visto los métodos que nuestra clase HTMLDoc expone cara al exterior. Digamos que “lo que ve la suegra”. Pero, como vimos en el proyecto anterior (http://elrubymola.blogspot.com.es/2013/02/compare-compare-2-de-2.html) las clases pueden tener también métodos privados, que sólo el propio objeto puede invocar.

Pero eso no termina de resolver nuestros problemas de hoy.

Os cuento: A lo largo de los anteriores epígrafes he ido dejando no sé ya cuántos métodos “para más adelante”. Y aún no los hemos desarrollado. Ya habrá quien diga “este tío es un mal pagador” o “no es de fiar” o “mucho prometer, pero...”.

Como no quiero quedar mal, y como no es algo que cueste dinero, voy a ponerme con ello. Interesante, eso de estudiar las “estrategias para parecer un tipo honesto”.

Vayamos al tajo y pensemos en el método “getElement”, uno de los que tenemos que escribir aún. Recibe un parámetro numérico y genera un objeto de tipo HTMLDoc que representa el elemento del documento que comienza en la posición indicada por dicho número.

Pensemos, que es gratis: El método no debe ser público porque no aporta nada a la funcionalidad de la clase y trata con temas de cómo representamos a nivel interno los datos. Pero tampoco puede ser privado, porque los métodos privados sólo pueden ser invocados por el propio objeto.

Y se trata de que un objeto HTMLDoc pueda crear otro y modificar su contenido interno.

Ruby tiene una solución: los métodos protegidos. Si un objeto tiene un método protegido, éste puede ser invocado por todos los objetos de su misma clase. ¡Justo lo que necesitábamos! Y para empezar a definir métodos protegidos basta con poner la palabra “protected”. Todo lo que pongamos a continuación estará “protegido”, mientras no cambiemos de modo utilizando “public” o “private”.

Vamos a hacer dos cosas:
  1. Definir unos accesores a los atributos del objeto. De ese modo, cuando un objeto genere otro con “getElement”, podrá asinarle el contenido que sea pertinente.
  2. Definir un nuevo método que vacía el contenido de un objeto y lo marca como eliminado. Aún no nos ha hecho falta, pero ya lo utilizaremos más adelante, cuando quitemos contenido del documento.

Ahí van ambas cosas de un tirón:

protected
# Parent Document/Tag may use these to create objects representing its descendants
attr_accessor :type, :nodes, :starts_at, :ends_at, :document, :nodes_created

# Clears data from a deleted object
def clear
  @type = :deleted
  @nodes = nil
  @starts_at = -1
  @ends_at = -2
  @document.nodes_created.delete_if{|x| x.equal?(self)}

  @document = nil
end

Observad como el método clear elimina el objeto de la lista de nodos creados a partir del documento original haciendo uso de “delete_if”.

sábado, 24 de agosto de 2013

Cambiando el documento

Cambiando el documento

Hasta aquí hemos consultado el documento y su contenido. Lo cual está muy bien. Pero... ¿y si quiero hacer cambios?

Vayamos por partes. Comencemos por un método que permita eliminar un nodo, junto con sus posibles nodos hijos:

def removeFromDocument
  if typeOf == :document
   raise 'Cannot remove the whole document'
  end

 
deleteTags(tagRange)
end

Mal empezamos. Este método llama a otro que dejaremos para más adelante. ¿Cuántos os debo ya? Uno, dos, tres, cuatro...

Pero si lo vamos a dejar para más adelante, lo dejamos y seguimos. Que si no nos atrancamos. Que, como bien apuntan los Monty Python, ya se sabe que hay que hablar menos y hacer más. Porque sólo diciendo no se avanza y es preciso actuar. Porque lo importante es lo que se hace y habría que dejar a un lado la palabrería para centrarnos en lo importante...

¿Por dónde íbamos? Eso... vamos a crear un método que nos permita cambiar el “innerHTML” de una etiqueta. Para ello, primero miramos si la etiqueta no cuenta con un cierre explícito y, si es necesario, lo añadimos. Después, eliminamos los nodos correspondientes al “innerHTML” antiguo. Y finalmente insertamos en su lugar lo nuevo:

def changeInnerHTML(html)
  if typeOf != :HTMLTag
   raise 'Not a tag'
  end

  # If there is no closing tag, add it
  
if @nodes[@starts_at].autoclosed
   insertTags(@starts_at + 1, [HTMLclose.new(tagName)])
   @ends_at = @starts_at + 1
   @nodes[@starts_at].autoclosed = false
  end
   

  deleteTags((@starts_at + 1)..(@ends_at - 1))
   

  insertTags(@starts_at + 1, HTMLParser.new.parseHTML(html))
end

Para las inserciones, realizamos llamadas a otro método, “insertTags”. Y sabéis qué: voy a dejar éste también para más adelante... Por ahora sólo diré que inserta, en la posición que le indiquemos como primer parámetro, el código HTML que le pasemos como segundo.

Utilizando “insertTags” podemos crear otros dos métodos que permitan insertar código HTML antes o después del objeto que estamos manipulando:

def insertBefore(html)
  insertTags(@starts_at, HTMLParser.new.parseHTML(html))
end

def insertAfter(html)
  insertTags(@ends_at + 1, HTMLParser.new.parseHTML(html))
end

miércoles, 7 de agosto de 2013

Extraer información

Los atributos

Aunque ya tenemos definido el método llamado “attributes”, no viene mal tener unos cuantos más para consultar o modificar los contenidos de los atributos. 

Para ello podemos utilizar los operadores de consulta y asignación de índices. Así, para consulta el valor de un atributo tendremos:

def [](index)
  if typeOf != :HTMLTag
   raise "Not a tag"
  end

  return @nodes[@starts_at].attributes[index]
end

Y, para asignarle un valor:

def []=(index, value)
  if typeOf != :HTMLTag
   raise "Not a tag"
  end

  
@nodes[@starts_at].attributes[index] = value
end

Sacando cosas de un documento

Hasta aquí tenemos forma de crear un documento y de obtener información del mismo.

Y también de los elementos que se puedan crear a partir de él. Lo cual estaría muy bien, si no fuera porque aún no sabemos como crear estos elementos.

Eso... ¿Cómo creamos los elementos a partir de un documento?

Bien... buena pregunta... Comencemos por ver como podemos obtener un elemento del documento a partir de su atributo “ID”. Para eso, cuando manipulamos HTML mediante scripts, se suele contar con un método llamado “getElementById”, aplicable a documentos y etiquetas. Su implementación en Ruby para nuestra clase sería:

def getElementById(id)
  if typeOf != :HTMLTag and typeOf != :document
   raise "Not a tag"
  end

  
tagRange.each do |x|
   if @nodes[x].class.name == "HTMLTag" and @nodes[x].attributes['ID'] == id
    return getElement(x)
   end

 end

  return nil
end
  

El método recorre con la variable “x” el rango de nodos correspondiente al objeto actual y, para cada uno de ellos comprueba si se trata de una etiqueta HTML y, de ser así, si su atributo “ID” es igual al que estamos buscando.

Cuando se cumplen estas condiciones, es que hemos dado con lo que estamos buscando. Sólo queda retornar la representación del objeto que comienza en la posición “x” actual.

Pero para eso utilizamos otro método, “getElement” que vamos a dejar para más adelante.

Y con eso van tres métodos que os debo.

Otra cosa interesante sería poder obtener un array, por ejemplo, de todas las etiquetas “<DIV>” de un documento. O de todos los “<li>” subordinados a un determinado “<ul>”. Para cosas como esas podemos definir el siguiente método:

def getElementsByTagName(thetagname)
  return tagRange.select{|x| @nodes[x].class.name == "HTMLTag"

     and @nodes[x].name == thetagname}.collect{|e| getElement(e)}
end

Básicamente, con el “select” miramos dentro del rango de nodos del objeto actual y creamos un array de los valores de x en que aparece una etiqueta con el nombre dado. Posteriormente, a partir de este array, con el “collect” generamos un array de los elementos que empiezan en dichos valores.

Algo parecido podemos hacer para obtener un array de aquellas etiquetas que tengan un determinado atributo igual a un valor dado:

def getElementsByAttribute(attribute, value)
  return tagRange.select{|x| @nodes[x].class.name == "HTMLTag"

     and @nodes[x].attributes[attribute] == value}.collect{|e| getElement(e)}
end

Para ir acabando con esto de generar elementos, podemos crear un array de los nodos hijos de una etiqueta dada o del documento completo como sigue:

def children
 range = tagRange
 
  last_tag = range.last + (typeOf== document ? 1 : 0)
 x = typeOf == :document ? 0 : (range.first + 1)
  result = []
  while x < last_tag
   elemento = getElement(x)
   result.push(elemento)
   x = elemento.ends_at + 1
 end

   
 return result
end
  

Dicho en román paladino: mira dentro de lo que se correspondería con el “innerHTML” y cada vez que encuentres un nodo, añádelo a la lista de resultados, mira donde termina su definición, y continúa buscando a partir de ahí.

lunes, 5 de agosto de 2013

El contenido de los nodos

El contenido de los nodos

Una vez que tengamos un documento o uno de sus elementos, quizá queramos saber qué tiene dentro. Tanto su código HTML como el texto que pudiera contener.

En cuanto al código HTML podemos distinguir entre HTML interno y externo. Si nos fijamos en la etiqueta “<div>” del siguiente ejemplo:

<div>
<a href="#asd">Prueba</a>
</div>

… su HTML interno es lo que hay dentro del <div>. Así, quitándole la cáscara a nuestra etiqueta nos queda:

<a href="#asd">Prueba</a>

… mientras que su HTML externo es todo el código fuente correspondiente a la etiqueta <div>:

<div>
<a href="#asd">Prueba</a>
</div>

Y, en lo que al texto de la etiqueta respecta, tendríamos sólo:

Prueba

Para conseguir el HTML externo definiremos el método outerHTML:

def outerHTML
  if typeOf == :deleted
   raise "Deleted"
  
end
  return nodesHTML(tagRange)
end

Primero miramos si el nodo no ha sido eliminado, porque si sí lo ha sido, no hay nada que mirar. Si todo está OK, llamamos a otro método que definiremos más adelante llamado “nodesHTML” que lista el contenido de unos cuantos de los nodos que componen el documento principal.

¿Que cuáles nodos lista? Bueno, eso lo indicamos mediante el parámetro que le pasamos, que debe ser un rango. Y, en este caso, ese rango es el resultado de “tagRange”, otro método que dejamos para más adelante y que nos proporciona el rango de nodos que componen el elemento actual.

Ya os debo dos métodos.

También vamos a crear el método “to_s” como un alias para “outerHTML”.

alias_method :to_s, :outerHTML

Del HTML interno sólo tiene sentido hablar si estamos ante una etiqueta o bien de un documento.

def innerHTML
  if typeOf == :document
   return outerHTML
  
elsif typeOf != :HTMLTag
   raise 'Not a tag'
  end

  range = tagRange
  return nodesHTML((range.first + 1) .. (range.last - 1))
end

Finalmente, para el contenido de texto de un nodo habría que seleccionar aquellos elementos de tipo “texto” que le pertenezcan y concatenar sus contenidos. Para quienes sólo tengan interés por la palabrería pura y dura, su método preferido será:


def textContent
  if typeOf == :deleted
   raise "Deleted"
  
end
  return tagRange.select{|x| @nodes[x].class.name == 'HTMLText'}.collect{|t|
       @nodes[t].to_s}.join.to_s
end