Como compilar mkbitmap para WebAssembly

Em O que é o WebAssembly e de onde ele veio?, Expliquei como chegamos ao WebAssembly de hoje. Neste artigo, vou mostrar minha abordagem para compilar um programa C existente, mkbitmap, para o WebAssembly. Ele é mais complexo do que o exemplo hello world, porque inclui trabalhar com arquivos, se comunicar entre as áreas do WebAssembly e do 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 sobre o WebAssembly e mostra passo a passo como proceder se você quiser compilar algo como mkbitmap para o WebAssembly. Como aviso, é normal que um app ou biblioteca não seja compilado na primeira execução. Por isso, algumas das etapas descritas abaixo não funcionaram. Por isso, precisei voltar e tentar 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 mkbitmap lê uma imagem e aplica uma ou mais das seguintes operações a ela, nesta ordem: inversão, filtragem de passagem alta, dimensionamento e limiar. Cada operação pode ser controlada e ativada ou desativada individualmente. O uso principal de mkbitmap é converter imagens coloridas ou em tons de cinza em um formato adequado como entrada para outros programas, principalmente o programa de rastreamento potrace, que forma a base do SVGcode. Como ferramenta de pré-processamento, mkbitmap é particularmente útil para converter arte de linha digitalizada, como desenhos animados ou texto manuscrito, em imagens bidimensionais de alta resolução.

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

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

Acessar o código

A primeira etapa é acessar o código-fonte de mkbitmap. Ele está disponível no site do projeto. No momento da redação deste artigo, potrace-1.16.tar.gz é 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 levar algum tempo. Durante a execução, ele mostra algumas mensagens informando quais recursos estão sendo verificados.

  2. Digite make para compilar o pacote.

  3. Opcionalmente, digite make check para executar os autotestes que vêm com o pacote, geralmente usando os binários desinstalados que acabaram de ser criados.

  4. Digite make install para instalar os programas e todos os arquivos de dados e documentação. Ao instalar em um prefixo pertencente ao raiz, é recomendado que o pacote seja configurado e criado como um usuário comum, e apenas a fase make install seja executada com privilégios raiz.

Ao seguir essas etapas, você terá dois executáveis, potrace e mkbitmap. O último é o foco deste artigo. Para verificar se ele funcionou corretamente, execute mkbitmap --version. Confira a saída das quatro etapas da minha máquina, bastante reduzida para ser mais breve:

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, significa que o mkbitmap foi compilado e instalado. Em seguida, faça com que o equivalente dessas etapas funcione com o WebAssembly.

Compilar mkbitmap para o WebAssembly

O Emscripten é uma ferramenta para compilar programas C/C++ para WebAssembly. A documentação Building Projects do Emscripten afirma o seguinte:

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

A documentação continua (um pouco editada para ser mais breve):

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

./configure
make

Para criar com o Emscripten, use os seguintes comandos:

emconfigure ./configure
emmake make

Ou seja, ./configure se torna emconfigure ./configure e make se torna emmake make. O exemplo a seguir demonstra 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 você terá 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 parecem promissores, então cd no 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 verificados com uma chamada rápida 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 de fazer o primeiro teste para conferir se ele funcionou executando o arquivo com o 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. Agora, 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 modelo index.html 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 exiba o diretório mkbitmap e abra-o no navegador. Você vai encontrar uma solicitação de entrada. Isso é esperado, já que, de acordo com a página de manual da ferramenta, [i]se nenhum argumento de nome de arquivo for fornecido, mkbitmap vai atuar como um filtro, lendo a partir da entrada padrão", que, por padrão, é um prompt() para Emscripten.

O app mkbitmap mostrando uma solicitação de entrada.

Impedir a execução automática

Para impedir que mkbitmap seja executado 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 fornecer uma implementação de Module para controlar a execução do código. Quando um aplicativo Emscripten é iniciado, ele analisa e aplica os valores no objeto Module.

No caso de mkbitmap, defina Module.noInitialRun como true para impedir a execução inicial que causou a exibição do comando. Crie um script chamado script.js, inclua-o antes do <script src="mkbitmap.js"></script> em index.html e adicione o seguinte código a script.js. Agora, quando você recarrega o app, o aviso não aparece mais.

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

Criar um build modular com mais flags de build

Para fornecer entrada ao app, use o suporte ao sistema de arquivos do Emscripten em Module.FS. A seção Como incluir suporte ao sistema de arquivos da documentação afirma:

O Emscripten decide se vai 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, então o Emscripten evita incluí-lo quando não há 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 serão incluídos na saída. Por outro lado, se o 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 o suporte ao sistema de arquivos. Portanto, é necessário informá-lo explicitamente. Isso significa que você precisa seguir as etapas emconfigure e emmake descritas anteriormente, com mais algumas flags definidas por um argumento CFLAGS. As flags a seguir também podem ser úteis para outros projetos.

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

O comando emconfigure final fica assim:

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

Não se esqueça de executar emmake make novamente e copiar 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();

Quando você abrir o app no navegador, o objeto Module vai aparecer no console do DevTools, e o prompt vai desaparecer, já que a função main() de 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 a um ao que você transmitiria na linha de comando. Se você executar mkbitmap -v na linha de comando, vai chamar Module.callMain(['-v']) no navegador. Isso registra o número da versão 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) é o console. No entanto, é possível redirecionar para outra coisa, por exemplo, uma função que armazena a saída em uma variável. Isso significa que você pode 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.

Receber o arquivo de entrada no sistema de arquivos na memória

Para colocar o arquivo de entrada no sistema de arquivos de memória, você precisa do equivalente de mkbitmap filename na linha de comando. Para entender como abordar isso, primeiro, confira alguns antecedentes sobre como mkbitmap espera a entrada e cria a saída.

Os formatos de entrada compatíveis de mkbitmap são PNM (PBM, PGM, PPM) e BMP. Os formatos de saída são PBM para bitmaps e PGM para mapas de cinza. Se um argumento filename for fornecido, o mkbitmap vai criar por padrão um arquivo de saída cujo nome será 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 seria example.pbm.

O Emscripten fornece um sistema de arquivos virtual que simula o sistema de arquivos local. Assim, o código nativo que usa APIs de arquivos síncronos pode ser compilado e executado com poucas ou nenhuma mudança. Para que mkbitmap leia um arquivo de entrada como se ele tivesse sido transmitido como um argumento de linha de comando filename, é necessário usar o objeto FS fornecido pelo Emscripten.

O objeto FS é armazenado em um sistema de arquivos na memória (comumente chamado de MEMFS) e tem uma função writeFile() que você usa para gravar arquivos no sistema de arquivos virtual. Use writeFile(), conforme mostrado no exemplo de código abaixo.

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

A chamada anterior para Module.callMain(['-v']) para imprimir 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ê vai encontrar 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 acessar 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 no disco, já que os navegadores geralmente não oferecem suporte a arquivos PBM para visualização direta no navegador. Há maneiras mais elegantes de salvar um arquivo, mas o uso de um <a download> criado dinamicamente é o mais aceito. Depois de salvar o arquivo, você pode abri-lo no 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é o momento, o arquivo de entrada está codificado e 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 é muito difícil de analisar. Com algum código JavaScript, você pode até mostrar uma prévia da imagem de saída. Confira o código-fonte da demonstração incorporada abaixo para saber como fazer isso.

Conclusão

Parabéns! Você compilou mkbitmap para o WebAssembly e fez com que ele funcionasse no navegador. Houve alguns impasses e você teve que compilar a ferramenta mais de uma vez até que ela funcionasse, mas, como escrevi acima, isso faz parte da experiência. Se tiver dificuldades, consulte a tag webassembly do StackOverflow. Boa compilação!

Agradecimentos

Este artigo foi revisado por Sam Clegg e Rachel Andrew.