En ¿Qué es WebAssembly y de dónde provino?, haz lo siguiente: Le expliqué cómo concluimos hoy con WebAssembly. En este artículo, te mostraré mi enfoque para compilar un programa 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 aun así es lo suficientemente manejable como para no abrumarte.
El artículo está dirigido a desarrolladores web que quieran aprender sobre WebAssembly y muestra paso a paso cómo puedes proceder si quisieras compilar algo como mkbitmap
en WebAssembly. Como advertencia, es completamente normal no obtener una app o biblioteca para compilar en la primera ejecución, por lo que algunos de los pasos descritos a continuación no funcionaron, así que tuve que retroceder y volver a intentarlo de otra manera. En el artículo no se muestra el comando mágico de compilación final como si se hubiera caído del cielo, sino que se describe mi progreso real, incluidas algunas frustraciones.
Acerca de mkbitmap
El programa C mkbitmap
lee una imagen y 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, en particular para el programa de seguimiento potrace
que forma la base de SVGcode. Como herramienta de procesamiento previo, mkbitmap
es particularmente útil para convertir arte de línea 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 archivo. Para obtener todos los detalles, consulta la página del manual de la herramienta:
$ mkbitmap [options] [filename...]
Cómo obtener 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 codelab, 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:
cd
al directorio que contiene el código fuente del paquete y escribe./configure
para configurar el paquete para tu sistema.Ejecutar
configure
puede tardar un poco. Mientras se ejecuta, imprime algunos mensajes en los que se indica qué funciones se están verificando.Escribe
make
para compilar el paquete.De manera opcional, escribe
make check
para ejecutar las autopruebas que vienen con el paquete, generalmente mediante los objetos binarios recién compilados y desinstalados.Escribe
make install
para instalar los programas y cualquier archivo de datos y documentación. Cuando realizas la instalación en un prefijo que pertenece a la raíz, se recomienda que el paquete se configure y compile como un usuario normal, y que solo la fasemake install
se ejecute con privilegios de raíz.
Si sigues estos pasos, deberías terminar con dos ejecutables, potrace
y mkbitmap
. Este último es el tema central de este artículo. Puedes ejecutar mkbitmap --version
para verificar que funcionó correctamente. Este es el resultado de los cuatro pasos de mi máquina, muy recortados 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.
Compila mkbitmap
en WebAssembly
Emscripten es una herramienta para compilar programas C/C++ en WebAssembly. En la documentación sobre proyectos de construcción de Emscripten, se indica lo siguiente:
Crear proyectos grandes con Emscripten es muy sencillo. Emscripten proporciona dos secuencias de comandos simples que configuran tus archivos makefile para usar
emcc
como reemplazo directo degcc
. En la mayoría de los casos, el resto del sistema de compilación actual de tu proyecto no se modifica.
Luego, la documentación continúa (un poco editada para ser más breve):
Considera el caso en el que normalmente compilas con los siguientes comandos:
./configure
make
Para compilar con Emscripten, usa los siguientes comandos en su lugar:
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 encontrarlos, ejecuta find . -name "*.wasm"
:
$ find . -name "*.wasm"
./a.wasm
./src/mkbitmap.wasm
./src/potrace.wasm
Las dos últimas parecen prometedoras, por lo que cd
debe estar en el directorio src/
. Ahora también hay dos archivos nuevos 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 el momento de realizar la primera prueba para ver si funcionó. Para ello, ejecuta el archivo con Node.js en la línea de comandos mediante la ejecución de node mkbitmap.js --version
:
$ node mkbitmap.js --version
mkbitmap 1.16. Copyright (C) 2001-2019 Peter Selinger.
Compilaste mkbitmap
en WebAssembly correctamente. Ahora, el siguiente 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 HTML estándar 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 tu navegador. Deberías ver un mensaje que te solicita información. Esto es como se espera, ya que, de acuerdo con la página man de la herramienta, "[i]f no se proporcionan argumentos de nombre de archivo, entonces mkbitmap actúa como un filtro, lee desde una entrada estándar", que para Emscripten, de forma predeterminada, es un prompt()
.
Impide la ejecución automática
Para evitar que mkbitmap
se ejecute de inmediato y hacer que espere a 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 de código.
Cuando se inicia una aplicación de Emscripten, esta observa los valores del objeto Module
y los aplica.
En el caso de mkbitmap
, establece Module.noInitialRun
en true
para evitar la ejecución inicial que hizo que apareciera el mensaje. Crea una secuencia de comandos llamada script.js
, inclúyela antes de la <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 más marcas de compilación
Para proporcionar entradas a la app, puedes usar la compatibilidad con el sistema de archivos de Emscripten en Module.FS
. La sección Incluye compatibilidad con el sistema de archivos de la documentación indica lo siguiente:
Emscripten decide si incluir automáticamente la compatibilidad con el sistema de archivos. Muchos programas no necesitan archivos, y la compatibilidad con el sistema de archivos no tiene un tamaño insignificante, por lo que Emscripten evita incluirlo cuando no encuentra una razón para hacerlo. Eso significa que, si tu código C/C++ no accede a los archivos, el objeto
FS
y 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.
Desafortunadamente, mkbitmap
es uno de los casos en los que Emscripten no incluye automáticamente compatibilidad con el sistema de archivos, por lo que debes indicarle de forma explícita 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.
- Establece
-sFILESYSTEM=1
para que se incluya la compatibilidad con el sistema de archivos. - Configura
-sEXPORTED_RUNTIME_METHODS=FS,callMain
para que se exportenModule.FS
yModule.callMain
. - Configura
-sMODULARIZE=1
y-sEXPORT_ES6
para generar un módulo ES6 moderno. - Configura
-sINVOKE_RUN=0
para evitar la ejecución inicial que hizo que apareciera el mensaje.
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 importas 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 inicio.
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 a uno, lo que pasarías en la línea de comandos. Si ejecutaras mkbitmap -v
en la línea de comandos, llamarías 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();
Redirecciona la salida estándar
La salida estándar (stdout
) de forma predeterminada es la consola. Sin embargo, puedes redireccionarlo a otro elemento, como una función que almacena el resultado en una variable. Esto significa que puedes configurar la propiedad Module.print
para agregar el resultado al HTML.
// 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();
Cómo colocar el archivo de entrada en el sistema de archivos de la memoria
Para obtener el archivo de entrada en el sistema de archivos de la memoria, necesitas el equivalente de mkbitmap filename
en la línea de comandos. Para comprender cómo abordo esto, primero obtén 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 obtiene del nombre de 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 pueda compilarse y ejecutarse con poco o ningún cambio.
Para que mkbitmap
lea un archivo de entrada como si se pasara como un argumento de línea de comandos de 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 de archivos virtual. Usa writeFile()
como se indica en la siguiente muestra de código.
Para verificar que funcionó la operación de escritura de archivo, ejecuta la función readdir()
del objeto FS
con el parámetro '/'
. Verás example.bmp
y una cantidad 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, se ejecuta 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();
Primera ejecución real
Con todo en su lugar, 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();
Cómo extraer 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 una Uint8Array
que conviertes en un objeto File
y guardas en el disco, ya que los navegadores no suelen admitir 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 guardado el archivo, puedes 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();
Agrega una IU interactiva
En este punto, el archivo de entrada está codificado y mkbitmap
se ejecuta con parámetros predeterminados. El paso final es permitir que el usuario seleccione un archivo de entrada de forma dinámica, ajuste 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 PBM no es particularmente difícil de analizar, por lo que, con código JavaScript, puedes incluso 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 manera de hacerlo.
Conclusión
Felicitaciones. Compilaste correctamente mkbitmap
en WebAssembly y lo lograste en el navegador. Había algunos callejones sin salida y tuviste que compilar la herramienta más de una vez hasta que funcionara, pero como escribí antes, esa es parte de la experiencia. Recuerda también la etiqueta webassembly
de StackOverflow si no puedes avanzar. Esperamos que disfrutes la compilación.
Agradecimientos
Sam Clegg y Rachel Andrew revisaron este artículo.