Техническая статья

Управление подстановкой шрифтов PDF в Delphi с помощью PDFium

PDFium Component позволяет приложению на Delphi решать, какие байты шрифта используются, когда PDF ссылается на шрифт, который не встраивает. ConfigureSystemFontProvider устанавливает реализацию IPdfSystemFontProvider, получающую каждый запрос на сопоставление шрифта, который делает PDFium, со всеми деталями — именем начертания, насыщенностью, флагом курсива, набором символов и семейством шага, — и отвечающую байтами TrueType, коллекции TrueType или OpenType для использования

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

Почему не установить шрифты прямо на сервере?

Иногда это и есть ответ, и когда это так — примените его. Но он даёт сбой в трёх распространённых ситуациях. Лицензия может запрещать установку шрифта на сервер для автоматизированного рендеринга. Образы контейнеров пересобираются часто, и вручную установленный шрифт исчезает при следующем развёртывании. А регулируемые рабочие процессы требуют, чтобы стек рендеринга воспроизводился из артефактов под контролем версий, чего установка шрифта на уровне машины не даёт

Поставщик решает все три проблемы, перенося решение в ваше приложение. Шрифты поставляются как ресурсы, которые вы контролируете, политика сопоставления — это код, который можно проверить, а один и тот же исполняемый файл рендерит одинаково везде, поскольку ничто не зависит от того, что случайно установлено

Установка поставщика

Настройка должна произойти до загрузки библиотеки. PDFium принимает структуру информации о системных шрифтах при инициализации и хранит выданные впоследствии дескрипторы, поэтому замена поставщика при открытых документах сделала бы недействительными дескрипторы шрифтов, которые PDFium всё ещё держит; компонент попросту отклоняет это, вместо того чтобы позволить повредить рендеринг:

uses
  PDFium;

type
  TAppFontProvider = class(TInterfacedObject, IPdfSystemFontProvider)
  public
    function ResolveFont(const Request: TPdfSystemFontRequest;
      out Font: TPdfSystemFontData): Boolean;
  end;

function TAppFontProvider.ResolveFont(const Request: TPdfSystemFontRequest;
  out Font: TPdfSystemFontData): Boolean;
var
  Path: string;
begin
  // Детерминированное сопоставление: имя начертания плюс насыщенность
  // и курсив решают, какой файл мы поставим для этого запроса
  Path := MapFaceToBundledFile(Request.FaceName, Request.Weight,
    Request.Italic, Request.Charset);
  Result := Path <> '';
  if not Result then
    Exit;
  Font.FaceName := Request.FaceName;
  Font.FontData := LoadFileBytes(Path);   // полные байты sfnt или TTC
  Font.Charset := Request.Charset;
  Font.TTCIndex := 0;                     // индекс внутри коллекции
end;

var
  Policy: TPdfSystemFontPolicy;
begin
  Policy := TPdfSystemFontPolicy.Default;
  Policy.AllowDefaultFallback := False;   // хост решает всё
  Policy.AllowFaceSubstitution := False;  // отклонить другое имя начертания
  Policy.MaxFontBytes := 32 * 1024 * 1024;
  Policy.MaxCacheEntries := 64;

  ConfigureSystemFontProvider(TAppFontProvider.Create, Policy);
  // Только теперь загружайте библиотеку и открывайте документы
end;

Завершение работы выполняется в обратном порядке: поставщик сначала отсоединяется от PDFium, затем выгружается библиотека. Пропуск отсоединения оставляет нативные дескрипторы шрифтов указывающими на объекты Pascal, которые вот-вот будут освобождены, — классическое нарушение доступа при завершении работы в коде, смешивающем интерфейсы со счётчиком ссылок с библиотекой на C

Что на самом деле решают флаги политики

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

AllowFaceSubstitution определяет, может ли поставщик отвечать другим именем начертания, чем запрошенное. Отключение делает подстановку явным решением, а не случайностью, что важно, когда документ называет шрифт, чьи метрики достаточно отличаются, чтобы изменить разбиение на страницы

Компонент проверяет каждый ответ поставщика перед тем, как он достигнет PDFium: пустые данные отклоняются, слишком большие шрифты отклоняются относительно MaxFontBytes, индекс TTC проверяется, а отдельные таблицы sfnt обслуживаются из каталога шрифта, когда PDFium запрашивает таблицу, а не весь файл. Эта последняя возможность означает, что поставщик может передать целый файл шрифта и позволить компоненту отвечать на запросы уровня таблицы, вместо того чтобы раскрывать сырые объекты Pascal через границу C ABI

Кеширование без повисших данных шрифта

Запросы на сопоставление шрифтов повторяются постоянно во время рендеринга, поэтому ответы кешируются по ключу, охватывающему каждый параметр выбора шрифта, вытесняемому по ограниченному порядку наименее недавнего использования. Тонкость — во времени жизни: PDFium может всё ещё читать байты шрифта, чья запись кеша только что была вытеснена

Кеш хранит динамические массивы со счётчиком ссылок, и каждый нативный дескриптор держит собственный снимок, поэтому вытеснение сбрасывает ссылку, а не освобождает используемую память. Обратный вызов удаления освобождает дескриптор и поддерживает счётчик активных. Практически это означает, что MaxCacheEntries можно настраивать под память без какого-либо риска вытащить данные из-под рендеринга в процессе

Вызывается ли поставщик в моём потоке?

Нет, не обязательно. PDFium может вызывать сопоставитель из собственных рабочих потоков, поэтому реализация должна быть потокобезопасной. Общие счётчики, кеш и наблюдение за конфигурацией защищены внутри компонента собственными критическими секциями каждый, но код внутри ResolveFont — ваша забота сделать безопасным

Самая безопасная форма — поставщик, не трогающий изменяемое общее состояние: читать из таблицы, построенной при запуске, загружать байты из файла или ресурса, вернуть. Если поиску нужен собственный общий кеш, защитите его. И держите исключения внутри своей реализации, поскольку исключение Pascal никогда не должно раскручиваться через стек PDFium; компонент перехватывает на границе C ABI и преобразует в сбой или опциональный откат по умолчанию, но полагаться на это как на обычный поток управления стоит производительности и скрывает ошибки. Правила потокобезопасности для остальной части компонента следуют тем же принципам, что и в статье дисциплина блокировки рендеринга

Доказательство сопоставления в продакшене

Статистика превращает подстановку шрифтов из гадания в нечто, что можно проверить. GetSystemFontProviderStatistics сообщает, настроен ли и установлен ли поставщик, сколько запросов на сопоставление было сделано и как они были удовлетворены — разбито на попадания кеша, попадания поставщика и попадания отката по умолчанию, — вместе с отклонёнными ответами, неудавшимися запросами, живыми дескрипторами и кешированными шрифтами:

var
  Stats: TPdfSystemFontStatistics;
begin
  Stats := GetSystemFontProviderStatistics;
  Writeln(Format('requests=%d cache=%d provider=%d fallback=%d',
    [Stats.MapRequests, Stats.CacheHits, Stats.ProviderHits,
     Stats.DefaultFallbackHits]));
  Writeln(Format('rejected=%d failed=%d handles=%d cached=%d',
    [Stats.RejectedProviderResponses, Stats.FailedRequests,
     Stats.ActiveHandles, Stats.CachedFonts]));

  // В прогоне на соответствие с отключённым откатом любое попадание
  // отката или неудавшийся запрос означают, что документ сослался
  // на шрифт, который мы не поставляем
  if (Stats.DefaultFallbackHits > 0) or (Stats.FailedRequests > 0) then
    raise Exception.Create('unmapped font encountered - update the font set');
end;

Растущий счётчик RejectedProviderResponses — сигнал того, что поставщик отвечает данными, которые политика отклоняет, обычно слишком большим файлом или подставленным начертанием, и на это стоит настроить оповещение, поскольку такие запросы молча деградируют до отката или сбоя. Для диагностики того, какие шрифты документ действительно требует, прежде чем строить таблицу сопоставления, путь инспекции в статье анализ свойств шрифтов PDF перечисляет встроенные и не встроенные шрифты по документу

Обеспечение шрифтами, рендеринг и извлечение текста используют один экземпляр библиотеки в Delphi, C++Builder и Lazarus; детали развёртывания описаны на странице компонента PDFium для Delphi