Como compilar mkbitmap para WebAssembly

Em O que é o WebAssembly e de onde ele veio?, Expliquei como ficamos com o WebAssembly de hoje. Neste artigo, vou mostrar minha abordagem de compilação de um programa C já existente, o mkbitmap, para o WebAssembly. Ele é mais complexo do que o exemplo de hello world, porque inclui trabalhar com arquivos, comunicar-se entre os espaços WebAssembly e JavaScript e desenhar em uma tela, mas ainda é gerenciável o suficiente para não sobrecarregar você.

O artigo foi escrito para desenvolvedores da Web que querem aprender a usar o WebAssembly e mostra um passo a passo para compilar algo como mkbitmap no WebAssembly. Como aviso, não ter um app ou biblioteca para compilar na primeira execução é completamente normal. É por isso que algumas das etapas descritas abaixo não funcionaram. Foi necessário recuar e tentar novamente de novo. O artigo não mostra o comando de compilação final mágico como se tivesse caído do céu, mas descreve meu progresso real, incluindo algumas frustrações.

Sobre mkbitmap

O programa C do mkbitmap lê uma imagem e aplica a ela uma ou mais das operações a seguir, nesta ordem: inversão, filtragem de passagem alta, escalonamento e limite. Cada operação pode ser controlada e ativada ou desativada individualmente. O uso principal do mkbitmap é converter imagens coloridas ou em escala de cinza em um formato adequado como entrada para outros programas, especialmente o programa de rastreamento potrace, que forma a base do SVGcode. Como ferramenta de pré-processamento, o mkbitmap é útil principalmente para converter linhas de arte digitalizadas, como desenhos animados ou texto manuscrito, em imagens de dois níveis de alta resolução.

Para usar mkbitmap, transmita várias opções e um ou vários nomes de arquivo. Para mais detalhes, consulte a página do manual da ferramenta:

$ mkbitmap [options] [filename...]
Imagem de desenho animado colorida.
Imagem original (Origem).
Imagem de desenho animado convertida em escala de cinza após o pré-processamento.
Primeiro escalonado e depois com limite: mkbitmap -f 2 -s 2 -t 0.48 (Origem).

Acessar o código

A primeira etapa é conseguir o código-fonte de mkbitmap. Você pode encontrá-lo no site do projeto. No momento em que este artigo foi escrito, potrace-1.16.tar.gz era a versão mais recente.

Compilar e instalar localmente

A próxima etapa é compilar e instalar a ferramenta localmente para ter uma ideia de como ela se comporta. O arquivo INSTALL contém as seguintes instruções:

  1. cd para o diretório que contém o código-fonte do pacote e digite ./configure para configurar o pacote para seu sistema.

    A execução de configure pode demorar um pouco. Durante a execução, ele mostra algumas mensagens informando quais recursos está verificando.

  2. Digite make para compilar o pacote.

  3. Se quiser, digite make check para executar os autotestes que acompanham o pacote, geralmente usando os binários desinstalados recém-criados.

  4. Digite make install para instalar os programas e os arquivos de dados e a documentação. Ao instalar em um prefixo de propriedade de raiz, recomenda-se que o pacote seja configurado e criado como um usuário normal, e somente a fase make install seja executada com privilégios raiz.

Ao seguir essas etapas, você terá dois executáveis, potrace e mkbitmap, que são o foco deste artigo. Verifique se ele funcionou corretamente executando mkbitmap --version. Aqui está a saída das quatro etapas da minha máquina, muito cortadas para facilitar:

Etapa 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

Etapa 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'.

Etapa 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'.

Etapa 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 verificar se funcionou, execute mkbitmap --version:

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

Se você receber os detalhes da versão, isso significa que o mkbitmap foi compilado e instalado. Em seguida, faça o equivalente a essas etapas funcionar com o WebAssembly.

Compilar mkbitmap para o WebAssembly

O Emscripten é uma ferramenta de compilação de programas C/C++ para o WebAssembly. A documentação Como criar projetos do Emscripten afirma o seguinte:

Criar projetos grandes com o Emscripten é muito fácil. O Emscripten fornece dois scripts simples que configuram seus makefiles para usar emcc como uma substituição simples para gcc. Na maioria dos casos, o restante do sistema de compilação atual do projeto permanece inalterado.

Em seguida, a documentação continua (um pouco editada para simplificar):

Considere o caso em que você normalmente cria com os seguintes comandos:

./configure
make

Para criar com Emscripten, use os seguintes comandos:

emconfigure ./configure
emmake make

Basicamente, a ./configure se torna emconfigure ./configure e a make se torna emmake make. Confira a seguir como fazer isso com mkbitmap.

Etapa 0, make clean:

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

Etapa 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

Etapa 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'.

Se tudo correu bem, agora haverá arquivos .wasm em algum lugar do diretório. Para encontrá-los, execute find . -name "*.wasm":

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

Os dois últimos elementos parecem promissores, então cd para o diretório src/. Agora, também há dois novos arquivos correspondentes, mkbitmap e potrace. Para este artigo, apenas mkbitmap é relevante. O fato de eles não terem a extensão .js é um pouco confuso, mas eles são arquivos JavaScript, que podem ser confirmados com uma rápida chamada 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)

Renomeie o arquivo JavaScript como mkbitmap.js chamando mv mkbitmap mkbitmap.js (e mv potrace potrace.js, respectivamente, se quiser). Agora é hora do primeiro teste para ver se ele funcionou executando o arquivo com Node.js na linha de comando executando node mkbitmap.js --version:

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

Você compilou mkbitmap para o WebAssembly. A próxima etapa é fazer com que ele funcione no navegador.

mkbitmap com WebAssembly no navegador

Copie os arquivos mkbitmap.js e mkbitmap.wasm para um novo diretório chamado mkbitmap e crie um arquivo padrão HTML index.html que carregue o arquivo 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>

Inicie um servidor local que disponibilize o diretório mkbitmap e abra-o no navegador. Será exibida uma solicitação de entrada. Isso é o esperado, já que, de acordo com a página do manual da ferramenta, "[i]se nenhum argumento de nome de arquivo for fornecido, o mkbitmap atuará como um filtro, lendo a entrada padrão, que, por padrão, é um prompt() para o Emscripten.

O app mkbitmap mostrando um prompt que solicita entrada.

Impedir a execução automática

Para interromper a execução do mkbitmap imediatamente e fazer com que ele aguarde a entrada do usuário, é necessário entender o objeto Module do Emscripten. Module é um objeto JavaScript global com atributos que o código gerado pelo Emscripten chama em vários pontos da execução. Você pode oferecer uma implementação de Module para controlar a execução do código. Quando um aplicativo Emscripten é iniciado, ele analisa os valores no objeto Module e os aplica.

No caso de mkbitmap, defina Module.noInitialRun como true para evitar a execução inicial que causou a exibição da solicitação. Crie um script com o nome script.js, inclua-o antes de <script src="mkbitmap.js"></script> em index.html e adicione o seguinte código a script.js. Agora, quando você recarregar o app, a solicitação terá sumido.

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

Criar um build modular com mais algumas flags de build

Para fornecer entradas para o app, você pode usar o suporte ao sistema de arquivos do Emscripten em Module.FS. A seção Como incluir suporte ao sistema de arquivos da documentação declara o seguinte:

O Emscripten decide se quer incluir o suporte ao sistema de arquivos automaticamente. Muitos programas não precisam de arquivos, e o suporte ao sistema de arquivos não é desprezível em tamanho, de modo que o Emscripten evita incluí-lo quando não há um motivo para isso. Isso significa que, se o código C/C++ não acessar arquivos, o objeto FS e outras APIs do sistema de arquivos não vão ser incluídas na saída. Por outro lado, se seu código C/C++ usar arquivos, o suporte ao sistema de arquivos será incluído automaticamente.

Infelizmente, mkbitmap é um dos casos em que o Emscripten não inclui automaticamente suporte ao sistema de arquivos, então você precisa pedir isso explicitamente. Isso significa que você precisa seguir as etapas emconfigure e emmake descritas anteriormente, com mais algumas flags definidas por um argumento CFLAGS. As sinalizações a seguir também podem ser úteis para outros projetos.

Além disso, neste caso específico, você precisa definir a flag --host como wasm32 para informar ao script configure que está sendo compilado para o WebAssembly.

O comando emconfigure final vai ficar assim:

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

Execute emmake make novamente e copie os arquivos recém-criados para a pasta mkbitmap.

Modifique index.html para que ele carregue apenas o módulo ES script.js, de onde você vai importar o 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();

Ao abrir o app agora no navegador, você verá o objeto Module registrado no console do DevTools e o prompt desaparecerá, já que a função main() do mkbitmap não é mais chamada no início.

O app mkbitmap com uma tela branca, mostrando o objeto Module registrado no console do DevTools.

Executar manualmente a função principal

A próxima etapa é chamar manualmente a função main() de mkbitmap executando Module.callMain(). A função callMain() usa uma matriz de argumentos, que correspondem um por um ao que você transmitiria na linha de comando. Se na linha de comando você executar mkbitmap -v, chame Module.callMain(['-v']) no navegador. Isso registra o número da versão do mkbitmap no console do DevTools.

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

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

run();

O app mkbitmap com uma tela branca, mostrando o número da versão do mkbitmap registrado no console do DevTools.

Redirecionar a saída padrão

A saída padrão (stdout) é, por padrão, o console. No entanto, é possível redirecioná-lo para outra coisa, por exemplo, uma função que armazene a saída em uma variável. Isso significa que é possível adicionar a saída ao HTML definindo a propriedade 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();

O app mkbitmap mostrando o número da versão do mkbitmap.

Colocar o arquivo de entrada no sistema de arquivos de memória

Para inserir o arquivo de entrada no sistema de arquivos de memória, você precisa do equivalente a mkbitmap filename na linha de comando. Para entender como abordo isso, primeiro, algumas informações básicas sobre como mkbitmap espera a entrada e cria a saída.

Os formatos de entrada com suporte de mkbitmap são PNM (PBM, PGM, PPM) e BMP. Os formatos de saída são PBM para bitmaps e PGM para mapas cinza. Se um argumento filename for fornecido, o mkbitmap vai criar, por padrão, um arquivo de saída com o nome extraído do nome do arquivo de entrada, mudando o sufixo para .pbm. Por exemplo, para o nome do arquivo de entrada example.bmp, o nome do arquivo de saída é example.pbm.

O Emscripten fornece um sistema de arquivos virtual que simula o sistema de arquivos local, para que o código nativo que usa APIs de arquivos síncronos possa ser compilado e executado com pouca ou nenhuma alteração. Para que mkbitmap leia um arquivo de entrada como se ele tivesse sido transmitido como um argumento de linha de comando filename, você precisa usar o objeto FS fornecido pelo Emscripten.

O objeto FS tem o suporte de um sistema de arquivos na memória (frequentemente conhecido como MEMFS) e tem uma função writeFile() que é usada para gravar arquivos no sistema de arquivos virtual. Use writeFile(), conforme mostrado no exemplo de código a seguir.

Para verificar se a operação de gravação de arquivos funcionou, execute a função readdir() do objeto FS com o parâmetro '/'. Você vai encontrar example.bmp e alguns arquivos padrão que são sempre criados automaticamente.

Observe que a chamada anterior para Module.callMain(['-v']) para exibir o número da versão foi removida. Isso ocorre porque Module.callMain() é uma função que geralmente precisa ser executada apenas uma 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();

O app mkbitmap mostrando uma matriz de arquivos no sistema de arquivos de memória, incluindo example.bmp.

Primeira execução real

Com tudo pronto, execute mkbitmap executando Module.callMain(['example.bmp']). Registre o conteúdo da pasta '/' do MEMFS. Você verá o arquivo de saída example.pbm recém-criado ao lado do arquivo 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();

O app mkbitmap mostrando uma matriz de arquivos no sistema de arquivos de memória, incluindo example.bmp e example.pbm.

extrair o arquivo de saída do sistema de arquivos de memória;

A função readFile() do objeto FS permite extrair o example.pbm criado na última etapa do sistema de arquivos de memória. A função retorna um Uint8Array, que você converte em um objeto File e salva em disco, já que os navegadores geralmente não são compatíveis com arquivos PBM para visualização direta no navegador. Há maneiras mais elegantes de salvar um arquivo, mas usar um <a download> criado dinamicamente é a opção mais aceita. Depois que o arquivo for salvo, você poderá abri-lo em seu visualizador de imagens 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();

Finder do macOS com uma prévia do arquivo .bmp de entrada e do arquivo .pbm de saída.

Adicionar uma interface interativa

Até esse ponto, o arquivo de entrada está fixado no código e o mkbitmap é executado com parâmetros padrão. A etapa final é permitir que o usuário selecione dinamicamente um arquivo de entrada, ajuste os parâmetros mkbitmap e execute a ferramenta com as opções selecionadas.

// 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']);

O formato de imagem PBM não é particularmente difícil de analisar. Portanto, com um código JavaScript, você pode até mesmo mostrar uma visualização da imagem de saída. Confira o código-fonte da demonstração incorporada abaixo para ver uma maneira de fazer isso.

Conclusão

Parabéns, você compilou mkbitmap no WebAssembly e fez com que ele funcionasse no navegador. Tivemos alguns becos sem saída e você teve que compilar a ferramenta mais de uma vez até funcionar, mas, como escrevi acima, isso faz parte da experiência. Lembre-se também da tag webassembly do StackOverflow (link em inglês), caso você tenha dificuldades. Boa compilação!

Agradecimentos

Este artigo foi revisado por Sam Clegg e Rachel Andrew.