PDFium Component даёт редактору PDF настоящую отмену и повтор действий на уровне документа, а также пакет восстановления после сбоя, через журнал правок. Вы оборачиваете изменения в BeginEditTransaction и CommitEditTransaction, и каждая зафиксированная транзакция становится двунаправленной дельтой, которую UndoEdit и RedoEdit могут воспроизвести в любом направлении. SaveRecoverySnapshot сохраняет этот журнал, чтобы приложение, завершившееся аварийно посреди сеанса, могло восстановить работу пользователя
Пробел, который это закрывает, конкретен. Встроенная возможность отмены применяется только к полю формы, находящемуся в фокусе, поэтому текстовое поле помнит только собственные правки, и ничего больше. У вставки, удаления и изменения порядка страниц, у изменений объектов страницы, аннотаций, вложений и метаданных вообще нет никакого журнала. Редактор, построенный только на этом, может предложить отмену внутри поля и больше нигде, а это не то, что пользователи понимают под отменой
Почему снимки, а не журнал операций?
Очевидный подход к проектированию — записывать семантические операции — «удалена страница 4», «перемещена аннотация 7» — и обращать их. Он работает, пока набор операций не разрастётся, и в этот момент каждая новая функция требует своей обратной операции, а любая операция, чья обратная не точна, незаметно повреждает документ на длинной цепочке отмен
Журнал вместо этого захватывает состояние документа на границах транзакций и хранит разницу. Каждая дельта хранит самый длинный общий префикс и суффикс и несёт как удалённые, так и вставленные байты, поэтому её можно применить как вперёд, так и назад. Новым функциям редактирования не нужна никакая поддержка отмены: всё, что они меняют, находится внутри снимка
Такой подход зависит от одной предпосылки. Обычное полное сохранение выдаёт недетерминированные идентификаторы и поля времени, поэтому сохранение неизменённого документа дважды может дать разные байты. Поэтому снимки сначала нормализуются через путь воспроизводимого вывода. Без этого шага пустая транзакция выглядела бы как изменение, и отмена не прошла бы собственную проверку, потому что дайджест, вычисленный при фиксации, не совпал бы с документом впоследствии
Транзакции на практике
Каждая транзакция принимает опциональную метку — именно её редактор показывает в своём меню отмены:
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.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