Artigo Técnico

Carimbos de Página Reutilizáveis via Form XObjects com PDFium

Carimbar uma marca de água ou um logótipo em todas as páginas de um documento parece um trabalho de cinco minutos até abrir o resultado num inspetor de tamanho de ficheiros. A abordagem óbvia é percorrer as páginas e, em cada uma delas, construir novamente os mesmos objetos de texto ou imagem. Isso funciona visualmente, mas é um desperdício de uma forma que se agrava. Uma marca de água diagonal "RASCUNHO" desenhada diretamente num relatório de cem páginas significa cem cópias dos mesmos dados de caminho e texto localizados nos fluxos de conteúdo, e o ficheiro guardado transporta cada um deles

Um Form XObject é a construção que o PDF fornece para evitar exatamente isto. Ele envolve uma parte de conteúdo reutilizável, uma página inteira ou um pequeno modelo, num único objeto nomeado que pode ser pintado várias vezes em várias posições. O conteúdo reside no ficheiro uma vez. Cada página que pretende o carimbo contém uma pequena instrução que diz "pintar o XObject N aqui, com esta transformação". Uma marca de água de cem páginas adiciona então um objeto de conteúdo ao ficheiro em vez de cem, e essa é a diferença entre um documento que cresce linearmente com a sua contagem de páginas e um que não o faz. Marcas de água, carimbos de logótipos, modelos de números de página e selos são todos o mesmo tipo de problema, e o Form XObject é a ferramenta certa para cada um deles

Por que um objeto armazenado supera cem redesenhos

A poupança é estrutural, não cosmética. Uma página PDF é renderizada executando o seu fluxo de conteúdo, uma sequência de operadores de desenho. Ao redesenhar um carimbo por página, está a anexar a sequência de operadores completa para esse carimbo ao fluxo de cada página, e os bytes são duplicados tantas vezes quantas as páginas que tiver. Um Form XObject move esses operadores para um fluxo armazenado uma vez no documento. A referência que uma página individual mantém é pequena: envia uma matriz de transformação, invoca o XObject e restaura o estado. A contagem de páginas deixa de multiplicar o custo do trabalho gráfico

Isto é mais importante quando o carimbo é pesado. Um selo vetorial com centenas de segmentos de caminho, ou um bitmap de logótipo, é caro para armazenar. Armazenada uma vez e referenciada, a parte pesada é paga uma única vez e a sobrecarga por página é de alguns bytes de invocação. O resultado visual na página é idêntico a um redesenho direto, o que é o objetivo. O leitor não consegue perceber a diferença; o tamanho do ficheiro sim

Capturar uma página num XObject

O PDFium constrói o objeto reutilizável a partir de uma página existente. A origem é uma página nalgum documento que tem aberto, um pequeno PDF de uma página que contém apenas o trabalho gráfico da sua marca de água, ou uma página específica de um ficheiro maior. O CreateXObjectFromPage captura o conteúdo dessa página de origem num handle reutilizável que pertence ao documento de destino, aquele que está a carimbar

var
  Dest, Stamp: TPdf;
  XObject: TPdfXObject;
begin
  Dest := TPdf.Create(nil);
  Stamp := TPdf.Create(nil);
  try
    Dest.FileName := 'Report.pdf';
    Dest.Active := True;
    Stamp.FileName := 'Watermark.pdf';   // one page of artwork
    Stamp.Active := True;
    if not (Dest.Active and Stamp.Active) then
      raise Exception.Create('Could not open the input documents');

    // Capture page 0 of the stamp document into a reusable handle that
    // is owned by Dest. Source must be Active; the index is zero-based.
    XObject := Dest.CreateXObjectFromPage(Stamp, 0);
    if XObject = nil then
      raise Exception.Create('Could not build the stamp XObject');
    // ... place it, then free it before closing Stamp (see below) ...

A assinatura é CreateXObjectFromPage(Source: TPdf; SourcePageIndex: Integer): TPdfXObject. O método levanta uma exceção se o documento de origem não estiver Active, e devolve nil em vez de levantar uma exceção quando o PDFium não consegue construir o objeto, portanto, a verificação explícita acima não é opcional. O handle devolvido é um TPdfXObject que o utilizador possui, e as duas restrições de tempo de vida associadas a ele são a parte de todo este exercício que surpreende as pessoas, por isso têm a sua própria secção abaixo

Colocar o carimbo numa página

Um XObject capturado não faz nada por si só. Para o fazer aparecer, insere uma cópia do mesmo na página atual do documento, aquela selecionada pela propriedade PageNumber baseada em 1, com InsertFormObjectFromXObject. Essa chamada devolve o objeto da página subjacente, um FPDF_PAGEOBJECT, e o handle devolvido é a forma como posiciona a colocação. Sem uma transformação, o carimbo fica na origem nas próprias coordenadas da página de origem, o que raramente é onde o deseja

Uma vez que InsertFormObjectFromXObject insere uma cópia por chamada e devolve um objeto de página novo de cada vez, pode pintar o mesmo XObject várias vezes numa página com diferentes transformações, e o conteúdo armazenado continua a ser contado uma vez no ficheiro. Um logótipo de canto e uma ténue marca de água de página inteira podem vir do mesmo objeto capturado

var
  PageObj: FPDF_PAGEOBJECT;
  M: TPdfMatrix;
  RawM: FS_MATRIX;
begin
  // The current page of Dest receives one copy of the XObject.
  PageObj := Dest.InsertFormObjectFromXObject(XObject);
  if PageObj = nil then
    raise Exception.Create('Insert failed on this page');

  // Position it: move 200 units right, 500 up, at 70% scale.
  M := TPdfMatrix.Create;
  try
    M.Scale(0.7, 0.7);
    M.Translate(200, 500);
    RawM := M.Handle;
    if FPDFPageObj_SetMatrix(PageObj, RawM) = 0 then
      raise Exception.Create('Cannot assign the stamp matrix');
  finally
    M.Free;
  end;
  Dest.UpdatePage;   // commit this page's edits to its content stream
  // if not Dest.SaveAs(...) then ... when every page is done.
end;

Dois pormenores de manutenção tornam isto seguro. Em primeiro lugar, uma vez inserido, o objeto da página pertence à página, não ao XObject. Libertar o XObject mais tarde não invalida as colocações que já fez. É isso que permite que a ordenação de criar-colocar-libertar descrita abaixo funcione. Em segundo lugar, a inserção e o posicionamento apenas alteram a lista de objetos da página na memória; UpdatePage é o que serializa essa lista de volta para o fluxo de conteúdo da página, portanto, uma página que edita sem o chamar é guardada como se o carimbo nunca tivesse sido colocado

A regra do tempo de vida do handle que morde as pessoas

Duas restrições regem o handle do XObject, e ignorar qualquer uma delas produz uma falha que parece não estar relacionada com a sua causa. Primeiro, o documento de origem deve estar ativo no momento em que chama CreateXObjectFromPage. A captura lê o conteúdo da página de origem do documento de origem ativo, por isso esse documento e a sua página têm de estar abertos e válidos quando o handle é construído. Segundo, e esta é a que surpreende as pessoas, o handle deve ser libertado antes de a página de origem ser fechada e, na prática, antes de fechar ou libertar o documento de origem de onde veio

O motivo é que o XObject é uma referência para a estrutura que o documento de origem ainda possui. Não é uma cópia isolada e autossuficiente que se pode transportar após o desaparecimento da origem. Feche a origem primeiro e o handle ficará a apontar para conteúdo que foi desmontado, pelo que libertá-lo mais tarde, ou qualquer outra utilização dele, opera em memória que já não é válida. O sintoma é o clássico para um handle pendurado: uma violação de acesso no encerramento, ou corrupção intermitente que se move dependendo da ordem de alocação, com uma pilha que aponta para o código de limpeza em vez da linha que realmente causou o problema. A correção é a ordenação, não a codificação defensiva. Construa o XObject, insira-o em todas as páginas que dele necessitem, liberte o XObject e só então feche o documento de origem. O destrutor do TPdfXObject liberta o handle do PDFium subjacente para si, pelo que libertar o wrapper na altura certa é toda a sua responsabilidade

A matriz, e o que significam os seus seis números

A colocação é uma transformação afim 2D, a mesma que o PDF utiliza em todo o lado para o posicionamento de conteúdo (ISO 32000-1, secção 8.3.4). São seis números, escritos a, b, c, d, e, f, e o PDFium expõe-os como o registo FS_MATRIX. Eles mapeiam um ponto do próprio espaço do objeto para o espaço da página:

// x' = a*x + c*y + e
// y' = b*x + d*y + f
//
// a, d : horizontal and vertical scale
// b, c : the shear / rotation terms
// e, f : translation (where the origin lands on the page)

Pode preencher esses seis valores à mão, mas compô-los manualmente é onde a rotação corre mal, porque a rotação mistura todos os quatro de a, b, c, d. O wrapper TPdfMatrix, da unit FPdfMatrix, compõe as operações comuns para si e pós-multiplica à medida que avança, de modo a que Translate, Scale e Rotate sejam encadeados na ordem em que os chama. Uma marca de água diagonal é uma rotação seguida de uma translação para a voltar a centrar; um logótipo de canto é uma escala seguida de uma translação. Quando a matriz estiver pronta, copie o seu valor em bruto, a propriedade Handle do tipo FS_MATRIX, para uma variável local e passe-a para FPDFPageObj_SetMatrix; a importação declara a matriz como um parâmetro var, pelo que não lhe pode ser entregue uma propriedade diretamente, e o seu resultado é 0 em caso de falha. O nível inferior FPDFPageObj_Transform, que recebe os seis valores diretamente como duplos, está disponível quando prefere passar números a construir um wrapper

Carimbar cada página, na ordem certa

O padrão completo junta as peças com a ordenação que a regra de tempo de vida exige. Abra ambos os documentos, capture o carimbo uma vez, percorra as páginas de destino definindo o PageNumber baseado em 1 sequencialmente e inserindo e posicionando uma cópia, confirmando cada página com UpdatePage, depois liberte o XObject, depois guarde com SaveAs, e deixe o documento de origem fechar em último

procedure StampEveryPage(const ASource, AStamp, AOutput: string);
var
  Dest, Stamp: TPdf;
  XObject: TPdfXObject;
  PageObj: FPDF_PAGEOBJECT;
  M: TPdfMatrix;
  RawM: FS_MATRIX;
  I: Integer;
begin
  Dest := TPdf.Create(nil);
  Stamp := TPdf.Create(nil);
  try
    Dest.FileName := ASource;
    Dest.Active := True;
    Stamp.FileName := AStamp;
    Stamp.Active := True;
    if not (Dest.Active and Stamp.Active) then
      raise Exception.Create('Could not open the input documents');

    // 1. Capture the artwork once. Stamp is Active here.
    XObject := Dest.CreateXObjectFromPage(Stamp, 0);
    if XObject = nil then
      raise Exception.Create('Could not capture the stamp page');
    try
      // 2. Place a copy on every page of Dest. PageNumber is 1-based.
      for I := 1 to Dest.PageCount do
      begin
        Dest.PageNumber := I;                // make page I current
        PageObj := Dest.InsertFormObjectFromXObject(XObject);
        if PageObj = nil then
          Continue;

        M := TPdfMatrix.Create;
        try
          M.Rotate(45);                      // diagonal watermark
          M.Translate(150, 100);             // nudge into position
          RawM := M.Handle;
          FPDFPageObj_SetMatrix(PageObj, RawM);
        finally
          M.Free;
        end;
        Dest.UpdatePage;                     // commit this page's edits
      end;
    finally
      XObject.Free;                          // 3. free BEFORE Stamp closes
    end;

    // 4. Write the result while Dest is still open.
    if not Dest.SaveAs(AOutput) then
      raise Exception.Create('Could not save ' + AOutput);
  finally
    Stamp.Free;                              // source closes last
    Dest.Free;
  end;
end;

A forma dos blocos try está a fazer o verdadeiro trabalho. O finally interior liberta o XObject antes que o controlo possa alguma vez chegar ao finally exterior que liberta o Stamp, de modo a que o handle seja sempre libertado enquanto a sua origem ainda estiver viva, mesmo que dispare uma exceção a meio do loop. Se acertar nesse aninhamento, a regra do tempo de vida cuida de si mesma

A estampagem é um aspeto de um kit de ferramentas maior para construir e editar o conteúdo da página. Se o seu carimbo for em si uma imagem e não uma página capturada, converter imagens em documentos PDF com PDFium aborda como colocar primeiro esse bitmap num documento. E quando a coisa que pretende transportar juntamente com o carimbo visível for um ficheiro e não tinta na página, trabalhar com anexos PDF em Delphi mostra o lado do ficheiro incorporado. Tudo isso é fornecido com o Componente PDFium para Delphi e C++Builder, juntamente com as APIs de renderização, edição e documentos abordadas noutros locais deste blogue