Kompiluję mkbitmap do WebAssembly

W sekcji Co to jest WebAssembly i skąd się wzięła? Jak doszliśmy do dzisiejszej sesji WebAssembly. W tym artykule zademonstruję moje podejście do kompilowania istniejącego programu C (mkbitmap) do WebAssembly. Jest bardziej skomplikowany niż przykład z elementem hello world, bo obejmuje pracę z plikami, komunikację między obszarami WebAssembly i JavaScript oraz rysowanie na obszarze roboczym, ale w dalszym ciągu jest wystarczająco efektywny, by nie przytłoczyć Cię.

Ten artykuł jest przeznaczony dla programistów stron internetowych, którzy chcą nauczyć się języka WebAssembly. Przedstawia krok po kroku, co należy zrobić, jeśli chcesz skompilować kod podobny do mkbitmap z WebAssembly. Przypominamy, że brak aplikacji lub biblioteki do skompilowania przy pierwszym uruchomieniu jest zupełnie normalny. Dlatego niektóre z podanych niżej czynności nie zadziałały. Musiałam się cofnąć i spróbować jeszcze raz. Artykuł nie przedstawia magicznego polecenia końcowej kompilacji, jak gdyby spadł z nieba, ale raczej opisuje moje prawdziwe postępy, w tym pewne frustracje.

mkbitmap – informacje

Program mkbitmap C odczytuje obraz i wykonuje na nim co najmniej 1 z tych operacji w tej kolejności: odwrócenie, filtrowanie górnoprzepustowe, skalowanie i określanie progów. Każdą operację można kontrolować oraz włączać i wyłączać. Podstawowym zastosowaniem funkcji mkbitmap jest konwertowanie obrazów kolorowych lub w skali szarości na format odpowiedni do użycia w innych programach, a zwłaszcza do programu śledzenia potrace, który jest podstawą SVGcode. mkbitmap, jako narzędzie do wstępnego przetwarzania, przydaje się zwłaszcza do konwertowania zeskanowanych grafik, np. kreskówek lub tekstów pisanych odręcznie, na obrazy dwupoziomowe w wysokiej rozdzielczości.

Użycie polecenia mkbitmap polega na przekazaniu mu różnych opcji i co najmniej 1 nazwy pliku. Szczegółowe informacje znajdziesz na stronie podręcznika narzędzia:

$ mkbitmap [options] [filename...]
Kolorowy obrazek kreskówki.
oryginalny obraz (źródło).
Obraz kreskówki przekonwertowany na tryb szarości po wstępnym przetworzeniu.
Najpierw skalowano, a potem wartość progowa: mkbitmap -f 2 -s 2 -t 0.48 (źródło).

Pobierz kod

Pierwszym krokiem jest pobranie kodu źródłowego mkbitmap. Znajdziesz go na stronie projektu. W tym momencie najnowsza wersja to potrace-1.16.tar.gz.

Kompilacja i instalacja lokalnie

Następnym krokiem jest skompilowanie i zainstalowanie narzędzia lokalnie, aby poznać jego działanie. 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.

    Uruchamianie pliku configure może chwilę potrwać. Podczas uruchamiania wyświetla komunikaty o funkcjach, których szuka.

  2. Wpisz make, aby skompilować pakiet.

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

  4. Wpisz make install, aby zainstalować programy oraz dowolne pliki danych i dokumentację. W przypadku instalowania w prefiksie należącym do roota zalecamy skonfigurowanie i skompilowanie pakietu jako zwykłego użytkownika, a tylko etap make install wykonywany z uprawnieniami użytkownika root.

Gdy wykonasz te czynności, uzyskasz 2 pliki wykonywalne: potrace i mkbitmap. Te pliki są głównym tematem tego artykułu. Możesz sprawdzić, czy program działa prawidłowo, uruchamiając polecenie mkbitmap --version. Oto dane wyjściowe ze wszystkich czterech kroków z mojego komputera, bardzo skrócone dla zachowania 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 wszystko zadziałało, uruchom mkbitmap --version:

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

Jeśli znasz szczegóły wersji, oznacza to, że program mkbitmap został skompilowany i zainstalowany. Następnie zadbaj o to, aby odpowiednik tych kroków działał z WebAssembly.

Kompilowanie mkbitmap z użyciem WebAssembly

Emscripten to narzędzie do kompilowania programów w C/C++ w WebAssembly. Dokumentacja Building Projects (Projekty budowlane) w serwisie Emscripten:

Tworzenie dużych projektów w Emscripten jest bardzo łatwe. Emscripten udostępnia 2 proste skrypty, które konfigurują pliki tworzenia plików pod kątem używania emcc jako zamiennika gcc – w większości przypadków reszta systemu kompilacji projektu pozostaje bez zmian.

Dalej dokumentuje się (trochę przeredagowana, aby była zwięzła):

Weź pod uwagę przypadek, w którym zazwyczaj tworzysz kompilację przy użyciu następujących poleceń:

./configure
make

Do tworzenia w Emscripten użyj tych poleceń:

emconfigure ./configure
emmake make

Ogólnie rzecz biorąc, ./configure zmienia się w emconfigure ./configure, a make – na 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 poszło dobrze, w dowolnym miejscu w katalogu powinny znajdować się teraz pliki .wasm. Znajdziesz je, uruchamiając polecenie find . -name "*.wasm":

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

Dwie ostatnie z nich wyglądają obiecująco, więc cd znajdzie się w katalogu src/. Dostępne są też 2 nowe odpowiadające im pliki: mkbitmap i potrace. W tym artykule znaczenie ma tylko mkbitmap. Brak rozszerzenia .js może być trochę mylące, ale w rzeczywistości są to pliki JavaScript, które można zweryfikować za pomocą szybkiego 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 metodę mv mkbitmap mkbitmap.js (lub odpowiednio mv potrace potrace.js, jeśli chcesz). Czas na pierwszy test, aby sprawdzić, czy się udało. Aby to zrobić, uruchom plik z użyciem Node.js z poziomu wiersza poleceń i uruchom node mkbitmap.js --version:

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

Udało Ci się skompilować zestaw mkbitmap do WebAssembly. Teraz trzeba umożliwić działanie przeglądarki w przeglądarce.

mkbitmap z WebAssembly w przeglądarce

Skopiuj pliki mkbitmap.js i mkbitmap.wasm do nowego katalogu o nazwie mkbitmap i utwórz stały plik HTML index.html, który będzie wczytywać 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. Powinien pojawić się prośba o wpisanie tekstu. Jest to zgodne z oczekiwaniami. Zgodnie z informacjami na stronie podręcznika „[i]jeśli nie podano argumentów nazwy pliku, mkbitmap działa jak filtr, odczytując dane ze standardowych danych wejściowych”, który w przypadku Emscripten domyślnie to prompt().

Aplikacja mkbitmap z prośbą o wpisanie danych.

Zapobieganie automatycznemu wykonywaniu

Aby zatrzymać natychmiastowe wykonywanie kodu mkbitmap i czekać na dane wejściowe użytkownika, musisz zrozumieć obiekt Module programu Emscripten. Module to globalny obiekt JavaScript z atrybutami, które są wywoływane przez kod wygenerowany przez Emscripten w różnych momentach wykonywania. Możesz udostępnić implementację Module, aby kontrolować wykonywanie kodu. Po uruchomieniu aplikacja Emscripten sprawdza wartości w obiekcie Module i je stosuje.

W przypadku funkcji mkbitmap ustaw Module.noInitialRun na true, aby zapobiec wyświetlaniu przy pierwszym uruchomieniu, które spowodowało wyświetlenie tego komunikatu. Utwórz skrypt o nazwie script.js, umieść go przed poleceniem <script src="mkbitmap.js"></script> w komponencie index.html i dodaj ten kod do script.js. Po ponownym załadowaniu aplikacji prośba powinna zniknąć.

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

Utwórz kompilację modułową z dodatkowymi flagami kompilacji

Aby wprowadzić dane wejściowe do aplikacji, możesz skorzystać z obsługi systemu plików Emscripten w pakiecie Module.FS. Sekcja Uwzględnianie obsługi systemu plików w dokumentacji zawiera następujące informacje:

Emscripten decyduje, czy włączyć obsługę systemu plików automatycznie. Wiele programów nie potrzebuje plików, a obsługa systemu plików nie jest niezbędna, więc w Emscripten nie ma powodu, aby ją uwzględniać. Oznacza to, że jeśli Twój kod w C/C++ nie ma dostępu do plików, obiekt FS ani inne interfejsy API systemu plików nie zostaną uwzględnione w danych wyjściowych. Jeśli natomiast w kodzie C/C++ używasz plików, obsługa systemu plików zostanie automatycznie uwzględniona.

Niestety usługa mkbitmap nie włącza automatycznie obsługi systemu plików, więc musisz wyraźnie wskazać mu, że to robi. Oznacza to, że musisz wykonać opisane wcześniej kroki emconfigure i emmake oraz dodać kilka dodatkowych flag za pomocą argumentu CFLAGS. Poniższe flagi mogą być przydatne również w innych projektach.

W tym przypadku musisz też ustawić flagę --host na wasm32, aby poinformować skrypt configure, który kompilowasz pod kątem WebAssembly.

Ostatnie 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ć narzędzia emmake make i skopiować nowo utworzone pliki do folderu mkbitmap.

Zmodyfikuj index.html, tak aby wczytywał się tylko moduł ES script.js, z którego chcesz zaimportować 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 być zalogowany w konsoli Narzędzi deweloperskich. Prompt zniknął, ponieważ funkcja main() obiektu mkbitmap nie jest już wywoływana na początku.

Aplikacja mkbitmap z białym ekranem i widocznym obiektem modułu zarejestrowanym w konsoli Narzędzi deweloperskich.

Ręcznie wykonaj główną funkcję

Następnym krokiem jest ręczne wywołanie funkcji main() na urządzeniu mkbitmap za pomocą polecenia Module.callMain(). Funkcja callMain() przyjmuje tablicę argumentów, które pojedynczo pasują do tego, co zostanie przekazane w wierszu poleceń. Jeśli w wierszu poleceń uruchomisz polecenie mkbitmap -v, wywołasz w przeglądarce 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 pokazująca numer wersji mkbitmap zarejestrowany w konsoli Narzędzi deweloperskich.

Przekieruj standardowe dane wyjściowe

Domyślnym rodzajem danych wyjściowych (stdout) jest konsola. Możesz jednak przekierować ją do czegoś innego, np. funkcji, która zapisuje dane wyjściowe w zmiennej. Oznacza to, że możesz dodawać dane wyjściowe do kodu HTML za pomocą właściwości 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 pokazująca numer wersji mkbitmap.

Pobierz plik wejściowy do systemu plików pamięci

Aby przenieść plik wejściowy do systemu plików pamięci, w wierszu poleceń potrzebujesz odpowiednika mkbitmap filename. Żeby zrozumieć, jak to wygląda, najpierw muszę się zorientować, jak mkbitmap oczekuje swoich 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 dla map bitowych i PGM dla map szarej. Jeśli zostanie podany argument filename, mkbitmap domyślnie utworzy plik wyjściowy, którego nazwa jest uzyskiwana z nazwy pliku wejściowego przez zmianę jego sufiksu na .pbm. Na przykład w przypadku pliku wejściowego example.bmp nazwa pliku wyjściowego będzie wyglądać tak: example.pbm.

Emscripten udostępnia wirtualny system plików, który symuluje lokalny system plików, dzięki czemu natywny kod korzystający z synchronicznych interfejsów API plików może być skompilowany i uruchomiony z niewielką lub zerową zmianą. Aby usługa mkbitmap mogła odczytać plik wejściowy tak, jakby był przekazywany jako argument wiersza poleceń filename, musisz użyć obiektu FS udostępnionego przez Emscripten.

Obiekt FS jest obsługiwany przez system plików w pamięci (nazywany zwykle MEMFS) i ma funkcję writeFile(), która pozwala zapisywać pliki w wirtualnym systemie plików. Używasz polecenia writeFile() w sposób podany poniżej.

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

Zwróć uwagę, że poprzednie wywołanie usługi Module.callMain(['-v']) w celu wydrukowania numeru wersji zostało usunięte. Wynika to z faktu, że funkcja Module.callMain() jest zwykle 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 pokazująca tablicę plików w systemie plików pamięci, w tym example.bmp.

Pierwsze rzeczywiste wykonanie

Gdy wszystko jest skonfigurowane, uruchom polecenie mkbitmap, uruchamiając polecenie Module.callMain(['example.bmp']). Zapisz zawartość folderu '/' MEMFS. Nowo utworzony plik wyjściowy example.pbm powinien pojawić się obok pliku wejściowego 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();

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

Pobierz plik wyjściowy z systemu plików pamięci

Funkcja readFile() obiektu FS umożliwia pobranie elementu example.pbm utworzonego w ostatnim kroku z systemu plików pamięci. Ta funkcja zwraca typ Uint8Array, który jest konwertowany na obiekt File i zapisany na dysku, ponieważ przeglądarki zazwyczaj nie obsługują plików PBM do bezpośredniego przeglądania w przeglądarce. Istnieją bardziej eleganckie sposoby zapisania pliku, ale najpopularniejsze jest <a download> tworzone dynamicznie. Po zapisaniu pliku możesz otworzyć go 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.

Dodaj interaktywny interfejs użytkownika

Na razie plik wejściowy jest zakodowany na stałe, a mkbitmap działa z parametrami domyślnymi. Ostatnim krokiem jest możliwość dynamicznego wybierania pliku wejściowego przez użytkownika, dostosowywania parametrów mkbitmap i uruchamiania 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 korzystając z kodu JavaScript, możesz nawet wyświetlić podgląd obrazu wyjściowego. Jeden ze sposobów wykonania tej czynności znajdziesz poniżej, w kodzie źródłowym umieszczonej prezentacji.

Podsumowanie

Gratulacje! Udało Ci się skompilować plik mkbitmap z WebAssembly i działać w przeglądarce. Było kilka ślepych zaległości i trzeba było skompilować narzędzie więcej niż raz, aż zadziałało, ale, jak już wspomnieliśmy, jest to część tego, czego potrzeba. Jeśli napotkasz problemy, pamiętaj też o tagu webassembly usługi StackOverflow. Miłego tworzenia!

Podziękowania

Ten artykuł sprawdzili Sam Clegg i Rachel Andrew.