Kompiluję mkbitmap do WebAssembly

Co to jest WebAssembly i skąd się wzięło? Wyjaśniłem, jak powstało 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ą poznać narzędzie WebAssembly. Przedstawia on krok po kroku, co należy zrobić, jeśli chcesz skompilować na przykład 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. Artykuł nie pokazuje końcowego polecenia kompilacji magicznej, tak jakby spadł z nieba. Opisuje on jedynie moje postępy, z pewnymi irytacją.

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 włączać i wyłączać oddzielnie. 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 rysunków 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...]
Obrazek kreskówki w kolorze.
Oryginalne zdjęcie (Source).
Obraz z kreskówką przekonwertowany na skalę szarości po wstępnej obróbce.
Pierwsza skala, a potem próg: 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 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.

    Uruchomienie listy configure może chwilę 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 udało się rozwiązać problem, 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ł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 odpowiedzi wyglądają obiecująco, więc cd do katalogu src/. Dostępne są też 2 nowe powiązane pliki: mkbitmap i potrace. W tym artykule istotna jest tylko wartość mkbitmap. Fakt, że nie mają 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 za pomocą 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 natychmiast zatrzymać wykonanie polecenia mkbitmap i zamiast niego zaczekać na dane wejściowe użytkownika, musisz zrozumieć obiekt Emscripten Module. 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ę Module. Gdy aplikacja Emscripten uruchamia się, sprawdza wartości obiektu Module i je stosuje.

W przypadku polecenia mkbitmap ustaw Module.noInitialRun na true, aby zapobiec pierwszemu uruchomieniu, które spowodowało wyświetlenie komunikatu. Utwórz skrypt o nazwie script.js, umieść go przed poleceniem <script src="mkbitmap.js"></script> w elemencie index.html i dodaj poniższy kod do 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 do aplikacji, możesz skorzystać z obsługi systemu plikó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 natomiast kod w C/C++ używa plików, obsługa systemu plików zostanie dołączona automatycznie.

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ć nowo utworzone pliki do folderu mkbitmap.

Zmień index.html tak, aby ładował tylko moduł script.js ES, 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 otworzysz aplikację teraz w przeglądarce, powinien być widoczny obiekt Module zapisany w konsoli Narzędzi deweloperskich. Prośba zniknęła, ponieważ funkcja main() w tabeli mkbitmap nie jest już wywoływana na początku.

Aplikacja mkbitmap z białym ekranem z obiektem Module zarejestrowanym 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ń użyjesz polecenia mkbitmap -v, wywołasz w przeglądarce 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 z numerem wersji mkbitmap zapisanym w konsoli Narzędzi deweloperskich.

Przekieruj standardowe dane wyjściowe

Standardowe wyjście (stdout) to domyślnie konsola. Możesz go jednak przekierować do innej funkcji, np. do funkcji, 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.

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

Aby w pamięci utworzyć system plików, musisz użyć odpowiednika polecenia mkbitmap filename w wierszu poleceń. Aby zrozumieć, co do tego podejmuję, najpierw przedstawię podstawowe informacje o tym, jak usługa 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 dla map bitowych i PGM dla map szarych. 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 usługa mkbitmap mogła odczytywać plik wejściowy tak, jakby był przekazywany jako argument wiersza poleceń filename, musisz użyć obiektu FS dostarczonego 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 metody Module.callMain(['-v']) dotyczące wydrukowania 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

Gdy wszystko jest gotowe, uruchom polecenie mkbitmap, uruchamiając polecenie 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 z systemem macOS z podglądem wejściowego pliku .bmp i wyjściowego pliku .pbm.

Dodaj interaktywny interfejs

Na tym etapie plik wejściowy jest zakodowany na stałe, a mkbitmap działa z parametrami domyślnymi. 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 przeanalizowania, 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ś skompilować 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. Powodzenia!

Podziękowania

Ten artykuł napisali Sam Clegg i Rachel Andrew.