Añades un rango de etiquetas nuevo al manual de un proveedor para que el apéndice lea App-a, App-b en lugar de A-1, A-2, guardas, y reabres el archivo. El page box sigue diciendo A-1. Abres el catálogo en un editor de texto y el diccionario /PageLabels muestra la razón: lleva un array /Kids heredado del productor y, justo al lado, un array /Nums recién creado con tu rango. ISO 32000-1 deja que la raíz de un árbol numérico tenga una de esas claves u otra, y un lector que encuentra /Kids en la raíz desciende por ella y no mira más
Cómo almacena un PDF los rangos de etiquetas de página
Las etiquetas de página son las cadenas que un visor muestra en su page box en lugar del índice físico de página, e ISO 32000-1 §12.4.2 las guarda en un árbol numérico bajo la clave de catálogo /PageLabels. Cada clave es un índice de página 0-based donde empieza un rango de etiquetado. Cada valor es un diccionario de etiqueta con hasta tres entradas: /S para el estilo de numeración (D, R, r, A o a), /P para una cadena de prefijo, y /St para el número de la primera página del rango, que por defecto es 1. Un rango corre hasta la siguiente clave. La especificación exige también una entrada para el índice de página 0, así que todas las páginas del documento caen dentro de algún rango
En PDF Library for Delphi el escritor es TPDFlib.AddPageLabels(Start, Style, Offset, Prefix), y se mapea a ese diccionario con tres reglas que conviene memorizar. Start es 1-based como cualquier otro argumento de página de la biblioteca, y entra en el árbol como Start - 1. Style va de 0 a 5: 0 no escribe ningún /S, de modo que la etiqueta es el prefijo solo (útil para una única página etiquetada Cover), y 1 a 5 se convierten en D, R, r, A y a. Un estilo fuera de 0..5 hace que la llamada devuelva 0 y deja el documento intacto. Offset se convierte en /St solo cuando es mayor que cero; pasa 0 y la clave se omite, lo que todo lector interpreta como 1
Un libro típico salido de una herramienta de maquetación necesita tres rangos: números romanos en minúscula para el preliminar, números arábigos para el cuerpo, y un apéndice con prefijo
uses
PDFlibrary;
var
Lib: TPDFlib;
begin
Lib := TPDFlib.Create;
try
if Lib.LoadFromFile('handbook.pdf', '') <> 1 then
Exit;
// Páginas 1-4: i, ii, iii, iv (romano en minúscula)
Lib.AddPageLabels(1, 3, 1, '');
// Páginas 5-120: 1, 2, 3 ... (decimal, reiniciando en 1)
Lib.AddPageLabels(5, 1, 1, '');
// Páginas 121 en adelante: A-1, A-2 ... (decimal con prefijo)
Lib.AddPageLabels(121, 1, 1, 'A-');
WriteLn(Lib.GetPageLabel(5)); // 1
WriteLn(Lib.GetPageLabel(122)); // A-2
Lib.SaveToFile('handbook-labeled.pdf');
finally
Lib.Free;
end;
end;
Las etiquetas de página llegaron en PDF 1.3, así que cada llamada pide también al documento que suba su versión de salida a 1.3 como mínimo. Si fijaste la versión con LockSaveVersion, esa petición se ignora y el fijado se mantiene, porque la biblioteca no se impone sobre una versión que elegiste a propósito. Si vas a fijar versión, fíjala a 1.3 o superior en cualquier trabajo que escriba etiquetas
¿Por qué desaparece un rango nuevo cuando la raíz tiene /Kids?
El rango desaparece porque ISO 32000-1 §7.9.7 (Tabla 37) da a la raíz de un árbol numérico o /Kids o /Nums, nunca ambos, y los lectores eligen /Kids cuando está presente. Los productores que escriben documentos largos suelen partir el árbol en nodos intermedios, cada uno con un par /Limits, colgados de una raíz que solo tiene /Kids. Una rutina de inserción que solo conoce /Nums mira esa raíz, no encuentra ningún array, crea uno junto al /Kids existente, y deja caer el rango nuevo ahí. La escritura tiene éxito. El archivo sigue siendo parseable. Entonces cada visor desciende por /Kids, ve los tres rangos originales del productor, y nunca toca el array perdido, que es exactamente el síntoma del manual del proveedor del párrafo inicial
La salida es dejar de tratar la raíz como un contenedor que podría ganar un segundo punto de entrada. Cuando AddPageLabels encuentra /Kids en la raíz, primero recorre cada hoja del árbol existente de izquierda a derecha y recolecta cada clave con su diccionario de etiqueta, y después construye un único array /Nums plano a partir de esa lista. /Kids, /Limits y cualquier /Nums sobrante se eliminan de la raíz antes de enganchar el array plano, y solo entonces entra el rango nuevo con un insert ordenado ordinario. Quitar /Limits también importa: la Tabla 37 lo permite solo en nodos intermedios y hojas, y una raíz que lo conserve está malformada aunque ahora lleve /Nums
Un efecto secundario es fácil de perder. Si un archivo te llega ya roto, con ambas claves en la raíz porque alguna herramienta anterior hizo el insert ingenuo, el recorrido de hojas sigue /Kids igual que lo haría un visor, así que los rangos del array perdido no se trasladan. Un visor nunca mostró esos rangos tampoco, así que las etiquetas tras la edición coinciden con lo que los lectores ya veían
El árbol aplanado no se reconstruye después en nodos /Kids balanceados, y nos parece la decisión correcta para este árbol en particular. El balanceo compensa en árboles de nombres con miles de destinos o archivos incrustados. Un árbol de etiquetas de página tiene una entrada por rango de etiquetado, y hasta un manual de referencia grande rara vez necesita más de unas pocas docenas; una única hoja es además lo que escriben la mayoría de los productores cuando no parten el árbol por gusto. Una pasada de rebalanceo añadiría código que puede fallar en cada guardado, por una ganancia de búsqueda que nadie podría medir en un árbol tan pequeño
// Reetiqueta el apéndice en un archivo cuya raíz /PageLabels usa /Kids
if Lib.LoadFromFile('vendor-manual.pdf', '') = 1 then
begin
WriteLn('Before: ', Lib.GetPageLabel(121)); // p. ej. A-1
// Misma página de inicio que el rango existente, así que su valor se sustituye
if Lib.AddPageLabels(121, 5, 1, 'App-') = 1 then
begin
WriteLn('After: ', Lib.GetPageLabel(121)); // App-a
WriteLn('Front: ', Lib.GetPageLabel(2)); // ii, sin cambios
Lib.SaveToFile('vendor-manual-relabeled.pdf');
end;
end;
Como el rango nuevo empieza en el mismo índice de página que una clave existente, el insert sustituye el diccionario de esa clave en lugar de añadir una segunda entrada para el índice de página 120. Los rangos romano y decimal de las hojas del productor atraviesan con sus diccionarios originales, incluidos los valores /St que tuvieran
El recorrido de hojas tiene que sobrevivir también a árboles malformados. Una entrada /Kids que apunta de vuelta a uno de sus propios ancestros, o dos ramas que comparten una hoja, aparecen en archivos de pipelines dañados. La travesía recuerda qué nodos ya visitó, así que un ciclo se recorre una vez y se deja atrás, y una hoja compartida aporta sus rangos una vez. Las mismas preocupaciones para árboles de nombres están tratadas en manejo de ciclos de árboles de nombres, /Limits incorrectos y hojas grandes
Recorrer un array /Nums en pares
Un array /Nums es una tira plana de entradas alternantes, [key0 value0 key1 value1 ...], y solo las posiciones pares son claves. Esa disposición invita a un error clásico. Un bucle que avanza un elemento cada vez y pregunta "¿esto es un número?" comparará valores como si fueran claves cada vez que un valor resulte ser numérico. Si esa comparación gana un test menor-que, el punto de inserción aterriza en un índice impar, el par nuevo entra en medio de uno existente, y todas las claves y valores posteriores intercambian posiciones
Los valores de etiqueta de página son diccionarios, así que el bug se esconde en la propia /PageLabels. Sale a la luz el día en que el mismo helper se reutiliza para un árbol numérico cuyos valores son números planos, y nada en la sintaxis del array te avisa de qué tipo estás sosteniendo. PDF Library for Delphi lee claves en el índice X * 2 y valores en X * 2 + 1 tanto en el recorrido de hojas como en el insert, y una coincidencia exacta de clave sustituye el valor y detiene el escaneo. Si escribes tu propio código de árbol numérico contra objetos en crudo, copia ese paso antes que nada
Leer etiquetas de vuelta con GetPageLabel
TPDFlib.GetPageLabel(Page) devuelve la etiqueta de una página 1-based, y sus dos reservas se comportan distinto a propósito. Un documento sin ninguna entrada /PageLabels devuelve el número de página decimal, así que quien llama puede usar la función sin condiciones para mostrar. Un documento que tiene árbol pero ningún rango que cubra la página pedida devuelve una cadena vacía. Ese segundo caso es lo que produce un archivo que se salta la entrada obligatoria del índice de página 0: las primeras páginas no tienen etiqueta en el sentido de la especificación, y una cadena vacía lo reporta con más honestidad que un número inventado. Empieza siempre tu conjunto de etiquetas en la página 1
Los estilos de letras siguen la especificación de PDF en lugar de las columnas de una hoja de cálculo. Después de Z viene AA, luego BB, luego CC; la letra se repite en lugar de acarrear, así que la página 53 de un rango A es AAA. Los números romanos se generan en mayúsculas para R y en minúsculas para r, y el prefijo se antepone sin cambios en ambos casos
var
P: Integer;
Data: WideString;
begin
// Auditoría rápida de lo que un visor mostrará en su page box
for P := 1 to Lib.PageCount do
WriteLn(P, ' -> ', Lib.GetPageLabel(P));
// El valor de opción 4 exporta solo los rangos de etiquetas, un bloque
// PageLabelBegin por rango
Data := Lib.ExportDocumentData(4);
// ... edita el texto, o llévalo a otro archivo ...
// El primer registro de etiqueta limpia los rangos existentes, y después
// cada registro se repite vía AddPageLabels; devuelve los registros aplicados
WriteLn(Lib.ImportDocumentData(Data, 4), ' label ranges applied');
end;
Para ediciones en masa, ExportDocumentData con el valor de opción 4 escribe cada rango como un bloque PageLabelBegin seguido de líneas PageLabelNewIndex (la página de inicio 1-based), PageLabelStart, un PageLabelPrefix opcional y PageLabelNumStyle, con nombres como LowercaseRomanNumerals y DecimalArabicNumerals. ImportDocumentData trata el primer registro de etiqueta que encuentra como un reemplazo completo: llama una vez a ClearPageLabels, que elimina toda la entrada /PageLabels del catálogo, y después alimenta cada registro a AddPageLabels. El árbol reconstruido es una única hoja desde el principio, así que un viaje de ida y vuelta por texto de un original con forma de /Kids regresa en la misma forma plana que produce la ruta de reetiquetado
Orden de hojas y movimientos de página tras reetiquetar
Aplanar conserva los rangos en el orden en que los encuentra. La §7.9.7 exige claves en orden ascendente, pero circulan archivos con hojas desordenadas, y GetPageLabel aplica el último rango de ese orden cuya clave esté en o por debajo del índice de página. Para un archivo que declara sus rangos como [0 roman, 120 A-, 4 decimal], con el rango decimal empezando en 1, la página 122 resuelve al rango decimal y lee 118 donde esperabas A-2. Si la auditoría página a página de arriba muestra etiquetas así, no las remiendes con una llamada AddPageLabels más. Llama a ClearPageLabels y escribe el conjunto completo otra vez en orden ascendente; cuatro llamadas cuestan menos que razonar qué rango gana
Las etiquetas están ligadas además a índices de página, no a objetos de página. Cualquier cosa que cambie el recuento o el orden de páginas deja los rangos exactamente donde estaban, apuntando a las páginas que ahora ocupen esos índices. Un intercambio en sitio como reemplazar páginas conservando los números de objeto mantiene el recuento, así que las etiquetas siguen alineadas. Una fusión como intercalar escaneos dúplex produce una secuencia de páginas nueva, y esa salida debería recibir un conjunto de rangos recién escrito tras la fusión, empezando en la página 1
Las llamadas de etiquetas, el aplanado de /Kids y la exportación e importación de datos de documento mostradas aquí forman parte de PDF Library for Delphi, que apunta a Delphi, C++Builder y Lazarus; su entrada de referencia de AddPageLabels enumera los valores de estilo y códigos de retorno en una sola tabla