Artículo técnico

Salida PDF linealizada en Delphi: tablas hint de HotPDF

HotPDF escribe archivos PDF linealizados, el diseño que Acrobat etiqueta como Fast Web View, mediante la propiedad LinearizeOutput de 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 tras obtener solo la parte inicial del archivo, en lugar de descargar primero el documento completo. El mecanismo es el Anexo F de la norma ISO 32000-1

El motivo por el que esto importa es poco vistoso. Un PDF normal coloca su tabla de referencias cruzadas 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 se queda mirando un indicador de carga durante toda la transferencia, aunque lo único que quería era la página 1. La linealización lo soluciona pagando un coste 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 estrictos; para el contexto conceptual sobre lo que aporta Fast Web View, el artículo anterior explicación de la linealización de PDF y Fast Web View cubre ese terreno

Qué garantiza realmente el diseño 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 y no de ningún 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 referencias cruzadas temprana, los objetos de nivel documento, el flujo hint primario, la primera página y sus objetos privados, luego el resto de páginas, luego los objetos compartidos, luego todo lo demás, y finalmente la tabla de referencias cruzadas principal

La partición se deriva, no se declara. 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 qué 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 referencie bajo /ViewerPreferences, /OpenAction, /Threads y /AcroForm, más el diccionario de cifrado cuando la protección está activa, forman el grupo de nivel 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 nada más: /L para la longitud total del archivo, /H para el desplazamiento y la longitud del flujo hint, /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 referencias cruzadas principal. Cada uno de esos valores es un desplazamiento de bytes dentro de un archivo que todavía no existe en el momento en que necesitas escribirlos

¿Por qué tienen que converger los desplazamientos de la tabla hint?

Porque los números del 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 el motivo por el que HotPDF mide repetidamente en lugar de escribir una sola vez. Amplía /T de 6 dígitos a 7 y el diccionario de parámetros crece un byte; la cabecera crece; cada objeto se desplaza; la tabla de referencias cruzadas principal se mueve; /T ahora necesita un valor distinto. El diseño tiene que alcanzar un punto fijo antes de que se confirme un solo byte de salida real

HotPDF gestiona esto con una iteración acotada. Primero serializa cada objeto en un flujo de conteo que registra la longitud sin conservar los bytes, de modo que cada objeto tiene un tamaño serializado conocido. Luego ejecuta una pasada de diseño que asigna desplazamientos al grupo de nivel documento, al flujo hint, al grupo de la primera página, a los grupos de páginas posteriores, al grupo compartido y al resto, e informa de dónde caería la tabla de referencias cruzadas principal. Ese resultado se vuelve a introducir como entrada de la siguiente pasada. El bucle está limitado a ocho intentos, y la no convergencia lanza una excepción en lugar 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 se descontrole. El diccionario de parámetros se escribe en una ranura fija de 384 bytes, rellenada con espacios, así que su propio crecimiento nunca puede desestabilizar el diseño; si el texto del diccionario alguna vez superara esa reserva, HotPDF lanza una excepción en lugar de desplazar todo en silencio. Y tras la convergencia, HotPDF ejecuta una pasada de diseño de confirmación adicional y vuelve a comprobar la longitud del flujo hint, porque el propio flujo hint codifica desplazamientos que solo se conocieron una vez asentado el diseño. La recompensa de toda esta medición es que HotPDF nunca almacena en búfer una segunda copia del documento: 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 comprueba que los bytes escritos coinciden con el desplazamiento prometido

Activarlo desde Delphi

La superficie de API es un único booleano, y su único requisito es que lo establezcas antes de que comience la generación. LinearizeOutput vale False por defecto, y la pasada de diseño 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 en el lado del código. La linealización solo compensa cuando el transporte admite 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. Comprueba el servidor antes de comprobar el código

¿Por qué la linealización anula 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. Por eso HotPDF emite tablas de referencias cruzadas de texto tradicionales y objetos indirectos sin empaquetar siempre que LinearizeOutput está activado, incluso si el llamador también estableció UseXRefStream o UseObjectStreams. Esta es una anulación deliberada, no un conflicto que tengas que resolver tú mismo

El razonamiento se deriva de las tablas hint. Una tabla hint describe dónde empieza una sección de página y cuánto dura, para que un lector pueda solicitar exactamente ese rango. Un objeto empaquetado dentro de un contenedor /ObjStm no tiene ningún desplazamiento independiente en absoluto; existe solo como una porción dentro de otro flujo comprimido que debe obtenerse y descomprimirse como una unidad. Si contabas con los flujos de objetos para el tamaño del archivo, ten en cuenta que aquí la linealización y la compresión tiran en direcciones opuestas, y lee la compensación en el artículo complementario sobre flujos de objetos y actualizaciones incrementales en HotPDF. La misma tensión moldea los archivos de referencia híbrida, que existen precisamente para que los lectores más antiguos sigan funcionando junto a las tablas basadas en flujos, como se cubre en el artículo sobre flujos de referencias cruzadas híbridos en PDF generados por Office

También hay un límite mínimo 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 se establezca StrictVersionLock, en cuyo caso la escritura lanza una excepción en lugar de promocionar silenciosamente un documento que fijaste a propósito

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

Las tablas hint de linealización almacenan los 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 dicha salida con una excepción explícita en lugar de escribir un archivo con desplazamientos que se han dado la vuelta. 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 comprobación se aplica en tres lugares, y los tres importan. HotPDF valida cada objeto en cuanto se conoce su longitud serializada, valida la longitud de cada sección de página al construir las entradas hint, y valida la longitud final del archivo después de dimensionar la tabla de referencias cruzadas principal. Fallar pronto es el objetivo entero: una tabla hint con un desplazamiento truncado en silencio produce un archivo que se abre correctamente en un visor que lo descarga entero y falla solo para el cliente por rangos de bytes al que la linealización existía para servir, que es el peor modo de fallo posible porque tu visor de pruebas 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 con PDF grandes es la dirección a mirar

Detectar la linealización en un archivo que cargaste

THotPDF.IsLoadedLinearized informa de si el documento actualmente cargado ya se escribió en forma linealizada, y responde a partir de una instantánea tomada 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 explora en busca de la primera palabra clave obj y luego de 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 de esa descripción son estructurales. 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 ha movido, y no puede volver a leer bajo demanda porque LoadFromFile libera el flujo de origen interno una vez terminada la carga. De ahí el diseño de capturar antes de analizar y almacenar en caché. El análisis también es deliberadamente literal respecto al 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 diga otra cosa no está haciendo la promesa del Anexo F

Una trampa de registros de Delphi que merece la pena robar

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

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 array dinámico tiene conteo de referencias, así que el compilador lo pone a cero. El Count que lo acompaña es un entero ordinario sin esa garantía, y un Count sin inicializar envía el primerísimo añadido a un índice arbitrario. En Win32 la ranura de pila resultó contener cero, el añadido aterrizó en el índice 0, y todas las pruebas pasaron. En Win64 el mismo código escribió más allá del final del array. La lección se generaliza mucho más allá de la linealización: cuando un registro mezcla campos gestionados y no gestionados, asigna Default(TRecord) y deja de razonar sobre qué campos cubre el compilador, y nunca trates una ejecución en verde de Win32 como prueba de que la inicialización es correcta

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