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

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

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

מידע על mkbitmap

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

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

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

קבל את הקוד

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

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

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

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

// 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 למיפוי סיביות ב-bit, ו-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.