💡 Повний робочий приклад доступний на GitHub:
sign-pdf-in-linux-container-fonts-dotnet

Старий спосіб був болючим

Сервіс підписує рахунки. Він працює на ноутбуці з трьомастотами встановлених шрифтів, проходить рев’ю і в п’ятницю контейнеризується. У понеділок перше завдання в кластері завершується з кодом помилки, виводячи Sign document error: Font Arial was not found, і хтось проводить ранок, читаючи стек‑трейси, перш ніж хтось запитає, які шрифти дійсно містить образ mcr.microsoft.com/dotnet/runtime:8.0.

Відповідь — жодних. Нуль файлів шрифтів, виміряно в образі, у якому працює зразок цієї статті.

Варто знати, як інші середовища виконання порівнюються, бо помилка виглядає по‑різному в кожному. eclipse-temurin:17-jre містить 8 файлів DejaVu, а node:18-bookworm — 6, обидва для AWT, тому JVM і Node‑образи підписують латинський текст без проблем і падають лише коли з’являється японський або китайський рядок. python:3.11-slim не містить жодного, як і образ .NET runtime, тому він падає вже на першому підписі. Жоден з них не надає CJK безкоштовно.

Постачання шрифтів у контейнері — це крок, який робить підписання тексту можливим у Linux‑образі з GroupDocs.Signature for .NET. Це важливо, бо бібліотека не підмінює відсутню сімейство: вказання шрифту, який не встановлений, викликає помилку і не створює документ. У цій статті образ без шрифтів порівнюється з виправленим, показано, що змінилося, і розглянуто процес розв’язання під час виконання, який дозволяє одному й тому ж коду працювати на машині розробника.

Є кращий спосіб

Два умови мають бути виконані. Образ повинен містити принаймні один шрифт, а код — перестати припускати, який саме.

Перший — це шар Dockerfile. Другий — крок розв’язання: замість жорсткого кодування Arial запитати бібліотеку, який із кількох кандидатних сімейств вона може реально використати, і залишити перший, що працює. Результат працює без змін у тонкому контейнері, у Windows і в CI, бо ніколи не робить припущень про середовище, яке не було перевірено.

Один підхід, який не працює, і про який варто сказати відверто, бо це перше, що люди пробують: залишити шрифт незаданим. Без SignatureFont GroupDocs.Signature запитує свій власний шрифт за замовчуванням — Times New Roman, якого також немає в образі без шрифтів. Виклик провалюється ідентично.

Новий спосіб: два образи, одна різниця

Крок 1 — подивіться, що є в образі

Перед підписанням будь‑якого файлу перелічіть файли шрифтів. Підрахунок перетворює розпливчасте виключення на діагноз, бо нуль шрифтів і неправильна назва сімейства потребують різних виправлень:

string[] roots =
{
    "/usr/share/fonts",
    "/usr/local/share/fonts",
    Path.Combine(home, ".fonts"),
    Path.Combine(home, ".local/share/fonts"),
    Environment.GetFolderPath(Environment.SpecialFolder.Fonts),
    "/System/Library/Fonts",
    "/Library/Fonts",
};

Зверніть увагу, чого немає: System.Drawing. System.Drawing.Common доступний лише у Windows, починаючи з .NET 7, і викидає виключення в Linux, тому код шрифтів, побудований на ньому, не працює в контейнері з іншої причини.

Крок 2 — додайте шар шрифтів

Чотири пакети, один RUN, і помилка зникає:

RUN apt-get update && apt-get install -y --no-install-recommends \
        fontconfig \
        fonts-dejavu-core \
        fonts-liberation \
        fonts-noto-cjk \
    && fc-cache -f \
    && apt-get clean \
    && rm -rf /var/lib/apt/lists/*

fontconfig — це резолвер і надає fc-list для налагодження. fonts-dejavu-core — мінімум для латиниці, грецької та кирилиці. fonts-liberation забезпечує метрично сумісні замінники для Arial, Times New Roman і Courier New, які дійсно використовуються в документах, створених у Windows. fonts-noto-cjk покриває китайську, японську та корейську.

Крок 3 — розв’язуйте сімейство, а не називайте його

Портативний спосіб вибрати шрифт — спробувати «одноразовий» підпис для кожного кандидата і залишити перший, що не викидає виключення:

foreach (string candidate in candidates)
{
    if (TryFamily(sourcePath, candidate).Ok)
    {
        return candidate;
    }
}

return null;

Визначення за назвою файлу — це спокусливий, але неправильний скорочений шлях. Пакет Debian fonts-noto-cjk встановлює NotoSansCJK-Regular.ttc, сімейство якого називається Noto Sans CJK JP. Порівняння за назвою файлу пропускає шрифти, які присутні, і вказує сімейства, які не розв’язуються при передачі в SignatureFont.

Крок 4 — підписуйте те, що розв’язано, перевіряйте те, що підписали

Потрібне розв’язане латинське сімейство; розв’язане CJK‑сімейство — необов’язкове, і його відсутність означає пропуск, а не крах:

var options = new List<SignOptions>
{
    BuildTextOptions(LatinText, latinFamily, top: 50),
};

if (cjkFamily is not null)
{
    options.Add(BuildTextOptions(CjkText, cjkFamily, top: 120));
}

SignResult result = signature.Sign(outputPath, options);

Потім зчитайте файл назад, бо CJK без CJK‑шрифту може відображатися як порожні квадратики без жодного повідомлення про помилку:

var options = new TextSearchOptions { AllPages = true };
List<TextSignature> found = signature.Search<TextSignature>(options);

Порівняння: до і після

Dockerfile.nofonts Dockerfile
Файли шрифтів в образі 0 DejaVu, Liberation, Noto CJK
Підпис латинським текстом помилка, код 3 записано і успішно прочитано
Підпис CJK‑текстом помилка записано і успішно прочитано
Відображена помилка Font <name> was not found немає
Відмінність у коді немає — той самий бінарник немає — той самий бінарник

Останній рядок і є суттю. Ніщо в застосунку не змінилося між двома запусками. Репозиторій‑зразок постачає обидва файли, тому порівняння виконується двома командами docker build, а не наосліп. Залиште варіант без шрифтів у репозиторії й надалі: це найшвидший спосіб відтворити помилку, коли хтось через шість місяців змінить базовий образ, і підписи тихо перестануть з’являтися.

Чому не встановлювати всі шрифти?

Тому що розмір образу — реальне обмеження, а чотири пакети, зазначені вище, вже покривають скрипти, які використовують більшість документів. fonts-dejavu-core самостійно достатній для підпису латиниці, грецької та кирилиці; Liberation потрібен, коли документи посилаються на сімейства Windows за назвою; Noto CJK — це великий пакет, який оплачує себе лише тоді, коли ви підписуєте східно‑азійський текст. Встановлюйте лише те, що потрібне вашим документам, а потім перевіряйте за допомогою зворотного читання.

Реальний приклад: пакетний підписувач

Черга‑робітник підписує кілька тисяч PDF‑файлів за ніч. Завдяки розв’язуванню під час старту він записує один рядок з назвами сімейств, які буде використовувати, і якщо нічого не розв’язується, завершує роботу ще до того, як торкнеться черги, замість того, щоб падати на кожному повідомленні. Ця перевірка під час запуску перетворює проблему шрифту з потоку невдалих завдань у контейнер, який відмовляється стартувати з однорядковою причиною.

Вартість пробування достатньо мала, щоб її ігнорувати під час старту, і занадто велика, щоб повторювати для кожного документа. Кожне пробування — це реальний підпис, записаний у тимчасовий файл, тому список латиниці коштує до чотирьох підписів, а список CJK — до восьми, все це проти односторінкового PDF. Розв’яжіть один раз, кешуйте два назви сімейств, і шлях на документ залишається саме таким, яким був раніше: формуєте параметри, викликаєте Sign, читаєте кількість результатів.

Я втратив полудень, працюючи з версією, яка лише вгадувала. Вона сканувала каталог шрифтів, знайшла NotoSansCJK-Regular.ttc, повідомила, що CJK доступний, і потім впала на кожному імені сімейства, отриманому з назви файлу. Пробування реальним підписом було простішим і правильним.

Що ще «кусати» в контейнері?

Ще один момент, не пов’язаний зі шрифтами: InvariantGlobalization=true. Це стандартна рекомендація для видалення ICU з .NET‑образу, і з GroupDocs.Signature вона змушує перший new Signature(...) кинути CultureNotFoundException: ... en-US is an invalid culture identifier, бо SignatureSettings створює CultureInfo("en-US"). Тримайте глобалізацію ввімкненою і залишайте ICU в образі. Сторінка system requirements — це місце, де треба перевірити підтримку платформи перед вибором базового образу.

Висновок

Сервіс підпису, який працює локально, а в Docker падає, майже завжди страждає від відсутності шрифтів, і виправлення полягає у додаванні шару з чотирма пакетами та коді, який розв’язує сімейство, а не припускає його. Побудуйте обидва образи за зразком, запустіть їх поруч і перегляньте рядки [fonts]: весь аргумент вміщується в цьому одному порівнянні.

Додаткові ресурси