Kompiluję mkbitmap do WebAssembly

W artykule Czym jest WebAssembly i skąd się wziął? Wyjaśniłem, jak doszliśmy do obecnej wersji WebAssembly. W tym artykule pokażę, jak skompilować istniejący program w języku C, mkbitmap, do WebAssembly. Jest to bardziej skomplikowane niż przykład hello world, ponieważ obejmuje pracę z plikami, komunikację między WebAssembly a JavaScript oraz rysowanie na płótnie, ale jest wystarczająco proste, aby Cię nie przytłoczyć.

Artykuł jest przeznaczony dla programistów stron internetowych, którzy chcą poznać WebAssembly. Zawiera on szczegółowe instrukcje, jak postępować w przypadku kompilowania kodu takiego jak mkbitmap do WebAssembly. Ostrzegam, że brak możliwości skompilowania aplikacji lub biblioteki za pierwszym razem jest całkowicie normalny. Dlatego niektóre z opisanych poniżej kroków nie zadziałały, więc musiałem się cofnąć i spróbować jeszcze raz w inny sposób. Artykuł nie przedstawia magicznego polecenia kompilacji końcowej jako czegoś, co spadło z nieba, ale opisuje moje rzeczywiste postępy, w tym niektóre frustracje.

mkbitmap – informacje

Program mkbitmap C odczytuje obraz i wykonuje na nim w tej kolejności co najmniej 1 z tych działań: odwracanie, filtrowanie górnoprzepustowe, skalowanie i progowanie. Każda operacja może być sterowana indywidualnie i włączana lub wyłączana. Głównym zastosowaniem mkbitmap jest konwertowanie obrazów kolorowych lub w odcieniach szarości na format odpowiedni jako dane wejściowe dla innych programów, w szczególności programu do śledzenia potrace, który stanowi podstawę SVGcode. Jako narzędzie do przetwarzania wstępnego mkbitmap jest szczególnie przydatne do konwertowania zeskanowanych grafik wektorowych, takich jak kreskówki lub odręczny tekst, na obrazy dwupoziomowe o wysokiej rozdzielczości.

Używasz polecenia mkbitmap, przekazując mu liczbę opcji i co najmniej 1 nazwę pliku. Szczegółowe informacje znajdziesz na stronie man narzędzia:

$ mkbitmap [options] [filename...]
Kolorowy obraz z kreskówki.
Oryginalny obraz (Źródło).
Obraz z kreskówki przekonwertowany na skalę szarości po przetworzeniu wstępnym.
Najpierw skalowane, a potem progowane: mkbitmap -f 2 -s 2 -t 0.48 (Źródło).

Pobierz kod

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

Kompilowanie i instalowanie lokalnie

Następnym krokiem jest skompilowanie i zainstalowanie narzędzia lokalnie, aby sprawdzić, jak działa. Plik INSTALL zawiera te instrukcje:

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

    Uruchomienie configure może chwilę potrwać. Podczas działania wyświetla komunikaty informujące o sprawdzanych funkcjach.

  2. Aby skompilować pakiet, wpisz make.

  3. Opcjonalnie wpisz make check, aby uruchomić testy własne dołączone do pakietu, zwykle przy użyciu właśnie skompilowanych, niezainstalowanych plików binarnych.

  4. Wpisz make install, aby zainstalować programy oraz pliki danych i dokumentację. W przypadku instalacji w prefiksie należącym do roota zaleca się skonfigurowanie i zbudowanie pakietu jako zwykły użytkownik, a tylko faza make install powinna być wykonywana z uprawnieniami roota.

Po wykonaniu tych czynności powinny powstać 2 pliki wykonywalne: potracemkbitmap. Ten drugi jest tematem tego artykułu. Możesz sprawdzić, czy wszystko działa prawidłowo, uruchamiając polecenie mkbitmap --version. Oto wyniki wszystkich 4 kroków na moim komputerze, znacznie skrócone dla zwięzłości:

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 to zadziałało, uruchom polecenie mkbitmap --version:

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

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

Kompilowanie mkbitmap do WebAssembly

Emscripten to narzędzie do kompilowania programów w językach C i C++ do WebAssembly. W dokumentacji Building Projects (Tworzenie projektów) Emscripten podano:

Tworzenie dużych projektów za pomocą Emscripten jest bardzo proste. Emscripten udostępnia 2 proste skrypty, które konfigurują pliki makefile tak, aby używać emcc jako zamiennika gcc. W większości przypadków reszta obecnego systemu kompilacji projektu pozostaje bez zmian.

Dokumentacja zawiera następnie (nieco skrócone):

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

./configure
make

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

emconfigure ./configure
emmake make

W zasadzie ./configure staje się emconfigure ./configure, a make staje się emmake make. Poniżej pokazujemy, jak to zrobić za pomocą 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 przebiegło pomyślnie, w katalogu powinny się teraz znajdować .wasm pliki. Możesz je znaleźć, uruchamiając polecenie find . -name "*.wasm":

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

Dwa ostatnie wyglądają obiecująco, więc cd do katalogu src/. Dostępne są też 2 nowe pliki: mkbitmappotrace. W tym artykule istotne jest tylko mkbitmap. Brak rozszerzenia .js może być nieco mylący, ale są to pliki JavaScript, co można szybko sprawdzić za pomocą wywołania 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 mv mkbitmap mkbitmap.js (i odpowiednio mv potrace potrace.js, jeśli chcesz). Czas na pierwszy test, aby sprawdzić, czy wszystko działa. W tym celu wykonaj plik za pomocą Node.js w wierszu poleceń, uruchamiając node mkbitmap.js --version:

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

Udało Ci się skompilować mkbitmap do WebAssembly. Teraz musisz sprawić, aby działał w przeglądarce.

mkbitmap w przeglądarce za pomocą WebAssembly,

Skopiuj pliki mkbitmap.jsmkbitmap.wasm do nowego katalogu o nazwie mkbitmap i utwórz plik index.html 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 lokalny serwer, który obsługuje katalog mkbitmap, i otwórz go w przeglądarce. Powinna się wyświetlić prośba o wpisanie danych. Jest to zgodne z oczekiwaniami, ponieważ zgodnie ze stroną podręcznika narzędzia „[j]eśli nie podano argumentów nazwy pliku, mkbitmap działa jako filtr, odczytując dane ze standardowego wejścia”, które w przypadku Emscripten jest domyślnie prompt().

Aplikacja mkbitmap wyświetlająca prośbę o wpisanie danych.

Zapobieganie automatycznemu wykonywaniu

Aby zapobiec natychmiastowemu wykonaniu funkcji mkbitmap i sprawić, że będzie ona czekać na dane wejściowe użytkownika, musisz poznać obiekt Module w Emscriptenie. Module to globalny obiekt JavaScriptu z atrybutami, które kod wygenerowany przez Emscripten wywołuje w różnych momentach wykonywania. Możesz podać implementację Module, aby kontrolować wykonywanie kodu. Gdy aplikacja Emscripten się uruchamia, sprawdza wartości w obiekcie Module i je stosuje.

W przypadku mkbitmap ustaw wartość Module.noInitialRun na true, aby zapobiec pierwszemu 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 ten kod:script.js Po ponownym wczytaniu aplikacji komunikat powinien zniknąć.

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

Tworzenie modułowej kompilacji z dodatkowymi flagami kompilacji

Aby przekazywać dane do aplikacji, możesz używać obsługi systemu plików Emscripten w Module.FS. W sekcji Including File System Support (W tym obsługa systemu plików) w dokumentacji jest napisane:

Emscripten automatycznie decyduje, czy uwzględnić obsługę systemu plików. Wiele programów nie potrzebuje plików, a obsługa systemu plików ma znaczną wielkość, więc Emscripten unika jej uwzględniania, jeśli nie widzi powodu. Oznacza to, że jeśli kod C/C++ nie uzyskuje dostępu do plików, obiekt FS i inne interfejsy API systemu plików nie zostaną uwzględnione w danych wyjściowych. Z drugiej strony, jeśli Twój kod w C/C++ korzysta z plików, obsługa systemu plików zostanie automatycznie uwzględniona.

Niestety mkbitmap to jeden z przypadków, w których Emscripten nie uwzględnia automatycznie obsługi systemu plików, więc musisz mu to wyraźnie nakazać. Oznacza to, że musisz wykonać opisane wcześniej kroki emconfigureemmake, ustawiając jeszcze kilka flag za pomocą argumentu CFLAGS. Poniższe 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 na potrzeby 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ć emmake make i skopiować nowo utworzonych plików do folderu mkbitmap.

Zmodyfikuj index.html, aby wczytywał tylko moduł ES script.js, z którego następnie 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 teraz otworzysz aplikację w przeglądarce, w konsoli DevTools powinien pojawić się obiekt Module, a prompt zniknie, ponieważ funkcja main() obiektu mkbitmap nie jest już wywoływana na początku.

Aplikacja mkbitmap z białym ekranem, na którym w konsoli Narzędzi deweloperskich widać obiekt Module.

Ręczne wykonywanie funkcji głównej

Następnym krokiem jest ręczne wywołanie funkcji mkbitmapmain() przez uruchomienie Module.callMain(). Funkcja callMain() przyjmuje tablicę argumentów, które odpowiadają argumentom przekazywanym w wierszu poleceń. Jeśli w wierszu poleceń uruchamiasz polecenie mkbitmap -v, w przeglądarce wywołujesz Module.callMain(['-v']). Spowoduje to zarejestrowanie numeru wersji mkbitmap w konsoli Narzędzi deweloperskich.

// 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, na którym w konsoli Narzędzi deweloperskich widać numer wersji mkbitmap.

Przekierowywanie standardowego wyjścia

Domyślnym standardowym wyjściem (stdout) jest konsola. Możesz jednak przekierować go na coś innego, np. na funkcję, która zapisuje 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.

Przenieś plik wejściowy do systemu plików w pamięci.

Aby przenieść plik wejściowy do systemu plików w pamięci, musisz użyć odpowiednika polecenia mkbitmap filename w wierszu poleceń. Aby zrozumieć, jak do tego podchodzę, najpierw wyjaśnię, jak mkbitmap oczekuje danych wejściowych i tworzy dane wyjściowe.

Obsługiwane formaty wejściowe mkbitmap to PNM (PBM, PGM, PPM) i BMP. Formaty wyjściowe to PBM w przypadku bitmap i PGM w przypadku map odcieni szarości. Jeśli podano argument filename, mkbitmap domyślnie utworzy plik wyjściowy, którego nazwa zostanie utworzona na podstawie nazwy pliku wejściowego przez zmianę jego sufiksu na .pbm. Na przykład dla nazwy pliku wejściowego example.bmp nazwa pliku wyjściowego to 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żna skompilować i uruchomić z niewielkimi zmianami lub bez nich. Aby mkbitmap odczytywał 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 (nazywany zwykle MEMFS) i ma funkcję writeFile(), której używasz do zapisywania plików w wirtualnym systemie plików. Używasz znaku writeFile(), jak pokazano w tym przykładowym kodzie.

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

Zwróć uwagę, że poprzednie wywołanie funkcji Module.callMain(['-v']) do drukowania numeru wersji zostało usunięte. Wynika to z faktu, że Module.callMain() to funkcja, która zwykle powinna być uruchamiana 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 plik example.bmp.

Pierwsze rzeczywiste wykonanie

Gdy wszystko będzie gotowe, wykonaj mkbitmap, uruchamiając Module.callMain(['example.bmp']). Zaloguj zawartość folderu 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 w pamięci

Funkcja readFile() obiektu FS umożliwia pobranie z systemu plików w pamięci obiektu example.pbm utworzonego w ostatnim kroku. Funkcja zwraca wartość Uint8Array, którą przekształcasz w obiekt File i zapisujesz na dysku, ponieważ przeglądarki zwykle nie obsługują plików PBM do bezpośredniego wyświetlania w przeglądarce. (Istnieją bardziej eleganckie sposoby zapisywania pliku, ale używanie dynamicznie tworzonego elementu <a download> jest najbardziej rozpowszechnione). Po zapisaniu pliku możesz go otworzyć w ulubionej przeglądarce 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 wejściowego pliku .bmp i wyjściowego pliku .pbm.

Dodawanie interaktywnego interfejsu

Do tej pory plik wejściowy jest zakodowany na stałe, a mkbitmap działa z domyślnymi parametrami. Ostatnim krokiem jest umożliwienie użytkownikowi dynamicznego wyboru pliku wejściowego, dostosowania parametrów mkbitmap, a następnie uruchomienia 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 przeanalizowania, więc za pomocą kodu JavaScript możesz nawet wyświetlić podgląd obrazu wyjściowego. Jedną z metod znajdziesz w kodzie źródłowym poniższej wersji demonstracyjnej.

Podsumowanie

Gratulacje! Udało Ci się skompilować mkbitmap do WebAssembly i uruchomić go w przeglądarce. Było kilka ślepych zaułków i musiałem skompilować narzędzie więcej niż raz, zanim zaczęło działać, ale jak napisałem powyżej, to część procesu. Jeśli utkniesz, pamiętaj też o tagu webassembly w StackOverflow. Miłego kompilowania!

Podziękowania

Ten artykuł został sprawdzony przez Sama CleggaRachel Andrew.