Compilation de mkbitmap vers WebAssembly

Dans Qu'est-ce que WebAssembly et d'où vient-il ?, J'ai expliqué comment nous en sommes arrivés à WebAssembly tel qu'il est aujourd'hui. Dans cet article, je vais vous montrer comment compiler un programme C existant, mkbitmap, en WebAssembly. Il est plus complexe que l'exemple Hello World, car il inclut l'utilisation de fichiers, la communication entre les domaines WebAssembly et JavaScript, et le dessin sur un canevas, mais il reste suffisamment gérable pour ne pas vous submerger.

Cet article s'adresse aux développeurs Web qui souhaitent en savoir plus sur WebAssembly. Il explique étape par étape comment compiler un élément tel que mkbitmap dans WebAssembly. Pour vous prévenir, il est tout à fait normal qu'une application ou une bibliothèque ne se compile pas du premier coup. C'est pourquoi certaines des étapes décrites ci-dessous n'ont pas fonctionné, et j'ai dû revenir en arrière et réessayer différemment. L'article ne présente pas la commande de compilation finale magique comme si elle était tombée du ciel, mais décrit plutôt ma progression réelle, y compris certaines frustrations.

À propos de mkbitmap

Le programme C mkbitmap lit une image et lui applique une ou plusieurs des opérations suivantes, dans cet ordre : inversion, filtrage passe-haut, mise à l'échelle et seuillage. Chaque opération peut être contrôlée individuellement et activée ou désactivée. L'utilisation principale de mkbitmap est de convertir des images en couleur ou en niveaux de gris dans un format adapté comme entrée pour d'autres programmes, en particulier le programme de traçage potrace qui constitue la base de SVGcode. En tant qu'outil de prétraitement, mkbitmap est particulièrement utile pour convertir des dessins au trait numérisés, tels que des bandes dessinées ou du texte manuscrit, en images bichromatiques haute résolution.

Pour utiliser mkbitmap, vous devez lui transmettre un certain nombre d'options et un ou plusieurs noms de fichiers. Pour en savoir plus, consultez la page de manuel de l'outil :

$ mkbitmap [options] [filename...]
Image de dessin animé en couleur.
Image d'origine (source)
Image de dessin animé convertie en nuances de gris après prétraitement.
D'abord mise à l'échelle, puis seuillée : mkbitmap -f 2 -s 2 -t 0.48 (Source).

Obtenir le code

La première étape consiste à obtenir le code source de mkbitmap. Vous le trouverez sur le site Web du projet. Au moment de la rédaction de cet article, potrace-1.16.tar.gz est la dernière version.

Compiler et installer en local

L'étape suivante consiste à compiler et à installer l'outil en local pour vous faire une idée de son comportement. Le fichier INSTALL contient les instructions suivantes :

  1. cd dans le répertoire contenant le code source du package, puis saisissez ./configure pour configurer le package pour votre système.

    L'exécution de configure peut prendre un certain temps. Pendant son exécution, il affiche des messages indiquant les fonctionnalités qu'il vérifie.

  2. Saisissez make pour compiler le package.

  3. Vous pouvez également saisir make check pour exécuter les tests automatiques fournis avec le package, généralement à l'aide des binaires non installés qui viennent d'être compilés.

  4. Saisissez make install pour installer les programmes, les fichiers de données et la documentation. Lors de l'installation dans un préfixe appartenant à root, il est recommandé de configurer et de compiler le package en tant qu'utilisateur normal, et de n'exécuter que la phase make install avec les droits root.

En suivant ces étapes, vous devriez obtenir deux exécutables, potrace et mkbitmap. Cet article se concentre sur le second. Vous pouvez vérifier que l'opération a fonctionné correctement en exécutant mkbitmap --version. Voici le résultat des quatre étapes sur ma machine, fortement raccourci pour plus de concision :

Étape 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

Étape 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'.

Étape 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'.

Étape 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'.

Pour vérifier que l'opération a fonctionné, exécutez mkbitmap --version :

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

Si vous obtenez les informations sur la version, cela signifie que vous avez compilé et installé mkbitmap. Ensuite, faites en sorte que l'équivalent de ces étapes fonctionne avec WebAssembly.

Compiler mkbitmap en WebAssembly

Emscripten est un outil permettant de compiler des programmes C/C++ en WebAssembly. La documentation Building Projects d'Emscripten indique ce qui suit :

Il est très facile de créer de grands projets avec Emscripten. Emscripten fournit deux scripts simples qui configurent vos fichiers makefile pour utiliser emcc en remplacement de gcc. Dans la plupart des cas, le reste du système de compilation actuel de votre projet reste inchangé.

La documentation poursuit (légèrement modifiée pour plus de concision) :

Prenons l'exemple où vous compilez normalement avec les commandes suivantes :

./configure
make

Pour compiler avec Emscripten, vous devez utiliser les commandes suivantes :

emconfigure ./configure
emmake make

En d'autres termes, ./configure devient emconfigure ./configure et make devient emmake make. L'exemple suivant montre comment effectuer cette opération avec mkbitmap.

Étape 0, make clean :

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

Étape 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

Étape 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 tout s'est bien passé, des fichiers .wasm devraient maintenant se trouver quelque part dans le répertoire. Vous pouvez les trouver en exécutant find . -name "*.wasm" :

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

Les deux derniers semblent prometteurs, alors cd dans le répertoire src/. Deux nouveaux fichiers correspondants, mkbitmap et potrace, sont également disponibles. Pour cet article, seul mkbitmap est pertinent. Le fait qu'ils n'aient pas l'extension .js est un peu déroutant, mais il s'agit bien de fichiers JavaScript, comme vous pouvez le vérifier avec un simple appel 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)

Renommez le fichier JavaScript en mkbitmap.js en appelant mv mkbitmap mkbitmap.js (et mv potrace potrace.js respectivement si vous le souhaitez). Il est maintenant temps de procéder au premier test pour voir si cela a fonctionné en exécutant le fichier avec Node.js sur la ligne de commande en exécutant node mkbitmap.js --version :

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

Vous avez compilé mkbitmap en WebAssembly. L'étape suivante consiste à le faire fonctionner dans le navigateur.

mkbitmap avec WebAssembly dans le navigateur

Copiez les fichiers mkbitmap.js et mkbitmap.wasm dans un nouveau répertoire appelé mkbitmap, puis créez un fichier de modèle HTML index.html qui charge le fichier 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>

Démarrez un serveur local qui diffuse le répertoire mkbitmap et ouvrez-le dans votre navigateur. Une invite vous demandant une saisie devrait s'afficher. C'est normal, car, selon la page de manuel de l'outil, "[i]f aucun argument de nom de fichier n'est fourni, mkbitmap agit comme un filtre, en lisant l'entrée standard", qui pour Emscripten est par défaut un prompt().

L&#39;application mkbitmap affichant une invite demandant une saisie.

Empêcher l'exécution automatique

Pour empêcher l'exécution immédiate de mkbitmap et la faire attendre la saisie de l'utilisateur, vous devez comprendre l'objet Module d'Emscripten. Module est un objet JavaScript global avec des attributs que le code généré par Emscripten appelle à différents moments de son exécution. Vous pouvez fournir une implémentation de Module pour contrôler l'exécution du code. Lorsqu'une application Emscripten démarre, elle examine les valeurs de l'objet Module et les applique.

Dans le cas de mkbitmap, définissez Module.noInitialRun sur true pour empêcher l'exécution initiale qui a entraîné l'affichage de l'invite. Créez un script nommé script.js, incluez-le avant <script src="mkbitmap.js"></script> dans index.html et ajoutez le code suivant à script.js. Si vous actualisez l'application, l'invite ne devrait plus s'afficher.

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

Créer une compilation modulaire avec d'autres indicateurs de compilation

Pour fournir des entrées à l'application, vous pouvez utiliser la prise en charge du système de fichiers d'Emscripten dans Module.FS. La section Including File System Support (Inclure la compatibilité avec le système de fichiers) de la documentation indique :

Emscripten décide automatiquement d'inclure ou non la prise en charge du système de fichiers. De nombreux programmes n'ont pas besoin de fichiers, et la prise en charge du système de fichiers n'est pas négligeable en termes de taille. Emscripten évite donc de l'inclure lorsqu'il n'y a pas de raison de le faire. Cela signifie que si votre code C/C++ n'accède pas aux fichiers, l'objet FS et les autres API du système de fichiers ne seront pas inclus dans la sortie. En revanche, si votre code C/C++ utilise des fichiers, la prise en charge du système de fichiers sera automatiquement incluse.

Malheureusement, mkbitmap fait partie des cas où Emscripten n'inclut pas automatiquement la prise en charge du système de fichiers. Vous devez donc lui demander explicitement de le faire. Cela signifie que vous devez suivre les étapes emconfigure et emmake décrites précédemment, avec quelques indicateurs supplémentaires définis via un argument CFLAGS. Les indicateurs suivants peuvent également être utiles pour d'autres projets.

De plus, dans ce cas particulier, vous devez définir l'indicateur --host sur wasm32 pour indiquer au script configure que vous compilez pour WebAssembly.

La commande emconfigure finale se présente comme suit :

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

N'oubliez pas d'exécuter à nouveau emmake make et de copier les fichiers nouvellement créés dans le dossier mkbitmap.

Modifiez index.html pour qu'il ne charge que le module ES script.js, à partir duquel vous importez ensuite le module 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();

Lorsque vous ouvrez l'application dans le navigateur, l'objet Module doit être consigné dans la console d'outils de développement, et l'invite a disparu, car la fonction main() de mkbitmap n'est plus appelée au début.

L&#39;application mkbitmap avec un écran blanc, montrant l&#39;objet Module enregistré dans la console DevTools.

Exécuter manuellement la fonction principale

L'étape suivante consiste à appeler manuellement la fonction main() de mkbitmap en exécutant Module.callMain(). La fonction callMain() accepte un tableau d'arguments, qui correspondent un à un à ce que vous transmettriez sur la ligne de commande. Si vous exécutez mkbitmap -v sur la ligne de commande, vous devez appeler Module.callMain(['-v']) dans le navigateur. Le numéro de version de mkbitmap est alors consigné dans la console d'outils de développement.

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

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

run();

L&#39;application mkbitmap avec un écran blanc, affichant le numéro de version de mkbitmap enregistré dans la console DevTools.

Rediriger la sortie standard

La sortie standard (stdout) est la console par défaut. Toutefois, vous pouvez le rediriger vers autre chose, par exemple une fonction qui stocke la sortie dans une variable. Cela signifie que vous pouvez ajouter la sortie au code HTML en définissant la propriété 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();

L&#39;application mkbitmap affichant le numéro de version de mkbitmap.

Importer le fichier d'entrée dans le système de fichiers en mémoire

Pour que le fichier d'entrée soit intégré au système de fichiers de mémoire, vous avez besoin de l'équivalent de mkbitmap filename sur la ligne de commande. Pour comprendre mon approche, commençons par quelques informations sur la façon dont mkbitmap attend son entrée et crée sa sortie.

Les formats d'entrée compatibles de mkbitmap sont PNM (PBM, PGM, PPM) et BMP. Les formats de sortie sont PBM pour les bitmaps et PGM pour les graymaps. Si un argument filename est fourni, mkbitmap créera par défaut un fichier de sortie dont le nom est obtenu à partir du nom du fichier d'entrée en remplaçant son suffixe par .pbm. Par exemple, pour le nom de fichier d'entrée example.bmp, le nom de fichier de sortie serait example.pbm.

Emscripten fournit un système de fichiers virtuel qui simule le système de fichiers local. Ainsi, le code natif utilisant des API de fichiers synchrones peut être compilé et exécuté avec peu ou pas de modifications. Pour que mkbitmap lise un fichier d'entrée comme s'il avait été transmis en tant qu'argument de ligne de commande filename, vous devez utiliser l'objet FS fourni par Emscripten.

L'objet FS est soutenu par un système de fichiers en mémoire (communément appelé MEMFS) et possède une fonction writeFile() que vous utilisez pour écrire des fichiers dans le système de fichiers virtuel. Vous utilisez writeFile() comme indiqué dans l'exemple de code suivant.

Pour vérifier que l'opération d'écriture de fichier a fonctionné, exécutez la fonction readdir() de l'objet FS avec le paramètre '/'. Vous verrez example.bmp et un certain nombre de fichiers par défaut qui sont toujours créés automatiquement.

Notez que l'appel précédent à Module.callMain(['-v']) pour imprimer le numéro de version a été supprimé. En effet, Module.callMain() est une fonction qui ne doit généralement être exécutée qu'une seule fois.

// 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();

L&#39;application mkbitmap affichant un tableau de fichiers dans le système de fichiers de mémoire, y compris example.bmp.

Première exécution réelle

Une fois que tout est en place, exécutez mkbitmap en exécutant Module.callMain(['example.bmp']). Consignez le contenu du dossier '/' de MEMFS. Vous devriez voir le fichier de sortie example.pbm nouvellement créé à côté du fichier d'entrée 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();

L&#39;application mkbitmap affichant un tableau de fichiers dans le système de fichiers de mémoire, y compris example.bmp et example.pbm.

Récupérer le fichier de sortie du système de fichiers de mémoire

La fonction readFile() de l'objet FS permet d'extraire le example.pbm créé à la dernière étape du système de fichiers en mémoire. La fonction renvoie un Uint8Array que vous convertissez en objet File et enregistrez sur le disque, car les navigateurs ne sont généralement pas compatibles avec les fichiers PBM pour l'affichage direct dans le navigateur. (Il existe des moyens plus élégants d'enregistrer un fichier, mais l'utilisation d'un <a download> créé de manière dynamique est la méthode la plus largement prise en charge.) Une fois le fichier enregistré, vous pouvez l'ouvrir dans votre visionneuse d'images préférée.

// 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 macOS avec un aperçu du fichier .bmp d&#39;entrée et du fichier .pbm de sortie.

Ajouter une UI interactive

Jusqu'à présent, le fichier d'entrée est codé en dur et mkbitmap s'exécute avec les paramètres par défaut. La dernière étape consiste à permettre à l'utilisateur de sélectionner dynamiquement un fichier d'entrée, de modifier les paramètres mkbitmap, puis d'exécuter l'outil avec les options sélectionnées.

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

Le format d'image PBM n'est pas particulièrement difficile à analyser. Avec un peu de code JavaScript, vous pouvez même afficher un aperçu de l'image de sortie. Pour savoir comment procéder, consultez le code source de la démonstration intégrée ci-dessous.

Conclusion

Félicitations, vous avez réussi à compiler mkbitmap en WebAssembly et à le faire fonctionner dans le navigateur. Il y a eu quelques impasses et vous avez dû compiler l'outil plusieurs fois avant qu'il ne fonctionne, mais comme je l'ai écrit plus haut, cela fait partie de l'expérience. N'oubliez pas non plus le tag webassembly de Stack Overflow si vous rencontrez des difficultés. Bonne compilation !

Remerciements

Cet article a été relu par Sam Clegg et Rachel Andrew.