Artigo Técnico

Anular, Refazer e Recuperação de Falhas em PDF no Delphi

O PDFium Component dá a um editor de PDF um verdadeiro anular e refazer ao nível do documento, mais um pacote de recuperação de falhas, através de um diário de edição (edit journal). Envolvem-se as alterações em BeginEditTransaction e CommitEditTransaction, e cada transação submetida (commit) torna-se um delta bidirecional que UndoEdit e RedoEdit conseguem reproduzir em qualquer direção. SaveRecoverySnapshot persiste esse diário, para que uma aplicação terminada a meio da sessão possa reconstruir o trabalho do utilizador

A lacuna que isto preenche é específica. A capacidade de anular nativa aplica-se apenas ao campo de formulário que tem atualmente o foco, pelo que uma caixa de texto recorda as suas próprias edições e mais nada. A inserção, eliminação e reordenação de páginas, as alterações a objetos de página, as anotações, os anexos e os metadados não têm diário nenhum. Um editor construído apenas sobre isso só consegue oferecer anular dentro de um campo e em mais lado nenhum, o que não é o que os utilizadores entendem por anular

Porquê snapshots em vez de um registo de operações?

O design óbvio é registar operações semânticas — "página 4 eliminada", "anotação 7 movida" — e inverte-las. Funciona até o conjunto de operações crescer, ponto em que cada nova funcionalidade precisa de uma inversa, e qualquer operação cuja inversa não seja exata corrompe silenciosamente o documento ao longo de uma longa cadeia de anulações

O diário, em vez disso, captura o estado do documento nas fronteiras das transações e armazena a diferença. Cada delta guarda o maior prefixo e sufixo comuns e transporta tanto os bytes removidos como os inseridos, pelo que pode ser aplicado para a frente ou para trás. As novas funcionalidades de edição não precisam de qualquer suporte de anular: o que quer que alterem está dentro do snapshot

Diagrama de anular ao nível do documento em PDF no Delphi, em que o diário de edição do PDFium Component empilha deltas bidirecionais sobre um snapshot raiz, cada entrada protegida por digests SHA-256 durante a reprodução de anular e refazer
Cada transação submetida armazena o prefixo, os bytes removidos e inseridos, mais os digests SHA-256 de ambos os estados, pelo que um único registo se reproduz para a frente ou para trás sem uma operação inversa por funcionalidade

Essa abordagem depende de um pré-requisito. Uma gravação completa comum produz identificadores e campos de hora não determinísticos, pelo que gravar duas vezes um documento não modificado pode produzir bytes diferentes. Os snapshots são, por isso, primeiro normalizados através do percurso de saída reprodutível. Sem esse passo, uma transação vazia pareceria uma modificação, e o anular falharia na sua própria verificação, porque o digest calculado no momento da submissão não corresponderia ao documento depois

Transações na prática

Cada transação recebe uma etiqueta opcional, que é o que um editor mostra no seu menu de anular:

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;               // profundidade de anular
    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;                          // a página volta
  finally
    Pdf.Free;
  end;
end;

Uma submissão que falha — porque a captura falhou, um orçamento foi excedido ou o delta não pôde ser construído — repõe a base capturada em BeginEditTransaction e recarrega, em vez de deixar para trás uma transação ativa ou um diário meio escrito. Este é o modo de falha que mais importa, porque um diário num estado inconsistente envenena todo o anular subsequente

Verificação em cada passo

Cada delta armazena o digest SHA-256 do documento antes e depois, juntamente com o comprimento do prefixo e ambas as cargas úteis. O anular, o refazer, a reprodução importada e a reconstrução inversa verificam todos o digest atual e a região que pretendem substituir antes de tocarem em seja o que for, e depois verificam o digest resultante

O movimento através do diário é igualmente cauteloso. A posição só avança depois de o PDF de destino ter sido validado por um objeto de documento independente e ter substituído com sucesso o atual. Se isso falhar, o snapshot atual é reposto como reserva, pelo que um anular falhado deixa o utilizador onde estava, em vez de num estado danificado

O formato serializado é verificado como dados, e não confiado como entrada: o número mágico, a versão, os valores de entrada e de posição, a cadeia de digests, os comprimentos das etiquetas, os comprimentos dos patches e o orçamento total de armazenamento são todos validados. Um diário importado é um ficheiro que pode ter sido truncado, editado ou produzido por uma versão diferente, e tratá-lo como hostil é a única postura segura

O que acontece quando o diário fica cheio

Três limites delimitam o crescimento: a contagem de entradas, os bytes cumulativos de delta e o tamanho de um único snapshot. Quando a capacidade é atingida, a entrada mais antiga é despejada e a impressão digital (fingerprint) raiz avança

Esta última parte tem uma consequência que vale a pena ter em conta no design. Um diário exportado só pode ser aplicado a um documento que corresponda à impressão digital raiz que reporta, ou a uma impressão digital que tenha registado. Uma vez ocorrido o despejo, o diário deixa de alcançar o ficheiro original. A gravação automática tem, por isso, de tratar o snapshot raiz correspondente e o diário como uma única unidade de recuperação atómica — manter o diário sem a sua base é manter algo que não pode ser reproduzido

Pacotes de recuperação de falhas

O pacote de recuperação não incorpora deliberadamente o PDF de origem. Armazena o nome de apresentação da origem, as impressões digitais SHA-256 dos estados de origem e recuperado, e o diário de edição. A recuperação exige, por isso, o ficheiro original, que quem chama carrega primeiro; o pacote verifica então ambas as impressões digitais reproduzindo o diário para trás e para a frente:

Sequência de recuperação de falhas para a edição de PDF em Delphi, mostrando gravações atómicas de SaveRecoverySnapshot após cada submissão, uma falha a meio da sessão, e a verificação de impressões digitais de RestoreRecoverySnapshot contra o PDF original
As submissões substituem atomicamente um pequeno pacote selado por impressão digital; a recuperação recarrega primeiro o PDF original, e depois verifica ambas as impressões digitais registadas reproduzindo o diário nos dois sentidos
// Após cada submissão bem-sucedida, atualizar o ficheiro de recuperação
Pdf.BeginEditTransaction('Redact account numbers');
if Pdf.CommitEditTransaction then
  Pdf.SaveRecoverySnapshot('contract.recovery');

// Na sessão seguinte, após uma falha: carregar o original, e depois repor
Pdf.FileName := 'contract.pdf';
Pdf.LoadDocument;
Pdf.RestoreRecoverySnapshot('contract.recovery');

Não incorporar a origem mantém os ficheiros de recuperação pequenos, o que é o que torna a gravação automática por submissão comportável num documento de qualquer tamanho. Um digest exterior cobre o cabeçalho, o nome da origem e o diário, pelo que um pacote truncado ou adulterado é rejeitado antes de qualquer delta ser analisado, e o volume total de leitura tem um limite

As gravações de ficheiro vão para um ficheiro temporário no diretório de destino e são depois substituídas atomicamente, e definir o caminho de recuperação como o PDF de origem é explicitamente recusado. A gravação automática só corre depois de uma submissão, anular ou refazer bem-sucedidos que tenham alterado algo. Se a gravação em disco falhar, os estados do documento e do diário em memória já são válidos, pelo que a exceção reporta uma falha de persistência, e não uma perda de dados, e quem chama pode corrigir o caminho e tentar novamente

Documentos assinados são recusados por predefinição

Um snapshot de transação é uma nova revisão normalizada, e não os bytes assinados originais, pelo que submeter uma transação num documento assinado invalida a assinatura. Iniciar uma transação nesse documento é recusado, a menos que quem chama passe AllowSignedDocument, e a importação de diário e a reposição de recuperação assumem por predefinição a mesma recusa

Isto é um obstáculo deliberado, e não uma proibição. Alguns fluxos de trabalho editam legitimamente um documento assinado e voltam a assiná-lo depois, e a flag existe para eles. O que impede é o caso acidental, em que um editor retira silenciosamente a validade a um documento que um utilizador acredita continuar assinado. O lado da verificação dessa história está descrito em inspecionar assinaturas digitais de PDF e níveis PAdES

Para um editor, três hábitos fazem o diário compensar. Envolva cada ação visível para o utilizador numa única transação etiquetada, para que o menu de anular se leia como o modelo mental do utilizador, e não como a sua pilha de chamadas. Dimensione o limite de entradas em função do documento, já que cinquenta snapshots de uma digitalização de 200 MB não é uma definição modesta. E grave o snapshot de recuperação a cada submissão, em vez de num temporizador, porque um temporizador garante que o trabalho perdido é exatamente o que aconteceu depois do último tick. As operações de edição ao nível do documento que beneficiam disto estão descritas em atualizações incrementais de PDF encriptado

O diário de edição, os pacotes de recuperação e as primitivas de edição por trás deles fazem parte do mesmo componente para Delphi, C++Builder e Lazarus; a API completa está descrita na página do PDFium Component para Delphi