Artículo técnico

Sellos de página reutilizables con Form XObjects en PDFium

Estampar una marca de agua o un logo en cada página de un documento parece un trabajo de cinco minutos hasta que abre el resultado en un inspector de tamaño de archivo. El enfoque obvio es recorrer las páginas y, en cada una, volver a construir los mismos objetos de texto o de imagen. Eso funciona visualmente, y es un derroche que se va acumulando. Una marca de agua diagonal "DRAFT" dibujada directamente sobre un informe de cien páginas son cien copias de los mismos datos de trazado y de texto dentro de los flujos de contenido, y el archivo guardado carga con todas ellas

Un Form XObject es la construcción que PDF ofrece para evitar exactamente esto. Envuelve una pieza de contenido reutilizable, una página entera o una plantilla pequeña, en un único objeto con nombre que se puede pintar muchas veces en muchas posiciones. El contenido vive una sola vez en el archivo. Cada página que quiere el sello guarda una instrucción corta que dice "pinta aquí el XObject N, con esta transformación." Una marca de agua de cien páginas agrega entonces un objeto de contenido al archivo en lugar de cien, y esa es la diferencia entre un documento que crece linealmente con su cantidad de páginas y uno que no. Marcas de agua, sellos de logo, plantillas de numeración y precintos son todos la misma forma de problema, y el Form XObject es la herramienta correcta para cada uno de ellos

Diagrama que contrasta dibujar los operadores de la marca de agua en cada página PDF contra guardarlos una sola vez en un Form XObject con PDFium
Redibujar el sello en cada página duplica sus bytes en todos los flujos de contenido, mientras que un Form XObject guarda el arte una vez y deja que cada página lo referencie

Por qué un objeto guardado le gana a cien redibujados

El ahorro es estructural, no cosmético. Una página PDF se renderiza ejecutando su flujo de contenido, una secuencia de operadores de dibujo. Cuando redibuja un sello por página, está agregando la secuencia completa de operadores de ese sello al flujo de cada página, y los bytes se duplican tantas veces como páginas tenga. Un Form XObject mueve esos operadores a un flujo guardado una sola vez en el documento. La referencia que conserva una página individual es pequeña: empuja una matriz de transformación, invoca el XObject y restaura el estado. La cantidad de páginas ya no multiplica el costo del arte

Esto importa sobre todo cuando el sello es pesado. Un precinto vectorial con cientos de segmentos de trazado, o un logo de mapa de bits, es caro de almacenar. Guardado una vez y referenciado, la parte pesada se paga una sola vez y la sobrecarga por página son unos pocos bytes de invocación. El resultado visual en la página es idéntico al de un redibujado directo, que es justamente el objetivo. El lector no nota la diferencia; el tamaño del archivo la nota muchísimo

Capturar una página en un XObject

PDFium construye el objeto reutilizable a partir de una página existente. La fuente es una página de algún documento que tenga abierto, un PDF pequeño de una sola página que no contenga más que el arte de su marca de agua, o una página concreta de un archivo más grande. CreateXObjectFromPage captura el contenido de esa página fuente en un identificador reutilizable que pertenece al documento de destino, el que está estampando

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';   // una página de arte
    Stamp.Active := True;
    if not (Dest.Active and Stamp.Active) then
      raise Exception.Create('Could not open the input documents');

    // Captura la página 0 del documento del sello en un identificador
    // reutilizable de Dest. La fuente debe estar Active; el índice arranca en cero.
    XObject := Dest.CreateXObjectFromPage(Stamp, 0);
    if XObject = nil then
      raise Exception.Create('Could not build the stamp XObject');
    // ... colóquelo y libérelo antes de cerrar Stamp (vea más abajo) ...

La firma es CreateXObjectFromPage(Source: TPdf; SourcePageIndex: Integer): TPdfXObject. El método lanza una excepción si el documento fuente no está Active, y devuelve nil en lugar de lanzar cuando PDFium no puede construir el objeto, así que la comprobación explícita de arriba no es opcional. El identificador que vuelve es un TPdfXObject que le pertenece, y las dos restricciones de tiempo de vida asociadas a él son la parte de todo este ejercicio que sorprende a la gente, así que tienen su propia sección más abajo

Colocar el sello en una página

Un XObject capturado no hace nada por sí solo. Para que aparezca, inserta una copia de él en la página actual del documento, la que selecciona la propiedad PageNumber basada en 1, con InsertFormObjectFromXObject. Esa llamada devuelve el objeto de página subyacente, un FPDF_PAGEOBJECT, y ese identificador devuelto es la vía para posicionar la colocación. Sin una transformación, el sello cae en el origen, en las coordenadas propias de la página fuente, que rara vez es donde lo quiere

Como InsertFormObjectFromXObject inserta una copia por llamada y entrega un objeto de página nuevo cada vez, puede pintar el mismo XObject varias veces en una página con distintas transformaciones, y el contenido guardado sigue contando una sola vez en el archivo. Un logo de esquina y una marca de agua tenue a página completa pueden salir del mismo objeto capturado

var
  PageObj: FPDF_PAGEOBJECT;
  M: TPdfMatrix;
  RawM: FS_MATRIX;
begin
  // La página actual de Dest recibe una copia del XObject.
  PageObj := Dest.InsertFormObjectFromXObject(XObject);
  if PageObj = nil then
    raise Exception.Create('Insert failed on this page');

  // Colóquelo: 200 unidades a la derecha, 500 hacia arriba, al 70% de escala.
  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;   // confirma las ediciones de esta página en su flujo de contenido
  // if not Dest.SaveAs(...) then ... cuando todas las páginas estén listas.
end;

Dos detalles de mantenimiento hacen esto seguro. Primero, una vez insertado, el objeto de página pertenece a la página, no al XObject. Liberar el XObject más adelante no invalida las colocaciones que ya hizo. Eso es lo que permite que funcione el orden crear-colocar-liberar descrito abajo. Segundo, insertar y posicionar solo cambia la lista de objetos de la página en memoria; UpdatePage es lo que serializa esa lista de vuelta al flujo de contenido de la página, así que una página que edite sin llamarlo se guarda como si el sello nunca se hubiera colocado

La regla de tiempo de vida del identificador que muerde

Dos restricciones gobiernan el identificador del XObject, e ignorar cualquiera de las dos produce una falla que parece no tener relación con su causa. Primero, el documento fuente debe estar activo en el momento en que llama a CreateXObjectFromPage. La captura lee el contenido de la página fuente desde el documento fuente vivo, así que ese documento y su página tienen que estar abiertos y ser válidos cuando se construye el identificador. Segundo, y esta es la que sorprende a la gente, el identificador debe liberarse antes de que se cierre la página fuente y, en la práctica, antes de que cierre o libere el documento fuente del que provino

La razón es que el XObject es una referencia a una estructura que el documento fuente todavía posee. No es una copia desprendida y autocontenida que pueda llevar consigo después de que la fuente desaparezca. Cierre primero la fuente y el identificador queda apuntando a contenido que ya fue desmantelado, así que liberarlo después, o cualquier otro uso, opera sobre memoria que ya no es válida. El síntoma es el clásico de un identificador colgante: una violación de acceso al cerrar, o una corrupción intermitente que se mueve según el orden de asignación, con una pila que apunta al código de limpieza y no a la línea que realmente causó el problema. La solución es el orden, no la programación defensiva. Construya el XObject, insértelo en cada página que lo necesite, libere el XObject y solo entonces cierre el documento fuente. El destructor de TPdfXObject libera por usted el identificador de PDFium subyacente, así que liberar el envoltorio en el momento correcto es toda su responsabilidad

Diagrama de tiempo de vida ordenado para los sellos de página de PDFium que muestra la captura, la colocación, la liberación del identificador TPdfXObject y el cierre del documento del sello al final
Capture el sello una vez, colóquelo en cada página, libere el XObject mientras el documento del sello sigue abierto, y luego guarde y cierre la fuente al final

La matriz, y qué significan sus seis números

La colocación es una transformación afín 2D, la misma que PDF usa en todas partes para posicionar contenido (ISO 32000-1, sección 8.3.4). Son seis números, escritos a, b, c, d, e, f, y PDFium los expone como el registro FS_MATRIX. Mapean un punto desde el espacio propio del objeto al espacio de la página:

Anatomía de FS_MATRIX que muestra los seis coeficientes afines que PDFium usa para escalar, rotar y trasladar en la página un Form XObject estampado
Seis números mapean las coordenadas del sello al espacio de la página, y TPdfMatrix compone Scale, Rotate y Translate en el orden de llamada para asentar una marca de agua diagonal
// x' = a*x + c*y + e
// y' = b*x + d*y + f
//
// a, d : escala horizontal y vertical
// b, c : los términos de sesgo / rotación
// e, f : traslación (dónde cae el origen en la página)

Puede rellenar esos seis valores a mano, pero componerlos a mano es donde la rotación se tuerce, porque la rotación mezcla los cuatro términos a, b, c, d entre sí. El envoltorio TPdfMatrix, de la unidad FPdfMatrix, compone por usted las operaciones comunes y posmultiplica sobre la marcha, así que Translate, Scale y Rotate se encadenan en el orden en que las llama. Una marca de agua diagonal es una rotación seguida de una traslación para recentrarla; un logo de esquina es una escala seguida de una traslación. Cuando la matriz está lista, copie su valor crudo, la propiedad Handle de tipo FS_MATRIX, en una variable local y pase esa variable a FPDFPageObj_SetMatrix; la importación declara la matriz como parámetro var, así que no se le puede entregar una propiedad directamente, y su resultado es 0 en caso de falla. La función de más bajo nivel FPDFPageObj_Transform, que toma los seis valores directamente como dobles, está disponible cuando prefiera pasar números en lugar de construir un envoltorio

Estampar cada página, en el orden correcto

El patrón completo junta las piezas con el orden que exige la regla de tiempo de vida. Abra ambos documentos, capture el sello una vez, recorra las páginas de destino fijando por turno el PageNumber basado en 1 e insertando y posicionando una copia, confirme cada página con UpdatePage, luego libere el XObject, luego guarde con SaveAs, y deje que el documento fuente se cierre al final

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 el arte una sola vez. Aquí Stamp está Active.
    XObject := Dest.CreateXObjectFromPage(Stamp, 0);
    if XObject = nil then
      raise Exception.Create('Could not capture the stamp page');
    try
      // 2. Coloque una copia en cada página de Dest. PageNumber arranca en 1.
      for I := 1 to Dest.PageCount do
      begin
        Dest.PageNumber := I;                // vuelve actual la página I
        PageObj := Dest.InsertFormObjectFromXObject(XObject);
        if PageObj = nil then
          Continue;

        M := TPdfMatrix.Create;
        try
          M.Rotate(45);                      // marca de agua diagonal
          M.Translate(150, 100);             // ajuste fino de posición
          RawM := M.Handle;
          FPDFPageObj_SetMatrix(PageObj, RawM);
        finally
          M.Free;
        end;
        Dest.UpdatePage;                     // confirma las ediciones de esta página
      end;
    finally
      XObject.Free;                          // 3. libere ANTES de que Stamp cierre
    end;

    // 4. Escriba el resultado mientras Dest sigue abierto.
    if not Dest.SaveAs(AOutput) then
      raise Exception.Create('Could not save ' + AOutput);
  finally
    Stamp.Free;                              // la fuente cierra al final
    Dest.Free;
  end;
end;

La forma de los bloques try es la que hace el trabajo de verdad. El finally interno libera el XObject antes de que el control pueda llegar al finally externo que libera Stamp, así que el identificador siempre se suelta mientras su fuente sigue viva, incluso si salta una excepción a mitad del bucle. Acierte ese anidamiento y la regla de tiempo de vida se cuida sola

Estampar es un rincón de un conjunto de herramientas más amplio para construir y editar contenido de páginas. Si su sello es en sí una imagen y no una página capturada, convertir imágenes a documentos PDF con PDFium cubre primero cómo meter ese mapa de bits en un documento. Y cuando lo que quiere llevar junto al sello visible es un archivo en lugar de tinta sobre la página, trabajar con adjuntos PDF en Delphi muestra el lado de los archivos incrustados. Todo esto viene con el PDFium Component para Delphi y C++Builder, junto con las APIs de renderizado, edición y documentos cubiertas en otras partes de este blog