No PDFlibPas, a biblioteca de PDF para Delphi, uma página movida com MovePage costumava receber os mesmíssimos objetos MediaBox, CropBox e Resources que o node Pages antigo dela segurava, então um SetPageBox ou DrawText posterior na página movida reescrevia silenciosamente esse node e todo irmão ainda herdando dele. Desde a v3.539.36 a página movida ganha cópias próprias, e uma referência indireta continua sendo uma referência. A mesma release fecha dois caminhos relacionados: SetPageBox numa caixa indireta que várias páginas compartilham, e CopyPageRanges deixando páginas do documento de origem amarradas ao Pages node delas, com o CropBox amarrado ao MediaBox
Os relatos que levam até aqui nunca mencionam identidade de objeto. Eles dizem coisas como "recortei a página 7 e as páginas 8 a 12 também foram recortadas", ou "afinhei o CropBox e o MediaBox veio junto", ou, a mais confusa de todas, "copiei uma página para um documento novo e o arquivo original mudou". Nada trava, nada vaza, e o arquivo salvo é um PDF perfeitamente válido. Ele só contém uma geometria que ninguém pediu
Por que o SetPageBox numa página redimensiona as irmãs?
O SetPageBox redimensionava irmãs porque duas entradas da page tree apontavam para um único array em memória, e o SetPageBox edita o array alvo dele no lugar. Qualquer página ou Pages node segurando a mesma instância via a edição. Três caminhos de código no PDFlibPas produziam esse compartilhamento antes da v3.539.36:
- O
MovePagematerializa os atributos herdáveis na página antes de destacá-la do pai, e ele anexava os objetos do próprio ancestral em vez de cópias, então a página movida e os ex-irmãos dela compartilhavam um array de caixa e um dicionário Resources - O
SetPageBoxseguia referências indiretas e editava o array referenciado, então um arquivo em que várias páginas apontam para um objeto/MediaBox 11 0 Rtinha todas essas páginas redimensionadas por uma chamada, envolvesseMovePageou não - O
CopyPageRangesmaterializa os valores herdados na página de origem antes de cloná-la no documento alvo, e ele anexava as instâncias do Pages node à página de origem, mais a própria instância do MediaBox como CropBox default
O caso do MovePage tem uma história curta. Antes da v3.539.27, o MovePage carregava só o /Resources, então uma página movida para debaixo de um pai diferente assumia silenciosamente o tamanho e a rotação desse pai. A v3.539.27 consertou o MediaBox, CropBox e Rotate faltantes, de que o CollateDocumentsEx também depende quando reordena páginas, mas anexava os valores do ancestral como instâncias compartilhadas. Essa é a janela que a v3.539.36 fecha. Os caminhos do SetPageBox e do CopyPageRanges são mais antigos; qualquer build anterior à v3.539.36 os tem
Valores diretos, referências indiretas e herança de atributos de página
Uma cópia correta de um atributo de página herdado duplica valores diretos e mantém referências indiretas como referências, porque essa é a distinção que a própria ISO 32000-1 traça. Um objeto direto como [0 0 400 300] escrito dentro de um dicionário pertence só a esse dicionário. Um objeto indireto, definido uma vez como 11 0 obj e citado como 11 0 R, é compartilhado por design: a ISO 32000-1 §7.3.10 o torna endereçável de qualquer lugar do arquivo, e todo 11 0 R significa o mesmo objeto
A herança de atributos de página, ISO 32000-1 §7.7.3.4, acrescenta um terceiro caso. Resources, MediaBox, CropBox e Rotate podem morar num Pages node e valer para toda página descendente que não defina os próprios. A página não segura o valor; ela o procura através do /Parent. Essa cadeia de lookup quebra no instante em que uma página troca de pai, e é por isso que o MovePage e o BalancePageTree precisam primeiro gravar os valores efetivos na própria página. A questão é só como gravá-los
Por que um pool de objetos esconde o erro
No PDFlibPas todo objeto de PDF parseado ou criado é de propriedade do pool TPDFStructure do documento, e dicionários e arrays guardam ponteiros crus para as entradas deles. O TPDFDictionary.Add registra o ponteiro e mais nada. Adicionar uma instância a dois containers pais é portanto legal em todo nível que o runtime consegue checar: nenhum double free no teardown, nenhum reference count para dar errado, nenhuma exceção. A serialização é igualmente condescendente, já que cada container grava o valor atual da instância compartilhada inline, e antes de qualquer edição o output é byte a byte o que uma cópia correta produziria
O aliasing só aparece quando alguém muta a instância compartilhada no lugar. O SetPageBox faz exatamente isso através de um wrapper de retângulo sobre o array existente, e desenhar numa página faz isso com o dicionário Resources quando uma fonte ou imagem é registrada. A edição pousa, silenciosamente, em todo outro container que segura o ponteiro
Como o PDFlibPas v3.539.36 copia em vez de compartilhar
O PDFlibPas v3.539.36 conserta o problema nas duas pontas: a materialização agora anexa cópias, e as escritas de caixa agora editam só um array de que a página é dona. Cada correção cobre um caso que a outra não cobre
O helper de materialização, PLInheritPageAttributes, agora anexa Page.Owner.Decode(Value.Output) em vez de Value. Ir e voltar pelo serializador é um jeito contundente mas exato de ganhar a semântica de PDF de graça. Um array ou dicionário direto serializa para o texto literal dele e decodifica numa instância nova e independente. Uma referência indireta serializa para 11 0 R e decodifica num objeto de referência novo apontando para o mesmo objeto 11, então a página ainda se refere ao objeto compartilhado em vez de receber uma cópia inline, o que preserva o comportamento de referência introduzido na v3.539.27. A cópia é exatamente tão funda quanto a estrutura direta: qualquer coisa alcançada através de uma referência dentro de um dicionário copiado permanece compartilhada, como o formato de arquivo pretende. O BalancePageTree chama o mesmo helper para toda página cujo pai ele troca, então páginas materializadas ali também ganham instâncias separadas
Copiar sozinho não basta, porque o caso de referência ainda aponta para um objeto compartilhado. Se o SetPageBox seguisse essa referência e editasse o objeto 11, a página movida redimensionaria de novo o pai antigo e os outros filhos dele. Então o escritor de caixas agora aplica copy-on-write: ele edita no lugar só quando a própria entrada da página é um array direto, e substitui uma caixa indireta ou ausente por um array direto novo. O objeto 11 fica intacto para toda outra página que o citar
| Caminho de código | Antes da v3.539.36 | Desde a v3.539.36 |
|---|---|---|
Materialização do MovePage | A página segura as próprias instâncias diretas do ancestral | A página segura cópias decodificadas; referências continuam referências |
SetPageBox | Segue uma referência e edita o array compartilhado | Edita só um array direto na página, caso contrário escreve um novo |
Página de origem do CopyPageRanges | Compartilha as caixas do Pages node; o CropBox é a instância do MediaBox | Todo valor materializado na página de origem é uma cópia |
| Caixas default ao clonar resources de página | CropBox, BleedBox, TrimBox e ArtBox compartilham um array | Cada caixa default ganha o próprio array |
A última linha é a latente. Quando a biblioteca clona os resources de uma página para captura de página ou merge, ela preenche as entradas de CropBox, BleedBox, TrimBox e ArtBox faltantes, e essas costumavam ser a mesma instância de array. Nenhum caller atual deixou esse alias sobreviver tempo suficiente para ser editado, mas o próximo caller teria deixado. Como esses valores de caixa default são escolhidos é um tópico próprio, coberto no guia do PDFlibPas sobre defaults de TrimBox, BleedBox e CropBox
Reproduzindo o aliasing do MovePage com um PDF escrito à mão
O jeito mais rápido de conferir qualquer build do PDFlibPas é um PDF pequeno escrito à mão carregado com LoadFromString, em que todo número de objeto é conhecido de antemão. O helper abaixo escreve uma cross-reference table clássica com byte offsets corretamente calculados, para que o teste não dependa do comportamento de recuperação do parser para arquivos danificados
uses
System.SysUtils, PDFlibrary;
function BuildPdf(const Objects: array of AnsiString): AnsiString;
var
Offsets: array of Integer;
I, XRefPos: Integer;
begin
Result := '%PDF-1.4'#10;
SetLength(Offsets, Length(Objects));
for I := 0 to High(Objects) do
begin
Offsets[I] := Length(Result); // offset de byte base 0 de "N 0 obj"
Result := Result + AnsiString(IntToStr(I + 1)) + ' 0 obj'#10 +
Objects[I] + #10'endobj'#10;
end;
XRefPos := Length(Result);
Result := Result + 'xref'#10'0 ' + AnsiString(IntToStr(Length(Objects) + 1)) +
#10'0000000000 65535 f '#10;
for I := 0 to High(Offsets) do // cada entrada tem exatamente 20 bytes
Result := Result + AnsiString(Format('%.10d 00000 n ', [Offsets[I]])) + #10;
Result := Result + 'trailer'#10'<< /Size ' +
AnsiString(IntToStr(Length(Objects) + 1)) + ' /Root 1 0 R >>'#10 +
'startxref'#10 + AnsiString(IntToStr(XRefPos)) + #10'%%EOF'#10;
end;
function StreamObj(const Content: AnsiString): AnsiString;
begin
Result := '<< /Length ' + AnsiString(IntToStr(Length(Content))) +
' >>'#10'stream'#10 + Content + #10'endstream';
end;
O documento de teste tem dois nodes Pages intermediários. O node 3 carrega um MediaBox indireto (objeto 11, 400 por 300 pontos), um CropBox direto e um dicionário Resources direto, e possui duas páginas. O node 4 tem um MediaBox tamanho Letter e possui a terceira página. Mover a página 1 para a posição 3 a coloca de novo sob o node 4, que é exatamente o movimento que precisa de materialização: sem ela, a página viraria uma página Letter
procedure Check(Condition: Boolean; const Msg: string);
begin
if not Condition then
raise Exception.Create(Msg);
end;
procedure CheckMovedPageIsIsolated;
var
Lib: TPDFlib;
FontID: Integer;
begin
Lib := TPDFlib.Create;
try
Check(Lib.LoadFromString(BuildPdf([
'<< /Type /Catalog /Pages 2 0 R >>',
'<< /Type /Pages /Kids [3 0 R 4 0 R] /Count 3 >>',
'<< /Type /Pages /Parent 2 0 R /Kids [5 0 R 6 0 R] /Count 2 ' +
'/MediaBox 11 0 R /CropBox [10 20 390 280] /Resources << >> >>',
'<< /Type /Pages /Parent 2 0 R /Kids [7 0 R] /Count 1 ' +
'/MediaBox [0 0 612 792] >>',
'<< /Type /Page /Parent 3 0 R /Contents 8 0 R >>',
'<< /Type /Page /Parent 3 0 R /Contents 9 0 R >>',
'<< /Type /Page /Parent 4 0 R /Contents 10 0 R >>',
StreamObj('1 w'), StreamObj('2 w'), StreamObj('3 w'),
'[0 0 400 300]']), '') = 1, 'load failed');
Lib.SelectPage(1);
Check(Lib.MovePage(3) = 1, 'MovePage failed');
Lib.SelectPage(3); // a página que acabamos de mover
Check(Abs(Lib.GetPageBox(1, 2) - 400) < 0.001, 'inherited MediaBox lost');
Lib.SetPageBox(1, 0, 200, 200, 200); // MediaBox 200 x 200
Lib.SetPageBox(2, 0, 100, 100, 100); // CropBox 100 x 100
FontID := Lib.AddStandardFont(4); // Helvetica
Lib.SelectFont(FontID);
Lib.SetTextSize(12);
Lib.DrawText(20, 20, 'MOVED');
// Inspecione o pai antigo ANTES de selecionar outra página (veja abaixo)
Check(Pos(AnsiString('/Font'), Lib.GetObjectToString(3)) = 0,
'font registered in the old Pages node');
Lib.SelectPage(1); // ex-página 2, ainda sob o node 3
Check(Abs(Lib.GetPageBox(1, 2) - 400) < 0.001, 'sibling MediaBox changed');
Check(Abs(Lib.GetPageBox(2, 2) - 380) < 0.001, 'sibling CropBox changed');
Check(Pos(AnsiString('400'), Lib.GetObjectToString(11)) > 0,
'shared object 11 was rewritten');
finally
Lib.Free;
end;
end;
O GetPageBox(BoxType, Dimension) recebe o tipo de caixa 1 para MediaBox e 2 para CropBox, e a dimensão 2 para largura. Com a origem default no canto inferior esquerdo, SetPageBox(1, 0, 200, 200, 200) significa esquerda 0, topo 200, 200 de largura e 200 de altura. Em builds entre a v3.539.27 e a v3.539.35, as checagens de irmãs falham: a edição do CropBox pousa no array direto do node 3, e a edição do MediaBox reescreve o objeto 11 através da referência
O CopyPageRanges muda o documento de origem?
Desde a v3.539.36, o CopyPageRanges ainda grava nas páginas de origem, mas todo valor que ele grava é uma cópia separada, então edições posteriores na origem ficam locais à página que você edita. A gravação em si é intencional: a página de origem precisa de MediaBox, CropBox, Rotate e Resources explícitos antes de o dicionário dela ser clonado para o alvo, senão a cópia perderia tudo o que herdou. Renumerar e copiar a página para o alvo é coberto em deep copy de objetos entre documentos no PDFlibPas; esse bug morava no lado da origem, que a maioria das pessoas assume que uma cópia só lê
O output nunca mostrou isso. Compartilhado ou copiado, os valores materializados serializam identicamente, então ambos os documentos salvos eram byte a byte os mesmos antes e depois da correção. Só uma edição no documento de origem depois da cópia revelava o alias:
procedure CheckSourceSurvivesCopy;
var
Lib: TPDFlib;
SourceID, TargetID: Integer;
begin
Lib := TPDFlib.Create;
try
Check(Lib.LoadFromString(BuildPdf([
'<< /Type /Catalog /Pages 2 0 R >>',
'<< /Type /Pages /Kids [3 0 R 4 0 R] /Count 2 ' +
'/MediaBox [0 0 400 300] /Resources << >> >>',
'<< /Type /Page /Parent 2 0 R /Contents 5 0 R >>',
'<< /Type /Page /Parent 2 0 R /Contents 6 0 R >>',
StreamObj('1 w'), StreamObj('2 w')]), '') = 1, 'load failed');
SourceID := Lib.SelectedDocument;
TargetID := Lib.NewDocument; // vira o documento selecionado
Check(Lib.CopyPageRanges(SourceID, '1') = 1, 'copy failed');
Lib.SelectDocument(SourceID);
Lib.SelectPage(1);
Lib.SetPageBox(2, 50, 250, 100, 100); // estreita só o CropBox
Check(Abs(Lib.GetPageBox(1, 2) - 400) < 0.001, 'MediaBox followed CropBox');
Lib.SetPageBox(1, 0, 200, 200, 200);
Lib.SelectPage(2);
Check(Abs(Lib.GetPageBox(1, 2) - 400) < 0.001, 'sibling page resized');
Lib.SelectDocument(TargetID); // a cópia mantém o tamanho original
Lib.SelectPage(Lib.PageCount);
Check(Abs(Lib.GetPageBox(1, 2) - 400) < 0.001, 'copied page resized');
finally
Lib.Free;
end;
end;
Antes da v3.539.36 ambas as páginas aqui herdavam o MediaBox direto do node raiz, a cópia anexava essa instância à página de origem 1, e a anexava de novo como CropBox da página 1. Afinar o CropBox portanto afinava o MediaBox, e redimensionar o MediaBox redimensionava a página 2 através do node raiz. Workflows que copiam páginas para fora e depois continuam editando a origem, como colar scans duplex num único PDF antes de aparar os originais, são onde isso aparecia
Por que aliasing de instância é tão difícil de testar?
Aliasing de instância é difícil de testar porque o efeito observável precisa de três passos numa ordem específica: criar o alias, mutar um lado, depois inspecionar o outro lado antes que qualquer outra coisa o toque. A maioria dos testes faz só o primeiro passo e compara o output salvo, que é idêntico exista o alias ou não
A armadilha de ordenação no PDFlibPas é o SelectPage. Selecionar uma página reaplica a fonte atual através do SelectFont, que registra essa fonte nos resources da página. Uma página sem /Resources próprio resolve para o dicionário do pai dela, então simplesmente selecionar tal página acrescenta legitimamente /Font ao Pages node. No teste de MovePage acima, selecionar a ex-página 2 acrescenta a entrada Helvetica ao node 3, o que é comportamento correto e não é um vazamento. É por isso que a checagem do GetObjectToString(3) roda antes do SelectPage(1); troque os dois e o teste falha num build corrigido
Essa regra também marca o que a v3.539.36 deliberadamente deixa em paz. Gravar um resource numa página que herda o dicionário Resources dela grava no dicionário do ancestral, e todo irmão vê a entrada nova. Isso é herança funcionando conforme especificado, não compartilhamento de instância, e é inofensivo porque acrescentar um nome de fonte ou imagem a um dicionário compartilhado não muda como as outras páginas renderizam. Se você precisa que uma página pare de herdar, dê a ela um dicionário Resources próprio primeiro
Checklist para código de modelo de objetos de PDF
As lições se generalizam para qualquer modelo de objetos de PDF construído sobre um pool e containers de ponteiros, em Delphi ou fora dele:
- Ao materializar atributos herdados conforme a ISO 32000-1 §7.7.3.4, faça deep copy dos valores diretos e mantenha referências indiretas como referências novas para o mesmo objeto
- Nunca faça
Addde uma instância existente a um segundo container a menos que o compartilhamento seja intencional e documentado; a propriedade por um pool significa que o runtime nunca vai reclamar - Edite no lugar só o que o node atual possui como objeto direto; substitua valores indiretos ou herdados por um objeto direto novo (copy-on-write)
- Valores default derivados de outra entrada, como um CropBox de um MediaBox, precisam da própria instância
- Teste aliasing com sequências de mutar-para-inspecionar no outro holder, e confira a ordem de chamadas que podem gravar legitimamente no meio do caminho
- Comparar output salvo não prova nada aqui: valores compartilhados e copiados serializam identicamente até a primeira edição
- No PDFlibPas, faça upgrade para a v3.539.36 ou posterior se você chama
MovePage,CollateDocumentsEx,BalancePageTreeouCopyPageRangese depois edita caixas de página ou desenha em páginas
O PDFlibPas expõe edição de page tree, cópia entre documentos e controle de caixas de página através de uma única classe TPDFlib para Delphi, C++Builder e Free Pascal. Veja a página de produto da biblioteca de PDF PDFlibPas para Delphi para edições, plataformas e a referência completa de API