💡 Повний робочий приклад доступний на 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]: весь аргумент вміщується в цьому одному порівнянні.