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

Встраивание CFF2-шрифтов в PDF с HotPDF для Delphi

HotPDF встраивает вариативные шрифты OpenType с контурами CFF2, конвертируя их при выбранных значениях осей в CID-keyed программу CFF1, которую PDF-ридеры уже понимают. Обычный нативный текст, зарегистрированный через RegisterUnicodeTTF, использует собственные таблицы cmap и hmtx шрифта, а писатель встраивает сконвертированную программу под /FontFile3 с /Subtype /CIDFontType0C, удерживая каждый CID равным исходному glyph ID

Конвейер HotPDF для встраивания вариативного шрифта CFF2 в Delphi как CID-keyed CFF1 под FontFile3, с молчаливым крюком подмены через GDI, которого избегает путь с зарегистрированными байтами
HotPDF замораживает вариацию на выбранных значениях осей и пишет CID-keyed программу CFF1, где каждый CID равен исходному glyph ID, так что никакой слой не переводит между нумерациями

Очевидный маршрут стоит описать, потому что он проваливается, никому не сообщая. Распарсите и закэшируйте таблицы CFF2, а затем позвольте обычному текстовому пути разрешить шрифт через Windows GDI с совпадением только по TrueType: GDI понятия не имеет, что делать с контурами CFF2, и молча подставляет ArialUnicodeMS. Всё, что ниже по конвейеру читает подмену, получает неверные контуры, неверные ширины и неверное извлечение символов дополнительной плоскости. PDF открывается прекрасно. Просто набран он не тем шрифтом, который вы просили, — потому текстовому пути и приходится работать от зарегистрированных байтов, а не от GDI-поиска

Почему PDF не может встроить шрифт CFF2 напрямую?

Для программы CFF2 в PDF нет слота. ISO 32000-2 §9.9 перечисляет типы встроенных шрифтовых программ, которые ридер обязан обрабатывать: Type 1 под /FontFile, TrueType под /FontFile2, а под /FontFile3 — голую программу CFF (Type1C или CIDFontType0C) или OpenType-обёртку вокруг контуров TrueType или CFF. Каждый CFF-вариант в этом списке означает CFF версии 1 — формат, который ридеры парсят два десятилетия

Три слота встроенных шрифтов PDF, все ограничены CFF версии 1, и отсутствующий слот CFF2, вынуждающий HotPDF конвертировать перед встраиванием в Delphi
каждый CFF-вариант, который обязаны обрабатывать PDF-ридеры, — это CFF версии 1, поэтому у вариативного шрифта CFF2 нет слота, пока он не сконвертирован

CFF2 — другой формат, случайно унаследовавший имя. Он выбрасывает Name и String INDEX, выбрасывает charset, выносит ширины глифов из CharStrings, оставляя их hmtx, и добавляет операторы blend, чтобы один контур описывал всё пространство дизайна. Парсер CFF1, встретивший таблицу CFF2, не имеет оснований для догадок, и ничто в спецификации PDF не обязывает его пытаться. Завернуть шрифт CFF2 в OpenType-поток и надеяться на лучшее — ровно тот сорт файлов, который рендерится на одной машине и падает на следующей, поэтому HotPDF этого не делает

Конверсия должна быть и статичной. Как объясняет статья о статическом инстансировании вариативных шрифтов, PDF-шрифт — фиксированный набор контуров с фиксированными метриками, и никакой ридер не обязан применять координаты осей. Поэтому HotPDF сперва разрешает вариацию и только потом пишет CFF1

Нативный текст шрифтом CFF2

Для кода приложения шрифт CFF2 ведёт себя как любой другой зарегистрированный Unicode-шрифт: выберите значения осей, зарегистрируйте файл, выберите семейство по имени и рисуйте. RegisterUnicodeTTF хранит отдельную копию исходной таблицы имён, поэтому SetFont сопоставляет имя семейства, полное имя, PostScript-имя или типографское имя семейства и выбирает замороженные статичные байты шрифта — без установки в Windows и без GDI-поиска

var
  Pdf: THotPDF;
begin
  Pdf := THotPDF.Create(nil);
  try
    Pdf.FileName := 'brochure.pdf';
    Pdf.BeginDoc;
    // Значения осей применяются к шрифтам, регистрируемым после этого вызова
    Pdf.SetVariableFontAxis('wght', 650);
    Pdf.RegisterUnicodeTTF('C:\Fonts\AcmeSerifVF.otf');
    // Имя семейства, полное, PostScript или типографское имя из таблицы имён
    Pdf.CurrentPage.SetFont('Acme Serif VF', [], 12, DEFAULT_CHARSET);
    Pdf.CurrentPage.UnicodeTextOut(72, 100, 0, 'Variable weight 650, CFF2 outlines');
    Pdf.EndDoc;
  finally
    Pdf.Free;
  end;
end;

Идентификаторы глифов приходят из зарегистрированной cmap, ширины — из зарегистрированной hmtx. Глиф с нулевой шириной остаётся с нулевой вместо отката к GDI-измерению, а UTF-16 суррогатная пара даёт ровно одну ширину, а не две. BMP-символы, символы дополнительной плоскости и синтетические носители шейпинга держат Unicode-отображения по шрифту, поэтому переключение на другой встроенный шрифт или новая страница не позволяют тому шрифту обновить состояние сабсета этого или позаимствовать его идентификаторы глифов

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

Как HPDFConvertCFF2ToCFF собирает CID-keyed программу?

HPDFConvertCFF2ToCFF выдаёт сырую CID-keyed программу CFF1 с единственным Font DICT, в которой CID равен исходному glyph ID для каждого слота, включая пустые и неиспользуемые. Это тождество и есть весь дизайн. Имя шрифта, ширины /W в PDF, ширины CFF и ToUnicode CMap ссылаются на одну нумерацию, поэтому никакому слою не нужно переводить между glyph ID и CID, и ничто не может расползтись

На стороне PDF потомок — шрифт CIDFontType0 с кодировкой Identity-H или Identity-V, а поток программы лежит под /FontFile3 с /Subtype /CIDFontType0C. Поскольку CID уже равен GID, потомок не несёт /CIDToGIDMap, и поток исключён из хука последующей подмены потоков, применяемого к зарегистрированным TrueType-шрифтам. В отличие от обёртки /Subtype /OpenType, сконвертированный поток не поднимает требование версии PDF 1.6

С ширинами понадобился небольшой трюк. CharString Type 2 в CFF1 хранит ширину как опциональный первый операнд относительно nominalWidthX, и целые операнды ограничены диапазоном знакового 16-битного. Advance в OpenType — беззнаковое 16-битное значение, до 65535. HotPDF ставит nominalWidthX в 32768 и defaultWidthX в 0, затем пишет каждую ширину явно как advance - 32768. Нулевая ширина становится -32768, ширина 65535 — 32767, так что весь диапазон uint16 представим без обрезания ни одного глифа

Как HotPDF отображает беззнаковые 16-битные ширины в знаковые операнды CFF1 с nominalWidthX 32768, сохраняя каждую ширину шрифта в PDF-выводе Delphi без обрезания
nominalWidthX 32768 сдвигает окно операндов, так что нулевая ширина хранится как -32768, а 65535 как 32767, без обрезания глифов

Геометрии досталась та же забота. FontMatrix — это [1/UnitsPerEm 0 0 1/UnitsPerEm 0 0], поэтому шрифт на 2048 юнитов сохраняет родные координаты. Относительные сдвиги и линии, слишком крупные для одного операнда, разбиваются на представимые шаги, а кубические кривые делятся с сохранением формы. Исходные записи копирайта и лицензии, исходная таблица имён и флаги встраивания едут в строке CFF Notice

Вызов конвертера вне документа

Конвертер — публичная функция в юните HPDFOpenTypeAdvanced, написанная на Pascal, без зависимости от HarfBuzz и без шага установки системных шрифтов. Это делает её применимой сама по себе — например, чтобы пре-конвертировать шрифт на этапе сборки или осмотреть, что PDF-писатель будет встраивать

uses
  System.SysUtils, System.IOUtils, HPDFOpenTypeAdvanced;

procedure ConvertWeight650;
var
  Source, Cff: TBytes;
  Axes: THPDFVariableFontAxisValueArray;
begin
  Source := TFile.ReadAllBytes('C:\Fonts\AcmeSerifVF.otf');
  SetLength(Axes, 1);
  Axes[0].Tag := 'wght';
  Axes[0].Value := 650;
  Cff := HPDFConvertCFF2ToCFF(Source, Axes);
  if Length(Cff) = 0 then
    raise Exception.Create('Not convertible: not CFF2, embedding denied or bad axes');
  TFile.WriteAllBytes('AcmeSerif-650.cff', Cff);
end;

Вариативный вход проходит через геометрическое статичное инстансирование по заданным координатам, покрывающее и контуры, и ширины. Статичный шрифт CFF2 принимает только пустой выбор; передача ему значений осей трактуется как ошибка, а не игнорируется. Результат — голая программа CFF1, не sfnt-контейнер, так что не скармливайте её коду, ожидающему OpenType-файл

Неудача — пустой TBytes, а не исключение, для ожидаемых случаев: вход, не являющийся CFF2; выбор осей, который инстансер не принимает; нефинитные значения вроде NaN; шрифты, чей OS/2 fsType ставит встраивание с ограниченной лицензией (0x0002) или встраивание только битмапов (0x0200). Всегда проверяйте длину. Внутри EndDoc писатель делает это за вас и поднимает "StoreFont: CFF2 outline conversion failed" — и это верное решение: пустая шрифтовая программа в отгруженном PDF куда хуже видимой ошибки в логе

Бюджеты и отмена для серверных нагрузок

Опциональный третий параметр, THPDFVariableFontControl, ограничивает память и позволяет отменить долгую конверсию. Без него конвертер использует умолчания: лимит 128 МиБ на вход и выход шрифта, суммарный рабочий бюджет 1 ГиБ и лимит CFF1 в 65535 байт на CharString

type
  TFontJob = class
  private
    FCancelled: Boolean;
    procedure CheckCancel;
  public
    function Convert(const Source: TBytes): TBytes;
    property Cancelled: Boolean read FCancelled write FCancelled;
  end;

procedure TFontJob.CheckCancel;
begin
  if FCancelled then
    Abort;  // вылетает наружу из HPDFConvertCFF2ToCFF
end;

function TFontJob.Convert(const Source: TBytes): TBytes;
var
  Control: THPDFVariableFontControl;
begin
  Control := THPDFVariableFontControl.Create(
    32 * 1024 * 1024,           // максимум байт на выходе
    Int64(256) * 1024 * 1024,   // суммарный рабочий бюджет
    CheckCancel);
  try
    // nil-выбор: экземпляр по умолчанию вариативного шрифта или статичный CFF2
    Result := HPDFConvertCFF2ToCFF(Source, nil, Control);
  finally
    Control.Free;
  end;
end;

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

Сколько весит встроенный шрифт CFF2?

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

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

Ресурсные лимиты — конечные лимиты конверсии, и сам CFF1 ограничивает, что может вместить один CharString. Глиф со сконвертированным CharString свыше 65535 байт или шрифт, пробивающий рабочий бюджет, падают заметно, а не записываются с пустым контуром. Это задуманный размен: громкий провал на экзотическом шрифте лучше документа с молча пропавшими глифами

Нативный текст CFF2 и переносимый конвертер CFF1 поставляются с HotPDF Delphi PDF component для Delphi и C++Builder — рядом с инстансированием вариативных шрифтов, цветными глифами и субсеттингом шрифтов, раскрытыми в других статьях блога