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

Одит на размера на PDF в Delphi: разбивка по категории

За да разберете накъде всъщност отива размерът на PDF файл, losLab PDF Library предлага AuditDocumentSpace, който класифицира всеки индиректен обект в дванайсет категории — изображения, шрифтови програми, речници на шрифтове, потоци със съдържание, form XObjects, обектни потоци, вградени файлове, метаданни, дърво на структурата, анотации, дърво на страниците, друго — и отчита броя обекти, съхранените байтове и процентния дял на всяка

Ситуацията, за която това съществува, е позната. 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 използва индекси на елементи, започващи от 1, съответстващи на 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-компресиран payload. Начисляването на всеки член неговия дял, а после начисляването и на контейнера отново, би раздуло общата сума над реалния размер на файла. Това има пряко следствие за начина, по който четете изхода, разгледано по-долу и по-задълбочено в статията ни за обектни потоци и потоци за кръстосани референции

Защо шрифтова програма не може да класифицира сама себе си?

Защото 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 файл е малко повече от своите обекти. Заглавната част на файла, trailer-ът, интервалите между обектите и класическа таблица за кръстосани референции не са индиректни обекти, така че тези байтове не са приписани на нищо и общата сума от одита каца малко под размера на диска. Поток за кръстосани референции е различен — той е реален обект с /Type /XRef, така че в модерен файл тези байтове наистина се появяват, в категорията Other. Никое от двете поведения не е дефект, но ако равнявате одита спрямо байтов брой от файловата система, оттам идва разликата

Още две граници заслужават да бъдат казани ясно. Първо, числата описват файл, който е бил зареден, не такъв, който се авторства: за обекти, изградени в паметта, които все още нямат съхранена дължина, размерът се връща към сериализирания изход с номинален резерв за речника на потока, което е оценка на евентуалния запис, а не измерване. Одитирайте след запазване и презареждане, ако искате точни цифри. Второ, дебел ред Other е находка, не сигнал за бъг — обикновено означава осиротели обекти, към които вече нищо не реферира, което е работа за mark-and-sweep garbage collection, а не за какъвто и да е компресионен проход

Използван по този начин одитът променя формата на разговора. Вместо да гадаете за отчет от 80 МБ, отваряте го, изпълнявате едно извикване и четете, че изображенията са 8%, шрифтовите програми са 61%, и документът вгражда девет пълни шрифтови програми за фирмен стил, използващ три начертания. Това е поправим отговор с прикачено число. AuditDocumentSpace, заедно с оптимизационните проходи, към които ви насочва, се доставя в losLab PDF Library за Delphi и C++Builder, където референтните страници документират пълния списък с категории и API-то за списъци от низове около него