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