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

HotPDF CompressDocument: компактные сабсеты шрифтов

HotPDF THotPDF.CompressDocument — один переключатель, который заставляет BeginDoc выдавать самый маленький сжатый без потерь PDF, на который способен компонент: FlateDecode на максимальном уровне, cross-reference stream с object streams, субсеттинг шрифтов и компактные сабсеты шрифтов, перенумеровывающие оставленные глифы за явной /CIDToGIDMap. EndDoc затем возвращает ваши собственные настройки. Трёхстраничный тестовый документ из Arial и SimSun похудел с 10.2 МБ до 20 КБ при идентичном рендеринге

Что CompressDocument на самом деле включает?

CompressDocument перекрывает шесть настроек писателя плюс лимит object streams на один документ и возвращает всё после. На BeginDoc, прежде чем устоится версия PDF, HotPDF запоминает ваши значения и выставляет Compression в cmFlateDecode, CompressionLevel в clMaximum, включает EnableFontSubsetting и CompactFontSubsetting, а также UseXRefStream и UseObjectStreams (ISO 32000-1 §7.5.7 и §7.5.8). Object streams требуют PDF 1.5, поэтому более старый Version поднимается до 1.5, если не зафиксирован. PDF/A-1 запрещает обе структуры, так что PDF/A-1 документ сохраняет классическую таблицу кросс-ссылок и получает только Flate и шрифтовую работу. Изображения остаются ровно такими, какими вы их врезали

Схема жизненного цикла CompressDocument в HotPDF под Delphi: BeginDoc запоминает собственные значения писателя, перекрывает шесть настроек, включая Compression и UseObjectStreams, на один документ, а EndDoc возвращает каждое взятое значение в своём внешнем finally, тогда как само свойство CompressDocument остаётся True
Шесть настроек писателя и лимит object streams берутся взаймы ровно на один документ и возвращаются, когда отрабатывает EndDoc, так что упавший отчёт не оставит компонент залипшим на максимальном сжатии
var
  Pdf: THotPDF;
begin
  Pdf := THotPDF.Create(nil);
  try
    Pdf.AutoLaunch := False;
    Pdf.FileName := 'invoice-2026-1042.pdf';
    Pdf.CompressDocument := True;    // применяется BeginDoc, отменяется EndDoc
    Pdf.BeginDoc;
    Pdf.CurrentPage.SetFont('Arial', [], 12);
    Pdf.CurrentPage.TextOut(40, 40, 0, 'Invoice 2026-1042');
    Pdf.EndDoc;
  finally
    Pdf.Free;
  end;
end;

Возврат происходит в самом внешнем finally в EndDoc, так что исключение посреди отчёта не оставит долгоживущий компонент залипшим на максимальном сжатии до следующей задачи. Само свойство CompressDocument остаётся True; назад уходят только шесть взятых взаймы настроек. С версией обходятся аккуратнее. HotPDF откатывает собственное поднятие до 1.5, только если документ всё ещё кончает на 1.5: если другая фича задрала файл до 1.6 во время прогона (врезанный шрифт OpenType, скажем), высокая версия остаётся — ровно как была бы без сжатия

Почему сабсеты шрифтов без компакции всё ещё толстые?

Классический TrueType-сабсет выбрасывает контуры, которые вы никогда не рисуете, но оставляет каждый glyph ID на его месте, и именно эта нумерация делает его тяжёлым. Поток содержимого показывает CIDs, равные исходным GIDs, поэтому сабсет вынужден держать офсет loca и запись hmtx для каждого слота до самого высокого оставленного глифа, пустой или нет. Для латинского шрифта этот оверхед — шум. Для CJK-шрифта вроде SimSun, чьи иероглифы сидят глубоко в очень большой таблице глифов, два китайских символа тащат за собой таблицы, рассчитанные на весь шрифт. Правила замыкания сабсета для шейпленных глифов решают, какие глифы выживут; компакция — о том, сколько стоят выжившие

CompactFontSubsetting перенумеровывает оставленные глифы в плотный диапазон с нуля и пишет поток /CIDToGIDMap на CIDFont, который ISO 32000-1 §9.7.4.2 определяет как таблицу двухбайтовых GIDs, индексированных по CID. Вся хитрость — в этой таблице. Потоки содержимого, массив ширин /W и ToUnicode CMap хранят исходные CIDs, так что менять уже написанное не требуется; в карту переезжает только lookup из CID в глиф. В тесте, мотивировавшем фичу, SimSun с двумя символами похудел с 24.8 КБ шрифтовых данных до 3.1 КБ

Сравнение разреженного сабсета шрифта HotPDF, хранящего записи loca и hmtx для каждого исходного glyph ID вплоть до наивысшего оставленного GID, с выводом CompactFontSubsetting, который плотно перенумеровывает оставленные глифы с нуля и проецирует CIDs через поток CIDToGIDMap, тогда как потоки содержимого, /W и ToUnicode не меняются
Перенумерация выносит стоимость из шрифтовой программы в один маленький map-поток — два символа SimSun похудели с 24.8 КБ до 3.1 КБ, не тронув ни байта уже написанного содержимого

У компакции жёсткие пределы, и деградирует она тихо, а не падает. HotPDF строит компактные сабсеты только для Type 0 TrueType-шрифтов — как выставленных через SetFont с включённым субсеттингом, так и зарегистрированных через RegisterUnicodeTTF. Простой TrueType находит глифы через cmap внутри шрифтовой программы, которую перенумерация сломала бы, поэтому он держит разреженный сабсет. У OpenType-CFF компактного пути тоже нет. Компактная сборка, потерпев неудачу, откатывается к разреженному сабсету вместо исключения. Свойство по умолчанию выключено, так что существующий вывод остаётся побайтово тем же, а под PDF/A зарегистрированный Unicode-шрифт всегда получает компактный сабсет

Pdf.EnableFontSubsetting := True;
Pdf.CompactFontSubsetting := True;   // работает и без CompressDocument
Pdf.BeginDoc;
Pdf.CurrentPage.SetFont('SimSun', [], 12);
Pdf.CurrentPage.TextOut(40, 40, 0, WideString('Total: '#$4E2D#$6587));
Pdf.EndDoc;

Как упаковочный писатель отжимает структуру файла?

Когда шрифты и потоки малы, словари и данные кросс-ссылок становятся крупнейшей оставшейся статьёй расходов, поэтому писатель object streams за CompressDocument подрезает и их. Руководство по object streams и инкрементальным обновлениям разбирает сам формат контейнера; путь сжатия добавляет поверх четыре улучшения:

  • Компактный синтаксис по ISO 32000-1 §7.2.2: пробел пишется только между двумя токенами, которые иначе слились бы как обычные символы, так что /Type /Page превращается в /Type/Page
  • Поля cross-reference stream берут любую ширину, какую §7.5.8.2 позволяет, так что файл под 16 МБ хранит каждый офсет в 3 байтах вместо 4
  • До 250 объектов уезжает в каждый object stream вместо привычных 100, если вы не выставили собственный лимит через ConfigureAdaptiveObjectStreamPacking
  • Когда файл не зашифрован, Catalog и словарь Info тоже пакуются в object streams; шифрованный вывод держит их на верхнем уровне

С компактным синтаксисом пришла ловушка, которую стоит знать, если вы расширяете писатель. Подписание вписывает подпись после записи файла, ища в байтах литеральные заглушки /ByteRange ( и /Contents <, а компактное написание превратило бы их в /ByteRange( и /Contents<, которые поиск никогда не найдёт. Словари подписей (Type Sig или DocTimeStamp, FT Sig) и словарь шифрования поэтому держат разреженную вёрстку. Родственный дефект задевал сборки до v2.766.41: каждое сохранение с object streams, CompressDocument включительно, начиналось с двух строк заголовка %PDF- — обновляйтесь, если строгий валидатор помечает ваш вывод

Можно ли сжать уже загруженный PDF?

Да, через перегрузку с опциями CompressLoadedDocument(Options, Info), которая гоняет те же шаги без потерь по существующему файлу. С THPDFLoadedDocumentCompressionOptions.Default она вычищает неиспользуемые ресурсы страниц, сливает одинаковые шрифты и формы, режет сабсеты у врезанных шрифтов с компактными сабсетами, пересжимает нефильтрованные, Flate, LZW, ASCII и RunLength потоки через Flate, когда результат меньше, и заставляет следующее сохранение использовать object streams. HighRatioFlate по умолчанию выключен, а object streams пропускаются для PDF/A-1 и инкрементальных сохранений. Перегрузка CompressLoadedDocument без параметров — более старый, узкий вызов, который только сжимает Flate uncompressed-потоки

Схема CompressLoadedDocument в HotPDF под Delphi: вызов вычищает неиспользуемые ресурсы страниц, сливает одинаковые шрифты и формы, режет сабсеты врезанных шрифтов компактными сабсетами, пересжимает потоки через Flate, только когда результат меньше, и включает object streams для следующего сохранения, тогда как поля подписей триггерят RefusedBySignaturePolicy и оставляют файл нетронутым
Каждый шаг переписывает байты, накрытые подписью, поэтому весь документ отвергается, пока вы явно не разрешите инвалидацию — Info.BytesSaved тогда суммирует только работу с ресурсами, шрифтами и потоками
var
  Doc: THotPDF;
  Options: THPDFLoadedDocumentCompressionOptions;
  Info: THPDFLoadedDocumentCompressionInfo;
begin
  Doc := THotPDF.Create(nil);
  try
    Doc.AutoLaunch := False;
    Doc.LoadFromFile('quarterly-report.pdf');
    Options := THPDFLoadedDocumentCompressionOptions.Default;
    Doc.CompressLoadedDocument(Options, Info);
    if Info.RefusedBySignaturePolicy then
      Writeln(Format('Left untouched: %d signature fields', [Info.SignatureCount]))
    else
    begin
      Writeln(Format('Compact fonts: %d, stream bytes saved: %d',
        [Info.Fonts.CompactSubsetFontCount, Info.BytesSaved]));
      Doc.SaveLoadedDocument('quarterly-report-compact.pdf');
    end;
  finally
    Doc.Free;
  end;
end;

На загруженном пути важны две границы. Каждый шаг переписывает байты, накрытые подписью, поэтому документ с полями подписей отвергается целиком: вызов возвращает 0, выставляет RefusedBySignaturePolicy и не меняет ничего, пока вы не выставите AllowSignatureInvalidation, после чего Info.SignaturesInvalidated доложит, чем вы пожертвовали. Компакция здесь консервативнее, чем на пути создания. HotPDF уплотняет только шрифтовые программы, используемые исключительно шрифтами CIDFontType2 с Identity /CIDToGIDMap, где CID равен GID, и пропускает программы с уже существующим map-потоком, /CIDSet или таблицами цветных глифов вроде COLR, sbix, CBDT или SVG, потому что компактная пересборка выронила бы цветовые слои. Учтите также, что Info.BytesSaved суммирует только шаги ресурсов, шрифтов и потоков; выигрыш object streams проявляется при записи файла

Каких результатов ждать на практике?

Выигрыш следует за тем, какая доля файла — несжатая структура и разросшиеся шрифтовые данные, а не за числом страниц. Трёхстраничный образец Arial и SimSun сжался с 10.2 МБ до 20 КБ при генерации с CompressDocument и с 10.2 МБ до 19.8 КБ, когда несжатый оригинал загрузили и прогнали через CompressLoadedDocument, с идентичным рендерингом в обоих случаях. Уже компактный PDF почти не двигается: в регрессионном наборе такие файлы экономили в пределах от -0.07% до +0.06% исходного размера. Файлам, забитым фотографиями, достаётся мало, потому что ни один путь не трогает данные изображений

Если вы генерируете одни и те же CJK-отчёты каждую ночь, объединяйте компактные сабсеты с персистентным дисковым кэшем сабсетов шрифтов, чтобы работа субсеттинга не повторялась каждый прогон, и диффуйте сжатые выводы по содержимому объектов, а не по байтам: одно изменённое поле пере-Флейтит целый object stream. Полные справочники свойств и записей — на странице продукта HotPDF Delphi PDF component