Artículo técnico

Page Labels de PDF en Delphi: arreglo de number trees /Kids

PDF Library for Delphi escribe rangos de page labels con AddPageLabels, y desde la v3.539.10 esa llamada también funciona sobre archivos cargados cuyo number tree de /PageLabels está partido en nodos /Kids: la raíz se aplana a una sola hoja /Nums antes de que entre el rango nuevo, así que la etiqueta sí aparece en el visor en lugar de ignorarse en silencio. La víctima típica es un PDF estilo libro venido de una herramienta de maquetación, con números romanos en el front matter, numeración arábiga en el cuerpo y un apéndice etiquetado A-1, A-2, donde usted solo quería reetiquetar el apéndice y no cambió nada

¿Qué son los page labels de PDF y cómo se guardan?

Los page labels son los strings que un visor muestra en su caja de páginas en lugar del índice físico de página, e ISO 32000-1 §12.4.2 los guarda como un number tree bajo la clave de catálogo /PageLabels. Cada clave es un índice de página base 0 que arranca un rango de etiquetado, y cada valor es un diccionario de page label con hasta tres entradas: /S para el estilo de numeración (D, R, r, A o a), /P para un string de prefijo, y /St para el valor numérico de la primera página del rango, que por defecto es 1. Un rango corre hasta la siguiente clave, y la especificación exige que el árbol contenga un valor para el índice de página 0, así que cada página queda cubierta por algún rango

Almacenamiento de page labels en términos de PDFlibPas: el number tree de /PageLabels indexa cada rango por su página de inicio base cero, cada valor es un diccionario de etiqueta con estilo /S, prefijo /P y primer número /St, y el ejemplo del libro mapea front matter romano, páginas arábigas del cuerpo y un apéndice A- sobre tres rangos
Un rango corre hasta la siguiente clave, la especificación exige un valor para el índice de página 0, y GetPageLabel aplica el último rango cuya clave esté en o por debajo de la página, así que cada página resuelve a algo
var
  Lib: TPDFlib;
begin
  Lib := TPDFlib.Create;
  try
    if Lib.LoadFromFile('handbook.pdf', '') <> 1 then
      Exit;
    // Páginas 1-4: i, ii, iii, iv (romanos en minúscula)
    Lib.AddPageLabels(1, 3, 1, '');
    // Páginas 5-120: 1, 2, 3 ... (decimal)
    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;

TPDFlib.AddPageLabels(Start, Style, Offset, Prefix) mapea sus argumentos sobre ese diccionario sin sorpresas una vez que usted conoce tres reglas. Start es base 1 como cualquier otro argumento de página de la librería y se escribe en el árbol como Start - 1. Style va de 0 a 5, donde 0 significa solo prefijo y 1 a 5 se convierten en valores /S D, R, r, A y a; cualquier cosa fuera de ese rango devuelve 0 y no toca nada. Offset se convierte en /St solo cuando es mayor que cero, así que pasar 0 simplemente omite la clave y el visor cae al valor por defecto de 1. Como los page labels llegaron en PDF 1.3, la llamada también corre EnsureMinVersion('1.3', '/PageLabels'), que sube la versión de salida de un archivo más viejo salvo que usted haya bloqueado explícitamente la versión de guardado

¿Por qué desaparecen los page labels nuevos cuando el árbol tiene /Kids?

Las etiquetas nuevas desaparecían porque ISO 32000-1 §7.9.7 (Tabla 37) obliga a que la raíz de un number tree lleve /Kids o /Nums, nunca ambos, y el antiguo helper NumTreeSet solo sabía buscar /Nums. Los productores que emiten documentos largos suelen partir el árbol en nodos intermedios, cada uno con su par /Limits, y colgarlos de una raíz que tiene solo /Kids. El código viejo no encontraba /Nums en esa raíz, creaba uno nuevo al lado del /Kids existente, e insertaba ahí el rango nuevo. El resultado era una raíz con dos puntos de entrada mutuamente excluyentes. Los visores descienden por /Kids y jamás miran el array huérfano, el propio EnumNumTree de la librería también chequea /Kids primero, y NumTreeLookup rechaza un nodo donde HasKids xor HasNums es falso. AddPageLabels igual devolvía 1 y el archivo guardado seguía abriendo limpiamente, que es el peor tipo de fallo: nada se queja, las etiquetas simplemente quedan igual

El fix en NumTreeSet convierte la raíz en una hoja antes de insertar nada. Cuando la raíz lleva /Kids, EnumNumTree recorre cada hoja en orden y recolecta cada par clave-valor, se arma un nuevo array /Nums plano a partir de esa lista, y /Kids, /Limits y cualquier /Nums viejo se purgan de la raíz antes de colgar el array plano. Soltar /Limits no es cosmética: la Tabla 37 permite esa entrada solo en nodos intermedios y hojas, nunca en la raíz. Desde ahí la inserción es un insert ordenado ordinario sobre un solo array, y los rangos existentes sobreviven con sus diccionarios de etiqueta originales. El trade-off es deliberado: el árbol no se reconstruye después en nodos /Kids balanceados. Para page labels eso no cuesta nada, porque hasta un manual de referencia grande rara vez tiene más de unas docenas de rangos, y una sola hoja es lo que la mayoría de los productores escriben de todos modos

Reparación del number tree en PDFlibPas: una raíz que lleva /Kids y un array /Nums huérfano es invisible para los visores porque ISO 32000-1 permite solo uno de los dos, así que NumTreeSet aplana cada hoja a un único array /Nums y purga /Kids y /Limits, que la Tabla 37 nunca permite en una raíz
Nada se quejó porque todos los chequeos pasaron: AddPageLabels devolvió 1, el archivo guardado abrió limpio, y un lector que desciende primero por /Kids, como hacen tanto los visores como la propia librería, jamás encuentra el rango nuevo
// Reetiquetar 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));  // e.g. A-1
  // Reemplazar el rango que empieza en la página 121: App-a, App-b ...
  if Lib.AddPageLabels(121, 5, 1, 'App-') = 1 then
    Lib.SaveToFile('vendor-manual-relabeled.pdf');
  // Los rangos romano y decimal existentes siguen en la hoja aplanada
  WriteLn('After: ', Lib.GetPageLabel(121));   // App-a
  WriteLn('Front: ', Lib.GetPageLabel(2));     // ii, sin cambios
end;

¿Cómo puede leerse un array /Nums como si todo fueran claves?

Un array /Nums se lee mal cuando el código lo recorre un elemento por vez, porque el array es una tira plana de pares alternados, [key0 value0 key1 value1 ...], y solo las posiciones pares son claves. El bucle del viejo NumTreeSet testeaba cada elemento por tipo numérico, así que un valor que resultara ser número se comparaba como si fuera clave; un hit de menor-que podía fijar el punto de inserción en un índice impar y meter el par nuevo en medio de uno existente, desfasando todos los pares siguientes. EnumNumTree tenía el mismo recorrido de un paso. Ambos ahora iteran pares con un stride de dos, leyendo la clave en X * 2 y el valor en X * 2 + 1, y un match exacto de clave reemplaza el valor y sale con Break. Siendo justos, los valores de page label son diccionarios, así que este segundo bug rara vez se disparaba en /PageLabels mismo, pero un helper de number tree que lee el stride equivocado está corrompido en el momento en que un valor es numérico, y se arregló en la misma pasada

Fix de stride de pares en number trees de PDFlibPas: un array /Nums es una tira plana de entradas alternadas clave-valor, así que un recorrido que testea cada elemento podía insertar un par nuevo en un índice impar y desfasar los pares siguientes, mientras que el recorrido corregido lee la clave en X*2 y el valor en X*2+1
El bug rara vez se disparaba en /PageLabels porque los valores de etiqueta son diccionarios, pero un helper de number tree que lee el stride equivocado se corrompe apenas un valor es numérico, así que ambos recorridos ahora avanzan en pares

Leer las etiquetas de vuelta y hacerlas ida y vuelta

TPDFlib.GetPageLabel(Page) devuelve la etiqueta de una página base 1 y tiene dos fallbacks que conviene conocer. Sin ninguna entrada /PageLabels devuelve el número de página decimal, así que quien la llama puede usarla incondicionalmente. Con un árbol presente pero sin rango que cubra la página devuelve un string vacío, que es exactamente lo que pasa cuando un archivo se salta la entrada obligatoria del índice 0; la documentación de referencia dice que debe existir un rango que empiece en la página 1 para que las etiquetas se muestren correctamente, y el código hace visible ese requisito. Los estilos de letra siguen la especificación y no las columnas de una planilla: después de Z viene AA, luego BB, repitiendo la letra en lugar de acarrear

var
  P: Integer;
  Data: WideString;
begin
  // Auditoría rápida de lo que un visor mostrará en su caja de páginas
  for P := 1 to Lib.PageCount do
    WriteLn(P, ' -> ', Lib.GetPageLabel(P));

  // El valor de opción 4 exporta solo rangos de etiquetas como registros PageLabelBegin
  Data := Lib.ExportDocumentData(4);
  // La importación los relee vía ClearPageLabels + AddPageLabels
  Lib.ImportDocumentData(Data, 0);
end;

Para ediciones en bloque, ExportDocumentData con valor de opción 4 escribe cada rango como un bloque PageLabelBegin con líneas PageLabelNewIndex, PageLabelStart, PageLabelPrefix y PageLabelNumStyle, y ImportDocumentData trata el primer registro de etiqueta que ve como un reemplazo completo: llama a ClearPageLabels una vez y después le pasa cada registro a AddPageLabels. Eso hace que el ida y vuelta por texto sea determinista incluso cuando el archivo original usaba un árbol /Kids, porque limpiar remueve la entrada completa del catálogo y el árbol reconstruido es una sola hoja desde el principio

¿Qué sigue sin garantizar el fix?

El aplanado es de una sola vía y confía en el orden que encuentra. EnumNumTree recolecta los pares en orden de archivo, y GetPageLabel aplica el último rango cuya clave sea menor o igual al índice de página, así que un archivo ajeno con hojas fuera de orden, que §7.9.7 prohíbe pero que sí circula, todavía puede producir etiquetas equivocadas hasta que usted reconstruya los rangos con ClearPageLabels y llamadas frescas a AddPageLabels. Las etiquetas además están atadas a índices de página, no a objetos de página, así que cualquier operación que cambie el conteo o el orden de páginas deja los rangos donde estaban. Un swap in situ como reemplazar páginas preservando los números de objeto mantiene el conteo y por lo tanto las etiquetas alineadas, mientras que un merge como intercalar scans dúplex produce una secuencia de páginas nueva que merece un set de rangos recién escrito

Las llamadas de page labels, el manejo del number tree y el export e import de datos de documento descritos aquí llegan todos en PDF Library for Delphi para Delphi, C++Builder y Lazarus, con la entrada de referencia de AddPageLabels documentando los valores de estilo y los códigos de retorno