Artículo técnico

PDFlibPas MovePage: cajas heredadas que comparten instancias

En PDFlibPas, la librería de PDF para Delphi, una página movida con MovePage solía recibir los mismos objetos MediaBox, CropBox y Resources que sostenía su viejo nodo Pages, así que un SetPageBox o un DrawText posterior sobre la página movida reescribía en silencio ese nodo y cada hermana que todavía heredara de él. Desde v3.539.36 la página movida recibe sus propias copias, y una referencia indirecta sigue siendo una referencia. La misma versión cierra dos caminos relacionados: SetPageBox sobre una caja indirecta que varias páginas comparten, y CopyPageRanges dejando páginas del documento fuente atadas a su nodo Pages, con el CropBox atado al MediaBox

Los reportes que llevan hasta aquí jamás mencionan identidad de objetos. Dicen cosas como "recorté la página 7 y las páginas 8 a 12 también quedaron recortadas", o "achiqué el CropBox y el MediaBox se movió con él", o, la más confusa, "copié una página a un documento nuevo y el archivo original cambió". Nada se cuelga, nada hace leak, y el archivo guardado es un PDF perfectamente válido. Solo contiene geometría que nadie pidió

¿Por qué SetPageBox en una página redimensiona a sus hermanas?

SetPageBox redimensionaba hermanas porque dos entradas del árbol de páginas apuntaban a un mismo array en memoria, y SetPageBox edita su array objetivo in situ. Cualquier página o nodo Pages que sostuviera la misma instancia veía la edición. Tres caminos de código en PDFlibPas producían ese compartir antes de v3.539.36:

  • MovePage materializa los atributos heredables sobre la página antes de despegarla de su padre, y adjuntaba los objetos propios del ancestro en vez de copias, así que la página movida y sus ex hermanas compartían un array de cajas y un diccionario Resources
  • SetPageBox seguía referencias indirectas y editaba el array referenciado, así que un archivo en el que varias páginas apuntan a un objeto /MediaBox 11 0 R tenía todas esas páginas redimensionadas con una sola llamada, involucrara MovePage o no
  • CopyPageRanges materializa los valores heredados sobre la página fuente antes de clonarla al documento destino, y adjuntaba las instancias del nodo Pages a la página fuente, más la instancia del MediaBox como CropBox por defecto
Aliasing de MovePage en PDFlibPas donde la página movida y su ex hermana sostenían ambas la instancia propia del array MediaBox del ancestro, así que SetPageBox editaba una página y redimensionaba la otra; desde v3.539.36 la materialización adjunta copias decodificadas y las ediciones quedan locales a la página que usted toca
Dos entradas del árbol de páginas apuntando a un mismo array en memoria hacían que cada edición aterrizara en cada contenedor, y el PDF guardado siguió siendo válido todo el tiempo

El caso de MovePage tiene una historia corta. Antes de v3.539.27, MovePage trasladaba solo el /Resources, así que una página movida bajo otro padre tomaba en silencio el tamaño y la rotación de ese padre. v3.539.27 corrigió el MediaBox, CropBox y Rotate faltantes, que es de lo que también depende CollateDocumentsEx cuando reordena páginas, pero adjuntaba los valores del ancestro como instancias compartidas. Esa es la ventana que v3.539.36 cierra. Los caminos de SetPageBox y CopyPageRanges son más viejos; cualquier build anterior a v3.539.36 los tiene

Valores directos, referencias indirectas y herencia de atributos de página

Una copia correcta de un atributo de página heredado duplica los valores directos y conserva las referencias indirectas como referencias, porque esa es la distinción que el propio ISO 32000-1 traza. Un objeto directo como [0 0 400 300] escrito dentro de un diccionario pertenece solo a ese diccionario. Un objeto indirecto, definido una vez como 11 0 obj y citado como 11 0 R, se comparte por diseño: ISO 32000-1 §7.3.10 lo hace direccionable desde cualquier parte del archivo, y cada 11 0 R significa el mismo objeto

La herencia de atributos de página, ISO 32000-1 §7.7.3.4, agrega un tercer caso. Resources, MediaBox, CropBox y Rotate pueden sentarse en un nodo Pages y aplicar a cada página descendiente que no defina los suyos. La página no sostiene el valor; lo busca a través de /Parent. Esa cadena de búsqueda se rompe en el momento en que una página cambia de padre, y por eso MovePage y BalancePageTree deben primero escribir los valores efectivos sobre la propia página. La pregunta es solo cómo escribirlos

Por qué un object pool esconde el error

En PDFlibPas cada objeto PDF parseado o creado pertenece al pool TPDFStructure del documento, y los diccionarios y arrays guardan punteros simples a sus entradas. TPDFDictionary.Add registra el puntero y nada más. Agregar una instancia a dos contenedores padres es entonces legal en todos los niveles que el runtime puede chequear: sin double free al desarmar, sin reference count que se equivoca, sin excepción. La serialización es igual de indulgente, ya que cada contenedor escribe el valor actual de la instancia compartida inline, y antes de cualquier edición la salida es byte a byte lo que produciría una copia correcta

El aliasing solo sale a la luz cuando alguien muta la instancia compartida in situ. SetPageBox hace exactamente eso a través de un wrapper de rectángulo sobre el array existente, y dibujar en una página lo hace con el diccionario Resources cuando se registra una fuente o una imagen. La edición aterriza, en silencio, en todos los demás contenedores que sostienen el puntero

Cómo PDFlibPas v3.539.36 copia en vez de compartir

PDFlibPas v3.539.36 corrige el problema por ambos extremos: la materialización ahora adjunta copias, y las escrituras de cajas ahora editan solo un array que la página posee. Cada corrección cubre un caso que la otra no puede

El helper de materialización, PLInheritPageAttributes, ahora adjunta Page.Owner.Decode(Value.Output) en vez de Value. Pasar por el serializador y volver es una forma tosca pero exacta de conseguir la semántica de PDF gratis. Un array o diccionario directo se serializa a su texto literal y se decodifica en una instancia fresca e independiente. Una referencia indirecta se serializa a 11 0 R y se decodifica en un objeto de referencia nuevo que apunta al mismo objeto 11, así que la página sigue refiriéndose al objeto compartido en vez de recibir una copia inline, lo que preserva el comportamiento de referencia introducido en v3.539.27. La copia es exactamente tan profunda como la estructura directa: lo que se alcance a través de una referencia dentro de un diccionario copiado sigue compartido, como el formato de archivo lo pretende. BalancePageTree llama al mismo helper para cada página cuyo padre cambia, así que las páginas materializadas allí también reciben instancias separadas

Round-trip de materialización de PDFlibPas donde PLInheritPageAttributes adjunta Page.Owner.Decode(Value.Output): un array directo se serializa a texto literal y se decodifica en una instancia fresca, mientras que una referencia indirecta 11 0 R se serializa y decodifica en una referencia nueva que sigue apuntando al objeto 11 compartido
Serializar y reparsear consigue la semántica de objetos de PDF gratis: los valores directos se copian, las referencias siguen siendo referencias, exactamente como ISO 32000-1 lo pretende

Copiar solo no alcanza, porque el caso de la referencia sigue apuntando a un objeto compartido. Si SetPageBox siguiera esa referencia y editara el objeto 11, la página movida volvería a redimensionar al padre viejo y a sus otros hijos. Así que el escritor de cajas ahora aplica copy-on-write: edita in situ solo cuando la entrada propia de la página es un array directo, y reemplaza una caja indirecta o ausente con un array directo nuevo. El objeto 11 queda intacto para cualquier otra página que lo cite

Decisión copy-on-write de SetPageBox en PDFlibPas: cuando la entrada propia de la página es un array directo se edita in situ, y cuando es una referencia indirecta o falta, el escritor la reemplaza con un array directo nuevo así el objeto 11 compartido conserva su valor para cualquier otra página que lo cite
Copiar en la materialización no alcanza mientras las referencias sigan apuntando a objetos compartidos, así que el escritor de cajas edita solo lo que la página posee
Camino de códigoAntes de v3.539.36Desde v3.539.36
Materialización de MovePageLa página sostiene las instancias directas del ancestroLa página sostiene copias decodificadas; las referencias siguen siendo referencias
SetPageBoxSigue una referencia y edita el array compartidoEdita solo un array directo en la página, si no escribe uno nuevo
Página fuente de CopyPageRangesComparte las cajas del nodo Pages; el CropBox es la instancia del MediaBoxCada valor materializado en la página fuente es una copia
Cajas por defecto al clonar recursos de páginaCropBox, BleedBox, TrimBox y ArtBox comparten un arrayCada caja por defecto recibe su propio array

La última fila es la latente. Cuando la librería clona los recursos de una página para captura de páginas o merging, completa las entradas CropBox, BleedBox, TrimBox y ArtBox faltantes, y esas solían ser la misma instancia de array. Ningún caller actual dejaba que ese alias sobreviviera lo suficiente como para editarse, pero el próximo caller lo habría logrado. Cómo se eligen esos valores de cajas por defecto es tema aparte, cubierto en la guía de PDFlibPas sobre defaults de TrimBox, BleedBox y CropBox

Reproducir el aliasing de MovePage con un PDF armado a mano

La forma más rápida de probar cualquier build de PDFlibPas es un PDF pequeño escrito a mano y cargado con LoadFromString, donde cada número de objeto se conoce de antemano. El helper de abajo escribe una tabla cross-reference clásica con offsets de byte calculados correctamente, así que el test no depende del comportamiento de recuperación del parser para archivos dañados

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 0-based 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 es exactamente 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;

El documento de prueba tiene dos nodos Pages intermedios. El nodo 3 lleva un MediaBox indirecto (objeto 11, 400 por 300 puntos), un CropBox directo y un diccionario Resources directo, y posee dos páginas. El nodo 4 tiene un MediaBox tamaño Letter y posee la tercera página. Mover la página 1 a la posición 3 la re-engancha bajo el nodo 4, que es exactamente el movimiento que necesita materialización: sin ella, la página se convertiría en una 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);                       // la 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');

    // Inspeccione el padre viejo ANTES de seleccionar otra página (vea abajo)
    Check(Pos(AnsiString('/Font'), Lib.GetObjectToString(3)) = 0,
      'font registered in the old Pages node');

    Lib.SelectPage(1);                       // ex página 2, todavía bajo el nodo 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;

GetPageBox(BoxType, Dimension) recibe el tipo de caja 1 para MediaBox y 2 para CropBox, y la dimensión 2 para el ancho. Con el origen por defecto abajo a la izquierda, SetPageBox(1, 0, 200, 200, 200) significa izquierda 0, tope 200, 200 de ancho y 200 de alto. En builds entre v3.539.27 y v3.539.35 los chequeos de hermanas fallan: la edición del CropBox aterriza en el array directo del nodo 3, y la edición del MediaBox reescribe el objeto 11 a través de la referencia

¿CopyPageRanges cambia el documento fuente?

Desde v3.539.36, CopyPageRanges sigue escribiendo sobre las páginas fuente, pero cada valor que escribe es una copia separada, así que las ediciones posteriores sobre el fuente quedan locales a la página que usted edita. La escritura en sí es intencional: la página fuente necesita MediaBox, CropBox, Rotate y Resources explícitos antes de que su diccionario se clone al destino, de lo contrario la copia perdería todo lo que heredó. Renumerar y copiar la página al destino queda cubierto en la copia profunda de objetos entre documentos en PDFlibPas; este bug estaba del lado del fuente, que la mayoría asume que una copia solo lee

La salida nunca lo mostró. Compartido o copiado, los valores materializados se serializan igual, así que ambos documentos se guardaban byte a byte igual antes y después de la corrección. Solo una edición al documento fuente después de la copia revelaba el 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;             // se vuelve el documento seleccionado
    Check(Lib.CopyPageRanges(SourceID, '1') = 1, 'copy failed');

    Lib.SelectDocument(SourceID);
    Lib.SelectPage(1);
    Lib.SetPageBox(2, 50, 250, 100, 100);    // estrecha solo el 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);            // la copia conserva su tamaño original
    Lib.SelectPage(Lib.PageCount);
    Check(Abs(Lib.GetPageBox(1, 2) - 400) < 0.001, 'copied page resized');
  finally
    Lib.Free;
  end;
end;

Antes de v3.539.36 ambas páginas aquí heredaban el MediaBox directo del nodo raíz, la copia adjuntaba esa instancia a la página fuente 1, y la adjuntaba otra vez como CropBox de la página 1. Estrechar el CropBox estrechaba entonces el MediaBox, y redimensionar el MediaBox redimensionaba la página 2 a través del nodo raíz. Los workflows que copian páginas fuera y siguen editando el fuente, como colacionar escaneos duplex en un solo PDF antes de recortar los originales, son donde esto aparecía

¿Por qué el aliasing de instancias es tan difícil de testear?

El aliasing de instancias es difícil de testear porque el efecto observable necesita tres pasos en un orden específico: crear el alias, mutar un lado, y después inspeccionar el otro lado antes de que cualquier otra cosa lo toque. La mayoría de los tests hacen solo el primer paso y comparan la salida guardada, que es idéntica exista el alias o no

La trampa de orden en PDFlibPas es SelectPage. Seleccionar una página reaplica la fuente actual vía SelectFont, que registra esa fuente en los recursos de la página. Una página sin /Resources propio resuelve al diccionario de su padre, así que con solo seleccionar esa página se agrega legítimamente /Font al nodo Pages. En el test de MovePage de arriba, seleccionar la ex página 2 agrega la entrada Helvetica al nodo 3, lo que es comportamiento correcto y no un leak. Por eso el chequeo de GetObjectToString(3) corre antes de SelectPage(1); invierta los dos y el test falla en un build corregido

Esa regla también marca lo que v3.539.36 deliberadamente deja tranquilo. Escribir un recurso en una página que hereda su diccionario Resources escribe en el diccionario del ancestro, y cada hermana ve la entrada nueva. Eso es herencia funcionando como está especificada, no compartir de instancias, y es inofensivo porque agregar un nombre de fuente o imagen a un diccionario compartido no cambia cómo renderizan las otras páginas. Si necesita que una página deje de heredar, dele primero su propio diccionario Resources

Checklist para código con modelo de objetos PDF

Las lecciones se generalizan a cualquier modelo de objetos PDF construido sobre un pool y contenedores de punteros, en Delphi o en otro lado:

  • Al materializar atributos heredados según ISO 32000-1 §7.7.3.4, haga deep-copy de los valores directos y conserve las referencias indirectas como referencias nuevas al mismo objeto
  • Nunca haga Add de una instancia existente a un segundo contenedor salvo que el compartir sea intencional y esté documentado; la propiedad del pool implica que el runtime jamás se quejará
  • Edite in situ solo lo que el nodo actual posee como objeto directo; reemplace valores indirectos o heredados con un objeto directo fresco (copy-on-write)
  • Los valores por defecto derivados de otra entrada, como un CropBox a partir de un MediaBox, necesitan su propia instancia
  • Testee el aliasing con secuencias de mutar y luego inspeccionar sobre el otro contenedor, y chequee el orden de las llamadas que podrían escribir legítimamente en el medio
  • Comparar la salida guardada no prueba nada aquí: los valores compartidos y copiados se serializan igual hasta la primera edición
  • En PDFlibPas, actualice a v3.539.36 o posterior si llama a MovePage, CollateDocumentsEx, BalancePageTree o CopyPageRanges y después edita cajas de página o dibuja en páginas

PDFlibPas expone la edición del árbol de páginas, la copia entre documentos y el control de cajas de página a través de una sola clase TPDFlib para Delphi, C++Builder y Free Pascal. Vea la página del producto de la librería PDFlibPas Delphi PDF para ediciones, plataformas y la referencia completa de la API