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

Відновлення пошкодженого PDF xref: сканування в Delphi

Коли таблиця перехресних посилань PDF непридатна для використання, виправлення полягає в тому, щоб повністю її ігнорувати і перебудувати з тіла файлу. PDFlibPas Delphi PDF Library робить це однопрохідним токенним сканером, що записує кожен справжній заголовок непрямого об'єкта, який бачить, потім відновлює словник трейлера і передає реконструйовану таблицю звичайному завантажувачу

Що ламається першим, коли PDF пошкоджений

Таблиця перехресних посилань — найкрихкіша частина PDF, бо це єдина частина, що зберігає абсолютні зсуви в байтах. ISO 32000-1 §7.5.4 визначає ці записи як десятизначні зсуви від початку файлу, а §7.5.5 розміщує ключове слово startxref ближче до кінця, де воно вказує на саму таблицю. Кожне з цих чисел стає недійсним через будь-яке редагування, що зсуває байти. FTP-сесія, що йшла в текстовому режимі й перетворила CRLF, обірване завантаження, сектор, що зіпсувався на спільному диску, пакетний інструмент, що дописав дані без коректного запису інкрементального оновлення: усі вони лишають дані об'єктів цілком читабельними, а індекс, що вказує на сміття

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

Чому сканування на N 0 obj знаходить хибні збіги

Наївна перебудова шукає в сирих байтах шаблон "ціле число, ціле число, obj" і записує кожне влучання. Вона знаходить забагато. PDF — контейнерний формат, і три ділянки файлу непрозорі для граматики об'єктів: коментарі (§7.2), рядки (§7.3.4) та дані потоків (§7.3.8). Будь-яка з них може містити байти, що читаються рівно як заголовок об'єкта, і жодна з них заголовком об'єкта не є. Підпис у літеральному рядку, залишковий налагоджувальний коментар або два мегабайти виводу Flate чи DCT — усі вони охоче видадуть щось, що виглядає як 99 0 obj

const
  Trap: AnsiString =
    '4 0 obj'#10 +
    '(a caption that mentions 88 0 obj)'#10 +   // literal string, not an object
    'endobj'#10 +
    '% 77 0 obj left over from a debug dump'#10 +  // comment, not an object
    '5 0 obj'#10 +
    '<< /Length 2097152 >>'#10 +
    'stream'#10 +
    { two MiB of compressed bytes that contain the byte sequence
      99 0 obj and, further along, a complete endstream }
    'endstream'#10 +
    'endobj'#10;

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

Однопрохідна скінченна автомат-машина по блоках 64 КіБ

PDFlibPas сканує весь файл рівно один раз, блоками по 64 КіБ, за допомогою скінченного автомата, побудованого на правилах токенів ISO 32000-1 §7.2 та синтаксисі непрямих об'єктів §7.3.10. Токен закінчується на пробілі або одному з символів-роздільників, а заголовок об'єкта записується лише тоді, коли побачено повну послідовність з додатного номера об'єкта, невід'ємного номера покоління та голого ключового слова obj. Записаний зсув — це початок токена номера об'єкта, а саме на нього і має вказувати запис перехресних посилань, а не позиція ключового слова obj

function RebuildIsWhiteSpace(Value: Byte): Boolean;
begin
  Result := (Value = 0) or (Value = 9) or (Value = 10) or
            (Value = 12) or (Value = 13) or (Value = 32);
end;

function RebuildIsDelimiter(Value: Byte): Boolean;
begin
  Result := (Value = Ord('(')) or (Value = Ord(')')) or
            (Value = Ord('<')) or (Value = Ord('>')) or
            (Value = Ord('[')) or (Value = Ord(']')) or
            (Value = Ord('{')) or (Value = Ord('}')) or
            (Value = Ord('/')) or (Value = Ord('%'));
end;

Важлива деталь у тому, що стан токена і стан рядка переживають межу блоку. Заголовок, що перетинає риску на позначці 65536 байтів, все одно розпізнається, бо частковий токен, пара цілих чисел, що очікує завершення, і прапорці "всередині рядка" переносяться у наступний блок. Буфери фіксовані: 64 КіБ для сканування, 32 байти для найдовшого токена, який взагалі може мати значення, і єдині масиви, що ростуть разом з файлом, — це списки номерів об'єктів, номерів поколінь та 64-бітних зсувів, пропорційні реальній кількості об'єктів, а не розміру файлу. На практиці сканування виконує послідовні читання і щонайбільше два явних переходи (seek) по всьому документу, що й робить його придатним для файлів у сотні мегабайтів, обговорюваних у статті про пряме злиття та розділення з прямим доступом

Чому не можна довіряти, що потік закінчується на endstream

Тому що дані потоку — це довільні байти, а довільні байти можуть випадково скласти endstream. Потік, що починається після ключового слова stream, треба пропускати як непрозорі дані, доки він справді не закінчиться, але перше входження закриваючого ключового слова — лише кандидат. PDFlibPas вирішує це, вимагаючи підтвердження: токен endstream приймається як справжній кінець потоку лише тоді, коли наступний непробільний токен — самостійний endobj, послідовність, яку §7.3.8 вимагає навколо об'єкта-потоку. Випадкове влучання всередині стиснених даних майже ніколи не має такого продовження, тож сканер лишається всередині потоку і продовжує. Ще два менших правила мають не менше значення. Ключове слово stream переводить у стан потоку лише коли воно є голим ключовим словом, тож об'єкт-назва на кшталт /stream у словнику ніколи його не тригерить. А токен obj чи trailer враховується лише коли токен не перевищив 32-байтну межу і не починався з похилої риски. Без цих двох захистів словник ресурсів з неправильними назвами ключів був би достатнім, щоб збити сканування з пантелику, а це саме той клас ворожого вводу, що охоплений у нотатках про безпечний парсинг недовірених PDF

Пошук справжнього кінця словника трейлера

Відновлення об'єктів — лише половина роботи, бо завантажувачу все ще потрібен трейлер, щоб знайти /Root. PDFlibPas запам'ятовує останні 64 позиції ключового слова trailer, знайдені під час сканування, і валідує їх у зворотному порядку, від найновішого, тож перемагає найновіший придатний трейлер, а випадкове ключове слово, за яким не йде словник, просто не проходить валідацію і поступається місцем попередньому кандидату. Кожен кандидат читається з обмеженням 1 МіБ, а кінець словника знаходиться відстеженням глибини вкладених << та >> разом з екрануванням літеральних рядків, шістнадцятковими рядками та коментарями

// A naive reader that stops at the first '>>' truncates this trailer,
// and a fixed 2048-byte window can cut it in half on a large one
'trailer'#10 +
'<< /Size 5 /Root 1 0 R' +
'   /Custom << /Text (value >> preserved) >> >>'#10

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

Що реконструкція не може повернути

Реконструкція — це найкраще можливе зусилля, і бути чесним щодо її меж — частина того, як її постачають. Три випадки провалюються повністю. Об'єкти, запаковані всередині потоків об'єктів (§7.5.7), не видимі побайтовому скануванню окремо, тож якщо контейнер виживає, а його потік перехресних посилань (§7.5.8) — ні, об'єкти, що він тримає, перебудовою не індексуються. Файл, чиє тіло дійсно було пошкоджене, а не просто неправильно проіндексоване, дасть заголовки, чий вміст більше не парситься. А файл без відновлюваного ключового слова trailer і без читабельного каталогу не має до чого прив'язати дерево документа, скільки б заголовків об'єктів не було знайдено

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

var
  Pdf: TPDFlib;
begin
  Pdf := TPDFlib.Create;
  try
    if Pdf.LoadFromFile('truncated-invoice.pdf', '') = 1 then
    begin
      if Pdf.GetDocumentRepaired = 1 then
        LogWarning('xref was unusable; the table was reconstructed');
      if Pdf.PageCount > 0 then
        Pdf.SaveToFile('recovered-invoice.pdf');   // writes a clean xref
    end;
  finally
    Pdf.Free;
  end;
end;

Резервний механізм автоматичний: PDFlibPas запускає сире сканування щоразу, коли ланцюжок перехресних посилань неможливо прочитати, а також коли кожен запис у використанні стверджує нульовий зсув, що є ознакою таблиці, яку записали, але так і не заповнили. GetDocumentRepaired повертає 1, коли цей шлях спрацював, і його варто логувати, а не ігнорувати, бо документ, що завантажився через реконструкцію, слід перезберегти в чистий файл, а не лишати в конвеєрі так, ніби нічого не сталося. Збереження записує свіжу, узгоджену таблицю перехресних посилань, що є найдешевшим можливим виправленням для кожного подальшого споживача

Шлях реконструкції, прапорець GetDocumentRepaired та потоковий завантажувач, показані тут, є частиною PDFlibPas Delphi PDF Library, поряд з API парсингу, рендерингу та підписання, розглянутими в інших статтях цього блогу