Artigo Técnico

Edição de esquemas PDF e remapeamento de páginas em Delphi

Retire sete páginas de um manual de 200 páginas e todos os marcadores caem sítios errados. A correção não é reconstruir o esquema a partir de uma lista plana de títulos. O PDFiumPas expõe o TPdfOutlineEditor, que carrega a árvore de esquema real, deixa-o mover e redirecionar itens, e depois corre o ApplyPageMap para deslocar cada destino explícito através do seu plano de páginas

Porque é que apagar páginas estraga todos os marcadores?

Porque um item de esquema não armazena um número de página. Armazena uma referência a um objeto de página, e quando os objetos de página mudam a referência ou aponta para uma página que se moveu ou para nada. A ISO 32000-1 §12.3.2.2 define um destino explícito como um array cujo primeiro elemento é uma referência indireta a um dicionário de página, seguido de um nome de ajuste como /Fit ou /XYZ. Apague a página e fica com uma referência pendente; reordene as páginas e a referência continua válida mas agora descreve um capítulo diferente. O PDFiumPas resolve esse array de volta a um número de página quando carrega, pelo que o TPdfOutlineItem.PageNumber dá-lhe um índice de página baseado em um que corresponde à API pública do TPdf em vez de um número de objeto. Esse é o sentido inteiro da abstração: a sua lógica de remapeamento trabalha no mesmo sistema de coordenadas do plano de páginas que já construiu quando dividiu, reordenou ou impôs o documento. Se está a construir esse plano, a mesma convenção baseada em um corre por dividir documentos PDF em vários ficheiros e por imposição n-up e reordenação de páginas

O esquema é uma árvore duplamente ligada, não uma lista

A razão pela qual não pode simplesmente serializar um array plano de títulos é que a ISO 32000-1 §12.3.3 liga cada item de esquema a cinco ligações separadas: /Parent, /Prev, /Next, /First e /Last. Mover uma única subárvore reescreve portanto o pai antigo, o pai novo, os irmãos vizinhos de cada lado do corte e do ponto de inserção, e o ponteiro de pai do próprio nó movido. Errar um desses e os leitores conformes mostram uma árvore truncada, ou entram em ciclo. O PDFiumPas mantém o estado de edição como um array em profundidade de records TPdfOutlineItem com um Id inteiro estável, pelo que uma subárvore é uma fatia contígua e a cadeia de irmãos é derivada, nunca mantida à mão. O TPdfOutlineEditor.Move levanta essa fatia, reinsere-a sob o pai novo no índice de irmão pedido e reatribui apenas a raiz do bloco. Também recusa os dois movimentos que corromperiam o grafo: mover um item para a sua própria subárvore e nomear um pai que não existe

Edição de esquemas no PDFiumPas em Delphi: mover o Capítulo 3 para fora da Parte I e para baixo da raiz do documento reescreve o ponteiro /Parent do nó movido mais as ligações /First e de irmãos /Prev e /Next em torno do corte e do ponto de inserção
Uma chamada Move reescreve o ponteiro de pai da subárvore levantada e as ligações de irmãos de ambos os lados do corte e do ponto de inserção

Porque é que /Count é assinado?

Porque o sinal transporta o estado de expansão, não o tamanho. Um /Count positivo significa que o item está aberto e o número é quantos descendentes estão atualmente visíveis; um /Count negativo significa que o item está fechado. O PDFiumPas escreve a contagem de descendentes para cada item que tem filhos e negativa-a quando IsOpen é False, e ao carregar lê o estado de volta como IsOpen := HasCount and (CountValue > 0). Este é o bug mais comum de todos feito à mão em escritores de esquemas: emitir uma contagem sem sinal e forçar silenciosamente toda a árvore aberta

Como o PDFiumPas codifica o estado de expansão do esquema em Delphi: um /Count positivo significa que o item está aberto e conta descendentes visíveis, um /Count negativo significa fechado, e uma contagem sem sinal força todos os leitores a expandir toda a árvore
O sinal de /Count é o estado de expansão e a magnitude é a contagem de descendentes visíveis, pelo que uma contagem sem sinal força silenciosamente toda a árvore aberta
var
  Source, Dest: TMemoryStream;
  Editor: TPdfOutlineEditor;
  Options: TPdfOutlineEditOptions;
  Report: TPdfOutlineValidationReport;
  RootId, ChapterId: Integer;
begin
  Source := TMemoryStream.Create;
  Dest := TMemoryStream.Create;
  Editor := nil;
  try
    Source.LoadFromFile('handbook.pdf');
    Options := TPdfOutlineEditOptions.Default;   // MaxItems 100000, MaxDepth 64
    if not TPdfOutlineEditor.TryLoad(Source, Options, Editor, Report) then
      raise Exception.Create(Report.ErrorMessage);

    RootId := Editor[0].Id;
    ChapterId := Editor[2].Id;

    Editor.Move(ChapterId, RootId, 1);           // passa a segundo filho da raiz
    Editor.SetTitle(ChapterId, 'Appendix B');
    Editor.SetStyle(ChapterId, [posBold, posItalic]);
    Editor.SetColor(ChapterId, 0.25, 0.5, 0.75);
    Editor.SetExpanded(RootId, False);           // escreve um /Count negativo
    Editor.Retarget(ChapterId, 12, '/XYZ 10 20 1');

    if not Editor.SaveIncremental(Source, Dest, Report) then
      raise Exception.Create(Report.ErrorMessage);
    Dest.SaveToFile('handbook-edited.pdf');
  finally
    Editor.Free;
    Dest.Free;
    Source.Free;
  end;
end;

O Retarget trata ambas as formas que a especificação permite. Passe DestinationInAction como False e o PDFiumPas escreve um array /Dest direto; passe True e escreve uma ação Go-To, /A << /S /GoTo /D [ page ref suffix ] >>, conforme a ISO 32000-1 §12.6.4.2. De qualquer forma, remove primeiro qualquer /Dest e /A existentes do item para que os dois não possam coexistir e discordar. O sufixo predefinido é /Fit e tem de começar por um nome PDF, e é por isso que um sufixo vazio ou malformado lança exceção imediatamente em vez de produzir um array de destino que nenhum leitor consegue analisar

Como é que o ApplyPageMap consome um plano de páginas?

O ApplyPageMap recebe exatamente o array que o seu plano de páginas já validou: NewPageNumbers, indexado por página antiga menos um, contendo o novo número de página baseado em um, ou zero quando essa página não sobreviveu. Percorre o array de itens para trás para que apagar uma subárvore nunca invalide um índice que ainda não visitou, e comunica o que fez através de RemappedDestinationCount e RemovedDanglingItemCount

var
  NewPageNumbers: array of Integer;
  Report: TPdfOutlineValidationReport;
  I: Integer;
begin
  // Uma entrada por página do documento ORIGINAL
  SetLength(NewPageNumbers, OriginalPageCount);
  for I := 0 to OriginalPageCount - 1 do
    NewPageNumbers[I] := 0;              // 0 == esta página foi descartada

  NewPageNumbers[0] := 1;                // página antiga 1 -> nova página 1
  NewPageNumbers[1] := 2;
  NewPageNumbers[9] := 3;                // página antiga 10 -> nova página 3

  // True: apagar toda a subárvore pendente. False: manter o item, retirar-lhe o destino
  if not Editor.ApplyPageMap(NewPageNumbers, True, Report) then
    raise Exception.Create(Report.ErrorMessage);

  WriteLn(Format('%d remapped, %d dangling items removed',
    [Report.RemappedDestinationCount, Report.RemovedDanglingItemCount]));
end;

A flag DeleteDangling decide a política para um destino que mapeou para zero, e ambos os ramos são deliberados. Com True, o PDFiumPas apaga o item e a sua subárvore inteira, porque um nó de esquema cujo destino desapareceu normalmente encabeça um capítulo que desapareceu com ele. Com False, o item sobrevive com o título e a hierarquia intactos mas com o /Dest e o /A removidos, que é o que se quer quando uma pessoa vai redirecioná-lo em revisão. Entrada genuinamente malformada continua a falhar de forma ruidosa em vez de ser remendada: uma entrada negativa ou um destino que aponte para lá do fim do mapa fornecido devolve False com IssueKind definido como poviInvalidPageMap

Como o ApplyPageMap do PDFiumPas redireciona marcadores PDF em Delphi: um mapa de páginas indexado por página antiga menos um envia os destinos sobreviventes para os seus novos números de página, enquanto as entradas que mapeiam para zero são ou apagadas com a sua subárvore ou despojadas do seu destino
O mapa de páginas é indexado por página antiga menos um, e uma entrada zero ou apaga a subárvore pendente ou deixa o item com o destino retirado

Entradas opacas, e o compromisso honesto

Nem todo item de esquema tem um número de página sobre o qual o PDFiumPas consiga raciocinar. Três tipos são transportados intocados: destinos nomeados, ações que não são /S /GoTo, e chaves de dicionário desconhecidas adicionadas por quem produziu o ficheiro. Estes carregam com PageNumber igual a zero, mantêm os seus bytes originais no item, e são escritos de volta tal como estão a menos que chame explicitamente Retarget sobre eles

  • Um destino nomeado é uma chave para a árvore de nomes do documento, pelo que remapeá-lo corretamente significa resolver a árvore e reescrever a entrada de destino, não adivinhar ao nível do esquema
  • Uma ação /URI, /Launch ou JavaScript não tem semântica de página nenhuma e não deve ser convertida silenciosamente num Go-To
  • Chaves específicas de fornecedores e destinos de estrutura são preservados porque deitar fora o que não compreende é como as idas e voltas perdem dados

O custo é real e vale a pena enunciá-lo claramente: o ApplyPageMap salta esses itens por completo, pelo que um documento cujos marcadores usam todos destinos nomeados sairá de uma eliminação de páginas com o esquema estruturalmente válido e semanticamente caducado. Essa é a escolha deliberada — uma ligação caducada que um revisor apanha é melhor do que uma errada com confiança que ninguém nota. Se está a triar ficheiros à entrada antes de os editar, uma passagem de inventário num banco de revisão de admissão de PDF dir-lhe-á que documentos caem nesse balde

Gravação: revisão incremental, depois uma recarga independente

O TPdfOutlineEditor.SaveIncremental anexa uma revisão incremental dispersa em vez de reescrever o ficheiro. Os itens que foram carregados mantêm a sua referência de objeto indireta original, incluindo a geração exata, pelo que as referências cruzadas existentes continuam válidas; só os itens que adicionou tiram um número fresco, alocado a partir de um além do número máximo de objeto da revisão. O catálogo é atualizado na mesma revisão, e uma entrada /Outlines em falta é adicionada quando a origem não tinha esquema nenhum

O que acontece depois da escrita é a parte que vale a pena copiar. O PDFiumPas reabre o stream de destino com um editor completamente independente e compara a árvore recarregada com a em memória — contagem de itens, títulos, números de página, sufixos de destino, forma ação-versus-destino-direto, estilos, estado de expansão, e relações de parentesco. Qualquer discrepância, ou qualquer falha de carregamento, limpa o stream de destino e devolve poviVerificationFailure em vez de lhe entregar um ficheiro de aparência plausível. Origens encriptadas são recusadas de imediato com poviEncryptedInput, visto que títulos e destinos novos criam conteúdo de string que não pode ser produzido copiando o trailer /Encrypt para a frente

if not Editor.SaveIncremental(Source, Dest, Report) then
  case Report.IssueKind of
    poviEncryptedInput:
      Log('Source is encrypted; outline editing needs an unprotected copy');
    poviInvalidDestination:
      Log(Format('Item %d %d targets a missing page',
        [Report.ObjectNumber, Report.Generation]));
    poviVerificationFailure:
      Log('Reload check rejected the written revision: ' + Report.ErrorMessage);
  else
    Log(Report.ErrorMessage);
  end;

Trate o esquema como o que é — um grafo de objetos ligado com os seus próprios invariantes — e a eliminação de páginas deixa de ser um desastre de marcadores e torna-se um mapa de páginas que entrega a uma chamada de método. O TPdfOutlineEditor, o ApplyPageMap e o escritor incremental verificado acompanham o PDFiumPas desde a v3.98.0 para Delphi, C++Builder e Lazarus; pode rever a API completa e transferir uma versão de avaliação na página do produto PDFium Delphi Component