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

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