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

Сабсеттинг уже встроенных шрифтов PDF из Delphi

PDFium Component делает сабсеттинг шрифтов, уже встроенных в существующий PDF, из Delphi через SubsetPdfDocumentFonts в модуле FPdfDocumentFontSubset. Функция заменяет встроенную TrueType-программу /FontFile2 меньшей, только когда сами ресурсы шрифта доказывают каждый глиф, на который документ способен сослаться; шрифт, чей домен глифов не доказуем, остаётся ровно как был

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

Почему сабсеттинг существующего PDF рискует потерять глифы?

Сабсеттинг существующего PDF рискует потерять глифы, потому что постпроцессор не знает, какие глифы использовал исходный писатель. Продюсер, встраивавший шрифт, знал свой текст; инструменту, работающему потом, приходится реконструировать то знание, и любой пропущенный им глиф превращается в пустой квадрат или форму .notdef на странице, которую никто не смотрит, пока не посмотрит заказчик

Очевидный подход — разобрать каждый content stream, каждый Form XObject, каждое appearance аннотации и каждое default appearance поля, собрать коды, показанные с каждым шрифтом, и оставить те глифы. Работает до первой встречи с ресурсом шрифта, разделяемым страницами и формами, полем формы, чьё appearance вьювер перегенерирует из /DA, или content stream с последовательностью операторов, которую сканер не моделирует. SubsetPdfDocumentFonts нарочно не сканирует content stream. Он выводит домен глифов из метаданных, которые PDF и так обязан нести о каждом шрифте: /CIDToGIDMap, массивы ширин /W и /W2, битмап /CIDSet, CMap /ToUnicode, массив /Widths простого шрифта и /Encoding. Когда та метаданные не ограничивают домен, шрифт остаётся нетронутым

Как PDFium Component доказывает домен глифов при сабсеттинге встроенных TrueType шрифтов из Delphi: /CIDToGIDMap, ширины /W и /W2, /CIDSet, /ToUnicode, /Widths и /Encoding ограничивают домен без сканирования content stream, а недоказуемый шрифт сохраняется нетронутым
SubsetPdfDocumentFonts превращает метаданные шрифта в доказанный домен глифов прежде, чем что-либо отрезать, а шрифт, чьи ресурсы не могут ограничить домен, репортится и остаётся нетронутым

Сохранение исходных ID глифов, чтобы content stream не трогались

Бэкенд сабсета за SubsetPdfDocumentFonts, TPdfTrueTypeSubsetBackend, сохраняет каждый слот исходного ID глифа, поэтому ни один content stream, CID или /CIDToGIDMap никогда не приходится переписывать. Он перестраивает таблицы glyf и loca, даёт каждому неиспользуемому глифу нулевую длину диапазона loca и оставляет maxp.numGlyphs без изменений. Глиф 4711 остаётся глифом 4711 после сабсета; у него просто есть контуры, только если документ способен до него добраться

Тот выбор убирает самую рискованную часть сабсеттинга готового документа. Перенумерация глифов означала бы переписывание каждой строки в каждом content stream, использующем шрифт, плюс потока отображения, плюс всего, что кеширует ID глифов. Сохранённые ID стоят немного размера, потому что loca держит по записи на каждый исходный глиф, но основную массу TrueType-программы составляют данные контуров, и уходит именно она. Композитные глифы прослеживаются до своих компонентов, поэтому буква с диакритикой сохраняет базовую букву и диакритику, из которой построена; циклические или слишком глубокие композитные ссылки топят шрифт, вместо того чтобы выдать битое замыкание

Сохранённые ID глифов после сабсеттинга встроенных TrueType шрифтов с PDFium Component в Delphi: глиф 4711 сохраняет номер и контуры, неиспользуемые глифы получают диапазоны loca нулевой длины при неизменном maxp.numGlyphs, поэтому content stream, CIDs и /CIDToGIDMap никогда не переписываются
Поскольку ID глифов переживают сабсет, content stream, CIDs и /CIDToGIDMap никогда не нуждаются в переписывании — выигрыш в размере приходит от выброшенных контуров, а не от перенумерации

Переименованный шрифт заодно следует ISO 32000-1 §9.6.4, требующему, чтобы имя шрифта-сабсета начиналось с шести заглавных букв и плюса. Компонент выводит шесть букв из дайджеста SHA-256 новой шрифтовой программы, сперва срезая существующий префикс ABCDEF+, разрешает коллизии между шрифтами одного документа и пишет новое имя и в /FontName дескриптора, и в /BaseFont словаря шрифта, включая родителя Type0 CIDFont

uses
  System.SysUtils, System.Classes, FPdfDocumentFontSubset;

procedure SubsetEmbeddedFonts(const SourceName, TargetName: string);
var
  Source, Target: TFileStream;
  Options: TPdfDocumentFontSubsetOptions;
  Report: TPdfDocumentFontSubsetReport;
  Output: TBytes;
begin
  Options := TPdfDocumentFontSubsetOptions.Default;
  Source := TFileStream.Create(SourceName, fmOpenRead or fmShareDenyWrite);
  try
    if not SubsetPdfDocumentFonts(Source, Options, Output, Report) then
      raise Exception.Create('Font subsetting failed: ' + Report.ErrorMessage);
  finally
    Source.Free;
  end;
  // Output — всегда полный PDF: перестроенный файл, когда Report.Applied
  // равен True, иначе побайтовая копия источника
  Target := TFileStream.Create(TargetName, fmCreate);
  try
    if Length(Output) > 0 then
      Target.WriteBuffer(Output[0], Length(Output));
  finally
    Target.Free;
  end;
end;

Как SubsetPdfDocumentFonts доказывает домен глифов CIDFont?

SubsetPdfDocumentFonts доказывает домен глифов шрифта CIDFontType2 по его отображению CID-в-глиф, и то отображение решает, сколько дополнительных улик требуется. Явный поток /CIDToGIDMap (ISO 32000-1 §9.7.4.2) уже есть список каждого глифа, к которому шрифт способен адресоваться, поэтому каждый GID в потоке вступает в домен. Отображающий поток крупнее 65536 записей или с нечётным числом байтов проваливает ограниченный профиль, и шрифт сохраняется

/CIDToGIDMap /Identity — трудный случай, и одновременно самый ходовой в современных файлах. Identity говорит, что CID равен GID, что не сообщает ничего о том, какие CIDs использует документ. Компонент потому требует первичной улики: явный массив ширин /W или /W2 (§9.7.4.3), чьи записи CID помечают глифы, для которых продюсер объявил метрики, или поток /CIDSet в дескрипторе шрифта (§9.8.3) — битмап с битом на CID, старшим битом вперёд. Голый Identity-шрифт без того и другого сохраняется с причиной Identity CIDToGIDMap requires an explicit W array or CIDSet stream

Ловушка в использовании /W как улики — /DW. Продюсеру разрешено опустить каждый CID, чья ширина равна ширине по умолчанию, и хорошо оптимизированный писатель делает ровно это для самых ходовых ширин глифов. Возьми домен из одного /W — те глифы тихо исчезли бы из сабсета. Чтобы закрыть ту дыру, компонент проходит по каждому шрифту Type0, чьи /DescendantFonts указывают на CIDFont, требует, чтобы его /Encoding был /Identity-H или /Identity-V, так что 2-байтный код есть CID, и сливает в домен исходные коды каждой записи beginbfchar и beginbfrange из его CMap /ToUnicode (§9.10.3). Любое прочее кодирование, либо поток /ToUnicode, не поддающийся декодированию в рамках бюджета, сохраняет шрифт

Решение по уликам для Identity CIDToGIDMap в сабсеттинге шрифтов PDFium Component для Delphi: явный отображающий поток уже перечисляет каждый глиф, Identity требует массив ширин /W или /W2 либо битмап /CIDSet, а исходные коды /ToUnicode закрывают слепое пятно /DW ширины по умолчанию
Identity CIDToGIDMap сам по себе ничего не говорит об использовании, поэтому сабсеттер требует улик /W или /CIDSet и сливает коды /ToUnicode, покрывая глифы, которые /W опускает при /DW

Простые TrueType шрифты: от Widths к ID глифов

Однобайтные шрифты /Subtype /TrueType доказываются через диапазон кодов и кодирование, а затем разрешаются в ID глифов через собственный cmap шрифтовой программы. Компонент требует 0 <= FirstChar <= LastChar <= 255 и массив /Widths длиной ровно LastChar - FirstChar + 1; короткий, длинный или нечисловой массив значит, что домен кодов не доказан, и шрифт остаётся как есть

Каждый код того диапазона, плюс любой код, названный в /Differences или покрытый /ToUnicode, затем отображается так, как описывает ISO 32000-1 §9.6.6. Коды под /WinAnsiEncoding, /MacRomanEncoding или /StandardEncoding идут через соответствующую таблицу в Unicode. Имена глифов /Differences разрешаются через Adobe Glyph List, имена uniXXXX и uXXXX, 258 стандартных имён глифов Macintosh и собственную таблицу имён post формата 2.0 шрифта. Полученные значения ищутся в под-таблицах cmap форматов 0, 4, 6 и 12, включая Unicode (3,1), (3,10) и (0,*), Mac Roman (1,0) и Windows Symbol (3,0) на F000 плюс код

Когда у шрифта нет вовсе базового кодирования, компонент отмечает объединение всех правдоподобных интерпретаций, вместо того чтобы угадывать одну. Это сохраняет несколько глифов, которые наверняка не используются, — но лучше ошибаться именно в эту сторону. Противоположный случай строг: имя /Differences, отображающееся ни в один доказанный ID глифа, отвергает весь шрифт, потому что одно неразрешённое имя значит, что домен неполон

procedure ReportFontSubset(Source: TStream; Log: TStrings);
var
  Options: TPdfDocumentFontSubsetOptions;
  Report: TPdfDocumentFontSubsetReport;
  Output: TBytes;
  I: Integer;
begin
  Options := TPdfDocumentFontSubsetOptions.Default;
  Options.SubsetOptions.MinimumSavingPercent := 10;  // на каждый шрифт
  Options.MinimumDocumentSavingPercent := 5;         // весь файл
  if not SubsetPdfDocumentFonts(Source, Options, Output, Report) then
  begin
    Log.Add('Rejected: ' + Report.ErrorMessage);
    Exit;
  end;
  Log.Add(Format('Fonts %d, subset %d, preserved %d, glyphs %d',
    [Report.FontCount, Report.SubsetFontCount, Report.PreservedFontCount,
     Report.GlyphCount]));
  Log.Add(Format('Font bytes %d -> %d, file bytes %d -> %d',
    [Report.OriginalFontBytes, Report.SubsetFontBytes,
     Report.SourceByteCount, Report.OutputByteCount]));
  for I := 0 to High(Report.Issues) do
    Log.Add(Format('  object %d kept: %s',
      [Report.Issues[I].ObjectNumber, Report.Issues[I].Reason]));
end;

Что SubsetPdfDocumentFonts оставляет в покое?

SubsetPdfDocumentFonts оставляет в покое каждый шрифт, который не может доказать, и репортит каждый в Report.Issues с номером объекта и причиной вместо провала документа. Report.Complete равен True, только когда ни один шрифт не был сохранён. Сохраняемые случаи стоит знать, прежде чем обещать снижение размера:

  • Программы Type 1 /FontFile и CFF или OpenType /FontFile3, репортимые как Only TrueType FontFile2 programs are supported
  • Identity CIDFonts без /W, /W2 или /CIDSet, и CIDFonts без отображающего потока и без /Identity
  • Шрифтовые программы, на которые ссылаются откуда-то, кроме их шрифтовых дескрипторов, и дескрипторы, разделяемые с потребителем, который не CIDFontType2 и не простой TrueType
  • Шрифты, чей сабсет не достигает SubsetOptions.MinimumSavingPercent, и все шрифты разом, когда перестроенный файл не достигает MinimumDocumentSavingPercent; в том случае Report.Applied равен False, а вывод — исходные байты

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

Сабсеттинг нового текста при сохранении через saSubsetNewFonts

Шрифты, которые PDFium Component встраивает в текущей сессии, — отдельный случай, обслуживаемый опцией сохранения saSubsetNewFonts, а не SubsetPdfDocumentFonts. saSubsetNewFonts отображается на флаг PDFium FPDF_SUBSET_NEW_FONTS, который гоняет нативный сабсеттер HarfBuzz по шрифтам, загруженным из системы нативными вызовами вроде TPdf.AddText, включая гарнитуры OpenType CFF. Апстримный PDFium всё ещё помечает флаг экспериментальным. Он не трогает шрифты, уже лежавшие в файле

var
  Pdf: TPdf;
begin
  Pdf := TPdf.Create(nil);
  try
    Pdf.CreateDocument;
    Pdf.AddPage(0, 595, 842);
    Pdf.AddText('Quarterly statement', 'Arial', 14, 72, 770, clBlack, $FF, 0.0);
    if not Pdf.SaveAs('statement.pdf', saSubsetNewFonts) then
      raise Exception.Create('Save failed');
  finally
    Pdf.Free;
  end;
end;

В пробнике компонента документ с текстом Arial ужался с 590 КБ до 22 КБ под saSubsetNewFonts, а извлечение текста из сохранённого файла всё ещё совпадало в точности. Цветной шрифт COLR v0, Segoe UI Emoji на 7,0 МБ, прошёл тот же путь до 10,5 КБ, отрисовавшись базовыми контурами, поскольку у PDF нет понятия слоёв COLR. Вариативные шрифты — исключение, и блок стоит перед сабсеттером: GDI GetFontData вернул замещающий поток 18,3 МБ для файла Segoe UI Variable на 1,85 МБ, и создание текстового объекта провалилось прежде, чем сабсеттинг начался. В разборе встраивания шрифтов CID Type 2 из Delphi покрыт путь встраивания, который те шрифты проходят сперва

Где заканчивается доказательство

Доказательство внутри SubsetPdfDocumentFonts ровно настолько сильно, насколько сильны шрифтовые метаданные, написанные продюсером. У Identity CIDFont, чья улика — только /W, остаётся одно слепое пятно: глиф, чья ширина равна /DW, у которого нет записи /ToUnicode и которого дескриптор не перечисляет в /CIDSet. Такой глиф недоказуем из ресурсов, и компонент не может знать о его существовании без сканирования content stream, которого он нарочно избегает. Продюсеры, опускающие разом /ToUnicode и /CIDSet при ужимании /W, редки, но существуют, поэтому для архива из неизвестного генератора отрендерите сэмпл до и после и сравните пиксели и извлечённый текст

Перед пакетным прогоном полезно также знать, с чем имеешь дело. Подход из статьи о чтении свойств шрифтов PDF с PDFium Component сообщает, какие шрифты вообще встроены, а Report.Issues из сухого прогона — какие из них будут сохранены и почему. Значения по умолчанию заодно ограничивают работу: 1024 шрифта, около миллиона глифов суммарно, 128 МБ источника и фиксированный рабочий бюджет, с опциональным CancellationToken для долгих пакетных заданий

FAQ

Меняет ли SubsetPdfDocumentFonts текст или вёрстку PDF?

Нет. Исходные ID глифов, CIDs, ширины и content stream сохраняются, поэтому извлечение текста и рендеринг используют те же глифы, что и раньше; удаляются только недосягаемые контуры, а имена шрифтов получают новый префикс сабсета

Почему встроенный шрифт не был сабсетнут?

Прочтите Report.Issues. Каждый сохранённый шрифт несёт причину вроде отсутствующих /W или /CIDSet у Identity CIDFont, не-TrueType программы, кривого массива /Widths или экономии ниже порога

Равно ли saSubsetNewFonts SubsetPdfDocumentFonts?

Нет. saSubsetNewFonts делает сабсет шрифтов, встроенных в текущей сессии, при сохранении, тогда как SubsetPdfDocumentFonts работает над существующим PDF-потоком и заменяет шрифты, уже встроенные другим продюсером

Сабсеттинг шрифтов документа, сабсеттинг при сохранении и проход обрезки осиротевших объектов за ними поставляются с PDFium Component for Delphi and C++Builder, оборачивающим PDFium для проектов на Delphi, C++Builder и Lazarus