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
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
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
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,/Launchou 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