DartAyer publiqué dart-lzw, una implementación de LZW en Dart. Un algoritmo de compresión bastante antiguo, superado por otros algoritmos más populares como gzip, pero que sigue siendo utilizado ampliamente hoy en día en la medida que es el algoritmo que usan las imágenes en formato GIF.

LZW es un algoritmo sencillo de implementar. El típico proyecto para estudiantes de segundo año, o para los que tratan de ganar algo de soltura con un lenguaje nuevo, como es mi caso. No lo tenía en mi lista de cosas por hacer, pero encontré por casualidad una referencia al algoritmo y me pareció que el proyecto tenía el tamaño adecuado como para poder hacerlo tranquilamente en mi tiempo libre. Sobre todo aprovechando que ya tenía cierta experiencia con algoritmos de compresión, de cuando porté LZMA, primero a JavaScript y luego a Dart, aunque aquellas fueran traducciones directas del código original en Java, línea por línea, sin llegar a entender el mismo. Experiencias ambas que me gustaron, pero que no me convencieron del todo, al no llegar a entender realmente lo que estaba haciendo, por lo que he aprovechado esta nueva oportunidad para resarcirme de aquello.

Los algoritmos de compresión más comunes se basan en analizar la información a comprimir, intentando detectar secuencias de símbolos que se repiten dentro de la misma, y asignando códigos a dichas secuencias. La compresión se produce de una manera natural en el momento que los códigos son más cortos que las secuencias que representan. Los códigos forman una suerte de diccionario que el programa compresor crea y el programa descompresor utiliza para recuperar la información original. Lo que varía de un algoritmo a otro es la forma en la que construye y almacena el diccionario. LZW es peculiar en este sentido, ya que no incluye el diccionario dentro de los datos comprimidos, sino que define un mecanismo que permite que el compresor y descompresor creen el mismo diccionario de forma independiente.

LZW establece que se tiene que definir un tamaño para los símbolos de entrada, un tamaño mínimo y máximo para los códigos a emitir, y crear un diccionario inicial relleno con los todos los posibles símbolos. En las implementaciones habituales se suele utilizar 8 bits para los símbolos, 9 bits como tamaño de código mínimo, 12 bits como tamaño de código máximo, y un diccionario relleno inicialmente con 256 secuencias de un símbolo cada una, de forma que a la secuencia formada por el símbolo 0 se le asigna el código 0, a la secuencia formada por el símbolo 1 se le asigna el código 1, y así sucesivamente.

El primer símbolo (byte) que lee el compresor se emite tal cual. El segundo símbolo leído se emite tal cual también, pero además se concatena al anterior construyendo una primera secuencia que se inserta en el diccionario asignándole el primer código disponible. A partir de ahí se sigue el mismo procedimiento, concatenando el último símbolo leído a la secuencia anterior. Si la secuencia no se encuentra en el diccionario se emite el último símbolo leído y se inserta la secuencia en el diccionario asignándole el siguiente código disponible. Y si la secuencia se encuentra en el diccionario no se hace nada, simplemente se espera al siguiente símbolo. Con este sencillo proceder, los símbolos que aparecen por primera vez se emiten tal cual, y el diccionario se va poblando con secuencias potencialmente cada vez más largas.

El descompresor funciona de forma análoga al compresor, leyendo códigos, creando secuencias, y comprobando si se encuentran ya en el diccionario. Como los diccionarios se inicializan de una misma forma, tanto en el compresor como en el descompresor, el primer código leído se emite tal cual. El segundo código se emite también tal cual y además se concatena con el anterior para formar una secuencia que se añade al diccionario. A partir de ahí se sigue una lógica similar a la del compresor. Si el código existe en el diccionario se emite la secuencia de símbolos asociada al mismo. Y si el código no existe en el diccionario se crea y se le asigna la secuencia de símbolos en curso. De esta forma tanto compresor como descompresor construyen el mismo diccionario de forma sincronizada.

En la práctica hay otros detalles a tener en cuenta, como el hecho de que cuando se alcanza el tamaño de código máximo se tiene que borrar el diccionario y volver a empezar con el primer código de tamaño mínimo. Para señalar esto se reserva un código, normalmente el primero, que se conoce como “Clear Table”. De igual forma se suele reservar otro código, normalmente el siguiente al anterior, para indicar el fin de los datos comprimidos, que se conoce como “End of Data”. Algunas implementaciones utilizan estos dos códigos, algunas sólo uno de ellos, y otras siempre empiezan emitiendo el “Clear Table” como primer código. Por no mencionar el hecho de que algunas implementaciones emiten el código en LSB y otras en MSB. Y que algunas incluso alinean los códigos a la primera dirección múltiplo de 8 después de emitir un “Clear Table”. De hecho me he encontrado con tantas variaciones que al final desistí de intentar soportarlas todas después de haber añadido a la librería una clase de configuración con siete opciones distintas para permitir controlar el proceso por parte de los clientes de la librería.