En PDFlibPas, la biblioteca PDF para Delphi, una página movida con MovePage recibía los mismos objetos MediaBox, CropBox y Resources que sostenía su antiguo nodo Pages, de modo que un SetPageBox o un DrawText posterior sobre la página movida reescribía en silencio ese nodo y todas las hermanas que seguían heredando 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 informes que llevan hasta aquí nunca mencionan la identidad de objetos. Dicen cosas como "recorté la página 7 y las páginas 8 a 12 se recortaron también", o "estreché el CropBox y el MediaBox se movió con él", o, la más desconcertante, "copié una página a un documento nuevo y el archivo original cambió". Nada se cuelga, nada gotea, y el archivo guardado es un PDF perfectamente válido. Solo contiene una 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 en el sitio. Cualquier página o nodo Pages que sostuviera la misma instancia veía la edición. Tres caminos de código de PDFlibPas producían ese compartimiento antes de v3.539.36:
MovePagematerializa los atributos heredables sobre la página antes de despegarla de su padre, y colgaba los objetos del ancestro en lugar de copias, así que la página movida y sus antiguas hermanas compartían un array de caja y un diccionario ResourcesSetPageBoxseguía las referencias indirectas y editaba el array referenciado, así que un archivo en el que varias páginas apuntan a un objeto/MediaBox 11 0 Rveía todas esas páginas redimensionadas con una llamada, mediara o noMovePagede por medioCopyPageRangesmaterializa los valores heredados sobre la página fuente antes de clonarla al documento objetivo, y colgaba las instancias del nodo Pages en 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 solo trasladaba /Resources, así que una página movida bajo un padre distinto adoptaba en silencio el tamaño y la rotación de ese padre. v3.539.27 arregló el MediaBox, CropBox y Rotate ausentes, de lo que también depende CollateDocumentsEx cuando reordena páginas, pero colgaba los valores del ancestro como instancias compartidas. Esa es la ventana que cierra v3.539.36. 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, añade un tercer caso. Resources, MediaBox, CropBox y Rotate pueden sentarse en un nodo Pages y aplicarse a toda página descendiente que no defina los suyos. La página no sostiene el valor; lo consulta a través de /Parent. Esa cadena de consulta se rompe en cuanto una página cambia de padre, que es por lo que MovePage y BalancePageTree deben escribir primero los valores efectivos sobre la propia página. La cuestión es solo cómo escribirlos
Por qué un pool de objetos esconde el error
En PDFlibPas cada objeto PDF parseado o creado es propiedad del pool TPDFStructure del documento, y los diccionarios y arrays guardan punteros simples a sus entradas. TPDFDictionary.Add registra el puntero y nada más. Añadir una instancia a dos contenedores padre es por tanto legal en todos los niveles que el runtime puede comprobar: sin doble liberación al desmontar, sin recuento de referencias que se equivoque, sin excepción. La serialización es igual de indulgente, pues cada contenedor escribe el valor actual de la instancia compartida en línea, y antes de cualquier edición el resultado es byte a byte lo que produciría una copia correcta
El aliasing solo sale a la luz cuando alguien muta la instancia compartida en el sitio. SetPageBox hace exactamente eso a través de un envoltorio 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 lugar de compartir
PDFlibPas v3.539.36 arregla el problema por los dos extremos: la materialización cuelga ahora copias, y las escrituras de cajas editan ahora solo un array que la página posee. Cada arreglo cubre un caso que el otro no puede
El ayudante de materialización, PLInheritPageAttributes, cuelga ahora Page.Owner.Decode(Value.Output) en lugar de Value. El viaje de ida y vuelta por el serializador es una forma tosca pero exacta de conseguir gratis la semántica PDF. Un array o diccionario directo se serializa a su texto literal y se decodifica en una instancia nueva e independiente. Una referencia indirecta se serializa a 11 0 R y se decodifica en un objeto referencia nuevo que apunta al mismo objeto 11, así que la página sigue refiriéndose al objeto compartido en lugar de recibir una copia incrustada, lo que preserva el comportamiento de referencia introducido en v3.539.27. La copia es exactamente de la profundidad de la estructura directa: todo lo que se alcance a través de una referencia dentro de un diccionario copiado sigue compartido, como pretende el formato de archivo. BalancePageTree llama al mismo ayudante por cada página que re-parenta, así que las páginas materializadas allí también reciben instancias separadas
Copiar solo no basta, 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 antiguo y a sus demás hijos. Así que el escritor de cajas aplica ahora copy-on-write: edita en el sitio solo cuando la entrada propia de la página es un array directo, y sustituye una caja indirecta o ausente por un array directo nuevo. El objeto 11 se queda intacto para todas las demás páginas que lo citen
| 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 biblioteca clona los recursos de una página para captura o fusión de páginas, rellena las entradas CropBox, BleedBox, TrimBox y ArtBox ausentes, y esas solían ser la misma instancia de array. Ningún llamador actual dejó que ese alias sobreviviera lo bastante para editarse, pero el siguiente llamador lo habría hecho. Cómo se eligen esos valores de caja por defecto es un tema en sí mismo, cubierto en la guía de PDFlibPas sobre los valores por defecto de TrimBox, BleedBox y CropBox
Reproducir el aliasing de MovePage con un PDF escrito a mano
La manera más rápida de comprobar cualquier build de PDFlibPas es un pequeño PDF escrito a mano y cargado con LoadFromString, donde cada número de objeto se conoce de antemano. El ayudante 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 ante 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 mide 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 test 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-parenta bajo el nodo 4, que es justo 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 antiguo ANTES de seleccionar otra página (ver abajo)
Check(Pos(AnsiString('/Font'), Lib.GetObjectToString(3)) = 0,
'font registered in the old Pages node');
Lib.SelectPage(1); // antigua 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) toma tipo de caja 1 para MediaBox y 2 para CropBox, y dimensión 2 para el ancho. Con el origen por defecto abajo a la izquierda, SetPageBox(1, 0, 200, 200, 200) significa izquierda 0, arriba 200, 200 de ancho y 200 de alto. En builds entre v3.539.27 y v3.539.35 los checks 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
¿Cambia CopyPageRanges 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 se quedan en la página que usted edita. La escritura en sí es intencionada: la página fuente necesita MediaBox, CropBox, Rotate y Resources explícitos antes de que su diccionario se clone al objetivo; si no, la copia perdería todo lo heredado. Renumerar y copiar la página al objetivo está cubierto en la copia profunda de objetos entre documentos en PDFlibPas; este bug estaba en el lado del fuente, que la mayoría asume que una copia solo lee
El resultado nunca lo delató. Compartidos o copiados, los valores materializados se serializan igual, así que ambos documentos se guardaban byte a byte iguales antes y después del arreglo. Solo una edición del documento fuente tras 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; // pasa a ser 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 de aquí heredaban el MediaBox directo del nodo raíz, la copia colgaba esa instancia en la página fuente 1, y la colgaba otra vez como CropBox de la página 1. Estrechar el CropBox estrechaba por tanto el MediaBox, y redimensionar el MediaBox redimensionaba la página 2 a través del nodo raíz. Los flujos de trabajo que copian páginas fuera y siguen editando el fuente, como intercalar escaneos dúplex en un solo PDF antes de recortar los originales, son donde esto aparecía
¿Por qué cuesta tanto testear el aliasing de instancias?
El aliasing de instancias cuesta de testear porque el efecto observable necesita tres pasos en un orden concreto: crear el alias, mutar un lado y luego inspeccionar el otro antes de que nada más lo toque. La mayoría de los tests hacen solo el primer paso y comparan el resultado guardado, que es idéntico exista o no el alias
La trampa de ordenación en PDFlibPas es SelectPage. Seleccionar una página vuelve a aplicar la fuente en vigor vía SelectFont, que registra esa fuente en los recursos de la página. Una página sin /Resources propias resuelve al diccionario de su padre, así que con solo seleccionar semejante página se añade legítimamente /Font al nodo Pages. En el test de MovePage de arriba, seleccionar la antigua página 2 añade la entrada Helvetica al nodo 3, lo que es comportamiento correcto y no una fuga. Por eso el check de GetObjectToString(3) corre antes que SelectPage(1); invierta los dos y el test falla en un build arreglado
Esa regla marca también lo que v3.539.36 deja deliberadamente quieto. Escribir un recurso en una página que hereda su diccionario Resources escribe en el diccionario del ancestro, y todas las hermanas ven la entrada nueva. Eso es herencia funcionando según lo especificado, no compartimiento de instancias, y es inocuo porque añadir un nombre de fuente o de imagen a un diccionario compartido no cambia cómo renderizan las demás páginas. Si necesita que una página deje de heredar, dele primero su propio diccionario Resources
Lista de comprobación 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 donde sea:
- Al materializar atributos heredados según ISO 32000-1 §7.7.3.4, copie en profundidad 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 compartimiento esté previsto y documentado; la propiedad por pool significa que el runtime jamás se quejará - Edite en el sitio solo lo que el nodo actual posee como objeto directo; sustituya los valores indirectos o heredados por un objeto directo fresco (copy-on-write)
- Los valores por defecto derivados de otra entrada, como un CropBox a partir de un MediaBox, necesitan instancia propia
- Testee el aliasing con secuencias de mutar-y-luego-inspeccionar sobre el otro portador, y compruebe el orden de las llamadas que podrían escribir legítimamente en medio
- Comparar el resultado guardado no prueba nada aquí: los valores compartidos y los copiados se serializan igual hasta la primera edición
- En PDFlibPas, actualice a v3.539.36 o posterior si llama a
MovePage,CollateDocumentsEx,BalancePageTreeoCopyPageRangesy luego 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 única clase TPDFlib para Delphi, C++Builder y Free Pascal. Vea la página de producto de la biblioteca PDF PDFlibPas para Delphi para ediciones, plataformas y la referencia completa de la API