Artigo Técnico

Cópia de objetos PDF entre documentos em Delphi: ciclos

Junte dois PDFs à mão, mova um único objeto de página para o documento de destino, e a cópia esbarra direto em um access violation. O PDFlibPas corrige isso em CopyForeignObject: ele copia em profundidade um objeto indireto mais todo o seu fecho de referências e resolve back-references cíclicos como /Parent para null em vez de recorrer

Por que copiar uma página entre documentos trava?

Porque uma árvore de páginas PDF só é uma árvore se você a lê de cima para baixo. Percorra-a como faz um copiador recursivo, seguindo cada valor de cada dicionário, e o dicionário da página entrega /Parent, que aponta de volta para o nó /Pages de onde você veio, e esse nó entrega /Kids, que aponta de volta para a página. O ISO 32000-1 §7.7.3 torna /Parent obrigatório em todo nó de árvore de páginas exceto a raiz, de modo que este não é um arquivo malformado que você possa rejeitar — é a forma normal de todo documento que você algum dia receber

A segunda metade do problema é a numeração. Objetos indiretos são identificados por um número de objeto local a um arquivo (ISO 32000-1 §7.3.10), de modo que um objeto arrastado do documento A para o documento B precisa ser renumerado, e toda referência a ele dentro do fecho copiado precisa ser renumerada da mesma forma, senão duas referências que apontavam para uma mesma fonte compartilhada passam a apontar para duas coisas sem relação. Essa renumeração é o mesmo trabalho que um merge rápido faz no nível de bytes, e vale ler os dois lado a lado: o deslocamento de referências no nível de bytes para merge rápido de PDF resolve isso traduzindo arquivos inteiros, enquanto uma cópia no nível de objetos precisa resolvê-lo uma aresta por vez

Por que uma cópia PDF entre documentos em Delphi pede cuidado: o dicionário da página e seu nó /Pages fecham um ciclo por /Parent e /Kids, um fecho de fonte corre para baixo e termina, e o PDFlibPas remapeia cada número de objeto local ao arquivo
A árvore de páginas fecha um laço por /Parent e /Kids enquanto os fechos de conteúdo terminam, e cada número de objeto copiado precisa ser remapeado no caminho

O que o CopyForeignObject do PDFlibPas realmente copia

TPDFlib.CopyForeignObject(SourceDocumentID, ObjectNumber) clona um objeto indireto e tudo que é alcançável a partir dele — dicionários aninhados, arrays, strings, names, números e streams com seus dicionários intactos — para o documento atualmente selecionado, e devolve um handle diferente de zero para a nova referência indireta. Os números de objetos de origem são remapeados por um mapa vivo mantido durante a chamada, de modo que um objeto alcançado duas vezes no fecho é clonado uma vez e compartilhado duas. Devolve zero, sem levantar exceção, quando o ID do documento de origem é desconhecido, quando a origem é o próprio documento selecionado, ou quando ObjectNumber é menor que 1

var
  Lib: TPDFlib;
  SourceDoc, TargetDoc, Handle: Integer;
begin
  Lib := TPDFlib.Create;
  try
    TargetDoc := Lib.NewDocument;
    if Lib.LoadFromFile('source.pdf', '') <> 1 then
      Exit;                              // LoadFromFile devolve 1 em caso de sucesso
    SourceDoc := Lib.SelectedDocument;   // o load selecionou o que carregou
    Lib.SelectDocument(TargetDoc);       // a cópia tem como alvo o documento selecionado
    Handle := Lib.CopyForeignObject(SourceDoc, 12);
    if Handle = 0 then
      raise Exception.Create('cross-document copy rejected');
  finally
    Lib.Free;
  end;
end;

Dois detalhes mordem as pessoas na primeira execução. LoadFromFile responde 1 ou 0, não um ID de documento, de modo que o handle de que você precisa vem de SelectedDocument logo após o load; e a cópia sempre grava no documento que SelectDocument tornou atual por último, nunca no documento de onde você carregou. Internamente a recursão também carrega um teto rígido de profundidade de 64, que é um freio de segurança contra aninhamento patológico, não o mecanismo que trata ciclos — o tratamento de ciclos é separado e deliberado

Por que reservar um mapeamento Nil não quebra o ciclo?

Porque Nil na tabela de mapeamento significa duas coisas diferentes ao mesmo tempo, e o código não consegue distingui-las. A defesa óbvia contra um ciclo é adicionar a entrada no mapa antes de recorrer ao objeto, para que qualquer coisa que retorne encontre a entrada e pare. Mas a entrada ainda não pode guardar o alvo real — o alvo não existe até que o fecho abaixo dele tenha sido escrito — então ela guarda Nil, e a busca que deveria capturar a back-edge lê Nil e conclui que o objeto nunca foi mapeado

// Quebrado: um alvo Nil reservado é indistinguível de "ainda não mapeado"
NewRef := FindMapped(SrcRef.ObjNum);
if not Assigned(NewRef) then
begin
  SetLength(Map, Length(Map) + 1);
  Map[High(Map)].SourceObjNum := SrcRef.ObjNum;
  Map[High(Map)].Target := nil;          // reservado, ainda Nil
  NewRef := NewObjRef(CloneObject(SrcInd.Obj, Depth + 1));
  Map[High(Map)].Target := NewRef;       // preenchido de volta só na saída
end;

Siga isso pelo laço da página. O clone da página alcança /Parent, recorre ao nó /Pages, que alcança /Kids, que recorre de volta à página — cuja entrada reservada ainda lê Nil, de modo que ela é clonada uma segunda vez, e uma terceira, cada nível empilhando um frame novo e um objeto novo pela metade. O que você observa também não é um stack overflow limpo: os frames externos estão apoiados em referências cujos alvos nunca foram atribuídos, de modo que a primeira escrita por um desses slots é um access violation em algum lugar que não lembra em nada a cópia de página que o causou

Por que reservar um alvo Nil no mapa não detém o ciclo em uma cópia entre documentos do PDFlibPas: a busca não distingue uma entrada reservada de uma não mapeada, de modo que o copiador desce por frames cada vez mais profundos pela metade até uma escrita travar
Porque um alvo Nil responde a duas perguntas diferentes ao mesmo tempo, a back-edge nunca é reconhecida e a página é clonada de novo a cada passagem

A correção: um estado in-progress explícito

O conserto é parar de sobrecarregar Nil e fazer a pergunta diretamente. Uma entrada do mapa cujo alvo ainda não foi atribuído significa este objeto está sendo clonado agora, e um predicado InProgress testa exatamente isso antes de a busca ordinária rodar. Quando é verdadeiro, a aresta é um ciclo de volta a um ancestral do clone atual, e o PDFlibPas emite um objeto null para ela em vez de segui-la

// Uma entrada do mapa com alvo Nil marca um clone em andamento
function InProgress(Num: Integer): Boolean;
var
  I: Integer;
begin
  Result := False;
  for I := 0 to High(Map) do
    if (Map[I].SourceObjNum = Num) and (not Assigned(Map[I].Target)) then
      Exit(True);
end;

// ... dentro de CloneObject, para uma referência indireta:
if InProgress(SrcRef.ObjNum) then
  Exit(FStructure.NewNull);              // back-edge cíclico, não recorra
NewRef := FindMapped(SrcRef.ObjNum);
if not Assigned(NewRef) then
begin
  SrcInd := SourceDoc.FindObj(SrcRef.ObjNum, SrcRef.GenNum);
  if (not Assigned(SrcInd)) or (not Assigned(SrcInd.Obj)) then
    Exit(FStructure.NewNull);            // referência de origem pendente
  SetLength(Map, Length(Map) + 1);
  Map[High(Map)].SourceObjNum := SrcRef.ObjNum;
  Map[High(Map)].Target := nil;          // reserve, depois recorra
  NewRef := NewObjRef(CloneObject(SrcInd.Obj, Depth + 1));
  Map[High(Map)].Target := NewRef;       // preencha de volta
end;
Exit(NewRef);

Isso é seguro de generalizar apenas por causa de um fato estrutural do PDF: ciclos no grafo de objetos aparecem em back-links, não nas arestas de conteúdo. /Parent na árvore de páginas e /Prev em uma cadeia de outline apontam para cima ou para trás, para algo já visitado; o fecho de uma fonte, de um XObject de imagem ou de um XObject de formulário corre para baixo e termina. Assim, a cópia de um descritor de fonte, de um espaço de cor ou de um dicionário de shading não é afetada pela substituição por null — nada nesses fechos chega a InProgress. O custo, dito com franqueza, é que a aresta cíclica não sobrevive à cópia. Um dicionário de página clonado assim chega com /Parent como objeto null, que o ISO 32000-1 §7.3.9 torna equivalente a uma entrada ausente, de modo que a página copiada é um objeto válido que não pertence a nenhuma árvore de páginas até você vinculá-la ao nó /Pages de destino e ajustar /Count você mesmo. Um item de outline copiado perde seu /Prev da mesma forma e precisa da cadeia de irmãos reconstruída. Essa é a troca honesta: CopyForeignObject entrega um fecho correto e deixa o re-parenting estrutural para quem chama, que é a mesma fronteira dentro da qual substituir páginas preservando números de objetos trabalha

A correção no CopyForeignObject do PDFlibPas para Delphi: um teste InProgress explícito roda antes da busca no mapa, uma back-edge cíclica se torna um objeto null, e quem chama religa a página copiada à árvore de páginas de destino depois
Um estado in-progress explícito substitui o Nil sobrecarregado, de modo que a back-edge resolve para null e resta a quem chama um único reparo estrutural

Por que a entrada do mapa precisa ser reservada antes de NewObjRef

Uma alternativa óbvia contornaria toda a dança do in-progress: alocar primeiro um objeto casca vazio, registrar seu número real no mapa e preencher a casca depois que os filhos forem clonados. Isso não funciona aqui, porque TPDFIndObj.Obj é somente leitura e seu conteúdo não pode ser substituído após a construção — não há casca a preencher. O número e o conteúdo são decididos juntos por NewObjRef, o que significa que a entrada do mapa deve ser criada antes da chamada recursiva e completada depois dela, e o intervalo entre esses dois momentos é precisamente o que InProgress precisa cobrir. Uma consequência que vale conhecer antes de diffar a saída: como NewObjRef roda depois que o fecho de filhos é escrito, a numeração no alvo sai de baixo para cima, e os números de objetos não espelharão a ordem da origem. Nada no formato de arquivo se importa, mas uma comparação byte a byte contra uma expectativa construída à mão se importa. Se uma execução deixar objetos que você decidiu não vincular a nada, eles estão sem referência em vez de corrompidos, e a coleta mark-and-sweep de objetos PDF inalcançáveis é a ferramenta que os limpa antes de salvar

A regressão que cobre isso precisa de um detalhe que surpreende quem escreve testes contra TPDFlib: o construtor já guarda um documento padrão, de modo que DocumentCount começa em 1 e um fixture de dois documentos deve verificar >= 2, não = 2. Junto da cópia bem-sucedida, o teste fixa as três recusas — um ID de origem desconhecido, o documento selecionado como sua própria origem e um número de objeto zero — todas devolvendo 0 em vez de levantar exceção, porque um loop de merge é um mau lugar para descobrir que uma guard clause lança

Onde isso se encaixa em um pipeline de merge

A cópia no nível de objetos é a primitiva a que você recorre quando o merge de arquivos inteiros é grosso demais: extrair um programa de fonte de um template, trazer um único XObject de formulário para um documento de carimbo ou mover uma anotação com seus appearance streams entre arquivos sem arrastar o resto da página junto. O PDFlibPas a expõe como uma única chamada sobre documentos carregados, e você pode ver como ela se situa com o resto da API de objetos de baixo nível na referência da PDFlibPas Delphi PDF Library