Kompiluję mkbitmap do WebAssembly

Co to jest WebAssembly i skąd się wzięło? Wyjaśniłem, jak powstała obecna wersja WebAssembly. W tym artykule pokażę, jak skompilować istniejący program C, mkbitmap, do formatu WebAssembly. Jest ono bardziej skomplikowane niż przykład hello world, ponieważ obejmuje pracę z plikami, komunikację między WebAssembly a JavaScriptem oraz rysowanie na płótnie, ale nadal jest na tyle przystępne, że nie przytłoczy Cię.

Ten artykuł jest przeznaczony dla programistów stron internetowych, którzy chcą nauczyć się WebAssembly. Zawiera on szczegółowe instrukcje dotyczące kompilowania kodu, np. mkbitmap, do WebAssembly. Uprzedzam, że niekompilowanie się aplikacji lub biblioteki przy pierwszym uruchomieniu jest całkowicie normalne, dlatego niektóre z opisanych poniżej kroków nie zadziałały. Musiałem wrócić do poprzedniego stanu i spróbować jeszcze raz. W artykule nie znajdziesz magicznego polecenia kompilacji, które spadnie na Ciebie z nieba, ale opisuję w nim moje postępy, w tym pewne rozczarowania.

mkbitmap – informacje

Program w języku C mkbitmap odczytuje obraz i zastosuje do niego co najmniej jedną z tych operacji w podanej kolejności: odwrócenie, filtrowanie pasmowego, skalowanie i ustawienie progu. Każdą operację można sterować i włączać lub wyłączać osobno. Głównym zastosowaniem polecenia mkbitmap jest konwertowanie obrazów kolorowych lub w szarościach na format odpowiedni do użycia w innych programach, zwłaszcza w programie do tworzenia ścieżek potrace, który stanowi podstawę SVGcode. Jako narzędzie do wstępnej obróbki mkbitmap jest szczególnie przydatne do konwertowania zeskanowanych grafik wektorowych, takich jak kreskówki czy tekst odręczny, na dwupoziomowe obrazy o wysokiej rozdzielczości.

Funkcji mkbitmap używa się, przekazując jej pewną liczbę opcji i jedną lub więcej nazw plików. Szczegółowe informacje znajdziesz na stronie man tego narzędzia.

$ mkbitmap [options] [filename...]
Kolorowy obraz z kreskówką.
Oryginalne zdjęcie (Źródło).
Obraz z kreskówką przekonwertowany na skalę szarości po wstępnej obróbce.
Najpierw skalowanie, a potem zastosowanie progu: mkbitmap -f 2 -s 2 -t 0.48 (Source).

Pobierz kod

Pierwszym krokiem jest uzyskanie kodu źródłowego mkbitmap. Znajdziesz go na stronie projektu. W chwili pisania tego tekstu najnowszą wersją jest potrace-1.16.tar.gz.

Kompilowanie i instalowanie lokalnie

Kolejnym krokiem jest skompilowanie i zainstalowanie narzędzia lokalnie, aby sprawdzić, jak się zachowuje. Plik INSTALL zawiera te instrukcje:

  1. cd do katalogu zawierającego kod źródłowy pakietu i wpisz ./configure, aby skonfigurować pakiet dla swojego systemu.

    Uruchamianie configure może trochę potrwać. Podczas działania wyświetla komunikaty z informacją, które funkcje są sprawdzane.

  2. Aby skompilować pakiet, wpisz make.

  3. Opcjonalnie wpisz make check, aby uruchomić autotesty dołączone do pakietu, zazwyczaj przy użyciu właśnie skompilowanych, nieinstalowanych plików binarnych.

  4. Wpisz make install, aby zainstalować programy oraz pliki danych i dokumentację. Podczas instalowania w prefiksie należącym do roota zalecamy skonfigurowanie i skompilowanie pakietu jako zwykły użytkownik, a tylko fazę make install należy wykonać z uprawnieniami roota.

Po wykonaniu tych czynności powinny pojawić się 2 pliki wykonywalne: potracemkbitmap. Ten drugi jest tematem tego artykułu. Aby sprawdzić, czy wszystko działa prawidłowo, uruchom polecenie mkbitmap --version. Poniżej znajdziesz wyniki z mojego komputera w przypadku wszystkich 4 etapów, w którym mocno skrócone, aby były zwięzłe:

Krok 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

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

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

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

Aby sprawdzić, czy wszystko działa prawidłowo, uruchom mkbitmap --version:

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

Jeśli zobaczysz szczegóły wersji, oznacza to, że mkbitmap została skompilowana i zainstalowana. Następnie wykonaj te same czynności w przypadku WebAssembly.

Kompilowanie mkbitmap w standardzie WebAssembly

Emscripten to narzędzie do kompilowania programów C/C++ do WebAssembly. W dokumentacji Tworzenie projektów Emscripten można przeczytać:

Budowanie dużych projektów za pomocą Emscripten jest bardzo proste. Emscripten udostępnia 2 proste skrypty, które konfigurują pliki make, aby używać emcc jako zamiennika dla gcc. W większości przypadków pozostała część obecnego systemu kompilacji projektu pozostaje niezmieniona.

Dokumentacja zawiera też (w uproszczeniu) następujące informacje:

Załóżmy, że zwykle kompilujesz za pomocą tych poleceń:

./configure
make

Aby skompilować za pomocą Emscripten, użyj zamiast tego tych poleceń:

emconfigure ./configure
emmake make

Oznacza to, że ./configure staje się emconfigure ./configure, a make staje się emmake make. Poniżej pokazujemy, jak to zrobić w przypadku mkbitmap.

Krok 0, make clean:

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

Krok 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

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

Jeśli wszystko poszło dobrze, w katalogu powinny się teraz znajdować pliki .wasm. Aby je znaleźć, uruchom find . -name "*.wasm":

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

Ostatnie 2 wyglądają obiecująco, więc cd do katalogu src/. Dostępne są też 2 nowe pliki: mkbitmappotrace. W tym artykule istotna jest tylko wartość mkbitmap. Brak rozszerzenia .js może być nieco mylący, ale są to w istocie pliki JavaScript, co można sprawdzić, wykonując szybkie wywołanie 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)

Zmień nazwę pliku JavaScript na mkbitmap.js, wywołując funkcję mv mkbitmap mkbitmap.js (i odpowiednio mv potrace potrace.js). Czas na pierwszy test, aby sprawdzić, czy wszystko działa prawidłowo. Uruchom plik za pomocą Node.js w wierszu poleceń, wykonując polecenie node mkbitmap.js --version:

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

Kompilacja mkbitmap została zakończona i plik został zapisany w formacie WebAssembly. Następnym krokiem jest uruchomienie aplikacji w przeglądarce.

mkbitmap w standardzie WebAssembly w przeglądarce

Skopiuj pliki mkbitmap.jsmkbitmap.wasm do nowego katalogu o nazwie mkbitmap i utwórz szablon HTML index.html, który wczytuje plik 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>

Uruchom serwer lokalny, który obsługuje katalog mkbitmap, i otwórz go w przeglądarce. Powinien pojawić się komunikat z prośbą o podanie danych. To się zgadza, ponieważ według strony man narzędzia [i]f no filename arguments are given, then mkbitmap acts as a filter, reading from standard input", co w przypadku Emscripten domyślnie oznacza prompt().

Aplikacja mkbitmap z prośbą o wprowadzenie danych.

Zapobieganie automatycznemu wykonywaniu

Aby zapobiec natychmiastowemu wykonywaniu funkcji mkbitmap i zamiast tego spowodować, że będzie ona oczekiwać na dane wejściowe użytkownika, musisz zrozumieć obiekt Module w Emscripten. Module to globalny obiekt JavaScriptu z atrybutami, które kod wygenerowany przez Emscripten wywołuje w różnych punktach swojego wykonania. Aby kontrolować wykonywanie kodu, możesz podać implementację funkcji Module. Gdy aplikacja Emscripten uruchamia się, sprawdza wartości obiektu Module i je stosuje.

W przypadku mkbitmap ustaw wartość Module.noInitialRun na true, aby zapobiec początkowemu uruchomieniu, które spowodowało wyświetlenie promptu. Utwórz skrypt o nazwie script.js, umieść go przed tagiem <script src="mkbitmap.js"></script> w pliku index.html i dodaj do niego podany niżej kod.script.js Gdy ponownie wczytasz aplikację, ten komunikat powinien zniknąć.

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

Tworzenie modułowej wersji z dodatkowymi flagami kompilacji

Aby podać dane wejściowe aplikacji, możesz użyć obsługi systemu plików w Emscripten w Module.FS. W sekcji Obsługa systemu plików w dokumentacji można przeczytać:

Emscripten automatycznie decyduje, czy uwzględnić obsługę systemu plików. Wiele programów nie wymaga plików, a obsługa systemu plików nie jest nieznaczna pod względem rozmiaru, więc Emscripten nie uwzględnia jej, jeśli nie ma ku temu powodu. Oznacza to, że jeśli kod C/C++ nie ma dostępu do plików, obiekt FS i inne interfejsy API systemu plików nie będą uwzględniane w wyjściu. Jeśli jednak Twój kod C/C++ używa plików, obsługa systemu plików zostanie automatycznie uwzględniona.

Niestety mkbitmap to jeden z przypadków, w których Emscripten nie obsługuje automatycznie systemu plików, więc musisz wyraźnie to wskazać. Oznacza to, że musisz wykonać czynności opisane wcześniej w sekcji emconfigureemmake, ale z kilkoma dodatkowymi flagami ustawionymi za pomocą argumentu CFLAGS. Te flagi mogą być przydatne również w innych projektach.

W tym konkretnym przypadku musisz też ustawić flagę --host na wasm32, aby poinformować skrypt configure, że kompilujesz go dla WebAssembly.

Ostateczne polecenie emconfigure wygląda tak:

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

Nie zapomnij ponownie uruchomić polecenia emmake make i skopiować utworzone właśnie pliki do folderu mkbitmap.

Zmodyfikuj plik index.html, tak aby wczytywał tylko moduł ES script.js, z którego zaimportujesz moduł 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();

Gdy otworzysz aplikację w przeglądarce, obiekt Module powinien zostać zarejestrowany w konsoli DevTools, a prompt zniknie, ponieważ funkcja main() obiektu mkbitmap nie jest już wywoływana na początku.

Aplikacja mkbitmap z białym ekranem, pokazująca obiekt Module zarejestrowany w konsoli Narzędzi deweloperskich.

Ręczne wykonywanie głównej funkcji

Następnym krokiem jest ręczne wywołanie funkcji main() klasy mkbitmap, wykonując instrukcję Module.callMain(). Funkcja callMain() przyjmuje tablicę argumentów, które są pojedynczo zgodne z tym, co przekazujesz na linii poleceń. Jeśli w wierszu poleceń uruchomisz mkbitmap -v, w przeglądarce uruchomisz Module.callMain(['-v']). Spowoduje to zapisanie numeru wersji mkbitmap w konsoli DevTools.

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

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

run();

Aplikacja mkbitmap z białym ekranem, która pokazuje numer wersji mkbitmap zarejestrowany w konsoli DevTools.

Przekierowywanie standardowego wyjścia

Standardowe wyjście (stdout) to domyślnie konsola. Możesz jednak przekierować go na coś innego, np. na funkcję, która przechowuje dane wyjściowe w zmiennej. Oznacza to, że możesz dodać dane wyjściowe do kodu HTML, ustawiając właściwość 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();

Aplikacja mkbitmap z numerem wersji mkbitmap.

Przesyłanie pliku wejściowego do systemu plików w pamięci

Aby w pamięci utworzyć system plików, musisz użyć odpowiednika polecenia mkbitmap filename w wierszu poleceń. Aby zrozumieć, jak to robię, najpierw wyjaśnię, jak mkbitmap otrzymuje dane wejściowe i tworzy dane wyjściowe.

Obsługiwane formaty wejściowe mkbitmap to PNM (PBM, PGM, PPM) i BMP. Formaty wyjściowe to PBM dla bitmap i PGM dla map szarości. Jeśli podano argument filename, mkbitmap domyślnie utworzy plik wyjściowy, którego nazwa zostanie uzyskana z nazwy pliku wejściowego przez zmianę jego sufiksu na .pbm. Na przykład w przypadku nazwy pliku wejściowego example.bmp nazwa pliku wyjściowego będzie brzmieć example.pbm.

Emscripten udostępnia wirtualny system plików, który symuluje lokalny system plików, dzięki czemu kod natywny korzystający z synchronicznych interfejsów API plików może być kompilowany i uruchamiany z niewielkimi lub żadnymi zmianami. Aby mkbitmap odczytał plik wejściowy tak, jakby został przekazany jako argument wiersza poleceń filename, musisz użyć obiektu FS udostępnianego przez Emscripten.

Obiekt FS jest obsługiwany przez system plików w pamięci (powszechnie nazywany MEMFS) i ma funkcję writeFile(), której używasz do zapisywania plików w systemie plików wirtualnych. Użyj writeFile() w sposób pokazany w poniższym przykładowym kodzie.

Aby sprawdzić, czy operacja zapisu pliku się powiodła, uruchom funkcję readdir() obiektu FS z parametrem '/'. Zobaczysz example.bmp oraz kilka plików domyślnych, które są zawsze tworzone automatycznie.

Uwaga: poprzednie wywołanie funkcji Module.callMain(['-v']) służące do drukowania numeru wersji zostało usunięte. Wynika to z tego, że funkcja Module.callMain() jest zwykle wykonywana tylko raz.

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

Aplikacja mkbitmap wyświetlająca tablicę plików w systemie plików pamięci, w tym example.bmp.

Pierwsze faktyczne wykonanie

Po wykonaniu wszystkich czynności uruchom mkbitmap, aby wykonać Module.callMain(['example.bmp']). Zaloguj zawartość folderu '/' w MEMFS. Obok pliku wejściowego example.bmp powinien pojawić się nowo utworzony plik wyjściowy example.pbm.

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

Aplikacja mkbitmap wyświetlająca tablicę plików w systemie plików pamięci, w tym example.bmp i example.pbm.

Pobieranie pliku wyjściowego z systemu plików pamięci

Funkcja readFile() obiektu FS umożliwia pobranie example.pbm utworzonego na ostatnim kroku z systemu plików pamięci. Funkcja zwraca Uint8Array, który jest konwertowany na obiekt File i zapisywany na dysku, ponieważ przeglądarki zwykle nie obsługują plików PBM do bezpośredniego wyświetlania w przeglądarce. (istnieją bardziej eleganckie sposoby zapisu pliku, ale użycie dynamicznie utworzonego <a download> jest najbardziej powszechnie obsługiwane). Po zapisaniu pliku możesz go otworzyć w ulubionym programie do wyświetlania obrazów.

// 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 w systemie macOS z podglądem pliku wejściowego .bmp i pliku wyjściowego .pbm.

Dodawanie interaktywnego interfejsu

Do tego momentu plik wejściowy jest zakodowany na stałe, a mkbitmap działa z domyślnymi parametrami. Ostatnim krokiem jest umożliwienie użytkownikowi dynamicznego wybrania pliku wejściowego, dostosowanie parametrów mkbitmap, a następnie uruchomienie narzędzia z wybranymi opcjami.

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

Format obrazu PBM nie jest szczególnie trudny do zanalizowania, więc za pomocą niewielkiego kodu JavaScript możesz nawet wyświetlić podgląd obrazu wyjściowego. Poniżej znajdziesz kod źródłowy zaimplementowanego przykładu, który pokazuje jedną z możliwości wykonania tej czynności.

Podsumowanie

Gratulacje! Udało Ci się skompilować mkbitmap na WebAssembly i uruchomić w przeglądarce. W pewnych miejscach utknęłaś i musiałaś kompilować narzędzie więcej niż raz, zanim zaczęło działać, ale jak już pisałam powyżej, to normalna sytuacja. Jeśli utkniesz, pamiętaj też o tagu webassembly w witrynie StackOverflow. Udanego kompilowania!

Podziękowania

Ten artykuł został sprawdzony przez Samego CleggaRachel Andrew.