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

Отмена, повтор и восстановление после сбоя PDF в Delphi

PDFium Component даёт редактору PDF настоящую отмену и повтор действий на уровне документа, а также пакет восстановления после сбоя, через журнал правок. Вы оборачиваете изменения в BeginEditTransaction и CommitEditTransaction, и каждая зафиксированная транзакция становится двунаправленной дельтой, которую UndoEdit и RedoEdit могут воспроизвести в любом направлении. SaveRecoverySnapshot сохраняет этот журнал, чтобы приложение, завершившееся аварийно посреди сеанса, могло восстановить работу пользователя

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

Почему снимки, а не журнал операций?

Очевидный подход к проектированию — записывать семантические операции — «удалена страница 4», «перемещена аннотация 7» — и обращать их. Он работает, пока набор операций не разрастётся, и в этот момент каждая новая функция требует своей обратной операции, а любая операция, чья обратная не точна, незаметно повреждает документ на длинной цепочке отмен

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

Диаграмма отмены на уровне документа PDF в Delphi, где журнал правок PDFium Component складывает двунаправленные дельты поверх корневого снимка, а каждая запись защищена дайджестами SHA-256 при воспроизведении отмены и повтора
Каждая зафиксированная транзакция хранит префикс, удалённые и вставленные байты плюс дайджесты SHA-256 обоих состояний, поэтому одна запись воспроизводится вперёд или назад без отдельной обратной операции на каждую функцию

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

Транзакции на практике

Каждая транзакция принимает опциональную метку — именно её редактор показывает в своём меню отмены:

uses
  PDFium;

var
  Pdf: TPdf;
  Options: TPdfEditJournalOptions;
  Info: TPdfEditJournalInfo;
begin
  Pdf := TPdf.Create(nil);
  try
    Pdf.FileName := 'contract.pdf';
    Pdf.LoadDocument;

    Options := TPdfEditJournalOptions.Default;
    Options.MaxEntries := 50;               // глубина отмены
    Options.MaxStoredBytes := 256 * 1024 * 1024;
    Pdf.ConfigureEditJournal(Options);

    Pdf.BeginEditTransaction('Delete cover page');
    try
      Pdf.DeletePage(1);
      if not Pdf.CommitEditTransaction then
        raise Exception.Create('commit failed - document was reloaded');
    except
      Pdf.RollbackEditTransaction;
      raise;
    end;

    Info := Pdf.GetEditJournalInfo;
    Writeln(Format('entries=%d position=%d stored=%d bytes',
      [Info.EntryCount, Info.Position, Info.StoredBytes]));

    if Pdf.EditCanUndo then
      Pdf.UndoEdit;                          // страница возвращается
  finally
    Pdf.Free;
  end;
end;

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

Проверка на каждом шаге

Каждая дельта хранит дайджест SHA-256 документа до и после, вместе с длиной префикса и обеими полезными нагрузками. Отмена, повтор, воспроизведение импортированного журнала и обратное восстановление — все они проверяют текущий дайджест и область, которую собираются заменить, прежде чем что-либо трогать, а затем проверяют итоговый дайджест после операции

Перемещение по журналу столь же осторожно. Позиция продвигается только после того, как целевой PDF был проверен независимым объектом документа и успешно заменил текущий. Если это не удаётся, в качестве отката восстанавливается текущий снимок, поэтому неудачная отмена оставляет пользователя там, где он был, а не в сломанном состоянии

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

Что происходит, когда журнал заполняется

Три предела ограничивают рост: число записей, суммарные байты дельт и размер отдельного снимка. Когда достигнута ёмкость, самая старая запись вытесняется, а корневой отпечаток продвигается вперёд

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

Пакеты восстановления после сбоя

Пакет восстановления намеренно не встраивает исходный PDF. Он хранит отображаемое имя источника, отпечатки SHA-256 исходного и восстановленного состояний и журнал правок. Поэтому восстановление требует исходного файла, который вызывающий код загружает первым; затем пакет проверяет оба отпечатка, воспроизводя журнал назад и вперёд:

Последовательность восстановления после сбоя при редактировании PDF в Delphi: атомарные записи SaveRecoverySnapshot после каждой фиксации, сбой посреди сеанса и проверка отпечатков RestoreRecoverySnapshot по исходному PDF
Фиксации атомарно заменяют небольшой пакет, запечатанный отпечатком; восстановление сначала перезагружает исходный PDF, затем проверяет оба записанных отпечатка, воспроизводя журнал в обе стороны
// После каждой успешной фиксации обновляем файл восстановления
Pdf.BeginEditTransaction('Redact account numbers');
if Pdf.CommitEditTransaction then
  Pdf.SaveRecoverySnapshot('contract.recovery');

// Следующий сеанс, после сбоя: сначала загружаем исходник, затем восстанавливаем
Pdf.FileName := 'contract.pdf';
Pdf.LoadDocument;
Pdf.RestoreRecoverySnapshot('contract.recovery');

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

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

Подписанные документы по умолчанию отклоняются

Снимок транзакции — это новая нормализованная редакция, а не исходные подписанные байты, поэтому фиксация транзакции на подписанном документе делает подпись недействительной. Начало транзакции на таком документе отклоняется, если только вызывающий код не передаст AllowSignedDocument, и импорт журнала, и восстановление по умолчанию ведут себя так же

Это намеренное препятствие, а не запрет. Некоторые рабочие процессы законно редактируют подписанный документ и подписывают его заново впоследствии, и флаг существует именно для них. Что он предотвращает, так это случайный сценарий, когда редактор молча лишает силы документ, который пользователь считает всё ещё подписанным. Сторона проверки этой истории описана в статье проверка цифровых подписей PDF и уровней PAdES

Для редактора три привычки окупают журнал. Оборачивайте каждое видимое пользователю действие в одну помеченную транзакцию, чтобы меню отмены читалось как ментальная модель пользователя, а не как ваш стек вызовов. Подбирайте предел числа записей исходя из документа, поскольку пятьдесят снимков скана на 200 МБ — не скромная настройка. И записывайте снимок восстановления при каждой фиксации, а не по таймеру, потому что таймер гарантирует, что потерянной окажется именно та работа, что произошла после последнего тика. Операции редактирования на уровне документа, которые от этого выигрывают, описаны в статье инкрементные обновления зашифрованного PDF

Журналирование правок, пакеты восстановления и лежащие в их основе примитивы редактирования поставляются в одном компоненте для Delphi, C++Builder и Lazarus; полное описание API — на странице PDFium Component для Delphi