تحويل ملف mkbitmap إلى WebAssembly

في المقالة ما هو WebAssembly وما هو مصدره؟، شرحتُ كيف وصلنا إلى WebAssembly اليوم. في هذه المقالة، سأعرض لك طريقتي في تجميع برنامج C حالي، mkbitmap، إلى WebAssembly. وهو أكثر تعقيدًا من مثال hello world، إذ يتضمّن العمل مع الملفات والتواصل بين WebAssembly وJavaScript والرسم على لوحة الرسم، ولكنّه لا يزال بسيطًا بما يكفي لعدم إرباكك.

هذه المقالة موجّهة إلى مطوّري الويب الذين يريدون التعرّف على WebAssembly، وتوضّح بالتفصيل الخطوات التي يمكنك اتّخاذها إذا أردت تجميع رمز مثل mkbitmap إلى WebAssembly. أودّ أن أنبّهك إلى أنّ عدم إمكانية تجميع تطبيق أو مكتبة في المرة الأولى أمر طبيعي تمامًا، ولهذا السبب لم تنجح بعض الخطوات الموضّحة أدناه، لذا كان عليّ التراجع وإعادة المحاولة بطريقة مختلفة. لا تعرض المقالة الأمر النهائي المجمّع السحري كما لو أنّه سقط من السماء، بل تصف تقدّمي الفعلي، بما في ذلك بعض الإحباطات.

معلومات حول mkbitmap

يقرأ برنامج mkbitmap C صورة ويطبّق عليها عملية واحدة أو أكثر من العمليات التالية، بهذا الترتيب: العكس، والترشيح العالي التمرير، والتحجيم، والتحديد. يمكن التحكّم في كل عملية على حدة وتفعيلها أو إيقافها. ويُستخدم 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 لتثبيت البرامج وأي ملفات بيانات ومستندات. عند التثبيت في بادئة يملكها الجذر، يُنصح بتكوين الحزمة وإنشائها كمستخدم عادي، وتنفيذ مرحلة 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 ما يلي:

إنشاء مشاريع كبيرة باستخدام Emscripten سهل للغاية. توفّر Emscripten نصَين برمجيَين بسيطَين يضبطان ملفات makefile لاستخدام emcc كبديل جاهز للاستخدام بدلاً من gcc، وفي معظم الحالات، يظلّ باقي نظام الإصدار الحالي لمشروعك بدون تغيير.

تتابع المستندات بعد ذلك (مع تعديل بسيط للإيجاز):

لنفترض أنّك تنشئ عادةً باستخدام الأوامر التالية:

./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

يبدو أنّ آخر ملفين واعدان، لذا انتقِل إلى الدليل src/ باستخدام الأمر cd. يتوفّر الآن أيضًا ملفان جديدان متطابقان، 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.jsmv 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 وافتحه في المتصفّح. من المفترض أن تظهر لك رسالة تطلب منك إدخال معلومات. وهذا متوقّع، لأنّه وفقًا لصفحة الدليل الخاصة بالأداة، "إذا لم يتم تقديم وسيطات اسم الملف، ستعمل 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,
};

إنشاء بنية نموذجية باستخدام المزيد من علامات الإنشاء

لتوفير مدخلات للتطبيق، يمكنك استخدام ميزة نظام الملفات في Emscripten على Module.FS. يذكر قسم تضمين توافق نظام الملفات في المستندات ما يلي:

يحدّد Emscripten ما إذا كان سيتم تضمين إمكانية استخدام نظام الملفات تلقائيًا. لا تحتاج العديد من البرامج إلى ملفات، كما أنّ دعم نظام الملفات ليس صغير الحجم، لذا يتجنّب Emscripten تضمينه عندما لا يجد سببًا لذلك. وهذا يعني أنّه إذا كان رمز C/C++ لا يصل إلى الملفات، فلن يتم تضمين العنصر FS وواجهات برمجة التطبيقات الأخرى لنظام الملفات في الناتج. من ناحية أخرى، إذا كان رمز 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 مع شاشة بيضاء، يعرض كائن الوحدة الذي تم تسجيله في وحدة تحكّم &quot;أدوات مطوّري البرامج&quot;

تنفيذ الدالة الرئيسية يدويًا

الخطوة التالية هي استدعاء دالة 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 للصور النقطية وPGM للصور الرمادية. في حال توفير وسيطة filename، ستنشئ mkbitmap تلقائيًا ملف إخراج يتم الحصول على اسمه من اسم ملف الإدخال عن طريق تغيير لاحقته إلى .pbm. على سبيل المثال، إذا كان اسم ملف الإدخال هو example.bmp، سيكون اسم ملف الإخراج هو example.pbm.

توفّر Emscripten نظام ملفات افتراضيًا يحاكي نظام الملفات المحلي، ما يتيح تجميع الرمز البرمجي الأصلي الذي يستخدم واجهات برمجة تطبيقات الملفات المتزامنة وتشغيله بدون أي تغيير أو بتغييرات بسيطة. لكي يقرأ 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();

&quot;الباحث&quot; (Finder) على جهاز macOS يعرض معاينة لملف الإدخال ‎ .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 إذا واجهتك أي مشاكل. نتمنى لك تجربة ممتعة في تجميع الأغاني.

الإقرارات

راجع هذه المقالة سام كليغ وراشيل أندرو.