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

PDFlibPas: автоматический fallback шрифтов для CJK

PDFlibPas подставляет символы, которые выбранный шрифт не может отрисовать, перебирая цепочку резервных (fallback) начертаний покластерно, сохраняя при этом шейпинг и порядок двунаправленных прогонов текста. Функция включается вызовом SetAutomaticFontFallback, цепочка расширяется через AddFontFallback, а в файл встраиваются только те резервные шрифты, которые реально были использованы при выводе

Эта функция решает проблему, с которой сталкивается любой генератор документов, как только имя клиента приходит в письменности, которую шрифт шаблона никогда не предусматривал. Сбой при этом происходит тихо, и именно поэтому он обходится дорого

Почему неподдерживаемый текст исчезает, а не вызывает ошибку?

Потому что в PDF нет понятия шрифта, который не может отрисовать символ. Простой шрифт сопоставляет байтовые коды именам глифов через кодировку; составной шрифт сопоставляет коды индексам глифов через CMap. Запросите глиф, которого нет в начертании, — и получите нулевой индекс глифа, .notdef, который большинство начертаний рисуют как ничего или как пустой прямоугольник. Файл структурно корректен, оператор текста сформирован правильно, страница рендерится. Просто там, где должно быть имя, остаётся пустое место

ISO 32000-1 нигде не требует, чтобы генератор это заметил. Генератор, который пишет текст без проверки покрытия, создаёт технически конформный PDF, где содержимое молча потеряно, а эта потеря всплывает на экране клиента через несколько недель. Именно поэтому функция fallback и отчёт о недостающих глифах поставляются вместе: устранить то, что можно устранить, — это только половина работы, а сообщить о том, что устранить не удалось, — вторая половина

Fallback работает на уровне кластера, а не кодовой точки

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

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

var
  Lib: TPDFlib;
begin
  Lib := TPDFlib.Create;
  try
    Lib.SetOrigin(1);
    Lib.SetAutomaticFontFallback(1);

    // Порядок поиска: побеждает первое совпадение, поэтому самые широкие начертания ставьте последними
    Lib.AddFontFallback('Microsoft YaHei');   // упрощённый китайский
    Lib.AddFontFallback('Meiryo');            // японский
    Lib.AddFontFallback('Segoe UI Symbol');
    Lib.AddFontFallback('Segoe UI Emoji');

    Lib.SetMissingGlyphPolicy(PDF_MISSING_GLYPH_REPORT);

    Lib.AddTrueTypeFont('Arial', 1);          // 1 = встроить начертание
    Lib.SetTextSize(11);
    Lib.DrawText(72, 720, 'Invoice for 北京示例科技有限公司');
    Lib.DrawText(72, 700, 'Delivery status: on time');

    Lib.SaveToFile('invoice.pdf');
  finally
    Lib.Free;
  end;
end;

Выстраивайте цепочку осознанно. Разрешение берёт первое начертание, покрывающее кластер, поэтому широкий пан-юникодный шрифт, поставленный первым, перехватит почти всё, а тщательно подобранные шрифты для конкретных письменностей так и не будут задействованы. Ставьте специфичные начертания в начало, а универсальное — в конец

Report или abort: какой отказ вам нужен?

SetMissingGlyphPolicy принимает значение PDF_MISSING_GLYPH_REPORT — совместимое значение по умолчанию, — или PDF_MISSING_GLYPH_ABORT. При политике report операция вывода текста продолжается, неразрешённые кодовые точки отбрасываются как и раньше, но каждая из них фиксируется. При политике abort операция вывода текста отклоняется ещё до записи какого-либо содержимого, а LastErrorCode устанавливается в 521

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

var
  Lib: TPDFlib;
  Report: WideString;
begin
  Lib := TPDFlib.Create;
  try
    Lib.SetMissingGlyphPolicy(PDF_MISSING_GLYPH_ABORT);
    // ... формируем документ ...

    if Lib.DrawText(72, 660, CustomerName) <> 1 then
      if Lib.LastErrorCode = PDFLIB_ERROR_MISSING_GLYPH then
      begin
        Report := Lib.GetMissingGlyphReportJSON;
        // {"valid":false,"policy":1,"eventCount":1,"events":[
        //   {"sequence":1,"documentIndex":0,"page":1,"utf16Index":12,
        //    "codePoint":21271,"unicode":"U+5317","fontName":"Arial",
        //    "fontType":"TrueType","operation":"DrawText"}]}
        EscalateToOperator(Report);
      end;
  finally
    Lib.Free;
  end;
end;

Отчёт намеренно сделан машиночитаемым и ограниченным по размеру. Каждое событие содержит номер страницы, индекс UTF-16 внутри строки, кодовую точку в числовом виде и в форме U+XXXX, выбранный шрифт, его тип и операцию, на которой возникла проблема, — так тикет поддержки может назвать точный символ, а не описывать симптом. Трекер хранит последние 256 событий, чего достаточно для диагностики документа, но недостаточно, чтобы патологический прогон превратил диагностику в проблему с памятью

Измерение и отрисовка должны совпадать

Измерение ширины использует те же решения о fallback на уровне кластеров, что и отрисовка. Это звучит очевидно, но именно здесь ошибается большинство самодельных слоёв fallback: они патчат путь отрисовки, оставляя измерение на основном шрифте, и в итоге каждое текстовое поле, выравнивание по правому краю и колонка таблицы вычисляются по ширинам, которые не совпадают с тем, что реально отрисовано

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

Встраивается только то, что реально использовано

Резервные шрифты встраиваются лениво: начертание в цепочке, которое ни разу не разрешило ни одного кластера, ничего не добавляет в результат. Документ с одним китайским иероглифом и 5000 латинскими символами не несёт в себе полное начертание CJK — он несёт лишь то, что подмножественный (subsetting) проход сформировал для этого единственного глифа, и это поведение описано в статье об оптимизации размера файла и субсеттинге шрифтов

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

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

PDFlibPas — это библиотека PDF для Delphi, C++Builder и Lazarus с соответствующими интерфейсами DLL и ActiveX, поэтому API для fallback и отчётов о недостающих глифах доступны и вызывающему коду за пределами Pascal. Полная документация доступна на странице PDFlibPas Delphi PDF library