Artigo Técnico

Etiquetas de página PDF no Delphi: corrigir árvores /Kids

A PDF Library for Delphi escreve gamas de etiquetas de página com o AddPageLabels, e desde a v3.539.10 essa chamada também funciona em ficheiros carregados cuja árvore numérica /PageLabels está dividida em nós /Kids: a raiz é achatada numa única folha /Nums antes de a nova gama entrar, por isso a etiqueta aparece de facto no visualizador em vez de ser ignorada em silêncio. A vítima típica é um PDF estilo livro vindo de uma ferramenta de paginação, com numerais romanos nas páginas preliminares, numeração arábica no corpo e um apêndice etiquetado A-1, A-2, em que só se queria voltar a etiquetar o apêndice e nada mudou

O que são as etiquetas de página PDF e como são guardadas?

As etiquetas de página são as strings que um visualizador mostra na sua caixa de páginas em vez do índice físico da página, e a ISO 32000-1 §12.4.2 guarda-as como uma árvore numérica sob a chave de catálogo /PageLabels. Cada chave é um índice de página de base zero que inicia uma gama de etiquetagem, e cada valor é um dicionário de etiqueta de página com até três entradas: /S para o estilo de numeração (D, R, r, A ou a), /P para uma string de prefixo, e /St para o valor numérico da primeira página da gama, que por defeito é 1. Uma gama corre até à chave seguinte, e a especificação exige que a árvore contenha um valor para o índice de página 0, por isso todas as páginas ficam cobertas por alguma gama

Guarda das etiquetas de página em termos PDFlibPas: a árvore numérica /PageLabels chaveia cada gama pela sua página inicial de base zero, cada valor é um dicionário de etiqueta com estilo /S, prefixo /P e primeiro número /St, e o exemplo do livro mapeia páginas preliminares romanas, corpo arábico e um apêndice A- em três gamas
Uma gama corre até à chave seguinte, a especificação exige um valor para o índice de página 0, e o GetPageLabel aplica a última gama cuja chave está na página ou abaixo dela, por isso todas as páginas resolvem para alguma coisa
var
  Lib: TPDFlib;
begin
  Lib := TPDFlib.Create;
  try
    if Lib.LoadFromFile('handbook.pdf', '') <> 1 then
      Exit;
    // Páginas 1-4: i, ii, iii, iv (romanos minúsculos)
    Lib.AddPageLabels(1, 3, 1, '');
    // Páginas 5-120: 1, 2, 3 ... (decimal)
    Lib.AddPageLabels(5, 1, 1, '');
    // Páginas 121 em diante: A-1, A-2 ... (decimal com prefixo)
    Lib.AddPageLabels(121, 1, 1, 'A-');
    WriteLn(Lib.GetPageLabel(5));    // 1
    WriteLn(Lib.GetPageLabel(122));  // A-2
    Lib.SaveToFile('handbook-labeled.pdf');
  finally
    Lib.Free;
  end;
end;

O TPDFlib.AddPageLabels(Start, Style, Offset, Prefix) mapeia os seus argumentos nesse dicionário sem surpresas depois de se conhecerem três regras. O Start é base 1 como todos os outros argumentos de página na biblioteca e é escrito na árvore como Start - 1. O Style vai de 0 a 5, em que 0 significa apenas prefixo e 1 a 5 se tornam valores /S D, R, r, A e a; qualquer coisa fora desse intervalo devolve 0 e não toca em nada. O Offset só se torna /St quando é maior que zero, por isso passar 0 simplesmente omite a chave e o visualizador recua para o valor por defeito de 1. Como as etiquetas de página chegaram no PDF 1.3, a chamada também corre EnsureMinVersion('1.3', '/PageLabels'), que sobe a versão de output de um ficheiro mais velho a menos que tenha bloqueado explicitamente a versão de gravação

Porque é que as etiquetas de página novas desaparecem quando a árvore tem /Kids?

As etiquetas novas desaparecem porque a ISO 32000-1 §7.9.7 (Tabela 37) manda que a raiz de uma árvore numérica transporte ou /Kids ou /Nums, nunca os dois, e o antigo helper NumTreeSet só sabia procurar /Nums. Produtores que emitem documentos longos frequentemente dividem a árvore em nós intermédios, cada um com um par /Limits, e penduram-nos numa raiz que só tem /Kids. O código antigo não encontrava /Nums nessa raiz, criava um fresco ao lado do /Kids existente, e inseria a nova gama ali. O resultado era uma raiz com dois pontos de entrada mutuamente exclusivos. Os visualizadores descem pelos /Kids e nunca olham para o array extraviado, o EnumNumTree da própria biblioteca também verifica primeiro /Kids, e o NumTreeLookup recusa um nó em que HasKids xor HasNums seja falso. O AddPageLabels continuava a devolver 1 e o ficheiro gravado continuava a abrir sem ruído, que é a pior espécie de falha: nada se queixa, as etiquetas é que ficam iguais

A correção no NumTreeSet converte a raiz numa folha antes de inserir qualquer coisa. Quando a raiz transporta /Kids, o EnumNumTree percorre todas as folhas por ordem e recolhe cada par chave/valor, um novo array /Nums achatado é construído a partir dessa lista, e /Kids, /Limits e qualquer /Nums obsoleto são expurgados da raiz antes de o array achatado ser anexado. Deixar cair o /Limits não é cosmético, já que a Tabela 37 só permite essa entrada em nós intermédios e folhas, nunca na raiz. A partir daí a inserção é um insert ordenado comum num único array, e as gamas existentes sobrevivem com os seus dicionários de etiqueta originais. O compromisso é deliberado: a árvore não é reconstruída em nós /Kids equilibrados depois. Para etiquetas de página isso não custa nada, porque até um manual de referência grande raramente tem mais do que umas dúzias de gamas, e uma folha única é o que a maioria dos produtores escreve à mesma

Reparação da árvore numérica no PDFlibPas: uma raiz que transporte /Kids e um array /Nums extraviado é invisível para os visualizadores porque a ISO 32000-1 só permite um dos dois, por isso o NumTreeSet achata todas as folhas num único array /Nums e expurga /Kids e /Limits, que a Tabela 37 nunca permite numa raiz
Nada se queixou porque todas as verificações passaram: o AddPageLabels devolveu 1, o ficheiro gravado abriu sem ruído, e só um leitor que desça primeiro pelos /Kids, à maneira dos visualizadores e da própria biblioteca, nunca encontra a nova gama
// Voltar a etiquetar o apêndice num ficheiro cuja raiz /PageLabels usa /Kids
if Lib.LoadFromFile('vendor-manual.pdf', '') = 1 then
begin
  WriteLn('Before: ', Lib.GetPageLabel(121));  // por ex. A-1
  // Substituir a gama que começa na página 121: App-a, App-b ...
  if Lib.AddPageLabels(121, 5, 1, 'App-') = 1 then
    Lib.SaveToFile('vendor-manual-relabeled.pdf');
  // As gamas romana e decimal existentes continuam na folha achatada
  WriteLn('After: ', Lib.GetPageLabel(121));   // App-a
  WriteLn('Front: ', Lib.GetPageLabel(2));     // ii, inalterado
end;

Como pode um array /Nums ser mal lido como chaves?

Um array /Nums é mal lido quando o código o percorre um elemento de cada vez, porque o array é uma sequência plana de pares alternados, [key0 value0 key1 value1 ...], e só as posições pares são chaves. O antigo ciclo do NumTreeSet testava cada elemento quanto ao tipo numérico, por isso um valor que por acaso fosse um número era comparado como se fosse chave; uma batida menor podia pôr o ponto de inserção num índice ímpar e deixar o novo par a meio de um par existente, desfasando todos os pares seguintes. O EnumNumTree tinha a mesma caminhada de passo único. Ambos agora iteram pares com um passo de dois, lendo a chave em X * 2 e o valor em X * 2 + 1, e uma correspondência exacta de chave substitui o valor e sai com Break. Em abono da verdade, os valores das etiquetas de página são dicionários, por isso este segundo bug raramente disparava no /PageLabels em si, mas um helper de árvore numérica que leia o passo errado fica corrompido no momento em que qualquer valor é numérico, e foi corrigido na mesma passagem

Correção do passo de pares nas árvores numéricas do PDFlibPas: um array /Nums é uma sequência plana de entradas alternadas chave/valor, por isso uma caminhada que teste todos os elementos podia inserir um novo par num índice ímpar e desfasar os pares seguintes, enquanto a caminhada corrigida lê a chave em X*2 e o valor em X*2+1
O bug raramente disparava no /PageLabels porque os valores das etiquetas são dicionários, mas um helper de árvore numérica que leia o passo errado corrompe no momento em que qualquer valor é numérico, por isso as duas caminhadas agora avançam em pares

Ler as etiquetas de volta e fazer o round trip

O TPDFlib.GetPageLabel(Page) devolve a etiqueta de uma página de base 1 e tem dois recuos de segurança que valem a pena conhecer. Sem entrada /PageLabels nenhuma devolve o número decimal da página, por isso um chamador pode usá-lo incondicionalmente. Com uma árvore presente mas nenhuma gama a cobrir a página devolve uma string vazia, que é exatamente o que acontece quando um ficheiro salta a entrada obrigatória do índice 0; a documentação de referência diz que uma gama a começar na página 1 tem de existir para as etiquetas aparecerem corretamente, e o código torna esse requisito visível. Os estilos de letras seguem a especificação e não as colunas de uma folha de cálculo: depois do Z vem AA, depois BB, repetindo a letra em vez de transportar

var
  P: Integer;
  Data: WideString;
begin
  // Auditoria rápida do que um visualizador vai mostrar na sua caixa de páginas
  for P := 1 to Lib.PageCount do
    WriteLn(P, ' -> ', Lib.GetPageLabel(P));

  // O valor de opção 4 exporta só gamas de etiquetas como registos PageLabelBegin
  Data := Lib.ExportDocumentData(4);
  // A importação repete-as através de ClearPageLabels + AddPageLabels
  Lib.ImportDocumentData(Data, 0);
end;

Para edições em volume, o ExportDocumentData com valor de opção 4 escreve cada gama como um bloco PageLabelBegin com linhas PageLabelNewIndex, PageLabelStart, PageLabelPrefix e PageLabelNumStyle, e o ImportDocumentData trata o primeiro registo de etiqueta que vê como uma substituição completa: chama o ClearPageLabels uma vez e depois alimenta cada registo ao AddPageLabels. Isso torna um round trip em texto determinístico mesmo quando o ficheiro original usava uma árvore /Kids, porque a limpeza remove a entrada de catálogo inteira e a árvore reconstruída é uma folha única desde o início

O que é que a correção continua a não garantir?

O achatamento é num só sentido e confia na ordem que encontra. O EnumNumTree recolhe os pares pela ordem do ficheiro, e o GetPageLabel aplica a última gama cuja chave é menor ou igual ao índice da página, por isso um ficheiro alheio cujas folhas estão fora de ordem, que a §7.9.7 proíbe mas que circula na mesma, ainda pode produzir etiquetas erradas até reconstruir as gamas com ClearPageLabels e chamadas AddPageLabels frescas. As etiquetas também estão ligadas a índices de página, e não a objetos de página, por isso qualquer operação que mude a contagem ou a ordem de páginas deixa as gamas onde estavam. Uma troca no sítio como substituir páginas preservando números de objeto mantém a contagem e portanto as etiquetas alinhadas, ao passo que uma fusão como agrupar digitalizações duplex intercaladas produz uma nova sequência de páginas que merece um conjunto de gamas escrito de fresco

As chamadas de etiquetas de página, o tratamento da árvore numérica e a exportação e importação de dados de documento aqui descritas saem todos na PDF Library for Delphi para Delphi, C++Builder e Lazarus, com a entrada de referência do AddPageLabels a documentar os valores de estilo e os códigos de retorno