No PDFlibPas, a biblioteca PDF para Delphi, uma página movida com MovePage costumava receber exatamente os mesmos objetos MediaBox, CropBox e Resources que o seu antigo nó Pages segurava, por isso um SetPageBox ou DrawText posterior na página movida reescrevia em silêncio esse nó e todos os irmãos que ainda herdavam dele. Desde a v3.539.36 a página movida recebe as suas próprias cópias, e uma referência indireta continua referência. A mesma versão fecha dois caminhos relacionados: o SetPageBox numa caixa indireta partilhada por várias páginas, e o CopyPageRanges deixar as páginas do documento de origem presas ao seu nó Pages, com o CropBox preso ao MediaBox
Os relatórios que levam aqui nunca falam de identidade de objetos. Dizem coisas como "recortei a página 7 e as páginas 8 a 12 também ficaram recortadas", ou "estreitei o CropBox e o MediaBox mexeu com ele", ou, a mais desconcertante, "copiei uma página para um documento novo e o ficheiro original mudou". Nada crasha, nada perde memória, e o ficheiro gravado é PDF perfeitamente válido. Só contém geometria que ninguém pediu
Porque é que o SetPageBox numa página redimensiona as irmãs?
O SetPageBox redimensionava irmãs porque duas entradas da árvore de páginas apontavam para um único array em memória, e o SetPageBox edita o array alvo no sítio. Qualquer página ou nó Pages que segurasse a mesma instância via a edição. Três caminhos de código no PDFlibPas produziam essa partilha antes da v3.539.36:
- O
MovePagematerializa os atributos herdáveis na página antes de a destacar do pai, e anexava os objetos do próprio ancestral em vez de cópias, por isso a página movida e os seus antigos irmãos partilhavam um array de caixas e um dicionário Resources - O
SetPageBoxseguia referências indiretas e editava o array referenciado, por isso um ficheiro em que várias páginas apontam para um objeto/MediaBox 11 0 Rvia todas essas páginas redimensionadas por uma chamada, com ou semMovePageenvolvido - O
CopyPageRangesmaterializa os valores herdados na página de origem antes de a clonar para o documento alvo, e anexava as instâncias do nó Pages à página de origem, mais a própria instância do MediaBox como CropBox por omissão
O caso do MovePage tem uma história curta. Antes da v3.539.27, o MovePage transportava apenas o /Resources, por isso uma página movida para debaixo de outro pai assumia em silêncio o tamanho e a rotação desse pai. A v3.539.27 corrigiu o MediaBox, o CropBox e o Rotate em falta, de que o CollateDocumentsEx também depende quando reordena páginas, mas anexava os valores do ancestral como instâncias partilhadas. É 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 tem-nos
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 os valores diretos e mantém as 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, é partilhado por desenho: a ISO 32000-1 §7.3.10 torna-o endereçável de qualquer sítio do ficheiro, e cada 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 sentar-se num nó Pages e aplicar-se a todas as páginas descendentes que não definam os seus. A página não segura o valor; vai procurá-lo através do /Parent. Essa cadeia de procura parte no momento em que uma página muda de pais, razão pela qual o MovePage e o BalancePageTree têm primeiro de escrever os valores efetivos na própria página. A questão é só como escrevê-los
Porque é que um object pool esconde o erro
No PDFlibPas cada objeto PDF analisado ou criado pertence ao pool TPDFStructure do documento, e os dicionários e arrays guardam apontadores simples para as suas entradas. O TPDFDictionary.Add regista o apontador e mais nada. Acrescentar uma instância a dois contentores-pai é por isso legal em todos os níveis que o runtime consegue verificar: sem double free ao desmontar, sem contagem de referências para ir abaixo, sem exceção. A serialização é igualmente indulgente, já que cada contentor escreve inline o valor atual da instância partilhada, 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 partilhada no sítio. O SetPageBox faz exatamente isso através de um wrapper de retângulo sobre o array existente, e desenhar numa página faz isso ao dicionário Resources quando uma fonte ou imagem é registada. A edição cai, em silêncio, em todos os outros contentores que seguram o apontador
Como o PDFlibPas v3.539.36 copia em vez de partilhar
O PDFlibPas v3.539.36 corrige o problema nas duas pontas: a materialização agora anexa cópias, e as escritas de caixas agora editam só um array de que a página é dona. Cada correção cobre um caso que a outra não consegue
O helper de materialização, PLInheritPageAttributes, anexa agora Page.Owner.Decode(Value.Output) em vez de Value. O round-trip através do serializador é uma maneira bruta mas exata de ter a semântica PDF de graça. Um array ou dicionário direto serializa para o seu texto literal e descodifica numa instância fresca e independente. Uma referência indireta serializa para 11 0 R e descodifica num novo objeto de referência a apontar para o mesmo objeto 11, por isso a página continua a referir-se ao objeto partilhado 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 profunda como a estrutura direta: tudo o que se alcança através de uma referência dentro de um dicionário copiado permanece partilhado, como o formato de ficheiro pretende. O BalancePageTree chama o mesmo helper para cada página de que muda o pai, por isso as páginas materializadas aí também recebem instâncias separadas
Copiar sozinho não chega, porque o caso da referência ainda aponta para um objeto partilhado. Se o SetPageBox seguisse essa referência e editasse o objeto 11, a página movida voltaria a redimensionar o pai antigo e os seus outros filhos. Por isso o escritor de caixas aplica agora copy-on-write: edita no sítio só quando a entrada da própria página é um array direto, e substitui uma caixa indireta ou em falta por um novo array direto. O objeto 11 fica intocado para todas as outras páginas que o citam
| Caminho de código | Antes da v3.539.36 | Desde a v3.539.36 |
|---|---|---|
Materialização do MovePage | A página segura as instâncias diretas do próprio ancestral | A página segura cópias descodificadas; referências continuam referências |
SetPageBox | Segue uma referência e edita o array partilhado | Edita só um array direto na página, caso contrário escreve um novo |
Página de origem do CopyPageRanges | Partilha as caixas do nó Pages; o CropBox é a instância do MediaBox | Cada valor materializado na página de origem é uma cópia |
| Caixas por omissão ao clonar recursos de página | CropBox, BleedBox, TrimBox e ArtBox partilham um array | Cada caixa por omissão recebe o seu próprio array |
A última linha é a latente. Quando a biblioteca clona os recursos de uma página para captura de página ou fusão, preenche as entradas em falta de CropBox, BleedBox, TrimBox e ArtBox, e essas costumavam ser a mesma instância de array. Nenhum chamador atual deixou esse alias sobreviver tempo suficiente para ser editado, mas o próximo teria. Como esses valores de caixa por omissão são escolhidos é um tema próprio, coberto no guia do PDFlibPas sobre predefinições de TrimBox, BleedBox e CropBox
Reproduzir o aliasing do MovePage com um PDF feito à mão
A maneira mais rápida de verificar qualquer build do PDFlibPas é um pequeno PDF escrito à mão carregado com LoadFromString, em que cada número de objeto é conhecido de antemão. O helper abaixo escreve uma tabela de cross-reference clássica com offsets de bytes corretamente calculados, por isso o teste não depende do comportamento de recuperação do parser para ficheiros 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 bytes de 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 nós Pages intermédios. O nó 3 traz um MediaBox indireto (objeto 11, 400 por 300 pontos), um CropBox direto e um dicionário Resources direto, e é dono de duas páginas. O nó 4 tem um MediaBox tamanho Letter e é dono da terceira página. Mover a página 1 para a posição 3 muda-lhe o pai para o nó 4, que é exatamente o movimento que precisa de materialização: sem ela, a página tornar-se-ia 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 acabámos 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 (ver abaixo)
Check(Pos(AnsiString('/Font'), Lib.GetObjectToString(3)) = 0,
'font registered in the old Pages node');
Lib.SelectPage(1); // antiga página 2, ainda sob o nó 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 a largura. Com a origem por omissão 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 verificações de irmãos falham: a edição do CropBox cai no array direto do nó 3, e a edição do MediaBox reescreve o objeto 11 através da referência
O CopyPageRanges altera o documento de origem?
Desde a v3.539.36, o CopyPageRanges continua a escrever nas páginas de origem, mas cada valor que escreve é uma cópia separada, por isso edições posteriores na origem ficam locais à página que edita. A escrita em si é intencional: a página de origem precisa de MediaBox, CropBox, Rotate e Resources explícitos antes de o seu dicionário ser clonado para o alvo, senão a cópia perderia tudo o que herdou. A renumeração e a cópia da página para o alvo estão cobertas na cópia profunda de objetos entre documentos no PDFlibPas; este bug sentava-se do lado da origem, que a maioria das pessoas assume que uma cópia só lê
O output nunca o mostrou. Partilhados ou copiados, os valores materializados serializam-se igualmente, por isso ambos os documentos gravavam byte a byte o mesmo antes e depois da correção. Só uma edição ao 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; // torna-se 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 nó raiz, a cópia anexava essa instância à página 1 de origem, e anexava-a outra vez como CropBox da página 1. Estreitar o CropBox estreitava por isso o MediaBox, e redimensionar o MediaBox redimensionava a página 2 através do nó raiz. Os fluxos de trabalho que copiam páginas para fora e depois continuam a editar a origem, como agregar scans duplex num único PDF antes de aparar os originais, foram onde isto apareceu
Porque é que o aliasing de instâncias é tão difícil de testar?
O aliasing de instâncias é difícil de testar porque o efeito observável precisa de três passos numa ordem específica: criar o alias, mutar um dos lados, 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 gravado, que é idêntico quer o alias exista quer não
A armadilha de ordem no PDFlibPas é o SelectPage. Selecionar uma página reaplica a fonte atual através do SelectFont, que regista essa fonte nos recursos da página. Uma página sem /Resources próprio resolve ao dicionário do pai, por isso simplesmente selecionar tal página acrescenta legitimamente /Font ao nó Pages. No teste MovePage acima, selecionar a antiga página 2 acrescenta a entrada Helvetica ao nó 3, o que é comportamento correto e não uma fuga. É por isso que a verificação GetObjectToString(3) corre antes do SelectPage(1); troque as duas e o teste falha num build corrigido
Essa regra também marca o que a v3.539.36 deixa deliberadamente em paz. Escrever um recurso numa página que herda o seu dicionário Resources escreve no dicionário do ancestral, e todos os irmãos veem a nova entrada. Isso é herança a funcionar como especificado, não partilha de instâncias, e é inofensivo porque acrescentar um nome de fonte ou imagem a um dicionário partilhado não muda a forma como as outras páginas renderizam. Se precisa que uma página pare de herdar, dê-lhe primeiro o seu próprio dicionário Resources
Checklist para código de modelo de objetos PDF
As lições generalizam para qualquer modelo de objetos PDF construído sobre um pool e contentores de apontadores, em Delphi ou onde for:
- Ao materializar atributos herdados segundo a ISO 32000-1 §7.7.3.4, faça deep-copy dos valores diretos e mantenha as referências indiretas como novas referências para o mesmo objeto
- Nunca faça
Addde uma instância existente a um segundo contentor a menos que a partilha seja intencional e documentada; a pertença a um pool significa que o runtime nunca se queixará - Edite no sítio só aquilo que o nó atual tem como objeto direto; substitua valores indiretos ou herdados por um objeto direto fresco (copy-on-write)
- Valores por omissão derivados de outra entrada, como um CropBox a partir de um MediaBox, precisam da sua própria instância
- Teste o aliasing com sequências de mutar e depois inspecionar no outro detentor, e verifique a ordem das chamadas que podem legitimamente escrever entretanto
- Comparar o output gravado não prova nada aqui: valores partilhados e copiados serializam-se igualmente até à primeira edição
- No PDFlibPas, faça upgrade para a v3.539.36 ou posterior se chama
MovePage,CollateDocumentsEx,BalancePageTreeouCopyPageRangese depois edita caixas de página ou desenha em páginas
O PDFlibPas expõe edição da árvore de páginas, cópia entre documentos e controlo 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 PDF Delphi PDFlibPas para edições, plataformas e a referência completa da API