Artículo técnico

PDF linealizado en Delphi: tablas de sugerencias de HotPDF

HotPDF escribe archivos PDF linealizados, el layout que Acrobat llama Fast Web View, a través de la propiedad LinearizeOutput en THotPDF. Establecerla antes de BeginDoc hace que HotPDF reordene el grafo de objetos terminado para que un lector consciente de rangos de bytes pueda mostrar la página uno después de obtener solo la parte inicial del archivo, en vez de descargar todo el documento primero. El mecanismo es el Anexo F de ISO 32000-1

La razón por la que esto importa no es glamorosa. Un PDF normal pone su tabla de referencia cruzada al final, así que un visor debe llegar al último byte antes de saber dónde está cualquier cosa. Entrégale a un navegador un informe escaneado de 200 páginas y el usuario mira un spinner durante toda la transferencia, aunque lo único que quería era la página 1. La linealización arregla eso pagando un costo en el momento de la escritura. Este artículo trata específicamente sobre esa ruta de escritura, la partición, el bucle de medición y los límites duros; para el trasfondo conceptual de lo que te compra Fast Web View, la explicación previa de la linealización de PDF y Fast Web View cubre ese terreno

Qué garantiza realmente el layout linealizado

Un archivo linealizado es un PDF ordinario con un orden físico extremadamente específico, y cada garantía que ofrece proviene de ese orden en vez de cualquier tipo de objeto nuevo. HotPDF emite las partes en la secuencia que prescribe el Anexo F: el diccionario de parámetros de linealización dentro de los primeros 1024 bytes, una tabla de referencia cruzada temprana, los objetos a nivel de documento, el flujo de sugerencias primario, la primera página y sus objetos privados, luego las páginas restantes, luego los objetos compartidos, luego todo lo demás, y finalmente la tabla de referencia cruzada principal

La partición es derivada, no declarada. HotPDF recorre el grafo de referencias desde cada objeto de página y registra, para cada objeto indirecto, cuántas páginas lo alcanzan y cuál página lo alcanzó primero. Un objeto usado por exactamente una página se vuelve privado de esa página. Un objeto alcanzado por más de una se vuelve compartido. El catálogo, más lo que referencia bajo /ViewerPreferences, /OpenAction, /Threads y /AcroForm, más el diccionario de cifrado cuando la protección está activa, forman el grupo a nivel de documento que debe preceder a todo lo demás. Los nodos del árbol de páginas se retienen deliberadamente para que no contaminen la sección de la primera página

El diccionario de parámetros lleva los números que un lector necesita antes de haber leído cualquier otra cosa: /L para la longitud total del archivo, /H para el desplazamiento y longitud del flujo de sugerencias, /O para el número de objeto de la primera página, /E para el byte donde termina la sección de la primera página, /N para el número de páginas y /T para el desplazamiento de la entrada de la tabla de referencia cruzada principal. Cada uno de esos es un desplazamiento de byte en un archivo que aún no existe en el momento en que necesitas escribirlos

¿Por qué deben converger los desplazamientos de la tabla de sugerencias?

Porque los números en el diccionario de parámetros describen el archivo que los contiene, y cambiar cualquiera de ellos cambia el archivo. Esa es la dificultad central de un escritor linealizado, y es por eso que HotPDF mide repetidamente en vez de escribir una sola vez. Amplía /T de 6 dígitos a 7 y el diccionario de parámetros crece un byte; el encabezado crece; cada objeto se desplaza; la tabla de referencia cruzada principal se mueve; /T ahora necesita un valor diferente. El layout tiene que llegar a un punto fijo antes de comprometer un solo byte de salida real

HotPDF maneja esto con una iteración acotada. Primero serializa cada objeto en un flujo de conteo que registra la longitud sin conservar bytes, así que cada objeto tiene un tamaño serializado conocido. Luego ejecuta un pase de layout que asigna desplazamientos al grupo a nivel de documento, el flujo de sugerencias, el grupo de la primera página, los grupos de páginas posteriores, el grupo compartido y el resto, y reporta dónde aterrizaría la tabla de referencia cruzada principal. Ese resultado se retroalimenta como entrada al siguiente pase. El bucle está limitado a ocho intentos, y la no convergencia lanza una excepción en vez de producir un archivo con desplazamientos incorrectos que parecen plausibles

CandidateMainOffset := 0;
for Attempt := 0 to 7 do
begin
  CalculateLayout(CandidateMainOffset, FirstXRefData,
    HintOffset, EndFirstPage, NewMainOffset);
  if NewMainOffset = CandidateMainOffset then
    Break;
  CandidateMainOffset := NewMainOffset;
end;
if NewMainOffset <> CandidateMainOffset then
  raise Exception.Create('Linearization layout did not converge');

Dos detalles evitan que el bucle oscile sin control. El diccionario de parámetros se escribe en un espacio fijo de 384 bytes, relleno con espacios, así que su propio crecimiento nunca puede desestabilizar el layout; si el texto del diccionario alguna vez excediera esa reserva, HotPDF lanza una excepción en vez de desplazar todo silenciosamente. Y después de la convergencia, HotPDF ejecuta un pase de layout confirmatorio más y vuelve a verificar la longitud del flujo de sugerencias, porque el flujo de sugerencias mismo codifica desplazamientos que solo se conocieron una vez que el layout se estableció. El resultado de toda esta medición es que HotPDF nunca almacena una segunda copia del documento en un búfer: una vez fijados los desplazamientos, los objetos se serializan directamente en el flujo de destino, con una aserción en cada límite de sección que verifica que los bytes escritos coincidan con el desplazamiento que se prometió

Activarlo desde Delphi

La superficie de la API es un solo booleano, y su único requisito es que lo establezcas antes de que comience la generación. LinearizeOutput es False por defecto, y el pase de layout se ejecuta cuando se escribe el documento, así que asignarlo después de EndDoc no logra nada

var
  PDF: THotPDF;
begin
  PDF := THotPDF.Create(nil);
  try
    PDF.FileName := 'fast-view.pdf';
    PDF.Version := pdf17;
    PDF.LinearizeOutput := True;      // must precede BeginDoc
    PDF.BeginDoc;
    PDF.Canvas.TextOut(72, 72, 'First page');
    PDF.EndDoc;
  finally
    PDF.Free;
  end;
end;

Una advertencia de despliegue supera a todo lo demás del lado del código. La linealización solo rinde beneficios cuando el transporte soporta solicitudes de rango HTTP. Sirve el mismo archivo desde un endpoint que lo transmite completo, o desde una configuración de CDN que ignora Range, y te habrás comprado una ruta de escritura más lenta y un archivo más grande sin ninguna ganancia visible para el usuario. Verifica el servidor antes de verificar el código

¿Por qué la linealización sobrescribe UseXRefStream y UseObjectStreams?

Porque el escritor linealizado necesita que cada objeto tenga su propio desplazamiento de byte directamente direccionable, y ambas funciones se lo quitan. HotPDF por lo tanto emite tablas de referencia cruzada de texto tradicionales y objetos indirectos sin empaquetar siempre que LinearizeOutput esté habilitado, incluso si quien llama también estableció UseXRefStream o UseObjectStreams. Esta es una sobrescritura deliberada, no un conflicto que tengas que resolver tú mismo

El razonamiento sigue de las tablas de sugerencias. Una tabla de sugerencias describe dónde comienza una sección de página y cuánto dura, así que un lector puede solicitar exactamente ese rango. Un objeto empaquetado en un contenedor /ObjStm no tiene ningún desplazamiento independiente en absoluto; existe solo como un fragmento dentro de otro flujo comprimido que debe obtenerse e inflarse como una unidad. Si contabas con los flujos de objetos para el tamaño de archivo, entiende que la linealización y la compresión tiran en direcciones opuestas aquí, y lee el intercambio en la pieza complementaria sobre flujos de objetos y actualizaciones incrementales en HotPDF. La misma tensión da forma a los archivos de referencia híbrida, que existen precisamente para mantener funcionando a los lectores más antiguos junto a las tablas basadas en flujos, como se cubre en el artículo sobre flujos de referencia cruzada híbridos en PDFs generados por Office

También hay un piso de versión. La linealización requiere PDF 1.2 o posterior. Si la versión seleccionada es más antigua, HotPDF la eleva automáticamente, a menos que StrictVersionLock esté establecido, en cuyo caso escribir lanza una excepción en vez de promover silenciosamente un documento que fijaste a propósito

El muro de 4 GiB, y por qué HotPDF se niega en vez de truncar

Las tablas de sugerencias de linealización almacenan desplazamientos como valores de 32 bits, así que un archivo linealizado no puede direccionar nada en o más allá de 4 GiB, y HotPDF rechaza tal salida con una excepción explícita en vez de escribir un archivo con desplazamientos envueltos. El límite no es una decisión de implementación de HotPDF; es el ancho de los campos que define el Anexo F

La verificación se aplica en tres lugares, y los tres importan. HotPDF valida cada objeto una vez que se conoce su longitud serializada, valida cada longitud de sección de página al construir las entradas de sugerencias, y valida la longitud final del archivo después de dimensionar la tabla de referencia cruzada principal. Fallar temprano es todo el punto: una tabla de sugerencias con un desplazamiento truncado silenciosamente produce un archivo que se abre correctamente en un visor que lo descarga completo y falla solo para el cliente por rango de bytes al que la linealización existía para servir, que es el peor modo de falla posible porque tu visor de prueba nunca lo reproduce. Si estás produciendo salida de varios gigabytes, la linealización no es la herramienta, y el enfoque de streaming descrito en las notas sobre la Direct File API para flujos de trabajo de PDF grandes es la dirección a mirar

Detectar la linealización en un archivo que cargaste

THotPDF.IsLoadedLinearized reporta si el documento actualmente cargado ya fue escrito en forma linealizada, y responde a partir de un snapshot tomado antes del análisis, no del flujo en vivo. HotPDF lee los primeros 1024 bytes desde la posición cero del flujo de origen, los escanea buscando la primera palabra clave obj y luego una entrada /Linearized con el valor 1, y almacena en caché el resultado booleano

var
  PDF: THotPDF;
  PageCount: Integer;
begin
  PDF := THotPDF.Create(nil);
  try
    PageCount := PDF.LoadFromFile('incoming.pdf');
    if (PageCount > 0) and (not PDF.IsLoadedLinearized) then
      Writeln('Source is not Fast Web View ready');
  finally
    PDF.Free;
  end;
end;

Dos restricciones en esa descripción son fundamentales. La detección no puede depender de la posición del flujo, porque para cuando el código de la aplicación hace la pregunta el analizador ya la movió, y no puede releer bajo demanda porque LoadFromFile libera el flujo de origen interno una vez que termina la carga. De ahí el diseño de capturar-antes-de-analizar-y-cachear. El escaneo también es deliberadamente literal sobre el valor: solo se acepta /Linearized 1 o una forma numéricamente equivalente con una fracción totalmente cero, porque un archivo cuyo diccionario de parámetros dice otra cosa no está haciendo la promesa del Anexo F

Una trampa de registros de Delphi que vale la pena robar

Los registros locales que contienen arreglos dinámicos inicializan sus campos administrados y nada más, y si mantienes un campo Count plano junto al arreglo debes limpiarlo tú mismo. Esto afectó la partición de linealización durante el desarrollo, y es el tipo de error que cuesta un día precisamente porque una plataforma lo esconde

type
  THPDFLinearIndexList = record
    Values: THPDFIntegerArray;  // managed field: cleared for you
    Count: Integer;             // plain field: whatever was on the stack
  end;

// Required, not cosmetic:
Part4 := Default(THPDFLinearIndexList);
Part6 := Default(THPDFLinearIndexList);
Part8 := Default(THPDFLinearIndexList);
Part9 := Default(THPDFLinearIndexList);

El campo de arreglo dinámico tiene conteo de referencias, así que el compilador lo pone en cero. El Count a su lado es un entero ordinario sin tal garantía, y un Count sin inicializar envía el primer agregado a un índice arbitrario. Bajo Win32 la ranura de la pila resultó tener cero, el agregado aterrizó en el índice 0, y todas las pruebas pasaron. Bajo Win64 el mismo código escribió más allá del final del arreglo. La lección se generaliza mucho más allá de la linealización: cuando un registro mezcla campos administrados y no administrados, asigna Default(TRecord) y deja de razonar sobre qué campos cubre el compilador, y nunca trates una ejecución exitosa en Win32 como evidencia de que la inicialización es correcta

Los miembros LinearizeOutput e IsLoadedLinearized descritos aquí vienen con el HotPDF Component estándar para Delphi y C++Builder; la página de producto lleva la referencia completa de propiedades, incluyendo las reglas de interacción con flujos de referencia cruzada, flujos de objetos y bloqueo de versión