Artigo Técnico

PDFlibPas MovePage: caixas herdadas compartilham instância

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 MovePage materializa 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 SetPageBox seguia 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 R tinha todas essas páginas redimensionadas por uma chamada, envolvesse MovePage ou não
  • O CopyPageRanges materializa 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
Aliasing do MovePage no PDFlibPas em que uma página movida e a ex-irmã dela seguravam ambas a própria instância de array de MediaBox do ancestral, então o SetPageBox editava uma página e redimensionava a outra; desde a v3.539.36 a materialização anexa cópias decodificadas e as edições ficam locais à página que você toca
Duas entradas da page tree apontando para um array em memória faziam toda edição pousar em todo holder, e o PDF salvo continuava válido o tempo todo

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

Round-trip de materialização do PDFlibPas em que PLInheritPageAttributes anexa Page.Owner.Decode(Value.Output): um array direto serializa para texto literal e decodifica numa instância nova, enquanto um 11 0 R indireto serializa e decodifica numa referência nova que ainda aponta para o objeto 11 compartilhado
Serializar e reparsear ganha a semântica de objetos de PDF de graça: valores diretos copiam, referências continuam referências, exatamente como a ISO 32000-1 pretende

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

Decisão de copy-on-write do SetPageBox no PDFlibPas: quando a própria entrada da página é um array direto ela é editada no lugar, e quando é uma referência indireta ou está ausente o escritor a substitui por um array direto novo para o objeto 11 compartilhado manter o valor dele para toda outra página que o citar
Copiar na materialização não basta enquanto referências ainda apontam para objetos compartilhados, então o escritor de caixas edita só o que a página possui
Caminho de códigoAntes da v3.539.36Desde a v3.539.36
Materialização do MovePageA página segura as próprias instâncias diretas do ancestralA página segura cópias decodificadas; referências continuam referências
SetPageBoxSegue uma referência e edita o array compartilhadoEdita só um array direto na página, caso contrário escreve um novo
Página de origem do CopyPageRangesCompartilha as caixas do Pages node; o CropBox é a instância do MediaBoxTodo valor materializado na página de origem é uma cópia
Caixas default ao clonar resources de páginaCropBox, BleedBox, TrimBox e ArtBox compartilham um arrayCada 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 Add de 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, BalancePageTree ou CopyPageRanges e 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