💡 Full working example available on GitHub:
nodejs-docker-signing-with-fonts

مقدمه

حل مشکل فونت بخشی از امضای کانتینر است که تصمیم می‌گیرد سرویس Node شما اسناد را تولید کند یا استثناها را پرتاب کند. GroupDocs.Signature جایگزین یک خانواده‌ی گمشده نمی‌شود: اگر نام خانواده‌ای که تصویر ندارد را بدهید، فراخوانی خطا می‌دهد و هیچ‌چیزی نمی‌نویسد. پاک‌سازی فونت نیز راه‌حل نیست، چون کتابخانه سپس سعی می‌کند از پیش‌فرض خودش استفاده کند و به همان شکل شکست می‌خورد.

سه روش برای تصمیم‌گیری درباره‌ی نام خانواده وجود دارد و تنها یکی از آن‌ها در یک کانتینر زنده می‌ماند. این مقاله این روش‌ها را مقایسه می‌کند، سپس به تهیه‌ی پیش‌نیازها و رفتار بایندینگ که کد را شکل می‌دهد می‌پردازد، زیرا Node.js از طریق Java نسبت به سایر پلتفرم‌هایی که این کتابخانه روی آن‌ها عرضه می‌شود، بیشتر تحت تأثیر هر دو قرار دارد.

چرا این موضوع در Node.js مهم‌تر است

این بسته یک پل است: node-java یک JVM را درون پردازش بارگذاری می‌کند. بنابراین یک تصویر امضای Node به یک JDK، ابزار زنجیره‌ای node-gyp برای ساخت پل، و LD_LIBRARY_PATH که به libjvm.so اشاره دارد، نیاز دارد؛ همه این‌ها قبل از اینکه فونت‌ها مهم شوند. node:18-bookworm سپس ۶ فایل فونت DejaVu برای AWT فراهم می‌کند – برای لاتین کافی است، اما برای CJK هیچ‌چیزی نیست.

این ترکیب باعث شکست‌هایی می‌شود که شبیه باگ‌های برنامه به نظر می‌رسند. مسیر JVM گمشده، فونت گمشده و عدم تطابق مارشالینگ همگی به صورت Error running instance method ظاهر می‌شوند، زیرا این همان پیغامی است که node-java برای هر چیزی که در سمت Java پرتاب شود، گزارش می‌دهد.

پیش‌نیازها

Node 18 – پل بر پایه NAN ساخته می‌شود که با V8 موجود در Node 20 یا 22 سازگار نیست ('AccessorSignature' is not a member of 'v8'). JDK 8 تا 17: در JDK 25 لایه‌ی تصویری با خطای Cannot open an image. The image size can not be 0! شکست می‌خورد.

نصب

npm install @groupdocs/groupdocs.signature

در تصویر، این نصب به build-essential و python3 نیاز دارد، به‌علاوه openjdk-17-jdk-headless و مسیر لودر:

ENV JAVA_HOME=/usr/lib/jvm/java-17-openjdk-amd64
ENV PATH="${JAVA_HOME}/bin:${PATH}"
# node-java dlopens libjvm.so at run time; it is not on the default loader path.
ENV LD_LIBRARY_PATH="${JAVA_HOME}/lib/server:${LD_LIBRARY_PATH}"

روش ۱ – کدنویسی ثابت نام خانواده

نسخه‌ای که همه ابتدا می‌نویسند: Arial را انتخاب کنید، آن را بفرستید و ادامه دهید. این روش روی ماشین توسعه کار می‌کند اما در اولین اجرای کانتینر شکست می‌خورد، چون تصاویر Debian Arial را نصب نمی‌کنند – آنها Liberation Sans را نصب می‌کنند که از نظر متریک سازگار است اما نام خانواده متفاوتی دارد.

کدی برای نشان دادن اینجا وجود ندارد، که همان نکته است. محتوای کامل این روش فقط یک رشته‌ی ثابت است که در یک محیط درست است.

روش ۲ – شناسایی فونت‌ها از سیستم‌فایل

راه‌حل طبیعی: دایرکتوری‌های فونت را اسکن کنید، ببینید چه چیزی وجود دارد و چیزی را انتخاب کنید. نیمی از این کار واقعاً مفید است – موجودی به شما می‌گوید آیا تصویر ۰ فونت یا ۶ فونت دارد:

const roots = [
  '/usr/share/fonts',
  '/usr/local/share/fonts',
  path.join(home, '.fonts'),
  path.join(home, '.local', 'share', 'fonts'),
  '/System/Library/Fonts',
  '/Library/Fonts',
];

نیمه‌ی دیگر کار نمی‌کند. فایل‌های فونت به ندرت رشته‌ی خانواده‌ای که فراخوان باید پاس بدهد را حمل می‌کنند: بسته‌ی fonts-noto-cjk در Debian فایل NotoSansCJK-Regular.ttc را نصب می‌کند که خانواده‌اش Noto Sans CJK JP است. استخراج خانواده از نام فایل به شما NotoSansCJK-Regular می‌دهد که به هیچ‌چیزی حل نمی‌شود. تشخیص بر پایه نام فایل هم فونت‌های موجود را از دست می‌دهد و هم به‌اطمینان خانواده‌هایی را گزارش می‌کند که شکست می‌خورند.

موجودی را به‌عنوان یک ابزار تشخیصی نگه دارید. از آن برای انتخاب استفاده نکنید. شمارش پاسخ می‌دهد که آیا تصویر اصلاً تهیه شده است یا نه، که سؤال متفاوت و به همان اندازه مفیدی است.

روش ۳ – پرسیدن از کتابخانه

یک امضای آزمایشی برای هر خانواده‌ی کاندید بسازید و اولین موردی که خطا نمی‌دهد را نگه دارید. این کار به ازای هر کاندید یک نوشتن PDF هزینه دارد و تنها روشی است که پاسخ آن معتبر است، زیرا همان فراخوانی است که امضای واقعی انجام می‌دهد.

for (const candidate of candidates) {
  if (tryFamily(sourcePath, candidate) === null) {
    return candidate;
  }
}
return null;

در Node این آزمون به یک قطعه‌ی اضافی نیاز دارد. node-java تمام استثناهای Java را به Error running instance method تبدیل می‌کند، بنابراین پیام واقعی باید از استک‌تریس بسته‌بندی‌شده استخراج شود:

const stack = err.stack || '';
const match = stack.match(/com\.groupdocs\.signature\.exception\.[^\n]*/);
return match ? match[0].trim() : (err.message || String(err));

بدون این دو خط، یک کانتینر بدون فونت و یک مسیر JVM خراب لاگ‌های یکسانی تولید می‌کنند. من زمان بیشتری را صرف مقایسه دو کانتینری که همان خطا را به دلایل کاملاً متفاوت چاپ می‌کردند، کردم تا این regex را اضافه کنم.

هزینه‌ی این آزمون

اعتراض به آزمون این است که فایل می‌نویسد، و واقعاً می‌نویسد: به ازای هر کاندید یک PDF کوچک، که بلافاصله حذف می‌شود. لیست لاتین در نمونه چهار ورودی دارد و لیست CJK هشت ورودی، بنابراین یک استارت سرد حداکثر دوازده سند تک‌صفحه‌ای را در دایرکتوری موقت می‌نویسد قبل از اینکه سرویس آماده شود.

این یک هزینه‌ی راه‌اندازی است، نه هزینه‌ی هر درخواست، و یک خط لاگ که هر دو خانواده‌ی حل‌شده را نام می‌برد، به‌دست می‌دهد. در مقایسه با یک کانتینری که به‌صورت تمیز شروع می‌شود و سپس در اولین سند مشتری با خطای پل شکست می‌خورد، دوازده فایل موقت هزینه‌ای دشوار نیست.

مقایسه روش‌ها: چه زمانی از کدام استفاده کنیم

روش مناسب‌ترین حالت مزایای کلیدی محدودیت‌ها
نام خانواده ثابت یک محیط کنترل‌شده‌ی تک‌منظوره ساده، بدون هزینه‌ی استارتاپ در هر تصویری که آن خانواده دقیق را نداشته باشد، خراب می‌شود
تشخیص بر پایه نام فایل تشخیص محتوای یک تصویر سریع، بدون فراخوانی امضا نام فایل‌ها نام خانواده نیستند، بنابراین انتخاب‌های استخراج‌شده از آن شکست می‌خورند
آزمون کتابخانه هر چیزی که کانتینری یا قابل حمل باشد معتبر، روی لپ‌تاپ و تصویر به‌یک‌سان کار می‌کند به ازای هر کاندید یک نوشتن PDF، بنابراین در استارتاپ حل کنید و کش کنید

دو نکته‌ی بایندینگ که باید بدانید

پس از حل یک خانواده، فراخوانی امضا خود شکل خاصی در Node دارد. API جاوا یک لیست از گزینه‌ها می‌گیرد، اما یک آرایه‌ی JavaScript به java.util.List مارشال نمی‌شود، بنابراین پاس دادن یک آرایه منجر به Could not find method "sign(java.lang.String, [Ljava.lang.Object;)" می‌شود. راه‌حل این است که overload تک‑گزینه‌ای را زنجیره کنید و از طریق یک فایل موقت مرحله‌بندی کنید:

new signatureLib.Signature(sourcePath)
  .sign(firstOutput, buildTextOptions(LATIN_TEXT, latinFamily, 50));

if (stageTwo) {
  new signatureLib.Signature(firstOutput)
    .sign(outputPath, buildTextOptions(CJK_TEXT, cjkFamily, 120));
}

نکته‌ی دوم خواندن‑باز است. TextVerifyOptions از این بایندینگ عبور نمی‌کند: verify همان خطای عمومی پل را برمی‌انگیزد، بنابراین نمونه یک sentinel برمی‌گرداند و unavailable چاپ می‌کند به‌جای اینکه وانمود کند امضا شکست خورده است. بسته npm نسخه‌ی 24.12.0 است که در دسامبر 2024 منتشر شده و یک موتور 23.6.1 را باندل می‌کند، در حالی که .NET در نسخه‌ی 26.6 و Java در 26.5 هستند. امضا تحت تأثیر قرار نمی‌گیرد؛ فقط مسیر تأیید موجود نیست.

آیا هنوز باید بایندینگ Node.js را در محیط تولید استفاده کنم؟

برای امضای فقط لاتین، بله: به‌درستی امضا می‌کند و یک فونت گمشده خطا می‌دهد به‌جای اینکه به‌صورت ساکت کاهش کیفیت دهد، بنابراین حالت شکست واضح است. برای کارهای ترکیبی اسکریپت، فقدان خواندن‑باز را در نظر بگیرید، چون هیچ‌چیزی در فرآیند نمی‌تواند سپس تأیید کند که گلیف‌های CJK به‌درستی جاسازی شده‌اند یا به‌صورت جعبه نمایش داده می‌شوند. یک تأیید‌کننده کوچک در .NET یا Java در همان خط لوله این خلأ را پوشش می‌دهد.

بهترین شیوه‌ها و نکات

  • به ترتیب تهیه کنید: JDK و ابزار زنجیره‌ای، مسیر لودر، فونت‌ها، سپس برنامه. هر لایه به‌صورت متفاوتی شکست می‌خورد و ترکیب آن‌ها تشخیص را کند می‌کند.
  • خانواده‌ها را یک‌بار در استارتاپ حل کنید و آن‌ها را در کنار شمارش فونت‌ها لاگ کنید.
  • Node 18 و JDK بین 8 تا 17 را ثابت نگه دارید و هر دو را به‌عنوان زیرساخت ثابت در نظر بگیرید نه به‌عنوان به‌روزرسانی‌های روتین.
  • Dockerfile بدون فونت را در مخزن نگه دارید، تا شکست همیشه یک بیلد دور باشد.

نتیجه‌گیری

سه روش برای انتخاب فونت وجود دارد، تنها یکی از آن‌ها پس از استقرار زنده می‌ماند. کتابخانه را آزمون کنید، پاسخ را کش کنید و موجودی را به‌عنوان ابزار تشخیصی نه تصمیم‌گیری استفاده کنید. سپس با بایندینگ همان‌طور که هست کار کنید: یک گزینه را در هر بار امضا کنید، استثناهای Java را از استک‌تریس استخراج کنید و عدم وجود تأیید را صادقانه گزارش دهید نه اینکه پنهان کنید. مخزن نمونه هر دو تصویر را می‌سازد، بنابراین هر ادعایی که در اینجا آمده را می‌توانید با دو فرمان بررسی کنید.

منابع اضافی