Artigo Técnico

Outlines de PDF e remapeamento de páginas em Delphi

Remova sete páginas de um manual de 200 páginas e cada bookmark cai em algum lugar errado. A solução não é reconstruir o outline a partir de uma lista plana de títulos. O PDFiumPas expõe o TPdfOutlineEditor, que carrega a árvore de outline real, permite mover e redirecionar itens e depois executa o ApplyPageMap para deslocar cada destino explícito pelo seu plano de páginas

Por que excluir páginas quebra todos os bookmarks?

Porque um item de outline não armazena um número de página. Ele 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. Exclua a página e resta 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 para um número de página ao carregar, então o TPdfOutlineItem.PageNumber lhe dá um índice de página baseado em um que corresponde à API pública TPdf em vez de um número de objeto. Esse é todo o ponto da abstração: sua lógica de remapeamento trabalha no mesmo sistema de coordenadas do plano de páginas que você já construiu ao dividir, reordenar ou montar a imposição do documento. Se você está construindo esse plano, a mesma convenção baseada em um atravessa dividir documentos PDF em múltiplos arquivos e imposição n-up e reordenação de páginas

O outline é uma árvore duplamente encadeada, não uma lista

O motivo pelo qual você não pode simplesmente serializar um array plano de títulos é que a ISO 32000-1 §12.3.3 liga cada item de outline a cinco links separados: /Parent, /Prev, /Next, /First e /Last. Mover um único subtree, portanto, reescreve o pai antigo, o pai novo, os dois irmãos vizinhos em cada lado do corte e do ponto de inserção, e o ponteiro de pai do próprio nó movido. Errar um desses e leitores conformes mostram uma árvore truncada, ou entram em loop. O PDFiumPas mantém o estado de edição como um array em profundidade de registros TPdfOutlineItem com um Id inteiro estável, então um subtree é uma fatia contígua e a cadeia de irmãos é derivada, nunca mantida à mão. O TPdfOutlineEditor.Move ergue essa fatia, a reinsere sob o novo pai no índice de irmão solicitado e reatribui apenas a raiz do bloco. Ele também recusa os dois movimentos que corromperiam o grafo: mover um item para dentro do próprio subtree e nomear um pai que não existe

Edição de outline 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 os links /First e os irmãos /Prev e /Next ao redor do corte e do ponto de inserção
Uma chamada Move reescreve o ponteiro de pai do subtree erguido e os links de irmãos nos dois lados do corte e do ponto de inserção

Por que o /Count é assinado?

Porque o sinal carrega 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 visíveis no momento; um /Count negativo significa que o item está recolhido. O PDFiumPas grava a contagem de descendentes para cada item que tem filhos e a torna negativa quando IsOpen é False, e ao carregar lê o estado de volta como IsOpen := HasCount and (CountValue > 0). Este é o bug mais comum entre implementações caseiras de escritores de outline: emitir uma contagem sem sinal e forçar silenciosamente a árvore toda aberta

Como o PDFiumPas codifica o estado de expansão do outline em Delphi: um /Count positivo significa que o item está aberto e conta descendentes visíveis, um /Count negativo significa recolhido, e uma contagem sem sinal força todo leitor a expandir a árvore inteira
O sinal do /Count é o estado de expansão e a magnitude é a contagem de descendentes visíveis, então uma contagem sem sinal força silenciosamente a árvore inteira a abrir
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);           // vira o 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);           // grava 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 lida com as duas formas que a especificação permite. Passe DestinationInAction como False e o PDFiumPas grava um array /Dest direto; passe True e ele grava uma ação Go-To, /A << /S /GoTo /D [ page ref suffix ] >>, conforme a ISO 32000-1 §12.6.4.2. Em ambos os casos, ele primeiro remove qualquer /Dest e /A existentes do item para que os dois não possam coexistir e divergir. O sufixo tem /Fit como padrão e precisa começar com um nome PDF, e é por isso que um sufixo vazio ou malformado levanta exceção imediatamente em vez de produzir um array de destino que nenhum leitor consegue interpretar

Como 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 aquela página não sobreviveu. Ele percorre o array de itens de trás para frente para que excluir um subtree nunca invalide um índice que ainda não visitou, e informa o que fez por meio 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: excluir todo o subtree pendente. False: manter o item, remover seu alvo
  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;

O flag DeleteDangling decide a política para um destino que mapeou para zero, e os dois caminhos são deliberados. Com True, o PDFiumPas exclui o item e todo o seu subtree, porque um nó de outline cujo alvo desapareceu geralmente 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 você quer quando uma pessoa vai redirecioná-lo na revisão. Entrada genuinamente malformada ainda falha ruidosamente em vez de ser remendada: uma entrada negativa ou um destino apontando além do fim do mapa fornecido retorna False com IssueKind definido como poviInvalidPageMap

Como o ApplyPageMap do PDFiumPas redireciona bookmarks de PDF em Delphi: um mapa de páginas indexado por página antiga menos um envia os destinos sobreviventes para seus novos números de página, enquanto entradas que mapeiam para zero são ou excluídas com seu subtree ou privadas de seu alvo
O mapa de páginas é indexado por página antiga menos um, e uma entrada zero ou exclui o subtree pendente ou deixa o item com seu alvo removido

Entradas opacas e o trade-off honesto

Nem todo item de outline tem um número de página com que o PDFiumPas possa raciocinar. Três tipos atravessam intocados: destinos nomeados, ações que não são /S /GoTo e chaves de dicionário desconhecidas adicionadas por quem produziu o arquivo. Estes carregam com PageNumber igual a zero, mantêm seus bytes originais no item e são gravados de volta literalmente, a menos que você chame explicitamente o Retarget neles

  • Um destino nomeado é uma chave na name tree do documento, então remapeá-lo corretamente significa resolver a árvore e reescrever a entrada alvo, não adivinhar no nível do outline
  • Uma ação /URI, /Launch ou JavaScript não tem semântica de página alguma e não deve ser convertida silenciosamente em um Go-To
  • Chaves específicas de fornecedor e destinos de estrutura são preservados porque descartar o que você não entende é como idas e voltas perdem dados

O custo é real e vale enunciar com clareza: o ApplyPageMap pula esses itens por completo, então um documento cujos bookmarks usam todos destinos nomeados sairá de uma exclusão de páginas com o outline estruturalmente válido e semanticamente obsoleto. Essa é a escolha deliberada — um link obsoleto que um revisor pode capturar é melhor que um confiantemente errado que ninguém nota. Se você está triando arquivos de entrada antes de editá-los, uma passagem de inventário em um workbench de revisão de entrada de PDF dirá quais documentos caem nesse grupo

Salvamento: revisão incremental e depois um recarregamento independente

O TPdfOutlineEditor.SaveIncremental anexa uma revisão incremental esparsa em vez de reescrever o arquivo. Itens que foram carregados mantêm sua referência original de objeto indireto, incluindo a geração exata, então as referências cruzadas existentes continuam válidas; apenas itens que você adicionou tiram um número novo, alocado a partir de um além do número máximo de objetos da revisão. O catálogo é atualizado na mesma revisão, e uma entrada /Outlines ausente é adicionada a ele quando a fonte não tinha outline algum

O que acontece depois da gravação é a parte que vale copiar. O PDFiumPas reabre o stream de destino com um editor completamente independente e compara a árvore recarregada com a da memória — contagem de itens, títulos, números de página, sufixos de destino, forma de ação versus destino direto, estilos, estado de expansão e relações de pai. Qualquer divergência, ou qualquer falha de carga, limpa o stream de destino e retorna poviVerificationFailure em vez de entregar a você um arquivo de aparência plausível. Fontes criptografadas são recusadas de imediato com poviEncryptedInput, já que novos títulos e destinos criam conteúdo de string que não pode ser produzido copiando o trailer /Encrypt para 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 outline como o que ele é — um grafo de objetos encadeado com seus próprios invariantes — e a exclusão de páginas deixa de ser um desastre de bookmarks e se torna um mapa de páginas que você entrega a uma chamada de método. O TPdfOutlineEditor, o ApplyPageMap e o escritor incremental verificado estão no PDFiumPas a partir da v3.98.0 para Delphi, C++Builder e Lazarus; você pode revisar a API completa e baixar uma versão de avaliação na página do produto PDFium Delphi Component