Технічна стаття

Завантаження PDF із гібридними посиланнями з Word та Excel у Delphi

Відкрийте PDF, створений у Microsoft Word або Excel, погортайте його сторінки, і нічого не здасться незвичним. Завантажте його в програму Delphi, зчитайте кількість сторінок, і число буде правильним. Потім збережіть його знову з увімкненим шифруванням, і робота завершиться з помилкою EListError, або вихідний файл відкриється з попередженням про пошкоджену перехресну роздільну здатність. Файл ніколи не був пошкоджений. Це файл із гібридними посиланнями, і та сама структура, яка дозволяє відкрити його п'ятнадцятирічній програмі перегляду, — це структура, яка завдає поразки завантажувачу, який припиняє читання занадто рано

Це один із найпоширеніших способів, коли конвеєр PDF, який пройшов усі внутрішні перевірки, стикається з файлом, який він не може зберегти й відкрити знову. Усі вхідні дані генерувалися всередині компанії, тому вони ніколи не були гібридними. Перший гібридний файл надходить у день, коли клієнт пересилає рахунок-фактуру, експортований з електронної таблиці

Що насправді пишуть Word та Excel

ISO 32000-1 описує макет із гібридними посиланнями в параграфі 7.5.8.4. Програма, яка хоче використовувати функції PDF 1.5, такі як потоки об'єктів, водночас дозволяючи програмі читання PDF 1.4 відкривати файл, записує інформацію про перехресні посилання двічі. Існує класична таблиця перехресних посилань (рядки ASCII фіксованої ширини, якими закінчувався кожен PDF до версії 1.4), і існує потік перехресних посилань, який індексує решту. У трейлері класичного розділу міститься запис /XRefStm, значення якого — це байтовий зсув цього потоку

Розподіл праці є навмисним. Об'єкти, яких стара програма читання має досягти (зокрема каталог і дерево сторінок), адресовані з класичної таблиці. Об'єкти, які були згорнуті в стиснені потоки об'єктів, позначені як вільні в класичній таблиці, із записом типу f, тому програма читання 1.4 перескакує прямо через них і ніколи не стикається зі структурою, яку вона не може розібрати. Їх реальні розташування знаходяться лише в потоці перехресних посилань. Ознакою такого файлу є його хвіст: короткий класичний розділ, який часто є не чим іншим, як xref з подальшим заголовком підрозділу 0 0, чий трейлер вказує на /XRefStm, де знаходяться фактичні дані відновлення

Чому правильна кількість сторінок нічого не доводить

Оскільки каталог і дерево сторінок можна дістати з класичної таблиці навмисно, завантажувач, який читає лише цю таблицю, знаходить /Root, обходить дерево сторінок і повідомляє правильну кількість сторінок. Присутнє все, що потрібно старій програмі читання, тому файл виглядає здоровим. Об'єктами, які зникли, є ті, що запаковані в потоки об'єктів: словники полів AcroForm, елементи структури PDF із тегами, довгий хвіст маленьких словників, які ніколи не повинні були бути видимими для застарілої програми перегляду

Ви не помітите прогалину, доки щось не торкнеться цих об'єктів, а повне повторне збереження торкається всіх них. Обхід документа для його повторного шифрування або перезапису — це саме та операція, яка запитує номер кожного об'єкта по черзі, саме тому симптом випливає на етапі збереження, а не завантаження, далеко від своєї причини

Пастка — це детектор, який бачить xref і зупиняється

Найдешевший спосіб визначити, як індексується файл, — це прослідувати за startxref і перевірити перші байти, на які він вказує. Ключове слово xref означає класичну таблицю; об'єкт потоку означає потік перехресних посилань. Такий тест правильний для будь-якого файлу, який дотримується однієї схеми. Він є хибним для гібридного файлу, чий 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;

Коли детектор раннього виходу встановлено, завантаження виглядає нормальним, а повторне збереження — це місце, де відсутні об'єкти заявляють про себе. Виправлення полягає не в читанні більшої кількості байтів на початку; воно полягає в тому, щоб розпізнати гібридний трейлер і пройти за /XRefStm, перш ніж вирішити, що файл завершено

Порядок злиття не обговорюється

Як тільки обидва індекси прочитано, їх можна об'єднати лише в одному напрямку. Потік перехресних посилань має бути об'єднаний першим, а класичні записи мають бути заповнені навколо нього. Причина в маленькому обмані в серці формату. Гібридний файл позначає свої стиснені об'єкти як вільні в класичній таблиці, щоб старі програми читання ігнорували їх. Завантажувач, який дотримується політики "переможець — перший побачений" і читає класичну таблицю першою, запише ці номери об'єктів як вільні, а потім відкине записи потоку, які фактично їх знаходять, оскільки слоти вже зайняті. Змініть порядок, і записи типу 2 з потоку, кожен з яких є номером об'єкта потоку плюс індекс, завоюють слоти, якими вони повинні володіти, а класичні записи розташуються навколо них

Ця ж дисципліна захищає від старішої версії, яка воскрешає видалений об'єкт. Інкрементальні оновлення повертаються назад через /Prev, а вільний запис типу 0 є вартовим, який вказує на те, що новіший розділ зняв з експлуатації номер об'єкта. Пізнішому, старішому розділу в ланцюжку не можна дозволяти перезаписувати цього вартового застарілим розташуванням. Ставтеся до першого побаченого як до авторитетного для вільних маркерів, і видалений об'єкт залишиться видаленим; поставтеся до нього необережно, і власна історія файлу реанімує вміст, який видалила остання ревізія

Що це означає в HotPDF

Механізм розв'язує файли гібридних посилань за вас, і він робить це на кожному шляху, який повинен аналізувати дані перехресних посилань. Завантажте документ за допомогою LoadFromFile або LoadFromStream, внесіть свої зміни і викличте SaveLoadedDocument; або запустіть одноразову операцію, таку як EncryptFile, яка читає вхідний файл і записує вихідний. У будь-якому випадку відновлення читає /XRefStm, об'єднує розділ потоку перед класичними записами та вирішує об'єкти, які знаходяться в потоках, перш ніж запис перелічить їх. Шлях шифрування 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]);

Деталь, яку варто запам'ятати, знаходиться вище за API. Файли, які надходять із Word, Excel, PowerPoint і довгого списку конвеєрів "Зберегти як PDF", зазвичай є гібридними, тому завантажувач, який ви тестуєте лише на результатах свого власного генератора, може ніколи з ними не зустрітися в тестуванні. Наповніть свої тести документами, експортованими зі справжніх додатків Office, а не лише файлами, створеними вашим власним кодом

Перевірка файлу, який ви підозрюєте

Дві перевірки швидко вирішують питання. Відкрийте файл у шістнадцятковому поданні (hex view) і прочитайте байти після останнього startxref; гібридний файл показує короткий класичний розділ, чий словник трейлера містить /XRefStm. Або порівняйте кількість об'єктів, про яку повідомляє повний розбір, із найбільшим номером об'єкта, який /Size оголошує в трейлері. Великий розрив означає, що об'єкти ховаються в потоках, які завантажувач не відкрив, що є тією самою нестачею, яка згодом перетворюється на збій під час збереження

Хвіст типового експорту Excel робить першу перевірку конкретною. Усе після останнього ключового слова xref — це звичайний ASCII, тому підпис можна прочитати прямо з шістнадцяткового подання (зсуви є ілюстративними, додано анотації)

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 — це підказка: класична таблиця з нульовими записами існує лише для перенесення трейлера, а трейлер існує переважно для того, щоб сказати /XRefStm 87325. Детектор, який зупиняється на ключовому слові xref, на цьому етапі побачив індекс нічого. Якщо ви віддаєте перевагу написанню скрипта для перевірки, ніж її перевірці на око, маркер завжди знаходиться в межах останніх кількох кілобайтів файлу, тому достатньо обмеженого зворотного читання

// 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');

Ставтеся до зонда як до сортування, а не як до аналізатора: він говорить вам, які файли в пакеті заслуговують уваги до запуску завдання збереження, і більше нічого. Що завантажувач має робити з знайденим зсувом (відстежуючи ланцюжок розділів, об'єднуючи записи потоку перед класичними, поважаючи вартових вільних записів), крок за кроком розглянуто в нашій супутній статті про обробку PDF-файлів із гібридними посиланнями з програм Office

Письменницький бік цієї історії, тобто як потоки об'єктів і стиснені перехресні посилання створюються в першу чергу, розглядається в нашій статті про потоки об'єктів та інкрементальні оновлення. Якщо відповідний гібридний файл також є дуже великим, методи завантаження в покроковому керівництві Direct File API для робочих процесів з великими PDF дозволяють вам перевіряти його, не читаючи цілком у пам'ять. Обидва вони природним чином поєднуються з відновленням, описаним тут, яке постачається як частина Компонента HotPDF для Delphi та C++Builder разом з API для завантаження, редагування, шифрування та підпису, які розглядалися в іншому місці цього блогу