💡 Полный рабочий пример доступен на 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 начиная с .NET 7 доступен только в Windows и бросает исключение в 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 без соответствующего шрифта может отобразиться пустыми квадратами без какого‑либо сообщения об ошибке:
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 |
нет |
| Различие в коде | нет — тот же бинарник | нет — тот же бинарник |
Последняя строка — суть. В приложении ничего не изменилось между двумя запусками. Репозиторий‑пример поставляет оба Dockerfile, поэтому сравнение делается двумя командами 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]: весь аргумент укладывается в это одно сравнение.