Artigo Técnico

MovePage no PDFlibPas: caixas herdadas partilham instâncias

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 MovePage materializa 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 SetPageBox seguia 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 R via todas essas páginas redimensionadas por uma chamada, com ou sem MovePage envolvido
  • O CopyPageRanges materializa 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
Aliasing do MovePage no PDFlibPas em que uma página movida e o seu antigo irmão seguravam ambos a instância do array MediaBox do próprio ancestral, por isso o SetPageBox editava uma página e redimensionava a outra; desde a v3.539.36 a materialização anexa cópias descodificadas e as edições ficam locais à página que tocar
Duas entradas da árvore de páginas a apontar para um único array em memória faziam cada edição cair em todos os detentores, e o PDF gravado mantinha-se válido o tempo todo

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

Round-trip de materialização no PDFlibPas em que o PLInheritPageAttributes anexa Page.Owner.Decode(Value.Output): um array direto serializa para texto literal e descodifica numa instância fresca, enquanto um 11 0 R indireto serializa e descodifica numa nova referência que continua a apontar para o objeto 11 partilhado
Serializar e voltar a analisar dá a semântica de objetos PDF de graça: valores diretos copiam-se, referências continuam referências, exatamente como a ISO 32000-1 pretende

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

Decisão copy-on-write do SetPageBox no PDFlibPas: quando a entrada da própria página é um array direto é editada no sítio, e quando é uma referência indireta ou está em falta o escritor substitui-a por um novo array direto para o objeto 11 partilhado manter o seu valor para todas as outras páginas que o citam
Copiar na materialização não chega enquanto as referências continuarem a apontar para objetos partilhados, por isso o escritor de caixas edita só aquilo de que a página é dona
Caminho de códigoAntes da v3.539.36Desde a v3.539.36
Materialização do MovePageA página segura as instâncias diretas do próprio ancestralA página segura cópias descodificadas; referências continuam referências
SetPageBoxSegue uma referência e edita o array partilhadoEdita só um array direto na página, caso contrário escreve um novo
Página de origem do CopyPageRangesPartilha as caixas do nó Pages; o CropBox é a instância do MediaBoxCada valor materializado na página de origem é uma cópia
Caixas por omissão ao clonar recursos de páginaCropBox, BleedBox, TrimBox e ArtBox partilham um arrayCada 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 Add de 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, BalancePageTree ou CopyPageRanges e 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