Junte dois PDF à mão, mova um único objeto de página para o documento de destino, e a cópia entra direto numa violação de acesso. O PDFlibPas resolve isto em CopyForeignObject: copia em profundidade um objeto indireto mais todo o seu fecho de referências, e resolve referências de retorno cíclicas, como /Parent, para null em vez de recorrer
Porque é que copiar uma página entre documentos provoca um crash?
Porque uma árvore de páginas PDF só é uma árvore se a ler 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-lhe /Parent, que aponta de volta para o nó /Pages de onde veio, e esse nó entrega-lhe /Kids, que aponta de volta para a página. A ISO 32000-1 §7.7.3 exige /Parent em todos os nós da árvore de páginas exceto a raiz, pelo que isto não é um ficheiro malformado que se possa recusar — é a forma normal de qualquer documento que alguma vez lhe seja entregue
A segunda metade do problema é a numeração. Os objetos indiretos identificam-se por um número de objeto local a um ficheiro (ISO 32000-1 §7.3.10), pelo que um objeto arrastado do documento A para o documento B tem de ser renumerado, e cada referência a ele dentro do fecho copiado tem de ser renumerada da mesma forma, ou duas referências que apontavam para uma fonte partilhada passam a apontar para duas coisas sem relação entre si. Essa renumeração é o mesmo trabalho que um merge rápido faz ao nível dos bytes, e vale a pena ler os dois lado a lado: a deslocação de referências ao nível de bytes para merge rápido de PDF resolve-o traduzindo ficheiros inteiros, enquanto uma cópia ao nível de objetos tem de o resolver uma aresta de cada vez
O que o CopyForeignObject do PDFlibPas copia realmente
TPDFlib.CopyForeignObject(SourceDocumentID, ObjectNumber) clona um objeto indireto e tudo o que é alcançável a partir dele — dicionários aninhados, arrays, strings, names, números, e streams com os seus dicionários intactos — para o documento atualmente selecionado, e devolve um handle não nulo para a nova referência indireta. Os números de objeto de origem são remapeados através de um mapa vivo mantido durante a chamada, pelo que um objeto alcançado duas vezes no fecho é clonado uma vez e partilhado duas vezes. Devolve zero, sem lançar exceção, quando o ID do documento de origem é desconhecido, quando a origem é o próprio documento selecionado, ou quando ObjectNumber é inferior a 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 destina-se ao 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, pelo que o handle de que precisa vem de SelectedDocument logo a seguir ao load; e a cópia escreve sempre no que SelectDocument tornou atual por último, nunca no documento de onde carregou. Internamente, a recursão tem também um limite rígido de profundidade de 64, que é uma rede de segurança contra aninhamento patológico, não o mecanismo que trata ciclos — o tratamento de ciclos é separado e deliberado
Porque é 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 para dentro do objeto, para que tudo o que volte para trás encontre a entrada e pare. Mas a entrada ainda não pode conter o alvo real — o alvo não existe até que o fecho por baixo tenha sido escrito — por isso contém Nil, e a procura que devia apanhar a aresta de retorno lê Nil e conclui que o objeto nunca foi mapeado
// Errado: 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 apenas no regresso
end;
Siga isto ao longo do ciclo da página. O clone da página alcança /Parent, recorre para dentro do nó /Pages, que alcança /Kids, que recorre de volta para a página — cuja entrada reservada ainda lê Nil, pelo que é clonada uma segunda vez, e uma terceira, cada nível empurrando um novo frame e um novo objeto meio construído. O que se observa também não é um stack overflow limpo: os frames exteriores estão sentados em referências cujos alvos nunca foram atribuídos, pelo que a primeira escrita através de uma dessas posições é uma violação de acesso num sítio que não se parece nada com a cópia de página que a causou
A correção: um estado in-progress explícito
A reparação é deixar de sobrecarregar Nil e fazer a pergunta diretamente. Uma entrada do mapa cujo alvo ainda não está atribuído significa este objeto está neste momento a ser clonado, e um predicado InProgress testa exatamente isso antes de a procura normal correr. Quando é verdadeiro, a aresta é um ciclo de volta para um antepassado do clone atual, e o PDFlibPas emite um objeto null para ela em vez de a seguir
// Uma entrada do mapa com alvo Nil marca um clone em curso
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); // aresta cíclica de retorno, não recorrer
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; // reservar, depois recorrer
NewRef := NewObjRef(CloneObject(SrcInd.Obj, Depth + 1));
Map[High(Map)].Target := NewRef; // preencher no regresso
end;
Exit(NewRef);
Isto só é seguro de generalizar por causa de um facto estrutural sobre o PDF: os ciclos no grafo de objetos aparecem nas ligações de retorno, não nas arestas de conteúdo. /Parent na árvore de páginas e /Prev numa cadeia de marcadores 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 desce e termina. Por isso uma cópia de um descritor de fonte, de um espaço de cores ou de um dicionário de shading não é afetada pela substituição por null — nada nesses fechos chega a atingir InProgress. O custo, dito com clareza, é que a aresta cíclica não sobrevive à cópia. Um dicionário de página clonado desta forma chega com /Parent como objeto null, o que a ISO 32000-1 §7.3.9 torna equivalente a uma entrada ausente, pelo que a página copiada é um objeto válido que não pertence a nenhuma árvore de páginas até que a ligue ao nó /Pages de destino e corrija /Count você próprio. Um marcador copiado perde o /Prev da mesma forma e precisa de ver a cadeia de irmãos reconstruída. Esse é o compromisso honesto: o CopyForeignObject dá-lhe um fecho correto e deixa o re-posicionamento estrutural ao chamador, que é a mesma fronteira dentro da qual substituir páginas preservando números de objeto trabalha
Porque a entrada do mapa tem de ser reservada antes de NewObjRef
Uma alternativa óbvia contornaria toda a dança do in-progress: alocar primeiro um objeto shell vazio, registar o seu número real no mapa, e depois preencher o shell quando os filhos estiverem clonados. Isso não funciona aqui, porque TPDFIndObj.Obj é só de leitura e o seu conteúdo não pode ser substituído após a construção — não há shell para preencher. O número e o conteúdo são decididos em conjunto por NewObjRef, o que significa que a entrada do mapa tem de ser criada antes da chamada recursiva e completada depois, e o intervalo entre esses dois momentos é precisamente o que InProgress tem de cobrir. Uma consequência que vale a pena saber antes de fazer diff ao resultado: porque NewObjRef corre depois de o fecho dos filhos estar escrito, a numeração no destino sai de baixo para cima, e os números de objeto não espelharão a ordem da origem. Nada no formato de ficheiro se importa, mas uma comparação byte a byte contra uma expectativa construída à mão importa-se. Se uma execução deixar objetos que decidiu não ligar a nada, ficam não referenciados em vez de corrompidos, e a recolha mark-and-sweep de objetos PDF inalcançáveis é a ferramenta que os limpa antes de guardar
A regressão que cobre isto precisa de um detalhe que surpreende quem escreve testes contra o TPDFlib: o construtor já contém um documento predefinido, pelo que DocumentCount começa em 1 e um fixture de dois documentos tem de verificar >= 2, e não = 2. Além da cópia bem-sucedida, o teste fixa as três recusas — um ID de origem desconhecido, o documento selecionado como própria origem, e um número de objeto zero — todas a devolver 0 em vez de lançar exceção, porque um ciclo de merge é um mau sítio para descobrir que uma cláusula de guarda lança exceção
Onde isto se encaixa num pipeline de merge
A cópia ao nível de objetos é a primitiva a que se recorre quando o merge de ficheiros inteiros é demasiado grosseiro: extrair um programa de fontes de um template, puxar um único XObject de formulário para um documento de carimbagem, ou mover uma anotação com os seus appearance streams entre ficheiros sem arrastar o resto da página. O PDFlibPas expõe isto como uma única chamada sobre documentos carregados, e pode ver como se situa com o resto da API de objetos de baixo nível na referência da PDFlibPas Delphi PDF Library