💡 Полный рабочий пример доступен на 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 поставляет 6 файлов шрифтов 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}"

Метод 1 — Жёстко задать имя семейства

Версия, которую пишет каждый в первый раз: выбрать Arial, включить её и идти дальше. На машине разработчика всё работает, а при первом запуске в контейнере — нет, потому что в образах Debian Arial не устанавливается; вместо него ставится Liberation Sans, совместимый по метрикам, но под другим именем семейства.

Кода, который стоило бы показать, здесь нет, и в этом суть. Содержимое метода — просто строковый литерал, который оказывается верным только в одной среде.

Метод 2 — Обнаружить шрифты в файловой системе

Естественное решение: просканировать каталоги шрифтов, посмотреть, что есть, и выбрать что‑то. Половина полезна — инвентарь показывает, есть ли в образе 0 шрифтов или 6:

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

Вторая половина не работает. Файлы шрифтов редко содержат строку семейства, которую должен передать вызывающий: пакет Debian fonts-noto-cjk ставит NotoSansCJK-Regular.ttc, семейство которого — Noto Sans CJK JP. Вывод семейства из имени файла даст NotoSansCJK-Regular, которое не разрешается. Обнаружение по имени файла одновременно пропускает присутствующие шрифты и уверенно сообщает семейства, которые всё равно приведут к ошибке.

Сохраняйте инвентарь как диагностический инструмент. Не используйте его для выбора. Количество отвечает на вопрос, был ли образ вообще подготовлен, что является отдельным и столь же полезным вопросом.

Метод 3 — Спросить библиотеку

Сделайте «одноразовую» подпись для каждой кандидатской фамилии и оставьте первую, которая не бросит исключение. Это требует одной записи 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 выводят одинаковые логи. Я потратил больше времени, чем хотел бы признать, сравнивая два контейнера, которые печатали одну и ту же ошибку по совершенно разным причинам, прежде чем добавить регулярное выражение.

Сколько стоит проверка

Возражение против проверки в том, что она пишет файлы, и действительно пишет: один небольшой PDF на кандидат, удаляемый сразу же. В примере латинский список содержит четыре элемента, а CJK‑список — восемь, так что при холодном старте записывается максимум двенадцать одностраничных документов во временный каталог до того, как сервис будет готов.

Это затраты на запуск, а не на каждый запрос, и они дают строку лога с именами обеих найденных семейств. По сравнению с контейнером, который стартует без проблем, а затем падает на первом клиентском документе с ошибкой моста, двенадцать временных файлов — не слишком тяжёлая плата.

Сравнение методов: когда использовать каждый

Метод Лучшее применение Ключевые преимущества Ограничения
Жёстко заданное семейство единая контролируемая среда тривиально, без затрат на запуск ломается в любом образе, где нет именно этого семейства
Обнаружение по имени файла диагностика содержимого образа быстро, без вызовов подписи имена файлов не являются именами семейств, поэтому полученные варианты не работают
Проба библиотеки любой контейнеризованный или переносимый сценарий авторитетно, работает и на ноутбуке, и в образе запись одного PDF на кандидат, поэтому лучше выполнять при старте и кэшировать результат

Две особенности привязки, о которых стоит знать

Как только семейство найдено, сам вызов подписи имеет форму, специфичную для Node. Java‑API принимает список опций, но массив JavaScript не маршалится в java.util.List, поэтому передача его приводит к Could not find method "sign(java.lang.String, [Ljava.lang.Object;)". Обходной путь — вызвать перегрузку с единственной опцией и промежуточно записать в временный файл:

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 поднимает ту же общую ошибку моста, поэтому пример возвращает заглушку и выводит 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‑исключение из стека и честно сообщайте об отсутствии проверки, а не скрывайте её. Примерный репозиторий собирает оба образа, так что каждое утверждение здесь можно проверить двумя командами.

Дополнительные ресурсы