Como compilar mkbitmap para WebAssembly

Em O que é o WebAssembly e de onde ele veio?, Expliquei como acabamos com a WebAssembly de hoje. Neste artigo, mostrarei minha abordagem de compilação de um programa em C atual, mkbitmap, para o WebAssembly. Ele é mais complexo do que o exemplo do hello world, porque inclui trabalhar com arquivos, comunicar-se entre as páginas 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 Web que querem aprender a usar o WebAssembly e mostra um passo a passo de como você pode compilar algo como mkbitmap para o WebAssembly. Não é possível compilar um app ou uma biblioteca na primeira execução. É por isso que algumas das etapas descritas abaixo não funcionaram. Por isso, precisei recuar e tentar outra vez. O artigo não mostra o comando mágico de compilação final como se ele 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, escalonamento e limite. Cada operação pode ser controlada individualmente e ativada ou desativada. O principal uso 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 é particularmente útil para converter desenhos digitais digitalizados, como desenhos ou texto manuscrito, em imagens de dois níveis de alta resolução.

Para usar mkbitmap, transmita uma série de opções e um ou vários nomes de arquivo. Para todos os detalhes, consulte a página do manual da ferramenta:

$ mkbitmap [options] [filename...]
Imagem de desenho animado colorida.
A imagem original (origem).
Imagem de desenho convertida em escala de cinza após o pré-processamento.
Primeiro dimensionado, 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. Encontre o código no site do projeto. No momento em que este artigo foi escrito, 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 no 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 imprime 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 todos os arquivos de dados e documentação. Ao instalar em um prefixo de propriedade do root, recomendamos que o pacote seja configurado e criado como um usuário normal e apenas a fase make install seja executada com privilégios raiz.

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

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ê recebeu os detalhes da versão, isso significa que o mkbitmap foi compilado e instalado. Em seguida, faça o equivalente dessas etapas funcionar com o WebAssembly.

Compilar mkbitmap para o WebAssembly

O Emscripten é uma ferramenta para compilar programas C/C++ para o WebAssembly. A documentação Como criar projetos da Emscripten diz o seguinte:

Criar grandes projetos 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 seu projeto permanece inalterado.

A documentação segue em frente (um pouco editada para concisão):

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

Basicamente, ./configure se torna emconfigure ./configure, e make se torna emmake make. Veja 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 tiver dado certo, agora devem haver arquivos .wasm em algum lugar do diretório. É possível encontrá-los executando find . -name "*.wasm":

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

Os dois últimos são promissores, então cd no diretório src/. Agora, há também 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, na verdade, arquivos JavaScript, verificáveis 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 ver se funcionou executando o arquivo com Node.js na linha de comando usando node mkbitmap.js --version:

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

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

mkbitmap com o WebAssembly no navegador

Copie os arquivos mkbitmap.js e mkbitmap.wasm para um novo diretório chamado mkbitmap e crie um arquivo HTML boilerplate 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. Aparecerá um comando que solicita informações. 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 para Emscripten é, por padrão, um prompt().

O app mkbitmap mostrando um comando que pede entrada.

Impedir a execução automática

Para interromper a execução de mkbitmap imediatamente e fazer com que ele aguarde a entrada do usuário, você precisa 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 durante a 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 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. Quando você recarregar o app, a solicitação desaparecerá.

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 no Module.FS. A seção Incluindo o suporte a sistema de arquivos da documentação afirma:

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 é insignificante, então Emscripten evita incluí-los quando não há motivo para isso. Isso significa que, se o código C/C++ não acessar os 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, o mkbitmap é um dos casos em que o Emscripten não inclui automaticamente o suporte ao sistema de arquivos, então é necessário instruí-lo a fazer isso. Isso significa que você precisa seguir as etapas emconfigure e emmake descritas anteriormente, com mais algumas flags definidas usando um argumento CFLAGS. As sinalizações a seguir também podem ser úteis para outros projetos.

Além disso, nesse 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 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ê importa 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 no navegador, você verá o objeto Module registrado no console do DevTools. O prompt desaparece, 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() do 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, chame Module.callMain(['-v']) no navegador. Isso registra o número da versão de 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 registrada no console do DevTools.

Redirecionar a saída padrão

Por padrão, a saída padrão (stdout) é o console. No entanto, é possível redirecioná-lo para outra coisa, por exemplo, uma função que armazena a saída para 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.

Inserir o arquivo de entrada no sistema de arquivos de 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 abordo isso, primeiro vejamos como o 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 cinzas. Se um argumento filename for fornecido, mkbitmap vai criar, por padrão, um arquivo de saída cujo nome é obtido 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, para que o código nativo que usa APIs de arquivos síncronas possa ser compilado e executado com pouca ou nenhuma alteração. Para que o 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 tem como base um sistema de arquivos na memória (geralmente 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 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ê verá example.bmp e vários arquivos padrão que são sempre criados automaticamente.

A chamada anterior a Module.callMain(['-v']) para mostrar 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 no lugar, execute mkbitmap executando Module.callMain(['example.bmp']). Registre o conteúdo da pasta '/' do MEMFS e 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. Existem maneiras mais elegantes de salvar um arquivo, mas usar um <a download> criado dinamicamente é a mais amplamente compatível. Depois que o arquivo for salvo, você poderá 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 visualização do arquivo .bmp de entrada e do arquivo .pbm de saída.

Adicionar uma interface interativa

Até aqui, 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 é muito difícil de analisar, portanto, com algum código JavaScript, é possível até mesmo mostrar uma visualização da imagem de saída. Consulte o código-fonte da demonstração incorporada abaixo para saber como fazer isso.

Conclusão

Parabéns, você compilou o mkbitmap para o WebAssembly e o fez funcionar no navegador. Havia alguns becos sem saída, e era preciso compilar a ferramenta mais de uma vez até que funcionasse, mas, como escrevi acima, isso faz parte da experiência. Lembre-se também da tag webassembly do StackOverflow se tiver problemas. Boa compilação!

Agradecimentos

Este artigo foi revisado por Sam Clegg e Rachel Andrew.