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:
MovePagematerializa 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 ResourcesSetPageBoxseguí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 Rtenía todas esas páginas redimensionadas con una sola llamada, involucraraMovePageo noCopyPageRangesmaterializa 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
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
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
| Camino de código | Antes de v3.539.36 | Desde v3.539.36 |
|---|---|---|
Materialización de MovePage | La página sostiene las instancias directas del ancestro | La página sostiene copias decodificadas; las referencias siguen siendo referencias |
SetPageBox | Sigue una referencia y edita el array compartido | Edita solo un array directo en la página, si no escribe uno nuevo |
Página fuente de CopyPageRanges | Comparte las cajas del nodo Pages; el CropBox es la instancia del MediaBox | Cada valor materializado en la página fuente es una copia |
| Cajas por defecto al clonar recursos de página | CropBox, BleedBox, TrimBox y ArtBox comparten un array | Cada 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
Addde 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,BalancePageTreeoCopyPageRangesy 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