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

Субсеттинг шрифтов TrueType через fontsub.dll в Delphi

HotXLS, компонент Excel для Delphi и C++Builder, сокращает размер встроенного шрифта PDF через субсеттинг шрифтов TrueType: в момент экспорта в PDF он вызывает функцию CreateFontPackage системной библиотеки Windows fontsub.dll, чтобы перестроить встроенный шрифт TrueType вокруг только тех кодовых точек Unicode, которые реально использовал лист, вместо того чтобы поставлять весь файл гарнитуры целиком. Отчёту с двумя сотнями строк китайских названий товаров может понадобиться всего несколько сотен различных иероглифов хань, тогда как шрифты CJK, поставляемые с Windows, обычно весят от 5 до 20 МБ каждый. Встройте один такой целиком — и один только шрифт может перевесить все остальные объекты в PDF вместе взятые

fontsub.dll — библиотека, о которой большинство разработчиков на Delphi никогда не слышали, и тому есть причина: Microsoft поставляет её как небольшую, скудно задокументированную служебную DLL, а не как флагманский API Win32. HotXLS относится к ней как к опциональной возможности, а не как к жёсткой зависимости, так что то, как экспортёр её загружает, вызывает и откатывается в случае её отсутствия, говорит о защитном программировании под Windows не меньше, чем о форматах шрифтов, и обе половины этой истории стоит рассмотреть

Почему юникодный текст раздувает экспорт HotXLS в PDF?

Экспортёр PDF в HotXLS обращается к встроенному шрифту TrueType только тогда, когда текст листа выходит за пределы WinAnsi, а в остальное время остаётся на встроенном семействе Helvetica — путь по умолчанию, подробно описанный в руководстве по экспорту листа в PDF. WinAnsi покрывает западноевропейский текст достаточно хорошо, так что множество книг вообще никогда не запускают встраивание шрифта: PDF просто ссылается на Helvetica по имени, а программа чтения предоставляет её локально, так что файл остаётся небольшим. В момент, когда ячейка содержит что-то, что WinAnsi не может представить, — китайское название товара, корейскую заметку, случайный символ в комментарии, — экспортёру приходится встраивать настоящую программу шрифта, потому что у программы чтения PDF нет запасного источника глифов для символов вне стандартных 14 шрифтов

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

PDF-экспорт HotXLS в Delphi разделяется на путь WinAnsi, ссылающийся на Helvetica, и путь Unicode, встраивающий целый CJK TrueType-шрифт
Текст WinAnsi хранит ссылку на встроенную Helvetica, тогда как любой не-WinAnsi символ заставляет HotXLS встроить полный TrueType-шрифт. Именно весь встроенный шрифт позже ужимает сабсеттинг fontsub.dll
uses
  lxHandle, lxPDF;

var
  Book: TXLSWorkbook;
  Exporter: TXLSPDFExport;
begin
  Book := TXLSWorkbook.Create;
  try
    Book.Open('catalog-cn.xlsx');
    Exporter := TXLSPDFExport.Create;
    try
      // Необязательно: закрепить конкретный шрифт с поддержкой CJK вместо
      // exporter's automatic Windows\Fonts scan.
      Exporter.UnicodeFontFile := 'C:\Windows\Fonts\simhei.ttf';
      Exporter.SaveAsPDF(Book.ActiveSheet, 'catalog-cn.pdf');
    finally
      Exporter.Free;
    end;
  finally
    Book.Free;
  end;
end;

Что такое fontsub.dll и почему не написать субсеттер с нуля?

fontsub.dll — небольшая системная библиотека Windows, поставляемая начиная с Windows XP, которая предоставляет здесь одну важную функцию: CreateFontPackage. Передайте ей байты исходного шрифта TrueType и список кодовых точек Unicode для сохранения, и она вернёт минимальный шрифт, по-прежнему удовлетворяющий каждому ограничению формата шрифта: индексы глифов перенумерованы, glyf и loca перестроены вокруг только сохранённых контуров, hmtx и cmap переписаны в соответствии. HotXLS декларирует тип указателя функции прямо по этому контракту

HotXLS в Delphi передаёт байты исходного TrueType-шрифта и 16-битный список сохраняемых Unicode в fontsub.dll CreateFontPackage и получает субсет-шрифт для потока PDF FontFile2
CreateFontPackage превращает полный шрифт плюс список сохранения в минимальный шрифт с перестроенными таблицами. Затем HotXLS Flate-сжимает байты сабсета в поток FontFile2
const
  TTFCFP_FLAGS_SUBSET = 1;
  TTFMFP_SUBSET = 0;
  TTFCFP_MS_PLATFORMID = 3;
  TTFCFP_UNICODE_CHAR_SET = 1;

type
  TCreateFontPackage = function(puchSrcBuffer: Pointer; ulSrcBufferSize: Cardinal;
    var puchFontPackageBuffer: PAnsiChar; var pulFontPackageBufferSize: Cardinal;
    var pulBytesWritten: Cardinal; usFlags, usTTCIndex, usSubsetFormat,
    usSubsetLanguage, usSubsetPlatform, usSubsetEncoding: Word;
    pusSubsetKeepList: PWordArray; usSubsetKeepListCount: Word;
    lpfnAllocate, lpfnReAllocate, lpfnFree, reserved: Pointer): Cardinal; cdecl;

Написание работы CreateFontPackage вручную вместо её вызова означало бы реализацию корректного субсеттера TrueType: обход составных глифов, чтобы подтянуть каждый глиф-компонент, на который ссылается сохраняемый глиф, перестройку смещений loca после удаления контуров, соблюдение битов разрешения на встраивание в таблице OS/2 шрифта, и всё это правильно на любых экзотических шрифтах, какие только могут оказаться установлены на машине клиента. Microsoft уже решила эту задачу и поставляет решение как часть самой Windows, так что вызов системной DLL, которую она поддерживает, тестирует относительно собственного стека рендеринга шрифтов и распространяет на каждую машину бесплатно, обходится HotXLS в динамическую загрузку и указатель функции; повторная реализация той же логики означала бы содержание собственного парсера для бинарного формата с десятилетиями пограничных случаев ради функции, которая имеет значение только тогда, когда шрифт оказывается большим

Построение списка сохранения из реально отрисованных глифов

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

var
  keepList: array of Word;
  keepCount, i: Integer;
  codePoint: LongWord;
begin
  SetLength(keepList, FUnicodeGlyphMap.Count);
  keepCount := 0;
  for i := 0 to FUnicodeGlyphMap.Count - 1 do
  begin
    codePoint := LongWord(StrToIntDef('$' + FUnicodeGlyphMap.ValueFromIndex[i], 0));
    if codePoint > 0 then
    begin
      keepList[keepCount] := Word(codePoint);
      Inc(keepCount);
    end;
  end;
end;

На этапе финализации HotXLS повторно обходит ту же карту, чтобы построить список сохранения, который ожидает CreateFontPackage, — простой массив кодовых точек Unicode для сохранения в 16-битной форме, которую требует аргумент списка сохранения API. Поскольку этот аргумент — массив 16-битных слов, он чисто адресует основную многоязычную плоскость (Basic Multilingual Plane), которая без осложнений покрывает обычный CJK-текст, кириллицу, греческий и арабский; лист, опирающийся на символы дополнительных плоскостей, некоторые эмодзи или редкие исторические письменности, оказывается за пределами того, что может напрямую назвать одна запись списка сохранения, — это граница, о которой стоит знать, а не дефект, поскольку подавляющее большинство насыщенных Unicode деловых таблиц никогда даже не приближаются к этой плоскости

Что происходит, если fontsub.dll отсутствует?

HotXLS никогда не предполагает, что fontsub.dll присутствует, и экспорт в PDF никогда не проваливается из-за её отсутствия. Библиотека загружается динамически в момент, когда нужен субсеттинг, через SafeLoadLibrary и GetProcAddress, а не через статический импорт, именно потому, что fontsub.dll — не задокументированный, гарантированно присутствующий публичный API вроде kernel32.dll: это пакетный инструментарий встраивания шрифтов, и ничто в контракте Microsoft не обещает, что он сохранится на каждом SKU, каждой ветке обслуживания или каждом слое совместимости, пытающемся эмулировать Windows

HotXLS в Delphi грузит fontsub.dll динамически, и каждый путь сбоя сворачивается к сохранению полного встроенного шрифта, а успех пишет поток субсета
Каждый шаг динамической загрузки может провалиться независимо, и всякий провал схлопывается в один исход. Экспорт никогда не бросает исключений, и PDF остаётся валидным в любом случае
var
  hFontSub: HMODULE;
  CreateFontPackage: TCreateFontPackage;
begin
  hFontSub := SafeLoadLibrary('FontSub.dll');
  if hFontSub = 0 then
    Exit; // subsetting недоступен — сохранить полный встроенный шрифт
  try
    @CreateFontPackage := GetProcAddress(hFontSub, 'CreateFontPackage');
    if not Assigned(CreateFontPackage) then
      Exit;
    // ... вызвать CreateFontPackage, проверить его код возврата ...
  finally
    FreeLibrary(hFontSub);
  end;
end;

Каждый путь отказа сворачивается к одному и тому же исходу. Отсутствующая DLL, отсутствующий экспорт, ненулевой код возврата или шрифт, чья таблица OS/2 запрещает субсеттинг через биты разрешения на встраивание, — HotXLS просто сохраняет уже встроенный полный шрифт и продолжает работу. Ничего не выбрасывает исключений, ничто не прерывает экспорт, и вызывающему коду никогда не нужно оборачивать оптимизацию шрифта в собственную обработку исключений; экспортированный PDF корректен в любом случае, и единственная переменная — окажется ли он небольшим или несколько крупнее

Насколько на самом деле уменьшается PDF?

Субсеттинг шрифтов TrueType в HotXLS обычно сокращает экспортированный PDF насыщенного Unicode-текстом листа до величины от одной двадцатой до одной восьмой его несубсеттированного размера — сокращение в 8–20 раз, масштаб которого зависит от того, какую долю полного шрифта реально затрагивает данный документ: заказ на закупку, построенный вокруг нескольких сотен различных китайских иероглифов, сохраняет только эти несколько сотен глифов из десятков тысяч, что поставляет гарнитура CJK, тогда как лист, охватывающий более широкий набор символов, сохраняет пропорционально больше. HotXLS накладывает дополнительный проход сжатия Flate поверх байтов субсеттированного шрифта перед записью их в поток /FontFile2 PDF — то же сжатие, через которое уже проходят остальные потоки содержимого документа, — и ничто из этого не требует ничего дополнительного от вызывающего кода: лист, который никогда не выходит за пределы WinAnsi, никогда не затрагивает этот путь и продолжает экспортироваться через обычную Helvetica, тогда как лист, действительно запускающий путь юникодного шрифта, получает субсеттинг автоматически, без свойства для установки и без отдельного вызова, а единственное задействованное свойство, UnicodeFontFile, лишь выбирает, какой шрифт будет встроен и субсеттирован, а не то, произойдёт ли субсеттинг вообще

Субсеттинг шрифтов — одна деталь внутри более широкой поверхности экспорта в PDF компонента Excel для Delphi HotXLS, наряду с постраничной разбивкой, метаданными печати листа и путями экспорта в CSV, HTML и RTF, с которыми он поставляется