Чтобы выяснить, куда на самом деле уходит размер PDF-файла, losLab PDF Library предоставляет AuditDocumentSpace, который классифицирует каждый косвенный объект по двенадцати категориям — изображения, программы шрифтов, словари шрифтов, потоки содержимого, форма-объекты XObject, потоки объектов, встроенные файлы, метаданные, дерево структуры, аннотации, дерево страниц, прочее — и сообщает число объектов, сохранённые байты и процентную долю каждой
Ситуация, для которой это существует, знакома. 40-страничный отчёт выходит из вашего генератора весом в 80 МБ, клиент спрашивает почему, а вы можете предложить только догадку. Наверное, изображения. Может быть, шрифты. Поэтому вы включаете даунсэмплинг, отправляете — и файл оказывается 74 МБ, потому что реальный вес был совсем в другом месте. Наша сопутствующая статья о субсеттинге шрифтов и даунсэмплинге изображений рассказывает, как уменьшить PDF; эта статья посвящена шагу, который должен идти первым — измерению того, что вы собираетесь уменьшать
Почему нужно измерять до сжатия?
Потому что три стандартных прохода оптимизации дают совершенно разную отдачу на любом конкретном файле, и ничто в файле не подскажет, какой из них применим, пока вы не подсчитаете. Субсеттинг шрифтов в документе, чьи шрифты уже занимают 2% байт, — это вечер, потраченный на сдвиг ошибки округления. Даунсэмплинг изображений в файле, чей объём составляют несжатые потоки содержимого, приносит такое же разочарование. Оптимизатор — не самая сложная часть, он есть в каждой библиотеке. Знать, какой оптимизатор нацелить на этот файл, — вот в чём сложность, и это вопрос учёта, а не сжатия. Аудит также ловит случаи, когда ответ — вообще никакой оптимизатор: файл, оказавшийся на 60% встроенными вложениями, нуждается не в лучшем сжатии, а в разговоре о том, место ли этим вложениям в документе, а файл, на 30% состоящий из дерева структуры, платит за тегирование доступности, что обычно осознанная цена, которую не следует молча срезать. Как только байты атрибутированы, вы принимаете продуктовое решение, опираясь на цифры, а не хватаетесь за ближайший переключатель
Что содержит отчёт по двенадцати категориям
AuditDocumentSpace возвращает дескриптор списка строк, а не запись, поэтому отчёт без изменений переживает плоские фасады DLL и COM. Список содержит итоговую строку Total,Objects,Bytes,100.0, за которой следуют ровно двенадцать строк Category,Objects,Bytes,Percent в фиксированном порядке, который является частью контракта: Images, Font programs, Font dictionaries, Content streams, Form XObjects, Object streams, Embedded files, Metadata, Structure tree, Annotations, Page tree, Other. Тринадцать строк, всегда, даже когда категория пуста
var
Lib: TPDFlib;
ListID, I: Integer;
begin
Lib := TPDFlib.Create;
try
if Lib.LoadFromFile('report.pdf', '') <> 1 then
Exit;
ListID := Lib.AuditDocumentSpace; // 0 when no document is selected
if ListID = 0 then
Exit;
try
// GetStringListItem is 1-based: items run 1..GetStringListCount
for I := 1 to Lib.GetStringListCount(ListID) do
Memo1.Lines.Add(Lib.GetStringListItem(ListID, I));
finally
Lib.ReleaseStringList(ListID);
end;
finally
Lib.Free;
end;
end;
Одна деталь Delphi в этом цикле укусит вас ровно один раз. GetStringListItem использует индексацию элементов с единицы, согласованно с GetStringListCount, а выход за диапазон возвращает пустую строку, а не вызывает исключение. Напишите цикл по привычке как for I := 0 to Count - 1 — и получите пустую первую строку, молча пропущенную последнюю строку и ни одного исключения, которое подсказало бы, что индексация неверна. Сам отчёт будет выглядеть почти правильно, а это худший режим отказа, какой только может быть у диагностического инструмента
Почему аудит использует длину хранения, а не декодированный размер?
Потому что длина хранения — это одновременно и нужное число, и дешёвое в получении число. Каждый косвенный объект несёт TPDFIndObj.FLength, необработанную длину в байтах, которую объект занимает в разобранном файле. Использование этой длины означает, что изображение DCTDecode весом 900 КБ отчитывается как 900 КБ — байты, которые оно стоит вам на диске, — а не как 40 МБ RGB-сэмплов, в которые оно декодируется. Это также означает, что аудиту никогда не приходится ничего декодировать: лениво загруженные объекты остаются ленивыми, фильтры остаются незапущенными, а аудит файла в 500 МБ — это проход по заголовкам объектов, а не полный цикл распаковки
Второе правило — защита от двойного счёта. Когда объект находится внутри сжатого потока объектов, что указывается ненулевым FObjStrNum, его число байт записывается как ноль. Его хранение уже оплачено один раз потоком-контейнером, который ISO 32000-1 §7.5.7 определяет как поток /Type /ObjStm, содержащий множество объектов в одной полезной нагрузке, сжатой Flate. Начисление каждому члену своей доли и одновременно начисление за сам контейнер раздуло бы итог сверх реального размера файла. У этого есть прямое следствие для того, как читать вывод, о чём ниже и подробнее в нашей статье о потоках объектов и потоках перекрёстных ссылок
Почему программа шрифта не может классифицировать себя сама?
Потому что файл шрифта TrueType, встроенный в PDF, не несёт никакого маркера, говорящего об этом. ISO 32000-1 §9.8.1 определяет встроенную программу шрифта как значение /FontFile, /FontFile2 или /FontFile3 в дескрипторе шрифта, а словарь потока на другом конце этой ссылки несёт /Length1 и ключи фильтров, но не /Type и не /Subtype, идентифицирующие его как шрифт. Рассмотренный изолированно, это анонимный двоичный поток. Только дескриптор, указывающий на него, знает, что это такое. Та же асимметрия проявляется для аннотаций: §12.5.2 делает /Type /Annot необязательным в словаре аннотации, поэтому надёжным сигналом является членство в массиве /Annots страницы, а не сам словарь
Поэтому классификация выполняется в два прохода. Первый проход читает собственные /Type и /Subtype каждого объекта и берёт лёгкие победы: /ObjStm, /Subtype /Image, /Subtype /Form, /Type /Font и /Type /FontDescriptor, /Metadata, /EmbeddedFile и /Filespec, /StructTreeRoot и /StructElem, /Annot, /Page и /Pages. Всё остальное предварительно попадает в Other. Второй проход затем обходит ссылающуюся сторону и переопределяет: каждый словарь страницы переприписывает свой /Contents потокам содержимого, свои записи /Annots — аннотациям, а свой /Thumb — изображениям, в то время как каждый словарь шрифта обходит собственную цепочку дескриптора
// Shape of the second pass: the referrer names the object
Descriptor := DictOf(FontDict.FindValueByKeyName('FontDescriptor'));
if Assigned(Descriptor) then
begin
MarkRef(FontDict.FindValueByKeyName('FontDescriptor'), catFontDicts);
MarkRef(Descriptor.FindValueByKeyName('FontFile'), catFontPrograms);
MarkRef(Descriptor.FindValueByKeyName('FontFile2'), catFontPrograms);
MarkRef(Descriptor.FindValueByKeyName('FontFile3'), catFontPrograms);
end;
// Type0 fonts keep the descriptor one level down
Descendants := FontDict.FindValueByKeyName('DescendantFonts', True);
if (Descendants is TPDFArray) and (TPDFArray(Descendants).Count > 0) then
MarkFontProgramRefs(DictOf(TPDFArray(Descendants).Item[0]));
Чтение отчёта и выбор следующего шага
Читайте сначала доли, затем количество объектов, и относитесь к любому большому разрыву между ними как к сигналу. Современный PDF помещает большинство своих мелких словарей внутрь потоков объектов, поэтому строки Page tree и Structure tree регулярно показывают десятки объектов почти при нулевом числе байт — их реальная стоимость свёрнута в строку Object streams. Если сама строка Object streams велика, файл насыщен структурой, похожей на метаданные, а не содержимым, и рычаг здесь — удаление объектов, а не их сжатие. Потоки внешнего вида аннотаций ведут себя похоже: они несут /Subtype /Form, поэтому обильно проштампованный документ показывает свой вес под Form XObjects, а строка Annotations остаётся небольшой
function CategoryShare(Lib: TPDFlib; ListID: Integer;
const Category: string): Double;
var
I: Integer;
Parts: TArray<string>;
Inv: TFormatSettings;
begin
Result := 0;
Inv := FormatSettings;
Inv.DecimalSeparator := '.'; // the report is locale-independent
for I := 2 to Lib.GetStringListCount(ListID) do // line 1 is Total
begin
Parts := string(Lib.GetStringListItem(ListID, I)).Split([',']);
if (Length(Parts) = 4) and SameText(Parts[0], Category) then
Exit(StrToFloatDef(Parts[3], 0, Inv));
end;
end;
Если вы разбираете проценты, а не просто отображаете их, важны два формальных факта. Десятичный разделитель всегда буквальная точка независимо от локали машины, поэтому разбор с использованием текущих FormatSettings на немецкой или французской рабочей станции завершится ошибкой или, того хуже, неверным чтением. И конечные нули убираются, поэтому категория, занимающая ровно 40% байт, печатается как 40, а не 40.0 — никогда не рассчитывайте на фиксированное число знаков после запятой. Имея долю на руках, маршрутизация механическая: доминирующая доля Images указывает на DownsampleImages, доминирующая доля Font programs — на SubsetEmbeddedFonts, а объёмные Content streams — на CompressContent
О чём аудит намеренно не сообщает
Итог — это сумма по косвенным объектам, а PDF-файл немного больше суммы своих объектов. Заголовок файла, трейлер, межобъектные пробелы и классическая таблица перекрёстных ссылок не являются косвенными объектами, поэтому эти байты не приписываются ничему, и итог аудита оказывается чуть меньше размера на диске. Поток перекрёстных ссылок — другое дело: это реальный объект с /Type /XRef, поэтому в современном файле эти байты действительно появляются, в категории Other. Ни то ни другое поведение не является дефектом, но если вы сверяете аудит с числом байт от файловой системы, вот откуда берётся разрыв
Стоит прямо указать ещё две границы. Во-первых, цифры описывают загруженный файл, а не создаваемый: для объектов, построенных в памяти, у которых ещё нет сохранённой длины, размер откатывается к сериализованному выводу с номинальной надбавкой на словарь потока, что является оценкой предстоящей записи, а не измерением. Проводите аудит после сохранения и повторной загрузки, если нужны точные цифры. Во-вторых, толстая строка Other — это находка, а не отчёт об ошибке — обычно она означает осиротевшие объекты, на которые уже ничто не ссылается, а это работа для пометочно-очистительной сборки мусора, а не для какого-либо прохода сжатия
При таком подходе аудит меняет форму разговора. Вместо того чтобы гадать над 80-мегабайтным отчётом, вы открываете его, выполняете один вызов и читаете, что изображения — 8%, программы шрифтов — 61%, а документ встраивает девять полных программ шрифтов ради фирменного стиля, использующего три гарнитуры. Это исправимый ответ с прикреплённым к нему числом. AuditDocumentSpace вместе с проходами оптимизации, к которым он направляет, поставляется в составе losLab PDF Library для Delphi и C++Builder, где справочные страницы документируют полный список категорий и окружающий его API списков строк