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

Веб-шрифты в PDF: WOFF2 и вариативные оси в Delphi

PDF Library for Delphi принимает шрифты, которые веб-проект уже поставляет. Она импортирует контейнеры WOFF2, перечисляет дизайн-оси вариативного шрифта в виде JSON и инстанцирует выбранный набор координат осей в статичный шрифт, с которым обычные пути встраивания, создания подмножеств и извлечения работают, ничего не зная о вариациях. Охвачены и контуры TrueType, и контуры CFF2

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

Почему WOFF2 — не просто сжатый шрифт?

Если обращаться с WOFF2 как с обёрткой Brotli и просто распаковывать её, получаются байты, которые не являются валидным sfnt. Формат также допускает преобразованные и переупорядоченные таблицы: glyf, loca и hmtx могут храниться в перестроенной форме, которую нужно развернуть обратно, прежде чем шрифт станет разбираемым. Универсальный распаковщик этого сделать не может

На Windows платформа предоставляет анализ и распаковку контейнера начиная с Windows 10 версии 1703, поэтому библиотека использует системную реализацию, а не поставляет ещё один декодер. Это решение избавляет развёртывание от лишней DLL и означает, что разворот преобразования обрабатывается тем же кодом, что использует собственный текстовый стек операционной системы. На других платформах WOFF1 по-прежнему проходит чисто паскалевским путём, а WOFF2 явно сообщается как неподдерживаемый — это честный ответ, а не молча повреждённый шрифт

Путь импорта проверяет данные и до декодирования, и после: 48-байтовый заголовок, длина контейнера, зарезервированные поля, заявленный размер sfnt и потолок в 512 МиБ проверяются сначала, а размер декодированного потока сравнивается с заявленным впоследствии. Веб-шрифт — это загруженный контент, и два режима сбоя, которые стоит блокировать, — это несовпадение размера и бомба распаковки

Диаграмма конвейера, показывающая, как библиотека PDF для Delphi импортирует веб-шрифт WOFF2 через проверку заголовка, системное декодирование, разворот преобразований sfnt и проверку размера после декодирования
Контейнеры WOFF2 приходят как загруженный контент, поэтому импорт проверяет заголовок и заявленные размеры до и после декодирования
uses
  PDFlibrary;

var
  Lib: TPDFlib;
  FontID, InstanceID: Integer;
  AxesJSON: WideString;
begin
  Lib := TPDFlib.Create;
  try
    Lib.NewDocument;
    Lib.SetPageSize('A4');
    Lib.NewPage;

    // WOFF2, WOFF1, TTF, OTF и TTC все входят через один и тот же вызов
    FontID := Lib.AddOpenTypeFontFromFile('Inter.woff2', 0);
    if FontID <= 0 then
      raise Exception.Create('font could not be imported');

    // Что реально умеет этот шрифт?
    if Lib.GetFontVariationAxes(FontID, AxesJSON) = 1 then
      Log(AxesJSON);   // теги, минимум, значение по умолчанию, максимум по каждой оси

    // Запекаем одну дизайн-позицию в статичный шрифт
    InstanceID := Lib.CreateOpenTypeFontVariation(FontID,
      'wght=620,wdth=87.5');

    Lib.DrawOpenTypeText(56, 760, InstanceID, 24, 'Quarterly results', 0);
    Lib.SaveToFile('report.pdf');
  finally
    Lib.Free;
  end;
end;

Что инстанцирование обязано перестроить, а что удаляет

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

Для контуров TrueType это означает разбор fvar, avar, gvar, HVAR и MVAR, применение дельт кортежей с общей и частной нумерацией точек, а также интерполяцию точек, не несущих явной дельты. Горизонтальные фантомные точки вместе с HVAR определяют ширину продвижения и левый боковой интервал. Составным глифам сначала нужно разрешить зависимости их компонентов и отображения точек, затем применить смещения переноса, масштаба, матрицы и присоединения — с ограничениями глубины и обнаружением циклов, потому что иначе злонамеренный граф компонентов рекурсировал бы бесконечно

Метаданные тоже переносятся, и именно эту часть пропускает большинство реализаций. MVAR обновляет глобальные метрики в hhea, OS/2 и post, поэтому межстрочный интервал, позиция подчёркивания и угол наклона курсива соответствуют экземпляру, а не значению по умолчанию. Оси wght, wdth, ital и slnt дополнительно обновляют класс насыщенности, класс ширины, флаги выбора, macStyle и угол наклона курсива, поэтому инструмент, изучающий статичный шрифт, сообщает именно тот стиль, что вы запросили. Шрифт, чья геометрия жирная, а метаданные говорят «обычный», будет неверно промаркирован повсюду в дальнейшем

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

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

CFF2 идёт к той же цели другим путём

CFF2 нельзя встроить напрямую как программу шрифта PDF: его charstrings не несут ширин и опираются на окружающие таблицы OpenType для порядка глифов и метрик. Поэтому инстанцирование шрифта CFF2 вместо этого создаёт CID-ключевой шрифт CFF OpenType, с которым уже справляются существующие пути отрисовки, встраивания и создания подмножеств

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

Проверка экземпляра, прежде чем ему доверять

Инстанцирование — это та возможность, что может быть тонко неверна способами, о которых не сообщит ни одно исключение. Три проверки ловят почти всё

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

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

И извлеките текст обратно из сгенерированного PDF. Экземпляр, который встраивается, но не даёт извлекаемого текста, потерял своё отображение ToUnicode, что важно для поиска и доступности задолго после того, как визуальная проверка прошла успешно. Более широкий путь извлечения описан в статье извлечение текста, изображений и шрифтов

Диаграмма, показывающая три проверки для сгенерированного экземпляра вариативного шрифта: выгрузка экспортированного файла шрифта, сравнение продвижений глифов с независимым инстанцированием и извлечение текста обратно из PDF
Тонко неверный экземпляр не выбрасывает исключения, поэтому по очереди проверяются экспортированный файл, продвижения глифов и извлечённый текст
// Выгружаем сгенерированный экземпляр для проверки или повторного использования
Lib.SaveFontToFile('Inter-620-87.ttf');

// Запросите координаты за пределами заявленного диапазона, и они будут ограничены,
// поэтому читайте JSON осей, а не полагайтесь на предположения о пределах шрифта
// {"axes":[{"tag":"wght","min":100,"default":400,"max":900}, ...]}

Практические заметки для конвейера фирменных шрифтов

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

Измеряйте тем экземпляром, которым будете рисовать. Сжатый экземпляр имеет более узкие продвижения, чем экземпляр по умолчанию, поэтому любая вёрстка, измерившая текст до инстанцирования, измерила неверные ширины. Это самая частая единственная причина текста, который переполняет свой блок только в продакшене

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

Импорт веб-шрифтов, вариативное инстанцирование, создание подмножеств и откат — часть одного и того же шрифтового конвейера для Delphi, C++Builder и Free Pascal; полный список возможностей — на странице PDF Library for Delphi