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

Валидиране на компресирани PDF файлове: Обектни и XRef потоци

Пишете малък валидатор. Той отваря PDF, търси до края, намира startxref, чете отместването и очаква да попадне на ключовата дума xref с таблица с кръстосани препратки (cross-reference table) с фиксирана ширина под нея. От тази таблица събира отмествания (offsets) на обекти, след което сканира назад за ключовата дума trailer, за да научи /Root и /Size. Работи перфектно с всеки файл, който сте генерирали, за да го тествате. Тогава пристига файл, създаден от текуща версия на Word или от библиотека, която е насочена към PDF 1.5, и валидаторът го обявява за повреден. Няма ключова дума xref, където сочи отместването, никъде няма trailer речник (dictionary) и таблицата с обекти, която валидаторът е изградил, е почти празна. Файлът е валиден. Валидаторът го чете през петнадесетгодишна леща

Това е най-честата причина, поради която проверка на PDF на ниво байт, написана срещу класическото оформление, се проваля при съвременни документи. Структурата, от която зависи, таблицата с кръстосани препратки в обикновен текст (plaintext) и ключовата дума trailer, беше направена незадължителна в PDF 1.5 и често отсъства. Две функции я замениха: потокът с кръстосани препратки (cross-reference stream) и потокът с компресирани обекти (compressed object stream). И двете са описани в ISO 32000-1 и валидатор, който не знае за тях, вижда един здрав файл като купчина липсващи обекти

Какво промени PDF 1.5 относно опашката на файла

ISO 32000-1 §7.5.8 дефинира потока с кръстосани препратки, а §7.5.7 дефинира потока с обекти от тип /ObjStm. Заедно те позволяват на програмата за запис (writer) да изпусне двете структури, на които се фокусира (keys on) класическият анализатор (parser). PDF 1.5 файл може изобщо да не завършва с таблица xref. На нейно място обектът, към който сочи startxref, е обикновен стрийм обект, чийто речник носи /Type /XRef, и този поток съдържа данните за кръстосаните препратки в компактна двоична форма. Няма и ключова дума trailer, защото трейлърът вече е собственият речник на потока. Ключовете, които класическият анализатор търсеше, /Root, /Size и /ID, живеят в този речник

Втората промяна премества самите обекти. Вместо да записва всеки индиректен (indirect) обект на неговото собствено отместване на байтове (byte offset), програмата за запис може да опакова много малки обекти, речниците на страниците, речниците с анотации, дървото на структурата, в един единствен поток с обекти и да компресира целия контейнер с Flate. Отделните обекти вече нямат байтово отместване във файла. Те имат позиция вътре в компресиран blob. Валидатор, сканиращ суровите байтове за 1 0 obj, никога не ги намира, защото този текст съществува само след надуване (inflation). За класически анализатор половината от документа просто е изчезнал

Трейлър ключовете са в обикновен текст, дори в компресиран файл

Успокояващата част е, че четенето на трейлъра на поток с кръстосани препратки не изисква надуване (inflating) на каквото и да било. Един стрийм обект се записва като речник, последван от ключовата дума stream и след това компресираните байтове. Речникът е в обикновен текст (plaintext). Така че, когато startxref сочи към поток с кръстосани препратки, байтовете веднага след номера на обекта изглеждат като обикновен речник и /Root, /Size и /ID седят там на ясно, преди да започне ключовата дума stream и Flate данните

Това означава, че валидаторът може да научи трите факта, от които се нуждае най-много, къде е каталогът, колко обекта претендира (claims) файлът и идентификатора на файла, като анализира само речника на потока. Той не трябва да декомпресира данните за кръстосаните препратки и не трябва да интерпретира двоичните записи в тях. Работата, която побеждава наивния анализатор (naive parser), не е четенето на трейлъра; а намирането на обектите. Това са два разделими проблема и решаването на първия е евтино

Обектни потоци: хедър (header), след това Flate blob

Един обектен поток е контейнер. Неговият речник носи /Type /ObjStm, запис /N, даващ броя на пакетираните вътре обекти, и запис /First, даващ байтовото отместване в рамките на надутите данни, където започва тялото на първия обект. Компресираният полезен товар (compressed payload), след като бъде надут, започва с малък хедър от /N двойки цели числа. Всяка двойка е номер на обект и отместването на тялото на този обект спрямо /First. След хедъра идват самите тела на обектите, конкатенирани

Разширяването на един такъв е механично, след като байтовете бъдат надути. Прочитате речника, за да получите /N и /First, надувате потока с Flate декодер, обхождате (walk) водещите /N двойки, за да научите кой номер на обект на кое отместване живее, и след това изваждате всяко тяло, сякаш е обикновен индиректен обект. Единствената истинска зависимост е Flate декодерът, а вие вече имате такъв: Delphi се доставя с System.ZLib, а Free Pascal се доставя с модула zstream, като и двата обвиват zlib и надуват суров (raw) Flate поток без никакъв код на трети страни. Една рутина (routine), която добавя всеки извлечен обект към таблицата с обекти на валидатора, кара останалата част от валидатора, частта, която обхожда /Root и проверява дървото на страниците, да се държи точно както би се държала с класически файл

Какво не е нужно да имплементирате

Лесно е да надцените работата. Четенето на трейлър ключовете от компресиран файл не изисква декодиране на двоичните записи на потока с кръстосани препратки. Потокът с кръстосани препратки §7.5.8 използва три типа записи, а записът от тип 2, този, който казва този обект живее вътре в обектен поток N на индекс i, е това, което бихте декодирали, за да изградите пълна карта на отместванията (offset map). Имате нужда от тази карта, за да разрешите (resolve) произволни обекти по номер. Не ви е необходима, за да прочетете /Root, /Size и /ID, които са в речника с обикновен текст (plaintext), и не ви е необходима, за да разширите обектните потоци, защото всеки /ObjStm обявява собственото си съдържание чрез /N и /First

Също така не е необходимо да се справяте с функциите за прогнозиране (predictor functions) на PNG и TIFF, които потокът с кръстосани препратки може да приложи чрез своите /DecodeParms, само за да получите ключовете на трейлъра. Предикторите (predictors) филтрират двоичните редове с кръстосани препратки, за да ги накарат да се компресират по-добре; те нямат нищо общо с речника, който предшества потока. Следователно минималният ъпгрейд, който прави един класически валидатор запознат с модерния PDF (modern-PDF aware), е малък: когато startxref попадне на поток, а не на ключовата дума xref, анализирайте речника на потока за ключовете на трейлъра и разширете всички /ObjStm обекти, които срещнете, така че тяхното съдържание да влезе в таблицата с обекти. Декодирането на записи от тип 2 и предиктори е отделна, по-голяма задача, която можете да отложите, докато не се нуждаете истински от разрешаване на случайни обекти (random object resolution)

Защо проверката за съответствие трябва първо да разширява потоците

Това спира да бъде академично в момента, в който стартирате проверка на профила. PDF/A или PDF/X валидатор проверява специфични обекти: каталога на документа за масив /OutputIntents, потока /Metadata за XMP пакет с правилния идентификатор, всеки дескриптор на шрифт за вграден файл с шрифт, трейлъра за /ID. В компресиран файл повечето от тези обекти са вътре в обектни потоци. Валидатор, който не е разширил обектните потоци, не може да види ключовете на каталога, не може да намери метаданните и не може да изброи шрифтовете. Той ще отчете идеално съответстващ (conformant) документ като липсващ (missing) неговото изходно намерение (output intent), липсващ XMP и липсваща половината от неговата структура, защото доказателствата, от които се нуждае, все още седят във Flate blob, който никога не е надут

Редът има значение. Разширяването трябва да се случи преди да се стартират проверките, а не наред с тях, защото всяка проверка предполага, че може да достигне до обект по номер. Ако свържете проверка на профил директно към сканиране на сурови байтове, тя наследява слепотата на класическия анализатор и произвежда фалшиви нарушения точно върху съвременните файлове, които е най-вероятно да са добре оформени (well formed), тъй като на първо място са излезли от вериги от инструменти (toolchains), достатъчно нови, за да пишат потоци с кръстосани препратки

Оставяне на PDFium да свърши анализа (parsing) вместо вас

PDFium Component анализира потоците с кръстосани препратки и обектните потоци като част от зареждането на документ, което е практичният начин да избегнете ръчното писане (hand-rolling) на стъпката на надуване и разширяване. Когато заредите файл с компонента TPdf, обектите, опаковани в /ObjStm контейнери, вече са разрешени (resolved) и входните точки (entry points) за валидиране виждат напълно разширения документ. ValidatePdfA връща запис TPdfAValidationResult, чието поле Conformance е стойност на TPdfAConformance, като pac1b или pacNone, чието поле Issues е набор от намерените специфични проблеми, и чийто метод IsCompliant е верен (true), само когато е открито ниво на съответствие (conformance level) и наборът от проблеми е празен. Тъй като обектите са били разширени по време на зареждане, се намира масив /OutputIntents или вграден шрифт, който е живял вътре в обектен поток, а не се съобщава като липсващ

uses
  PDFium, FPdfPdfa;

function CheckPdfA(const FileName: string): TPdfAValidationResult;
var
  Pdf: TPdf;
begin
  Pdf := TPdf.Create(nil);
  try
    Pdf.FileName := FileName;
    Pdf.Active := True;            // анализира xref/обектни потоци при зареждане
    Result := Pdf.ValidatePdfA;    // вижда разширената таблица с обекти
  finally
    Pdf.Free;
  end;
end;

Същото важи и за ValidatePdfX, който връща TPdfXValidationResult със същата форма. Смисълът на маршрутизирането (routing) през PDFium е, че описаната по-горе структурна декомпресия се случва веднъж, правилно, вътре в програмата за зареждане (loader), така че вашият код за валидиране никога не вижда разликата между класически файл и напълно компресиран такъв. И двата пристигат във валидатора като разрешен (resolved) набор от обекти

function PdfXConformanceName(C: TPdfXConformance): string;
begin
  case C of
    pxc1a: Result := 'PDF/X-1a';
    pxc3 : Result := 'PDF/X-3';
    pxc4 : Result := 'PDF/X-4';
  else
    Result := 'none';
  end;
end;

var
  Pdf: TPdf;
  R  : TPdfXValidationResult;
  Issue: TPdfXValidationIssue;
  IssueCount: Integer;
begin
  Pdf := TPdf.Create(nil);
  try
    Pdf.FileName := 'Press_Ready.pdf';
    Pdf.Active := True;
    R := Pdf.ValidatePdfX;
    if R.IsCompliant then
      Writeln('PDF/X conformance: ', PdfXConformanceName(R.Conformance))
    else
    begin
      IssueCount := 0;
      for Issue in R.Issues do   // Issues е множество: пребройте неговите членове
        Inc(IssueCount);
      Writeln('Not conformant; issue count = ', IssueCount);
    end;
  finally
    Pdf.Free;
  end;
end;

Ако байтовете вече са в паметта, а не на диска, същата последователност на зареждане-след-това-валидиране (load-then-validate sequence) работи чрез overload (претоварване) LoadDocument(const Data: TBytes), който приема суровото съдържание на файла и анализира неговите кръстосани препратки и обектни потоци по същия начин, както го прави пътят на файла. Изводът за ръчно написан валидатор е структурното правило, а не API-то: прочетете ключовете на трейлъра от речника на потока в обикновен текст, разширете всеки /ObjStm с Flate декодер, преди да обходите документа, и третирайте декодирането на двоичните записи за кръстосани препратки като по-голямата, незадължителна задача, каквато е

След като структурата е разширена, валидаторът може да задвижи остатъка от работния процес върху нея. За програма за проверка (preflight harness) на командния ред (command-line), която отчита съответствието в папка с входни данни, вижте нашето ръководство за изграждане на CLI за групов отчет за проверка (batch preflight report CLI). Когато валидирането е порта (gate) преди раздробяването на голям документ, техниките в нашето ръководство за разделяне на PDF документи на множество файлове се съчетават естествено с модела зареждане-и-проверка (load-and-check pattern), показан тук. И двете се надграждат върху повърхността за зареждане и валидиране на PDFium Component за Delphi и C++Builder