Cómo compilar mkbitmap en WebAssembly

En ¿Qué es WebAssembly y de dónde provino?, Expliqué cómo terminamos con WebAssembly de hoy. En este artículo, te mostraré mi enfoque para compilar un programa de C existente, mkbitmap, en WebAssembly. Es más complejo que el ejemplo de hello world, ya que incluye trabajar con archivos, comunicarse entre WebAssembly y JavaScript y dibujar en un lienzo, pero aún es lo suficientemente administrable como para no abrumarte.

El artículo está dirigido a desarrolladores web que desean aprender a usar WebAssembly y muestra paso a paso cómo puedes proceder si quisieras compilar algo como mkbitmap en WebAssembly. Como advertencia, es completamente normal que no se compile una app o biblioteca en la primera ejecución, por lo que algunos de los pasos descritos a continuación no funcionaron, por lo que tuve que retroceder y volver a intentarlo de manera diferente. El artículo no muestra el comando mágico de compilación final como si hubiera caído del cielo, sino que describe mi progreso real y algunas frustraciones.

Acerca de mkbitmap

El programa C de mkbitmap lee una imagen y le aplica una o más de las siguientes operaciones, en este orden: inversión, filtrado de paso alto, escalamiento y umbral. Cada operación se puede controlar, activar o desactivar de forma individual. El uso principal de mkbitmap consiste en convertir imágenes de color o en escala de grises a un formato adecuado como entrada para otros programas, especialmente el programa de registro potrace que forma la base de SVGcode. Como herramienta de procesamiento previo, mkbitmap es particularmente útil para convertir el arte lineal escaneado, como dibujos animados o texto escrito a mano, en imágenes de dos niveles de alta resolución.

Para usar mkbitmap, debes pasarle varias opciones y uno o varios nombres de archivos. Para obtener todos los detalles, consulta la página del manual de la herramienta:

$ mkbitmap [options] [filename...]
Imagen de dibujo animado en color.
La imagen original (Fuente).
Se convirtió la imagen de dibujo a escala de grises después del procesamiento previo.
Primera escala y, luego, umbral: mkbitmap -f 2 -s 2 -t 0.48 (Fuente).

Obtén el código

El primer paso es obtener el código fuente de mkbitmap. Puedes encontrarlo en el sitio web del proyecto. En el momento de la redacción de este documento, potrace-1.16.tar.gz es la versión más reciente.

Compila e instala de manera local

El siguiente paso es compilar e instalar la herramienta de forma local para tener una idea de cómo se comporta. El archivo INSTALL contiene las siguientes instrucciones:

  1. cd al directorio que contiene el código fuente del paquete y escribe ./configure para configurarlo en tu sistema.

    Es posible que la ejecución de configure tarde un poco. Mientras se ejecuta, imprime algunos mensajes que indican qué funciones está verificando.

  2. Escribe make para compilar el paquete.

  3. De manera opcional, escribe make check para ejecutar cualquier autoprueba que incluya el paquete, por lo general, con los objetos binarios desinstalados que se acaban de compilar.

  4. Escribe make install para instalar los programas y cualquier archivo de datos y documentación. Cuando se instala en un prefijo que es propiedad de la raíz, se recomienda que el paquete se configure y compile como un usuario normal, y que solo la fase make install se ejecute con privilegios de administrador.

Si sigues estos pasos, deberías tener dos ejecutables, potrace y mkbitmap. Este último es el tema central de este artículo. Para verificar que funcionó correctamente, ejecuta mkbitmap --version. Este es el resultado de los cuatro pasos de mi máquina, muy recortado para abreviar:

Paso 1, ./configure:

 $ ./configure
checking for a BSD-compatible install... /usr/bin/install -c
checking whether build environment is sane... yes
checking for a thread-safe mkdir -p... ./install-sh -c -d
checking for gawk... no
checking for mawk... no
checking for nawk... no
checking for awk... awk
checking whether make sets $(MAKE)... yes
[…]
config.status: executing libtool commands

Paso 2, make:

$ make
/Applications/Xcode.app/Contents/Developer/usr/bin/make  all-recursive
Making all in src
clang -DHAVE_CONFIG_H -I. -I..     -g -O2 -MT main.o -MD -MP -MF .deps/main.Tpo -c -o main.o main.c
mv -f .deps/main.Tpo .deps/main.Po
[…]
make[2]: Nothing to be done for `all-am'.

Paso 3, make check:

$ make check
Making check in src
make[1]: Nothing to be done for `check'.
Making check in doc
make[1]: Nothing to be done for `check'.
[…]
============================================================================
Testsuite summary for potrace 1.16
============================================================================
# TOTAL: 8
# PASS:  8
# SKIP:  0
# XFAIL: 0
# FAIL:  0
# XPASS: 0
# ERROR: 0
============================================================================
make[1]: Nothing to be done for `check-am'.

Paso 4, sudo make install:

$ sudo make install
Password:
Making install in src
 .././install-sh -c -d '/usr/local/bin'
  /bin/sh ../libtool   --mode=install /usr/bin/install -c potrace mkbitmap '/usr/local/bin'
[…]
make[2]: Nothing to be done for `install-data-am'.

Para comprobar si funcionó, ejecuta mkbitmap --version:

$ mkbitmap --version
mkbitmap 1.16. Copyright (C) 2001-2019 Peter Selinger.

Si obtienes los detalles de la versión, significa que compilaste e instalaste mkbitmap correctamente. A continuación, haz que el equivalente de estos pasos funcione con WebAssembly.

Cómo compilar mkbitmap en WebAssembly

Emscripten es una herramienta para compilar programas C/C++ en WebAssembly. En la documentación sobre proyectos de edificios de Emscripten, se indica lo siguiente:

Crear proyectos grandes con Emscripten es muy fácil. Emscripten proporciona dos secuencias de comandos simples que configuran tus archivos makefile para usar emcc como reemplazo directo de gcc. En la mayoría de los casos, el resto del sistema de compilación actual de tu proyecto no se modifica.

Luego, continúa la documentación (un poco editada para abreviar):

Considera el caso en el que normalmente compilas con los siguientes comandos:

./configure
make

Para compilar con Emscripten, deberías usar los siguientes comandos:

emconfigure ./configure
emmake make

Por lo tanto, en esencia, ./configure se convierte en emconfigure ./configure, y make se convierte en emmake make. A continuación, se muestra cómo hacerlo con mkbitmap.

Paso 0, make clean:

$ make clean
Making clean in src
 rm -f potrace mkbitmap
test -z "" || rm -f
rm -rf .libs _libs
[…]
rm -f *.lo

Paso 1, emconfigure ./configure:

$ emconfigure ./configure
configure: ./configure
checking for a BSD-compatible install... /usr/bin/install -c
checking whether build environment is sane... yes
checking for a thread-safe mkdir -p... ./install-sh -c -d
checking for gawk... no
checking for mawk... no
checking for nawk... no
checking for awk... awk
[…]
config.status: executing libtool commands

Paso 2, emmake make:

$ emmake make
make: make
/Applications/Xcode.app/Contents/Developer/usr/bin/make  all-recursive
Making all in src
/opt/homebrew/Cellar/emscripten/3.1.36/libexec/emcc -DHAVE_CONFIG_H -I. -I..     -g -O2 -MT main.o -MD -MP -MF .deps/main.Tpo -c -o main.o main.c
mv -f .deps/main.Tpo .deps/main.Po
[…]
make[2]: Nothing to be done for `all'.

Si todo salió bien, ahora debería haber archivos .wasm en alguna parte del directorio. Para encontrarlas, ejecuta find . -name "*.wasm":

$ find . -name "*.wasm"
./a.wasm
./src/mkbitmap.wasm
./src/potrace.wasm

Los dos últimos parecen prometedores, por lo que debes cd en el directorio src/. Ahora también hay dos nuevos archivos correspondientes, mkbitmap y potrace. Para este artículo, solo mkbitmap es relevante. El hecho de que no tengan la extensión .js es un poco confuso, pero, en realidad, son archivos JavaScript que se pueden verificar con una llamada rápida a head:

$ cd src/
$ head -n 20 mkbitmap
// include: shell.js
// The Module object: Our interface to the outside world. We import
// and export values on it. There are various ways Module can be used:
// 1. Not defined. We create it here
// 2. A function parameter, function(Module) { ..generated code.. }
// 3. pre-run appended it, var Module = {}; ..generated code..
// 4. External script tag defines var Module.
// We need to check if Module already exists (e.g. case 3 above).
// Substitution will be replaced with actual code on later stage of the build,
// this way Closure Compiler will not mangle it (e.g. case 4. above).
// Note that if you want to run closure, and also to use Module
// after the generated code, you will need to define   var Module = {};
// before the code. Then that object will be used in the code, and you
// can continue to use Module afterwards as well.
var Module = typeof Module != 'undefined' ? Module : {};

// --pre-jses are emitted after the Module integration code, so that they can
// refer to Module (if they choose; they can also define Module)

Cambia el nombre del archivo JavaScript a mkbitmap.js llamando a mv mkbitmap mkbitmap.js (y mv potrace potrace.js, respectivamente, si lo deseas). Ahora, es momento de que la primera prueba compruebe si funcionó. Para ello, ejecuta el archivo con Node.js en la línea de comandos y ejecuta node mkbitmap.js --version:

$ node mkbitmap.js --version
mkbitmap 1.16. Copyright (C) 2001-2019 Peter Selinger.

Compilaste mkbitmap en WebAssembly correctamente. Ahora, el próximo paso es hacer que funcione en el navegador.

mkbitmap con WebAssembly en el navegador

Copia los archivos mkbitmap.js y mkbitmap.wasm en un directorio nuevo llamado mkbitmap y crea un archivo estándar HTML index.html que cargue el archivo JavaScript mkbitmap.js.

<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="utf-8" />
    <title>mkbitmap</title>
  </head>
  <body>
    <script src="mkbitmap.js"></script>
  </body>
</html>

Inicia un servidor local que entregue el directorio mkbitmap y ábrelo en el navegador. Deberías ver un mensaje en el que se te solicitará información. Esto es como se esperaba, ya que, según la página del manual de la herramienta, "[i]si no se proporcionan argumentos de nombre de archivo, entonces mkbitmap actúa como un filtro y lee desde la entrada estándar", que para Emscripten de forma predeterminada es un prompt().

La app mkbitmap muestra un mensaje que solicita información de entrada.

Evita la ejecución automática

Para evitar que mkbitmap se ejecute de inmediato y hacer que espere la entrada del usuario, debes comprender el objeto Module de Emscripten. Module es un objeto global de JavaScript con atributos que el código generado por Emscripten llama en varios puntos de su ejecución. Puedes proporcionar una implementación de Module para controlar la ejecución del código. Cuando se inicia una aplicación de Emscripten, esta observa los valores en el objeto Module y los aplica.

En el caso de mkbitmap, establece Module.noInitialRun en true para evitar la ejecución inicial que causó la aparición del mensaje. Crea una secuencia de comandos llamada script.js, inclúyela antes de <script src="mkbitmap.js"></script> en index.html y agrega el siguiente código a script.js. Cuando vuelvas a cargar la app, el mensaje debería desaparecer.

var Module = {
  // Don't run main() at page load
  noInitialRun: true,
};

Crea una compilación modular con algunas marcas de compilación más

Para proporcionar entradas a la app, puedes usar la compatibilidad con el sistema de archivos de Emscripten en Module.FS. En la sección Incluye compatibilidad con el sistema de archivos de la documentación, se establece lo siguiente:

Emscripten decide si incluir automáticamente la compatibilidad con el sistema de archivos. Muchos programas no necesitan archivos, y el tamaño del soporte del sistema de archivos es insignificante, por lo que Emscripten evita incluirlo cuando no ve un motivo para hacerlo. Esto significa que, si el código C/C++ no accede a los archivos, el objeto FS y las otras APIs del sistema de archivos no se incluirán en el resultado. Por otro lado, si tu código C/C++ usa archivos, se incluirá automáticamente la compatibilidad con el sistema de archivos.

Lamentablemente, mkbitmap es uno de los casos en los que Emscripten no incluye automáticamente la compatibilidad con el sistema de archivos, por lo que debes indicarle explícitamente que lo haga. Esto significa que debes seguir los pasos de emconfigure y emmake descritos anteriormente, con algunas marcas más establecidas mediante un argumento CFLAGS. Las siguientes marcas también pueden ser útiles para otros proyectos.

Además, en este caso particular, debes establecer la marca --host en wasm32 para indicarle a la secuencia de comandos configure que estás compilando para WebAssembly.

El comando final emconfigure se ve de la siguiente manera:

$ emconfigure ./configure --host=wasm32 CFLAGS='-sFILESYSTEM=1 -sEXPORTED_RUNTIME_METHODS=FS,callMain -sMODULARIZE=1 -sEXPORT_ES6 -sINVOKE_RUN=0'

No olvides volver a ejecutar emmake make y copiar los archivos recién creados en la carpeta mkbitmap.

Modifica index.html para que solo cargue el módulo de ES script.js, desde el cual luego importarás el módulo mkbitmap.js.

<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="utf-8" />
    <title>mkbitmap</title>
  </head>
  <body>
    <!-- No longer load `mkbitmap.js` here -->
    <script src="script.js" type="module"></script>
  </body>
</html>
// This is `script.js`.
import loadWASM from './mkbitmap.js';

const run = async () => {
  const Module = await loadWASM();
  console.log(Module);
};

run();

Cuando abras la app en el navegador, deberías ver el objeto Module registrado en la consola de Herramientas para desarrolladores y el mensaje desapareció, ya que no se llama a la función main() de mkbitmap al comienzo.

La app mkbitmap con una pantalla blanca que muestra el objeto Module registrado en la consola de Herramientas para desarrolladores.

Ejecuta la función principal de forma manual

El siguiente paso es llamar de forma manual a la función main() de mkbitmap ejecutando Module.callMain(). La función callMain() toma un array de argumentos, que coinciden uno por uno con lo que pasarías en la línea de comandos. Si ejecutas mkbitmap -v en la línea de comandos, deberás llamar a Module.callMain(['-v']) en el navegador. Esto registra el número de versión de mkbitmap en la consola de Herramientas para desarrolladores.

// This is `script.js`.
import loadWASM from './mkbitmap.js';

const run = async () => {
  const Module = await loadWASM();
  Module.callMain(['-v']);
};

run();

La app mkbitmap con una pantalla blanca que muestra el número de versión de mkbitmap registrado en la consola de Herramientas para desarrolladores.

Redirecciona la salida estándar

La salida estándar (stdout) es la consola de forma predeterminada. Sin embargo, puedes redireccionarlo a otra cosa, por ejemplo, una función que almacene el resultado en una variable. Esto significa que puedes agregar el resultado al HTML si configuras la propiedad Module.print.

// This is `script.js`.
import loadWASM from './mkbitmap.js';

const run = async () => {
  let consoleOutput = 'Powered by ';
  const Module = await loadWASM({
    print: (text) => (consoleOutput += text),
  });
  Module.callMain(['-v']);
  document.body.textContent = consoleOutput;
};

run();

La app mkbitmap muestra el número de versión del mapa mkbitmap.

Cómo obtener el archivo de entrada en el sistema de archivos de la memoria

Para obtener el archivo de entrada en el sistema de archivos de memoria, necesitas el equivalente de mkbitmap filename en la línea de comandos. Para entender este enfoque, primero veremos información sobre cómo mkbitmap espera su entrada y crea su salida.

Los formatos de entrada admitidos de mkbitmap son PNM (PBM, PGM, PPM) y BMP. Los formatos de salida son PBM para mapas de bits y PGM para mapas de grises. Si se proporciona un argumento filename, mkbitmap creará de forma predeterminada un archivo de salida cuyo nombre se obtendrá del nombre del archivo de entrada cambiando su sufijo a .pbm. Por ejemplo, para el nombre del archivo de entrada example.bmp, el nombre del archivo de salida sería example.pbm.

Emscripten proporciona un sistema de archivos virtual que simula el sistema de archivos local, de modo que el código nativo que usa APIs de archivos síncronas se pueda compilar y ejecutar con poco o ningún cambio. Para que mkbitmap lea un archivo de entrada como si se hubiera pasado como un argumento de línea de comandos filename, debes usar el objeto FS que proporciona Emscripten.

El objeto FS está respaldado por un sistema de archivos en la memoria (comúnmente denominado MEMFS) y tiene una función writeFile() que se usa para escribir archivos en el sistema virtual de archivos. Usa writeFile() como se indica en la siguiente muestra de código.

Para verificar que se haya realizado la operación de escritura del archivo, ejecuta la función readdir() del objeto FS con el parámetro '/'. Verás example.bmp y una serie de archivos predeterminados que siempre se crean automáticamente.

Ten en cuenta que se quitó la llamada anterior a Module.callMain(['-v']) para imprimir el número de versión. Esto se debe a que Module.callMain() es una función que, por lo general, espera ejecutarse solo una vez.

// This is `script.js`.
import loadWASM from './mkbitmap.js';

const run = async () => {
  const Module = await loadWASM();
  const buffer = await fetch('https://example.com/example.bmp').then((res) => res.arrayBuffer());
  Module.FS.writeFile('example.bmp', new Uint8Array(buffer));
  console.log(Module.FS.readdir('/'));
};

run();

La app mkbitmap muestra un array de archivos en el sistema de archivos de memoria, incluido example.bmp.

Primera ejecución real

Con todo listo, ejecuta mkbitmap con Module.callMain(['example.bmp']). Registra el contenido de la carpeta '/' de MEMFS. Deberías ver el archivo de salida example.pbm recién creado junto al archivo de entrada example.bmp.

// This is `script.js`.
import loadWASM from './mkbitmap.js';

const run = async () => {
  const Module = await loadWASM();
  const buffer = await fetch('https://example.com/example.bmp').then((res) => res.arrayBuffer());
  Module.FS.writeFile('example.bmp', new Uint8Array(buffer));
  Module.callMain(['example.bmp']);
  console.log(Module.FS.readdir('/'));
};

run();

La app mkbitmap muestra un array de archivos en el sistema de archivos de memoria, incluidos example.bmp y example.pbm.

Obtén el archivo de salida del sistema de archivos de la memoria

La función readFile() del objeto FS permite obtener el example.pbm creado en el último paso fuera del sistema de archivos de la memoria. La función muestra un Uint8Array que conviertes en un objeto File y guardas en el disco, ya que, por lo general, los navegadores no admiten archivos PBM para la visualización directa en el navegador. (Existen maneras más elegantes de guardar un archivo, pero el uso de un <a download> creado de forma dinámica es la más compatible). Una vez que se haya guardado el archivo, podrás abrirlo en tu visor de imágenes favorito.

// This is `script.js`.
import loadWASM from './mkbitmap.js';

const run = async () => {
  const Module = await loadWASM();
  const buffer = await fetch('https://example.com/example.bmp').then((res) => res.arrayBuffer());
  Module.FS.writeFile('example.bmp', new Uint8Array(buffer));
  Module.callMain(['example.bmp']);
  const output = Module.FS.readFile('example.pbm', { encoding: 'binary' });
  const file = new File([output], 'example.pbm', {
    type: 'image/x-portable-bitmap',
  });
  const a = document.createElement('a');
  a.href = URL.createObjectURL(file);
  a.download = file.name;
  a.click();
};

run();

Buscador de macOS con una vista previa del archivo .bmp de entrada y el archivo .pbm de salida.

Cómo agregar una IU interactiva

Hasta este punto, el archivo de entrada está codificado y mkbitmap se ejecuta con parámetros predeterminados. El último paso es permitir que el usuario seleccione de forma dinámica un archivo de entrada, modifique los parámetros de mkbitmap y, luego, ejecute la herramienta con las opciones seleccionadas.

// Corresponds to `mkbitmap -o output.pbm input.bmp -s 8 -3 -f 4 -t 0.45`.
Module.callMain(['-o', 'output.pbm', 'input.bmp', '-s', '8', '-3', '-f', '4', '-t', '0.45']);

El formato de imagen de PBM no es particularmente difícil de analizar, por lo que con algún código JavaScript, incluso podrías mostrar una vista previa de la imagen de salida. Consulta el código fuente de la demostración incorporada a continuación para conocer una forma de hacerlo.

Conclusión

¡Felicitaciones! Compilaste mkbitmap en WebAssembly correctamente y lo hiciste funcionar en el navegador. Hubo algunos callejones sin salida y tuviste que compilar la herramienta más de una vez hasta que funcionó, pero como escribí arriba, eso es parte de la experiencia. Además, recuerda la etiqueta webassembly de StackOverflow si no puedes avanzar. ¡Feliz compilación!

Agradecimientos

Sam Clegg y Rachel Andrew revisaron este artículo.