Como compilar mkbitmap para WebAssembly

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

O artigo é destinado a desenvolvedores da Web que querem aprender WebAssembly e mostra como proceder se você quiser compilar algo como mkbitmap para WebAssembly. Como um aviso justo, não conseguir compilar um app ou uma biblioteca na primeira execução é completamente normal. Por isso, algumas das etapas descritas abaixo não funcionaram, então precisei voltar atrás e tentar de novo de outra forma. 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 em C mkbitmap lê uma imagem e aplica uma ou mais das seguintes operações a ela, nesta ordem: inversão, filtragem de alta frequência, dimensionamento e limiarização. 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, principalmente o programa de rastreamento potrace, que forma a base do SVGcode. Como ferramenta de pré-processamento, o mkbitmap é especialmente útil para converter arte linear digitalizada, como desenhos animados ou texto manuscrito, em imagens bicolores de alta resolução.

Para usar mkbitmap, transmita várias opções e um ou mais 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 (Fonte).
Imagem de desenho animado convertida para escala de cinza após o pré-processamento.
Primeiro dimensionado e depois limitado: mkbitmap -f 2 -s 2 -t 0.48 (Fonte).

Acessar o código

A primeira etapa é conseguir 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 entender 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 imprime algumas mensagens informando quais recursos estão sendo verificados.

  2. Digite make para compilar o pacote.

  3. Se quiser, digite make check para executar os autotestes que vêm com o pacote, geralmente usando os binários não instalados 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 pertencente ao root, é recomendável que o pacote seja configurado e criado como um usuário comum, e apenas a fase make install seja executada com privilégios de root.

Ao seguir essas etapas, você vai ter dois executáveis, potrace e mkbitmap. Este artigo se concentra no segundo. Para verificar se ele funcionou corretamente, execute mkbitmap --version. Esta é a saída de todas as quatro etapas da minha máquina, bastante reduzida para ser 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 compilou e instalou o mkbitmap. Em seguida, faça o equivalente dessas etapas funcionar com o WebAssembly.

Compilar mkbitmap para WebAssembly

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

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

A documentação continua (um pouco editada para fins de brevidade):

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

Assim, ./configure se torna emconfigure ./configure e make se torna emmake make. Confira abaixo 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 der certo, 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 parecem promissores. Portanto, 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 não terem a extensão .js é um pouco confuso, mas eles são arquivos JavaScript, o que pode ser verificado com uma chamada rápida de 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 para mkbitmap.js chamando mv mkbitmap mkbitmap.js (e mv potrace potrace.js, respectivamente, se quiser). Agora é hora do primeiro teste para conferir se funcionou. Execute 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 mkbitmap para 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 arquivo de modelo 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 atenda ao diretório mkbitmap e abra-o no navegador. Você vai encontrar uma solicitação para inserir informações. Isso é esperado, já que, de acordo com a página de manual da ferramenta, "se nenhum argumento de nome de arquivo for fornecido, o mkbitmap vai agir como um filtro, lendo da entrada padrão", que para o Emscripten é um prompt() por padrão.

O app mkbitmap mostrando um comando que pede entrada.

Impedir a execução automática

Para impedir que mkbitmap seja executado imediatamente e, em vez disso, 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 os valores no objeto Module e os aplica.

No caso de mkbitmap, defina Module.noInitialRun como true para evitar a execução inicial que fez o aviso aparecer. 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. Ao recarregar o app, o aviso não vai mais aparecer.

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

O Emscripten decide se vai incluir suporte ao sistema de arquivos automaticamente. Muitos programas não precisam de arquivos, e o suporte ao sistema de arquivos não é insignificante em tamanho. Por isso, 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 serão incluídos na saída. Por outro lado, se o código C/C++ usar arquivos, a compatibilidade com o sistema de arquivos será incluída automaticamente.

Infelizmente, mkbitmap é um dos casos em que o Emscripten não inclui automaticamente o suporte ao sistema de arquivos. Portanto, você precisa informar explicitamente que quer fazer isso. 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, nesse caso específico, você precisa definir a flag --host como wasm32 para informar ao script configure que você está compilando para 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ê 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ê vai ver o objeto Module registrado no console do DevTools, e o aviso não vai mais aparecer, 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ê executasse mkbitmap -v na linha de comando, chamaria 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.

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

Para colocar o arquivo de entrada no sistema de arquivos da memória, você precisa do equivalente a mkbitmap filename na linha de comando. Para entender minha abordagem, primeiro vamos falar sobre como o 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 tons de cinza. 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 de arquivo de entrada example.bmp, o nome de arquivo de saída seria example.pbm.

O Emscripten oferece um sistema de arquivos virtual que simula o sistema de arquivos local. Assim, o código nativo que usa APIs de arquivos síncronas pode ser compilado e executado com pouca ou nenhuma mudança. Para que o mkbitmap leia um arquivo de entrada como se ele fosse transmitido como um argumento de linha de comando filename, use o objeto FS fornecido pelo Emscripten.

O objeto FS é apoiado por 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 na amostra de código a seguir.

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 espera 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 da 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. O arquivo de saída example.pbm recém-criado vai aparecer 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 da memória, incluindo example.bmp e example.pbm.

Extrair o arquivo de saída do sistema de arquivos na memória

A função readFile() do objeto FS permite extrair o example.pbm criado na última etapa do sistema de arquivos na 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 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 mais aceita. Depois que o arquivo for salvo, abra-o 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é agora, o arquivo de entrada foi codificado 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 algum código JavaScript, você pode até mostrar uma prévia 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 mkbitmap para WebAssembly e fez com que ele funcionasse no navegador. Houve alguns becos sem saída, 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. Lembre-se também da tag webassembly do StackOverflow se tiver dificuldades. Boa compilação!

Agradecimentos

Este artigo foi revisado por Sam Clegg e Rachel Andrew.