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

Ручное редактирование PDF в текстовом редакторе с последующим восстановлением

HotPDF умеет сохранять загруженный документ в диагностической форме, предназначенной для чтения и редактирования человеком, а затем восстанавливать файл после того, как вы его отредактировали. SaveLoadedDocumentDiagnostic записывает документ с детерминированным порядком ключей словаря, каждый объект — как обычный косвенный объект, без потоков объектов, без потока перекрёстных ссылок и без линеаризации. RepairDiagnosticDocument затем перестраивает байтовые смещения, длины потоков, таблицу перекрёстных ссылок и трейлер, которые сделало недействительными ваше редактирование

Эта пара существует для одного рабочего процесса: понять или исправить то, что на самом деле находится внутри PDF. Каждый разработчик, отлаживавший проблему рендеринга, хотел открыть файл, изменить одно число в одном словаре и посмотреть на результат. В обычном PDF это невозможно, потому что содержимое сжато в потоки объектов, и каждый вставленный вами байт сдвигает смещения, которые таблица перекрёстных ссылок фиксирует с точностью до байта

Чем отличается диагностическое сохранение?

Шесть настроек меняются одновременно. Ключи словарей записываются в детерминированном порядке, поэтому два сохранения одного и того же документа дают сопоставимые файлы. Диагностический режим вывода аннотирует структуру. Принудительно выполняется полная перезапись, поэтому ничего не переносится в виде нетронутой инкрементной ревизии. Линеаризация выключена, поскольку линеаризованный файл организован для просмотра в вебе, а не для чтения человеком. Потоки перекрёстных ссылок выключены, поэтому таблица представляет собой обычный текст. А потоки объектов выключены, поэтому ни один объект не скрыт внутри сжатого контейнера

В результате получается файл, в заголовке которого рядом с обычной сигнатурой %PDF- стоит диагностическая метка, а его тело можно листать в любом редакторе. Объекты идут один за другим, словари читаемы, а потоки содержимого лежат там, где их можно найти. Инструмент восстановления проверяет наличие этой метки и отказывается работать с файлами, где её нет, потому что компактную структуру обычного PDF не должен пытаться интерпретировать восстановитель, работающий по байтовым диапазонам

Диаграмма, противопоставляющая обычное производственное сохранение PDF диагностическому сохранению HotPDF, которое записывает обычные читаемые объекты и текстовую таблицу xref, открываемую в любом текстовом редакторе
Обычное сохранение прячет объекты внутри сжатых потоков, тогда как диагностический писатель раскладывает каждый объект по одному на блок с текстовой таблицей перекрёстных ссылок. Результат — файл, который можно листать и изменять в любом редакторе
uses
  HPDFDoc;

var
  Pdf: THotPDF;
begin
  Pdf := THotPDF.Create(nil);
  try
    if Pdf.LoadFromFile('invoice.pdf') <= 0 then
      Exit;
    // Читаемо, один объект на блок, ничего не скрыто в потоках объектов
    Pdf.SaveLoadedDocumentDiagnostic('invoice-diagnostic.pdf');
  finally
    Pdf.Free;
  end;
end;

Почему редактирование ломает файл и что приходится перестраивать?

Три вещи в PDF кодируют байтовые позиции или количество байт. Таблица перекрёстных ссылок сопоставляет каждому номеру объекта байтовое смещение. Значение startxref в конце файла указывает на эту таблицу. И каждый словарь потока несёт запись /Length, сообщающую, сколько байт занимает тело потока

Измените один символ в любом месте до конца файла — и все смещения после него окажутся неверны. Добавьте строку в поток содержимого — и его /Length тоже станет неверным. Большинство программ просмотра откажутся открывать такой файл, а те, что восстанавливают его, делают это сканированием, которое скрывает ошибку, а не исправляет её. Восстановление перестраивает все три величины исходя из того, что теперь на самом деле говорят байты

Диаграмма, показывающая, как ручное редактирование сдвигает байтовые смещения, из-за чего таблица перекрёстных ссылок, указатель startxref и длины потоков устаревают, пока RepairDiagnosticDocument не перестроит все три величины по фактической компоновке
Добавление байтов сдвигает каждое последующее смещение, поэтому записанные позиции, указатель startxref и заявленные длины потоков перестают соответствовать реальности. Восстановление перестраивает каждую из них исходя из того, что теперь действительно содержит отредактированный файл
var
  Options: THPDFDiagnosticRepairOptions;
  Report: THPDFDiagnosticRepairReport;
begin
  Options := THPDFDiagnosticRepairOptions.Default;
  Options.VerifyOutput := True;         // загрузить результат перед заменой

  if THotPDF.RepairDiagnosticDocument('invoice-diagnostic.pdf',
       'invoice-fixed.pdf', Options, Report) then
    Writeln(Format('%d objects, %d streams, %d lengths corrected, ' +
      '%d xref rows rebuilt, %d pages verified',
      [Report.ObjectCount, Report.StreamCount,
       Report.RepairedStreamLengthCount, Report.RebuiltXRefEntryCount,
       Report.VerifiedPageCount]))
  else
    Writeln(Format('repair failed (%d): %s',
      [Ord(Report.Status), string(Report.Diagnostic)]));
end;

Копируйте байты, не пересериализуйте

Решение в архитектуре, делающее этот инструмент заслуживающим доверия, состоит в том, что он никогда не загружает документ целиком и не записывает его заново. Он копирует байтовые диапазоны проверенных им объектов, меняет только те значения /Length, что оказались неверны, и перестраивает вокруг них таблицу, трейлер и startxref

Загрузка и пересериализация реализовывались бы гораздо проще, но незаметно меняли бы содержимое. Незнакомый синтаксис объектов нормализовался бы, экранирование строк переписывалось бы в предпочитаемой писателем форме, а уже закодированные полезные данные потоков могли бы перекодироваться. Для инструмента отладки это неприемлемо: вы отредактировали одно число, и diff должен показывать одно число, а не тысячу случайных изменений форматирования. Если вы хотите точно узнать, что изменилось между двумя файлами, это задача сравнения, описанная в статье сравнение двух PDF-файлов по структуре и пикселям

Диаграмма, сравнивающая восстановление HotPDF, дословно копирующее проверенные байтовые диапазоны, с путём загрузки и повторной сериализации, который нормализует синтаксис, переписывает экранирование строк и перекодирует полезные данные потоков
Восстановление копирует проверенные диапазоны байт в байт и исправляет только те значения /Length, что оказались неверны, поэтому ваш собственноручный diff остаётся нетронутым. Повторная загрузка и сериализация незаметно переписали бы синтаксис, экранирование и кодировки, которых вы никогда не касались

Два правила разбора, предотвращающие ложные объекты

Сканер, работающий по байтовым диапазонам, сталкивается с очевидной ловушкой: текст, похожий на заголовок объекта, может встретиться внутри полезных данных потока или внутри строки. Наивное сканирование выдало бы строку перекрёстных ссылок, указывающую в середину потока содержимого, и итоговый файл оказался бы сломан новым, ещё более запутанным способом

HotPDF избегает этого, пропуская весь байтовый диапазон каждого проверенного объекта по его зафиксированному конечному смещению, так что ничто внутри тела объекта никогда не рассматривается как кандидат. Между объектами допускаются только пробельные символы PDF и комментарии; всё остальное означает, что область не распознана и её байты нельзя переносить в вывод

Длины потоков требуют не меньшей аккуратности. Реальная длина полезных данных не включает разделитель конца строки, который писатель вставляет перед endstream. Когда заявленная длина точно попадает на маркер, это значение принимается. Когда нет, восстановитель выполняет ограниченный поиск пары endstream и endobj и вычитает ровно один разделитель — CR, LF или CRLF. Ошибка здесь на один байт даёт файл, который открывается и отрисовывает страницу с одним лишним оператором в конце потока содержимого

Что разрешено сохранить трейлеру

Перестроенный трейлер — не копия старого. /Size, /Prev и /XRefStm все описывают прежнюю компоновку файла и либо пересчитываются, либо отбрасываются. Выживают только записи, чьи цели были проверены: /Info, /ID и /Encrypt. А /Root обязан разрешаться ровно в один объект Catalog — не в ноль и не в несколько, что как раз и получается в файле, собранном копированием и вставкой блоков объектов

Безопасность вывода обеспечена так, как и должно быть для инструмента, работающего с файлами, которые людям важны. Результат записывается во временный файл в целевом каталоге, затем проверяется загрузкой по обычному пути с отключённым восстановлением перекрёстных ссылок, так что файл, который открывается только благодаря восстановлению, засчитывается как сбой. Только после успешной проверки дерева страниц временный файл атомарно заменяет целевой. Неудавшееся восстановление никогда не перезаписывает существующий вывод

Бюджеты и честные коды сбоев

Работу ограничивают три предела: входные байты, число объектов-кандидатов и максимальный номер объекта — по умолчанию 8 ГиБ, два миллиона и два миллиона. Потолок номера объекта важнее, чем кажется, потому что разреженная таблица с несколькими объектами, пронумерованными в миллиардах, иначе колоссально раздула бы перестроенную таблицу перекрёстных ссылок. Классические таблицы перекрёстных ссылок также отклоняют смещения, для которых потребовалось бы больше десяти десятичных цифр, — это ограничение формата, а не выбор реализации

Сбои сообщают конкретный статус, а не булево значение: неверные аргументы, источник слишком велик, не диагностический файл, превышен лимит объектов, недопустимый объект, дублирующийся объект, отсутствует каталог, недопустимый трейлер, ошибка вывода или сбой проверки. Каждому соответствует своё исправление. Дублирующийся объект означает, что вы дважды вставили блок объекта. Отсутствующий каталог обычно означает, что объект с /Type /Catalog был повреждён. Сбой проверки означает, что структура теперь непротиворечива, но дерево страниц не загружается — как правило, это массив /Kids, отредактированный не в такт со своим /Count

Две практические заметки завершают картину. Храните диагностический файл, а не только восстановленный, чтобы следующая правка начиналась с читаемой основы. И не отправляйте диагностический вывод конечным пользователям: он крупнее обычного сохранения, потому что ничто не сжато в потоки объектов, а производственный путь для компактного файла — это обычный путь сохранения, описанный в статье потоки объектов и инкрементные обновления. Когда цель — найти, что не так, а не изменить это, автоматизированный путь в статье автоматизация preflight-отчётов отвечает быстрее, чем чтение байтов

Диагностическое сохранение, восстановление, preflight-проверка и сравнение работают с одной и той же моделью загруженного документа в Delphi и C++Builder; полный список возможностей — на странице компонента HotPDF Delphi PDF Component