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

Зареждане на Hybrid-Reference PDF от Word и Excel в Delphi

Отворете PDF, произведен от Microsoft Word или Excel, прелистете го и нищо не изглежда необичайно. Заредете го в Delphi програма, прочетете броя на страниците обратно и числото е правилно. След това го запазете отново с включено криптиране и задачата се проваля с EListError, или изходът се отваря с предупреждение за повреден cross-reference. Файлът никога не е бил повреден (corrupt). Това е файл с хибридна референция (hybrid-reference) и същата структура, която позволява на петнадесетгодишен viewer да го отвори, е структурата, която побеждава loader, който спира да чете твърде рано

Това е един от най-честите начини, по които PDF конвейер, преминал всеки вътрешен тест, среща файл, който не може да направи пълен цикъл (round-trip). Всички входове (inputs) са генерирани вътрешно (in-house), така че никога не са били хибридни. Първият хибриден файл пристига в деня, в който клиент препрати фактура, експортирана от електронна таблица

Какво реално пишат Word и Excel

ISO 32000-1 описва hybrid-reference оформлението в §7.5.8.4. Приложение, което иска функции на PDF 1.5 като обектни потоци (object streams), докато все още позволява на PDF 1.4 четец да отвори файла, записва cross-reference информацията два пъти. Има класическа cross-reference таблица – ASCII редовете с фиксирана ширина, които завършваха всеки PDF до версия 1.4 – и има cross-reference поток (stream), който индексира останалото. Трейлърът (trailer) на класическата секция носи запис /XRefStm, чиято стойност е байтовото отместване (byte offset) на този поток

Разделението на труда е умишлено. Обекти, до които стар четец трябва да достигне, сред които каталогът и дървото на страниците, са адресируеми от класическата таблица. Обектите, които са били сгънати в компресирани обектни потоци, са маркирани като свободни (free) в класическата таблица със запис от тип f, така че 1.4 четец прескача направо покрай тях и никога не се спъва в структура, която не може да парсва (parse). Тяхното реално местоположение живее само в cross-reference потока. Подписът на такъв файл е неговата опашка: къса класическа секция, често нищо повече от xref, последвано от хедър на подсекция 0 0, чийто трейлър сочи към /XRefStm, където седят действителните данни за възстановяване

Защо правилният брой страници не доказва нищо

Тъй като каталогът и дървото на страниците са умишлено достижими от класическата таблица, loader, който чете само тази таблица, намира /Root, обхожда дървото на страниците и докладва правилния брой страници. Всичко, от което един стар четец се нуждае, е налице, така че файлът изглежда здрав. Обектите, които липсват, са тези, пакетирани в обектни потоци: речници (dictionaries) на полета от AcroForm, структурни елементи на tagged-PDF, дългата опашка от малки речници, които никога не е трябвало да бъдат видими за legacy viewer

Не забелязвате празнината, докато нещо не докосне тези обекти, а пълно повторно запазване докосва всички тях. Обхождането на документа за неговото повторно криптиране или пренаписване е точно операцията, която иска всеки обектен номер поред, поради което симптомът изплува по време на запазване, а не по време на зареждане, далеч от своята причина

Капанът е детектор, който вижда xref и спира

Евтиният начин да решите как е индексиран даден файл е да следвате startxref и да инспектирате първите байтове, към които сочи. Ключовата дума xref означава класическа таблица; обект поток (stream object) означава cross-reference поток. Този тест е правилен за всеки файл, който се ангажира с една схема. Той е грешен за хибриден файл, чийто startxref се цели в класическа секция с единствената цел да удовлетвори старите четци, докато /XRefStm в трейлъра на тази секция е мястото, където по-голямата част от документа всъщност е индексирана. Детектор, който връща "класически" при първия xref, който срещне, никога не чете /XRefStm и всеки обект, който живее само в потока, става невидим

var
  Pdf: THotPDF;
  PageCount: Integer;
begin
  Pdf := THotPDF.Create(nil);
  try
    PageCount := Pdf.LoadFromFile('Invoice_XLS.pdf');  // count is correct
    // inspect or edit the loaded document here
    Pdf.SaveLoadedDocument('Invoice_secured.pdf');     // walks every object
  finally
    Pdf.Free;
  end;
end;

С въведения детектор за ранно излизане (early-exit detector), зареждането изглежда добре и при повторното запазване отсъстващите обекти обявяват себе си. Поправката не е да се четат повече байтове в началото; тя е да се разпознае хибридният трейлър и да се последва /XRefStm, преди да се реши, че файлът е готов (done)

Редът на сливане (merge order) не подлежи на преговори

След като и двата индекса бъдат прочетени, те могат да бъдат комбинирани само в една посока. Cross-reference потокът трябва да бъде слят (merged) първо, като класическите записи се попълнят около него. Причината е малката измама в сърцето на формата. Хибриден файл маркира своите компресирани обекти като свободни (free) в класическата таблица, така че старите четци да ги игнорират. Loader, който спазва политика "първи видян печели" (first-seen-wins) и чете класическата таблица първо, ще запише тези обектни номера като свободни, след което ще отхвърли записите от потока, които всъщност ги локализират, защото слотовете вече са заети. Обърнете реда и записите от тип 2 от потока, всеки от които е номер на обектен поток плюс индекс, печелят слотовете, които са предназначени да притежават, а класическите записи се настаняват (settle) около тях

Същата дисциплина предпазва от това по-стара ревизия да възкреси изтрит обект. Инкременталните актуализации (Incremental updates) се свързват назад чрез /Prev и свободен запис от тип 0 е страж (sentinel), че по-скорошна секция е пенсионирала (retired) обектен номер. На по-късна, по-стара секция във веригата не трябва да се позволява да презапише този страж с остаряло (stale) местоположение. Третирайте first-seen като авторитетно за маркери за свободни обекти и изтритият обект остава изтрит; третирайте го небрежно и собствената история на файла реанимира съдържание, което последната ревизия е премахнала

Какво означава това в HotPDF

Енджинът разрешава (resolves) hybrid-reference файлове вместо вас и прави това по всеки път, който трябва да парсва cross-reference данните. Заредете документ с LoadFromFile или LoadFromStream, направете вашите промени и извикате SaveLoadedDocument; или стартирайте операция с един изстрел (one-shot operation) като EncryptFile, която чете вход и записва изход. И в двата случая възстановяването (recovery) чете /XRefStm, слива секцията на потока преди класическите записи и разрешава обектите, които живеят в потоци, преди записът (write) да ги изброи (enumerates). Пътят на криптиране AES-256 е мястото, където проблемът за първи път се показа, защото криптирането на документ пренаписва всеки обект и следователно изисква всеки обект вече да е бил локализиран

// One-shot: read the hybrid input, write an AES-256 encrypted copy
Pdf.EncryptFile('Letter_DOC.pdf', 'Letter_secured.pdf',
  'owner-secret', '', aes256, [prPrint, prFillAnnotations]);

Детайлът, който си струва да отнесете със себе си, седи нагоре по веригата (upstream) от API-то. Файлове, които пристигат от Word, Excel, PowerPoint и дълъг списък от "Save as PDF" конвейери, са рутинно хибридни, така че loader, който упражнявате само срещу изхода на вашия собствен генератор, може никога да не срещне такъв при тестване. Заредете вашите фикстури (fixtures) с документи, експортирани от реални Office приложения, а не само с файлове, които вашият собствен код е произвел

Проверка на файл, в който се съмнявате

Две инспекции уреждат въпроса бързо. Отворете файла в hex изглед и прочетете байтовете след финалния startxref; хибриден файл показва къса класическа секция, чийто речник-трейлър (trailer dictionary) съдържа /XRefStm. Или сравнете броя обекти, който пълно парсване докладва, срещу най-високия обектен номер, който /Size декларира в трейлъра. Голяма празнина означава, че обекти се крият в потоци, които loader-ът не е отворил, което е същият недостиг (shortfall), който по-късно се превръща в провал по време на запазване

Опашката на типичен експорт от Excel прави първата проверка конкретна. Всичко след финалната ключова дума xref е обикновен ASCII, така че подписът може да се чете направо от hex изглед (отместванията са илюстративни, добавени са анотации)

xref
0 0                          % empty classic subsection: no rows at all
trailer
<< /Size 216                 % one past the highest object number in use
   /Root 1 0 R
   /Info 15 0 R
   /ID [<5C9A...> <5C9A...>]
   /XRefStm 87325            % byte offset of the cross-reference stream
>>
startxref
88710                        % points at the classic section above
%%EOF

Подсекцията 0 0 е издайникът (the tell): класическа таблица с нула записи съществува само за да носи трейлъра, а трейлърът съществува главно, за да каже /XRefStm 87325. Детектор, който спира при ключовата дума xref, в този момент е видял индекс на нищо. Когато предпочитате да скриптирате проверката (script the check), вместо да я преценявате на око (eyeball it), маркерът винаги седи в рамките на последните няколко килобайта от файла, така че ограничено четене назад (bounded backward read) е достатъчно

// Returns the /XRefStm offset from the file's tail, or -1 if the
// marker is absent (the file is not hybrid, or not a PDF at all)
function FindXRefStm(const FileName: string): Int64;
var
  FS: TFileStream;
  Tail: AnsiString;
  Len, P: Integer;
begin
  Result := -1;
  FS := TFileStream.Create(FileName, fmOpenRead or fmShareDenyWrite);
  try
    Len := 2048;                        // the trailer lives in the tail
    if FS.Size < Len then
      Len := Integer(FS.Size);
    FS.Position := FS.Size - Len;       // bounded backward read: 2 KB max
    SetLength(Tail, Len);
    FS.ReadBuffer(Tail[1], Len);
  finally
    FS.Free;
  end;
  P := Pos(AnsiString('/XRefStm'), Tail);
  if P = 0 then
    Exit;                               // no hybrid marker in the tail
  Inc(P, Length('/XRefStm'));
  while (P <= Len) and (Tail[P] in [' ', #9, #13, #10]) do
    Inc(P);                             // skip whitespace after the key
  Result := 0;
  while (P <= Len) and (Tail[P] in ['0'..'9']) do
  begin
    Result := Result * 10 + Ord(Tail[P]) - Ord('0');
    Inc(P);
  end;
end;

// Usage: a non-negative result names the byte where the stream starts
if FindXRefStm('Invoice_XLS.pdf') >= 0 then
  Writeln('hybrid-reference file: resave will need the /XRefStm section');

Третирайте сондата като триаж (triage), не като парсер: тя ви казва кои файлове в партидата (batch) заслужават внимание, преди да се стартира задача за повторно запазване, и нищо повече. Какво трябва да направи след това един loader с отместването, което открие, следвайки веригата от секции, сливайки записите от потока преди класическите, спазвайки стражите за свободни записи, се преминава стъпка по стъпка в нашата съпътстваща статия за работа с hybrid-reference PDF от Office приложения

Страната на писателя (writer) в тази история – как се произвеждат обектни потоци и компресирани cross-reference на първо място – е обхваната в нашата статия за обектни потоци и инкрементални актуализации. Когато въпросният хибриден файл също е много голям, техниките за зареждане в ръководството за Direct File API за работни процеси с големи PDF ви позволяват да го инспектирате, без да четете цялото нещо в паметта. И двете се съчетават естествено с възстановяването, описано тук, което се доставя като част от компонента HotPDF за Delphi и C++Builder заедно с API-тата за зареждане, редактиране, криптиране и подписване, обхванати другаде в този блог