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

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

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

מידע על mkbitmap

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

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

קל מאוד לבנות פרויקטים גדולים באמצעות Emscripten. Emscripten מספק שני סקריפטים פשוטים שמגדירים את קובצי ה-Makefile לשימוש ב-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 ויוצרים קובץ boilerplate של 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. בקטע Include File System Support (כולל תמיכה במערכת קבצים) של מסמכי התיעוד כתוב:

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

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

בשלב הבא, מפעילים את הפונקציה Module.callMain() כדי להפעיל ידנית את הפונקציה main() של mkbitmap. הפונקציה 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 למיפוי סיביות ב-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.bmp.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();

האפליקציה 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. הידור שמח!

אישורים

המאמר הזה נבדק על ידי סם קלג ורייצ'ל אנדרו.