Artykuł techniczny

Cofanie, ponawianie i odzyskiwanie po awarii PDF w Delphi

PDFium Component daje edytorowi PDF prawdziwe cofanie i ponawianie na poziomie dokumentu, plus pakiet odzyskiwania po awarii, za pomocą dziennika edycji. Zmiany opakowujesz w BeginEditTransaction i CommitEditTransaction, a każda zatwierdzona transakcja staje się dwukierunkową deltą, którą UndoEdit i RedoEdit mogą odtworzyć w dowolnym kierunku. SaveRecoverySnapshot zapisuje ten dziennik trwale, dzięki czemu aplikacja zabita w połowie sesji może odbudować pracę użytkownika

Luka, którą to wypełnia, jest konkretna. Natywna zdolność cofania dotyczy tylko pola formularza, które akurat ma fokus, więc pole tekstowe pamięta swoje własne edycje i nic poza tym. Wstawianie, usuwanie i zmiana kolejności stron, zmiany obiektów strony, adnotacje, załączniki i metadane w ogóle nie mają dziennika. Edytor zbudowany wyłącznie na tym może oferować cofanie wewnątrz pola i nigdzie indziej, co nie jest tym, co użytkownicy rozumieją przez cofanie

Dlaczego migawki, a nie dziennik operacji?

Oczywistym projektem jest rejestrowanie operacji semantycznych — „usunięto stronę 4”, „przesunięto adnotację 7” — i ich odwracanie. Działa to, dopóki zbiór operacji nie urośnie, a wtedy każda nowa funkcja potrzebuje swojej odwrotności, a każda operacja, której odwrotność nie jest dokładna, po cichu psuje dokument w długim łańcuchu cofania

Dziennik zamiast tego przechwytuje stan dokumentu na granicach transakcji i przechowuje różnicę. Każda delta zachowuje najdłuższy wspólny prefiks i sufiks oraz niesie zarówno usunięte, jak i wstawione bajty, dzięki czemu można ją zastosować do przodu albo do tyłu. Nowe funkcje edycyjne nie potrzebują żadnej obsługi cofania: cokolwiek zmieniają, znajduje się wewnątrz migawki

Diagram cofania PDF na poziomie dokumentu w Delphi, gdzie dziennik edycji PDFium Component układa dwukierunkowe delty nad migawką główną, a każdy wpis jest chroniony skrótami SHA-256 podczas odtwarzania cofania i ponawiania
Każda zatwierdzona transakcja przechowuje prefiks, usunięte i wstawione bajty oraz skróty SHA-256 obu stanów, dzięki czemu jeden rekord odtwarza się do przodu albo do tyłu bez operacji odwrotnej dla każdej funkcji

To podejście zależy od jednego warunku wstępnego. Zwykły pełny zapis generuje niedeterministyczne identyfikatory i pola czasu, więc dwukrotny zapis niezmodyfikowanego dokumentu może dać różne bajty. Dlatego migawki są najpierw normalizowane przez ścieżkę odtwarzalnego wyjścia. Bez tego kroku pusta transakcja wyglądałaby jak modyfikacja, a cofanie nie przeszłoby własnej weryfikacji, ponieważ skrót obliczony przy zatwierdzeniu nie pasowałby potem do dokumentu

Transakcje w praktyce

Każda transakcja przyjmuje opcjonalną etykietę, którą edytor pokazuje w swoim menu cofania:

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;               // głębokość cofania
    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;                          // strona wraca
  finally
    Pdf.Free;
  end;
end;

Zatwierdzenie, które zawodzi — ponieważ przechwytywanie się nie powiodło, przekroczono budżet albo nie udało się zbudować delty — przywraca punkt odniesienia przechwycony przy BeginEditTransaction i ponownie wczytuje dokument, zamiast pozostawiać aktywną transakcję albo częściowo zapisany dziennik. To najważniejszy tryb błędu, ponieważ dziennik w niespójnym stanie zatruwa każde następne cofnięcie

Weryfikacja na każdym kroku

Każda delta przechowuje skrót SHA-256 dokumentu przed i po, wraz z długością prefiksu i obydwoma ładunkami. Cofanie, ponawianie, odtwarzanie zaimportowanego dziennika i odwrotna rekonstrukcja sprawdzają bieżący skrót i region, który zamierzają zastąpić, zanim czegokolwiek dotkną, a następnie weryfikują wynikowy skrót po fakcie

Poruszanie się po dzienniku jest równie ostrożne. Pozycja przesuwa się dopiero po zwalidowaniu docelowego PDF przez niezależny obiekt dokumentu i po pomyślnym zastąpieniu nim bieżącego. Jeśli to się nie powiedzie, jako zabezpieczenie przywracana jest bieżąca migawka, więc nieudane cofnięcie pozostawia użytkownika tam, gdzie był, a nie w zepsutym stanie

Zserializowany format jest sprawdzany jako dane, a nie ufany jako wejście: numer magiczny, wersja, wartości wpisów i pozycji, łańcuch skrótów, długości etykiet, długości łatek i całkowity budżet pamięci są wszystkie walidowane. Zaimportowany dziennik to plik, który mógł zostać obcięty, zedytowany albo wyprodukowany przez inną wersję, a traktowanie go jako wrogiego jest jedyną bezpieczną postawą

Co się dzieje, gdy dziennik się zapełni

Trzy limity ograniczają wzrost: liczbę wpisów, skumulowaną liczbę bajtów delty i rozmiar pojedynczej migawki. Gdy pojemność zostaje osiągnięta, najstarszy wpis jest usuwany, a odcisk główny przesuwa się dalej

Ta ostatnia część ma konsekwencję, wokół której warto zaprojektować rozwiązanie. Wyeksportowany dziennik można zastosować tylko do dokumentu pasującego do zgłaszanego przez niego odcisku głównego albo do zarejestrowanego przez niego odcisku. Gdy nastąpi usunięcie wpisu, dziennik nie sięga już z powrotem do oryginalnego pliku. Autozapis musi więc traktować pasującą migawkę główną i dziennik jako jedną atomową jednostkę odzyskiwania — zachowanie dziennika bez jego podstawy oznacza zachowanie czegoś, czego nie da się odtworzyć

Pakiety odzyskiwania po awarii

Pakiet odzyskiwania celowo nie osadza źródłowego PDF. Przechowuje wyświetlaną nazwę źródła, odciski SHA-256 stanu źródłowego i odzyskanego oraz dziennik edycji. Odzyskiwanie wymaga więc oryginalnego pliku, który wywołujący wczytuje jako pierwszy; pakiet następnie weryfikuje oba odciski, odtwarzając dziennik do tyłu i do przodu:

Sekwencja odzyskiwania po awarii dla edycji PDF w Delphi, pokazująca atomowe zapisy SaveRecoverySnapshot po każdym zatwierdzeniu, awarię w połowie sesji oraz weryfikację odcisków przez RestoreRecoverySnapshot względem oryginalnego PDF
Zatwierdzenia atomowo zastępują mały pakiet zapieczętowany odciskami; odzyskiwanie najpierw ponownie wczytuje oryginalny PDF, a następnie weryfikuje oba zarejestrowane odciski, odtwarzając dziennik w obie strony
// Po każdym udanym zatwierdzeniu odśwież plik odzyskiwania
Pdf.BeginEditTransaction('Redact account numbers');
if Pdf.CommitEditTransaction then
  Pdf.SaveRecoverySnapshot('contract.recovery');

// Następna sesja, po awarii: wczytaj oryginał, a następnie przywróć
Pdf.FileName := 'contract.pdf';
Pdf.LoadDocument;
Pdf.RestoreRecoverySnapshot('contract.recovery');

Nieosadzanie źródła utrzymuje pliki odzyskiwania w małym rozmiarze, co sprawia, że autozapis przy każdym zatwierdzeniu jest opłacalny na dokumencie dowolnej wielkości. Zewnętrzny skrót obejmuje nagłówek, nazwę źródła i dziennik, więc obcięty albo zmanipulowany pakiet jest odrzucany, zanim jakakolwiek delta zostanie sparsowana, a całkowita objętość odczytu jest ograniczona

Zapisy plików trafiają do pliku tymczasowego w katalogu docelowym, a następnie są atomowo zastępowane, a ustawienie ścieżki odzyskiwania na źródłowy PDF jest jawnie odrzucane. Autozapis uruchamia się tylko po udanym zatwierdzeniu zmiany, cofnięciu albo ponowieniu. Jeśli zapis na dysk się nie powiedzie, stany dokumentu i dziennika w pamięci są już prawidłowe, więc wyjątek zgłasza błąd trwałości, a nie utratę danych, i wywołujący może naprawić ścieżkę i spróbować ponownie

Podpisane dokumenty są domyślnie odrzucane

Migawka transakcji to nowa znormalizowana rewizja, a nie oryginalne podpisane bajty, więc zatwierdzenie transakcji na podpisanym dokumencie unieważnia podpis. Rozpoczęcie transakcji na takim dokumencie jest odrzucane, chyba że wywołujący przekaże AllowSignedDocument, a import dziennika i przywracanie odzyskiwania domyślnie stosują tę samą odmowę

To celowe spowolnienie, a nie zakaz. Niektóre przepływy pracy zasadnie edytują podpisany dokument i podpisują go ponownie później, a ta flaga istnieje właśnie dla nich. To, czemu zapobiega, to przypadek przypadkowy, w którym edytor po cichu pozbawia ważności dokument, o którym użytkownik sądzi, że wciąż jest podpisany. Stronę weryfikacji tej historii opisano w inspekcji podpisów cyfrowych PDF i poziomów PAdES

Dla edytora trzy nawyki sprawiają, że dziennik się opłaca. Opakuj każdą widoczną dla użytkownika akcję w jedną etykietowaną transakcję, aby menu cofania czytało się jak mentalny model użytkownika, a nie jak twój stos wywołań. Dostosuj limit wpisów do dokumentu, ponieważ pięćdziesiąt migawek 200-megabajtowego skanu to nieskromne ustawienie. I zapisuj migawkę odzyskiwania przy każdym zatwierdzeniu, a nie na czasomierzu, ponieważ czasomierz gwarantuje, że utracona praca to dokładnie to, co wydarzyło się po ostatnim tyknięciu. Operacje edycji na poziomie dokumentu, które na tym korzystają, opisano w przyrostowych aktualizacjach zaszyfrowanego PDF

Dziennikowanie edycji, pakiety odzyskiwania i stojące za nimi prymitywy edycyjne są dostarczane w tym samym komponencie dla Delphi, C++Builder i Lazarus; pełne API opisano na stronie PDFium Component dla Delphi