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

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

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

מידע על mkbitmap

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

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

כשפועלים לפי השלבים האלה, מקבלים שני קובצי הפעלה, 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. במסמכי התיעוד של Emscripten Building Projects מפורטים הפרטים הבאים:

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

לאחר מכן התיעוד ממשיך (מעט ערוך לשם קיצור):

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

./configure
make

כדי לפתח באמצעות 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 ויוצרים קובץ סטנדרטי של index.html ל-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 עם מסך לבן שמציג את אובייקט המודול שנרשם במסוף כלי הפיתוח.

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

השלב הבא הוא לקרוא באופן ידני לפונקציה 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 ביומן, שנרשמה במסוף כלי הפיתוח.

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

הפלט הסטנדרטי (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 למפות סיביות ו-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 של הקלט וקובץ הפלט.

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

בשלב הזה, קובץ הקלט הוא בתוך הקוד ו-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.