מתבצע עיבוד של מיפוי ה-mkbitmap ל-WebAssembly

במאמר מהו WebAssembly ומאיפה הוא הגיע? הסברתי איך הגענו ל-WebAssembly של היום. במאמר הזה אציג את הגישה שלי ל-compilation של תוכנית C קיימת, mkbitmap, ל-WebAssembly. הדוגמה הזו מורכבת יותר מהדוגמה של hello world, כי היא כוללת עבודה עם קבצים, תקשורת בין עולמות WebAssembly ו-JavaScript ורישום על לוח, אבל היא עדיין ניתנת לניהול ולא תהיה מרתיעה מדי.

המאמר מיועד למפתחי אתרים שרוצים ללמוד על WebAssembly, ומוסבר בו איך אפשר להמשיך אם רוצים לקמפל משהו כמו mkbitmap ל-WebAssembly. חשוב לדעת: לא תמיד אפשר לקמפל אפליקציה או ספרייה בפעם הראשונה. לכן, חלק מהשלבים שמפורטים בהמשך לא עבדו, ולכן נאלצתי לחזור אחורה ולנסות שוב בצורה שונה. בכתבה לא מופיעה פקודה קסומה סופית ל-compilation, כאילו היא נפלה מהשמיים, אלא מתוארת ההתקדמות בפועל שלי, כולל כמה תסכולים.

תוכנית ה-C‏ mkbitmap קוראת תמונה ומחילה עליה אחת או יותר מהפעולות הבאות, לפי הסדר הזה: היפוך, סינון מסנן מסנן גבוה, שינוי קנה מידה וערך סף. אפשר לשלוט בכל פעולה בנפרד ולהפעיל או להשבית אותה. השימוש העיקרי ב-mkbitmap הוא להמיר תמונות צבעוניות או בגווני אפור לפורמט שמתאים כקלט לתוכנות אחרות, במיוחד לתוכנת המעקב potrace שמהווה את הבסיס של SVGcode. ככלי לעיבוד מקדים, הפונקציה mkbitmap שימושית במיוחד להמרת גרפיקה של קווים סרוקים, כמו קריקטורות או טקסט בכתב יד, לתמונות דו-רמות ברזולוציה גבוהה.

כדי להשתמש ב-mkbitmap, מעבירים לו מספר אפשרויות ושמות של קובץ אחד או יותר. כל הפרטים מפורטים בדף העזרה של הכלי:

$ mkbitmap [options] [filename...]
תמונה של קריקטורה בצבע.
התמונה המקורית (מקור).
תמונה של קריקטורה שהומרה לגווני אפור לאחר עיבוד מקדים.
תחילה שינוי קנה המידה, ואז הגדרת ערך סף: mkbitmap -f 2 -s 2 -t 0.48 (מקור).

קבל את הקוד

השלב הראשון הוא לקבל את קוד המקור של mkbitmap. אפשר למצוא אותו באתר של הפרויקט. נכון למועד כתיבת המאמר, potrace-1.16.tar.gz היא הגרסה האחרונה.

הידור והתקנה באופן מקומי

השלב הבא הוא להדר את הכלי ולהתקין אותו באופן מקומי כדי לקבל מושג איך הוא פועל. הקובץ INSTALL מכיל את ההוראות הבאות:

  1. cd לתיקייה שמכילה את קוד המקור של החבילה, ומקלידים ./configure כדי להגדיר את החבילה למערכת.

    הפעלת configure עשויה להימשך זמן מה. במהלך ההרצה, התוכנית מדפיסה כמה הודעות שמציינות אילו תכונות היא בודקת.

  2. מקלידים make כדי לקמפל את החבילה.

  3. אפשר גם להקליד make check כדי להריץ את כל הבדיקות העצמיות שמגיעות עם החבילה, בדרך כלל באמצעות קובצי ה-binary שזה עתה נוצרו ולא הותקנו.

  4. מקלידים make install כדי להתקין את התוכנות ואת קובצי הנתונים והמסמכים. כשמתקינים תיקייה עם קידומת שבבעלות root, מומלץ להגדיר ולבנות את החבילה כמשתמש רגיל, ורק את השלב make install להריץ עם הרשאות root.

בסיום ביצוע השלבים האלה, אמורים להיווצר שני קובצי הפעלה, potrace ו-mkbitmap – הקובץ השני הוא מוקד המאמר הזה. כדי לוודא שהיא פועלת כמו שצריך, מריצים את הפקודה mkbitmap --version. זהו הפלט של כל ארבעת השלבים מהמכונה שלי, קצוב מאוד כדי לקצר:

שלב 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

שלב 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'.

שלב 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'.

שלב 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'.

כדי לבדוק אם זה עבד, מריצים את mkbitmap --version:

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

אם מופיעים פרטי הגרסה, סימן שהקמפלקציה וההתקנה של mkbitmap בוצעו בהצלחה. בשלב הבא, מבצעים את הפעולות המקבילות לשלבים האלה עם WebAssembly.

הידור mkbitmap ל-WebAssembly

Emscripten הוא כלי לעיבוד תוכניות C/C++ ל-WebAssembly. במסמך Building Projects של Emscripten כתוב:

קל מאוד ליצור פרויקטים גדולים באמצעות Emscripten. ב-Emscripten יש שני סקריפטים פשוטים שמגדירים את קובצי ה-makefiles כך שישתמשו ב-emcc כתחליף ל-gcc. ברוב המקרים, שאר מערכת ה-build הנוכחית של הפרויקט לא משתנה.

המסמך ממשיך (קצת עריכה לצורך קיצור):

נניח שבדרך כלל אתם מבצעים build באמצעות הפקודות הבאות:

./configure
make

כדי לבצע build באמצעות Emscripten, צריך להשתמש במקום זאת בפקודות הבאות:

emconfigure ./configure
emmake make

כלומר, ./configure הופך ל-emconfigure ./configure ו-make הופך ל-emmake make. בדוגמה הבאה מוסבר איך לעשות זאת באמצעות mkbitmap.

שלב 0, make clean:

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

שלב 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

שלב 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'.

אם הכל הלך כשורה, עכשיו אמורים להיות קבצים מסוג .wasm בספרייה. אפשר למצוא אותם על ידי הפעלת find . -name "*.wasm":

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

שתי הגרסאות האחרונות נראות מבטיחות, אז מעבירים את cd לספרייה src/. עכשיו יש גם שני קבצים תואמים חדשים, mkbitmap ו-potrace. במאמר הזה, רק mkbitmap רלוונטי. העובדה שאין להם את הסיומת .js קצת מבלבלת, אבל הם למעשה קובצי JavaScript, שאפשר לאמת באמצעות קריאה מהירה ל-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)

משנים את שם קובץ ה-JavaScript ל-mkbitmap.js באמצעות קריאה ל-mv mkbitmap mkbitmap.js (ול-mv potrace potrace.js בהתאמה, אם רוצים). עכשיו הגיע הזמן לבצע את הבדיקה הראשונה כדי לראות אם הקוד פועל. כדי לעשות זאת, מריצים את הקובץ עם Node.js בשורת הפקודה באמצעות הפקודה node mkbitmap.js --version:

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

קובץ mkbitmap עבר הידור ל-WebAssembly. השלב הבא הוא לגרום לזה לפעול בדפדפן.

mkbitmap עם WebAssembly בדפדפן

מעתיקים את הקבצים mkbitmap.js ו-mkbitmap.wasm לספרייה חדשה בשם mkbitmap ויוצרים קובץ HTML index.html שמטעין את קובץ ה-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>

מפעילים שרת מקומי שמציג את הספרייה mkbitmap ופותחים אותו בדפדפן. אמורה להופיע הודעה שבה תתבקשו להזין מידע. זה צפוי, כי לפי דף העזרה של הכלי, "[i]f no filename arguments are given, then mkbitmap acts as a filter, reading from standard input" (אם לא צוינו ארגומנטים של שמות קבצים, mkbitmap פועל כמסנן וקורא מהקלט הרגיל), שב-Emscripten הוא prompt() כברירת מחדל.

אפליקציית mkbitmap עם הנחיה לבקשת קלט.

מניעת ביצוע אוטומטי

כדי להפסיק את הביצוע המיידי של mkbitmap ולגרום לו להמתין לקלט מהמשתמש, צריך להבין את האובייקט Module של Emscripten. Module הוא אובייקט JavaScript גלובלי עם מאפיינים שקוד שנוצר על ידי Emscripten קורא אליהם בנקודות שונות במהלך הביצוע שלו. אפשר לספק הטמעה של Module כדי לשלוט בהרצת הקוד. כשאפליקציית Emscripten מופעלת, היא בודקת את הערכים באובייקט Module ומחילה אותם.

במקרה של mkbitmap, מגדירים את Module.noInitialRun לערך true כדי למנוע את ההפעלה הראשונית שגרמה להצגת ההנחיה. יוצרים סקריפט בשם script.js, כוללים אותו לפני ה-<script src="mkbitmap.js"></script> ב-index.html ומוסיפים את הקוד הבא ל-script.js. כשתטעינו מחדש את האפליקציה, ההנחיה אמורה להיעלם.

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

יצירת גרסה מודולרית של build עם עוד כמה דגלים של build

כדי לספק קלט לאפליקציה, אפשר להשתמש בתמיכה של Emscripten במערכת קבצים ב-Module.FS. בקטע הכללת תמיכה במערכת קבצים במסמכי התיעוד כתוב:

המערכת של Emscripten מחליטה אם לכלול תמיכה במערכת קבצים באופן אוטומטי. לתוכנות רבות אין צורך בקבצים, ותמיכה במערכת קבצים היא לא קטנה, לכן Emscripten נמנעת מלצרף אותה אם אין סיבה לעשות זאת. כלומר, אם קוד ה-C/C++ לא ניגש לקבצים, האובייקט FS ו-API אחרים של מערכת הקבצים לא ייכללו בפלט. לעומת זאת, אם קוד ה-C/C++ שלכם משתמש בקבצים, תמיכה במערכת קבצים תכלול באופן אוטומטי.

לצערנו, mkbitmap הוא אחד מהמקרים שבהם Emscripten לא כולל תמיכה במערכת קבצים באופן אוטומטי, ולכן צריך להורות לו לעשות זאת באופן מפורש. כלומר, צריך לפעול לפי השלבים emconfigure ו-emmake שתוארו למעלה, עם עוד כמה דגלים שמוגדרים באמצעות ארגומנט CFLAGS. הדגלים הבאים יכולים להיות שימושיים גם בפרויקטים אחרים.

בנוסף, במקרה הזה צריך להגדיר את הדגל --host לערך wasm32 כדי להודיע לסקריפט configure שאתם מבצעים הידור ל-WebAssembly.

הפקודה הסופית של emconfigure נראית כך:

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

אל תשכחו להריץ שוב את emmake make ולהעתיק את הקבצים החדשים שנוצרו לתיקייה mkbitmap.

משנים את index.html כך שיטען רק את מודול ES‏ script.js, שממנו מייבאים את המודול 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();

כשתפתחו את האפליקציה עכשיו בדפדפן, אובייקט Module אמור להופיע ביומן של מסוף DevTools וההנחיה תיעלם, כי הפונקציה main() של mkbitmap לא נקראת יותר בהתחלה.

אפליקציית mkbitmap עם מסך לבן, שבו מוצג אובייקט המודול שרשום ביומן במסוף DevTools.

הפעלה ידנית של הפונקציה הראשית

השלב הבא הוא להפעיל באופן ידני את הפונקציה main() של mkbitmap על ידי הפעלת Module.callMain(). הפונקציה callMain() מקבלת מערך של ארגומנטים, שתואמים אחד-אחת למה שתעבירו בשורת הפקודה. אם בשורת הפקודה מריצים את הפקודה mkbitmap -v, בדפדפן צריך להפעיל את הפקודה Module.callMain(['-v']). הפקודה הזו מתעדת את מספר הגרסה של mkbitmap במסוף DevTools.

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

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

run();

אפליקציית mkbitmap עם מסך לבן, שבו מוצג מספר הגרסה של mkbitmap שרשום ביומן במסוף DevTools.

הפניה אוטומטית של הפלט הרגיל

כברירת מחדל, הפלט הסטנדרטי (stdout) הוא מסוף. עם זאת, אפשר להפנות אותו למשהו אחר, למשל פונקציה ששומרת את הפלט במשתנה. המשמעות היא שאפשר להוסיף את הפלט ל-HTML על ידי הגדרת המאפיין 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();

אפליקציית mkbitmap שמוצג בה מספר הגרסה של mkbitmap.

העברת קובץ הקלט למערכת הקבצים בזיכרון

כדי להעביר את קובץ הקלט למערכת הקבצים של הזיכרון, צריך להשתמש בפקודה המקבילה ל-mkbitmap filename בשורת הפקודה. כדי להבין את הגישה שלי לבעיה, קודם אציג קצת רקע על האופן שבו mkbitmap מצפה לקלט ויוצר את הפלט שלו.

פורמטים נתמכים של קלט של mkbitmap הם PNM (PBM, ‏ PGM, ‏ PPM) ו-BMP. פורמטים הפלט הם PBM לקובצי bitmap ו-PGM למפות אפור. אם מציינים את הארגומנט filename, הפונקציה mkbitmap תיצור כברירת מחדל קובץ פלט שהשם שלו נגזר משם קובץ הקלט, על ידי שינוי הסיומת שלו ל-.pbm. לדוגמה, אם שם קובץ הקלט הוא example.bmp, שם קובץ הפלט יהיה example.pbm.

Emscripten מספקת מערכת קבצים וירטואלית שמחקה את מערכת הקבצים המקומית, כך שאפשר לקמפל ולהריץ קוד מקומי שמשתמש בממשקי API של קבצים סינכרוניים עם מעט שינויים או ללא שינויים כלל. כדי ש-mkbitmap יקרא קובץ קלט כאילו הוא הועבר כארגומנטים של שורת הפקודה של filename, צריך להשתמש באובייקט FS ש-Emscripten מספק.

האובייקט FS מגובה על ידי מערכת קבצים בזיכרון (שנקראת בדרך כלל MEMFS), ויש לו פונקציה writeFile() שמשמשת לכתיבה של קבצים במערכת הקבצים הווירטואלית. משתמשים ב-writeFile() כפי שמתואר בדוגמת הקוד הבאה.

כדי לוודא שפעולת הכתיבה לקובץ בוצעה, מריצים את הפונקציה readdir() של האובייקט FS עם הפרמטר '/'. יופיעו example.bmp ומספר קבצים שמוגדרים כברירת מחדל ותמיד נוצרים באופן אוטומטי.

שימו לב שהקריאה הקודמת ל-Module.callMain(['-v']) להדפסת מספר הגרסה הוסרה. הסיבה לכך היא ש-Module.callMain() היא פונקציה שמצופה שתופעל רק פעם אחת.

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

אפליקציית mkbitmap שמוצגת בה מערך של קבצים במערכת הקבצים של הזיכרון, כולל example.bmp.

ההרצה בפועל הראשונה

אחרי שמסיימים את כל ההכנות, מריצים את mkbitmap באמצעות Module.callMain(['example.bmp']). מתעדים ביומן את התוכן של התיקייה '/' ב-MEMFS, וקובץ הפלט example.pbm שיצרתם צריך להופיע לצד קובץ הקלט 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();

אפליקציית mkbitmap שמוצגת בה מערך של קבצים במערכת הקבצים של הזיכרון, כולל example.bmp ו-example.pbm.

אחזור קובץ הפלט ממערכת הקבצים בזיכרון

הפונקציה readFile() של האובייקט FS מאפשרת לקבל את ה-example.pbm שנוצר בשלב האחרון ממערכת הקבצים בזיכרון. הפונקציה מחזירה Uint8Array שממירים לאובייקט File ושומרים בדיסק, כי בדרך כלל דפדפנים לא תומכים בקבצי PBM לצפייה ישירה בדפדפן. (יש דרכים אלגנטיות יותר לשמירה של קובץ, אבל השימוש ב-<a download> שנוצר באופן דינמי הוא הדרך הנתמכת ביותר). אחרי שמירת הקובץ, אפשר לפתוח אותו בתוכנת הצפייה בתמונות המועדפת עליכם.

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

macOS Finder עם תצוגה מקדימה של קובץ הקלט ‎ .bmp וקובץ הפלט ‎ .pbm.

הוספת ממשק משתמש אינטראקטיבי

עד כאן, קובץ הקלט מקודד בקוד והפקודה mkbitmap פועלת עם פרמטרים שמוגדרים כברירת מחדל. השלב האחרון הוא לאפשר למשתמש לבחור באופן דינמי קובץ קלט, לשנות את הפרמטרים של mkbitmap ואז להריץ את הכלי עם האפשרויות שנבחרו.

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

לא קשה במיוחד לנתח את פורמט התמונה PBM, כך שבעזרת קוד JavaScript תוכלו אפילו להציג תצוגה מקדימה של תמונת הפלט. דרך אחת לעשות זאת מפורטת בקוד המקור של הדמו המוטמע בהמשך.

סיכום

כל הכבוד, הצלחת לקמפל את mkbitmap ל-WebAssembly והוא פועל בדפדפן! היו כמה קצוות סתומים ונאלצתי להדריך את הכלי יותר מפעם אחת עד שהוא עבד, אבל כמו שכתבתי למעלה, זה חלק מהחוויה. אם תיתקלו בבעיה, תוכלו להיעזר גם בתג webassembly של StackOverflow. שתהיה לכם עבודה מהנה!

תודות

הבדיקה של המאמר בוצעה על ידי Sam Clegg ו-Rachel Andrew.