Artículo técnico

Salida PDF linealizada en Delphi: tablas hint de HotPDF

El primer objeto de un PDF linealizado tiene que declarar la longitud exacta del archivo en el que se encuentra, al byte, y tiene que aparecer dentro de los primeros 1024 bytes. El Anexo F de la ISO 32000-1 llama /L a esa entrada, y a un lector que la encuentre en desacuerdo con el tamaño real del archivo se le ordena tratar el documento como un PDF ordinario e ignorar todas las pistas de linealización que lleva dentro. Así que el writer debe imprimir la longitud de un archivo que no ha terminado de escribir, al lado del offset de un hint stream, del final de la primera página y de la posición de una tabla de referencias cruzadas cerca del final del archivo. Los cuatro números se mueven siempre que cualquier cosa delante de ellos cambie de tamaño

La recompensa es el Fast Web View. Un PDF ordinario mantiene su tabla de referencias cruzadas al final, de modo que un navegador al que le entregan un informe escaneado de 200 páginas no puede localizar la página 1 hasta que llega el último byte; un archivo linealizado mueve la página 1 y una pequeña sección de referencias cruzadas al frente, así que un cliente que use peticiones de rango HTTP pinta la primera página pronto. Lo que sigue es el lado de la escritura. El cuadro del lado lector está en la explicación de la linealización de PDF y el Fast Web View

Cómo dispone HotPDF un archivo linealizado

HotPDF escribe salida linealizada cuando THotPDF.LinearizeOutput es True, y el archivo sigue el orden físico que prescribe el Anexo F, parte a parte. Tras la cabecera viene el diccionario de parámetros de linealización, luego la sección de referencias cruzadas de la primera página con su trailer, el catálogo con los demás objetos a nivel de documento, el hint stream primario, la primera página con todos los objetos que necesita, las páginas restantes en orden, los objetos compartidos entre las páginas posteriores, todo lo demás y, por fin, la tabla principal de referencias cruzadas

Diagrama del orden de bytes de un PDF linealizado escrito por HotPDF en Delphi, desde el diccionario de parámetros de linearización y el stream de pistas primario hasta la tabla principal de referencias cruzadas
HotPDF emite las partes del Anexo F en un orden de bytes fijo, con el diccionario de parámetros y el flujo hint primario colocados lo bastante pronto para que los lectores de rangos de bytes empiecen a renderizar la página uno casi de inmediato

Nada en el modelo de documento dice a qué parte pertenece un objeto. El writer lo deduce del grafo de referencias cuando EndDoc guarda, caminando desde cada objeto página y contando cuántas páginas alcanzan cada objeto indirecto. Un objeto alcanzado desde una página va a la sección de esa página. Un objeto alcanzado desde varias páginas es compartido, con un giro que una lectura rápida de la especificación se pierde: cuando la página 1 está entre las páginas que lo usan, el objeto va de todos modos a la sección de la primera página, porque la página 1 debe renderizarse solo con los bytes iniciales. Solo los objetos compartidos puramente entre páginas posteriores esperan en la parte compartida. El catálogo, apunten donde apunten sus entradas /ViewerPreferences, /OpenAction, /Threads y /AcroForm, y el diccionario de cifrado de un documento protegido forman el grupo a nivel de documento. Los nodos del árbol de páginas se empujan a la parte final para que nunca inflen la primera página

Abra la salida en un editor hexadecimal y la numeración parece ir al revés. El bloque frontal, del diccionario de linealización al hint stream, lleva los números de objeto más altos del archivo, mientras que todo lo que va después se numera desde 1. La pequeña sección de referencias cruzadas del frente cubre exactamente ese bloque alto, y la tabla principal cubre del 1 al N

Una trampa de inicialización de records en las listas de partición

Las listas así se escriben naturalmente como un record que guarda un array dinámico más un conteo aparte, y esa forma esconde una trampa de Delphi. El compilador inicializa los campos gestionados de un record local. El Integer corriente sentado al lado no recibe nada. Bajo Win32 la casilla de la pila suele contener cero por casualidad, así que el primer append aterriza en el índice 0 y todas las pruebas pasan; bajo Win64 la casilla puede contener restos y el primer append escribe en un índice arbitrario

type
  TPartList = record
    Values: TArray<Integer>;  // gestionado: lo limpia el compilador
    Count: Integer;           // no gestionado: lo que hubiera en la pila
  end;

procedure Append(var List: TPartList; Value: Integer);
begin
  if List.Count = Length(List.Values) then
    SetLength(List.Values, List.Count * 2 + 16);
  List.Values[List.Count] := Value;
  Inc(List.Count);
end;

procedure BuildParts;
var
  FirstPage, Shared: TPartList;
begin
  FirstPage := Default(TPartList);   // obligatorio, no cosmético
  Shared := Default(TPartList);
  Append(FirstPage, 12);
  Append(Shared, 40);
end;

El writer de HotPDF asigna Default a cada lista de partición antes de tocarla. Una ejecución en verde bajo Win32 no dice nada de si semejante record quedó inicializado

¿Por qué debe converger el layout antes de escribir el primer byte?

Porque el frente del archivo guarda offsets que apuntan hacia atrás, y el número de dígitos de esos offsets desplaza todo lo que tienen detrás. El trailer de la primera página lleva una entrada /Prev con el offset en bytes de la tabla principal de referencias cruzadas. Escriba ese offset como 99998, descubra luego que la tabla realmente aterriza en 100003, y el trailer crece un dígito. La sección de referencias cruzadas de la primera página crece con él, cada objeto detrás se mueve un byte, y la tabla principal ahora está en 100004. Que una sola pasada lo acierte es pura suerte

HotPDF rompe el ciclo en dos movimientos. El diccionario de parámetros va a una ranura fija de 384 bytes rellenada con espacios, así que /L, /H, /E y /T pueden tomar los dígitos que necesiten sin mover un solo objeto; si el texto del diccionario llegara a necesitar más de 384 bytes, la escritura lanzaría una excepción en lugar de desplazar el archivo. Eso deja /Prev como la única variable libre, y pasa por un bucle de punto fijo. Cada objeto se serializa primero en un stream de conteo que registra longitudes y no guarda bytes. Una pasada de layout asigna entonces offsets sección por sección y reporta dónde aterrizaría la tabla principal, y esa respuesta se convierte en la entrada de la siguiente pasada. El bucle se para cuando el offset propuesto deja de moverse y se rinde con una excepción tras ocho intentos

Diagrama de flujo del bucle de convergencia de maquetación linealizada de HotPDF en Delphi, donde los desplazamientos de referencia cruzada propuestos se miden repetidamente hasta alcanzar un punto fijo o que una excepción aborte la escritura
Hasta ocho pasadas de medición convergen el offset principal de referencias cruzadas antes de comprometer un solo byte; la no convergencia lanza una excepción en lugar de escribir offsets estimados

Solo un cambio en la cuenta de dígitos mueve algo, así que hace falta una tercera pasada solo cerca de un borde de potencia de diez, como en el caso del 99998. Tras la convergencia el writer ejecuta una pasada de confirmación, reconstruye el hint stream con offsets reales y comprueba que su longitud no cambió; los offsets están en campos de ancho fijo, así que la versión de prueba y la final tienen que coincidir. Entonces la salida va directa al stream de destino sin segunda copia en búfer, y en cada frontera de sección los bytes escritos se comparan con el offset prometido. Un desajuste lanza una excepción desde EndDoc

Tablas hint, y dónde el Anexo F se lee de dos maneras

El hint stream primario lleva dos tablas. La tabla de offsets de página registra, para cada página, cuántos objetos guarda su sección, cuánto mide esa sección en bytes y de qué objetos compartidos depende. La tabla de objetos compartidos lista la longitud de cada grupo de objetos compartidos. Con ambas, un lector puede pedir la página 37 directamente, y la entrada /S del stream da la posición dentro del stream donde empieza la tabla compartida

Dos detalles del Anexo F F.4 son fáciles de torcer. El primero es que los offsets guardados en las tablas hint son offsets lógicos, calculados como si el hint stream primario no existiera. El objeto de la primera página y el primer objeto compartido están físicamente después del hint stream, así que el writer les resta la longitud del objeto hint antes de registrar sus offsets. Un writer que registrara offsets físicos aterrizaría cada petición de rango resultante desviada exactamente el tamaño del objeto hint

El segundo es una ambigüedad de verdad. El texto de F.4 describe cada tabla como un único flujo de bits continuo, el bit más significativo primero, y lista los grupos compartidos de la primera página y los de las páginas posteriores como dos secuencias separadas. Los hint streams en circulación rellenan cada columna hasta un borde de byte y tratan los grupos compartidos como una única lista combinada, y esa es la forma que se sabe que aceptan los lectores que consumen hints. HotPDF escribe el layout alineado a byte y combinado. Ese es el lado correcto que elegir: una tabla que satisface la letra de F.4 pero desalinea para los clientes que la leen falla justo donde la linealización se suponía que iba a pagar, y una descarga del archivo completo jamás expone el problema

Activar la salida linealizada desde Delphi

En el lado de la API el interruptor es un booleano. LinearizeOutput vale False por defecto, y el writer lo consulta cuando EndDoc serializa el documento, así que fíjelo con las demás opciones de salida antes de BeginDoc y no lo toque más. Cada fallo descrito arriba (no convergencia, un diccionario de parámetros sobredimensionado, un desajuste de frontera, el techo de 4 GiB que cubrimos abajo) aflora como una excepción desde EndDoc, lo que hace que un fallback sea directo

procedure WriteReport(const FileName: string; Linearize: Boolean);
var
  PDF: THotPDF;
begin
  PDF := THotPDF.Create(nil);
  try
    PDF.FileName := FileName;
    PDF.Version := pdf17;
    PDF.LinearizeOutput := Linearize;   // se fija antes de BeginDoc
    PDF.BeginDoc;                       // crea la página 1
    PDF.CurrentPage.TextOut(72, 72, 0, 'Quarterly summary');
    PDF.AddPage;
    PDF.CurrentPage.TextOut(72, 72, 0, 'Details');
    PDF.EndDoc;                         // el layout y la escritura pasan aquí
  finally
    PDF.Free;
  end;
end;

procedure PublishReport(const FileName: string);
begin
  try
    WriteReport(FileName, True);
  except
    on E: Exception do
    begin
      LogWarning('Linearization failed: ' + E.Message);  // su propio logger
      WriteReport(FileName, False);                       // PDF plano en su lugar
    end;
  end;
end;

El fallback produce un PDF ordinario que cualquier visor abre; solo pierde la primera página rápida. Para un pipeline de informes ese trato es fácil, porque una primera pintura lenta es una molestia y un informe ausente es un incidente

El lado del despliegue manda sobre el código. La linealización solo paga cuando el servidor honra las peticiones HTTP Range; un endpoint que manda el archivo entero en stream le compra una escritura más lenta y un archivo más grande para nada. Deje LinearizeOutput apagado para los archivos que van por correo, como adjuntos o como descargas antes de que nadie los vea. Actívelo para documentos que un navegador abre directamente desde una URL, tras confirmar que la respuesta lleva Accept-Ranges: bytes

¿Por qué LinearizeOutput apaga los object streams?

Porque cada entrada de una tabla hint describe un rango de bytes, y un objeto empaquetado en un contenedor /ObjStm no tiene rango de bytes propio; existe solo como una rebanada de otro stream comprimido que hay que descargar e inflar entero. Con LinearizeOutput activado, HotPDF escribe tablas de referencias cruzadas de texto tradicionales y objetos indirectos sin empaquetar incluso cuando UseXRefStream o UseObjectStreams también está puesto. El camino linealizado toma precedencia al guardar, así que su código no tiene conflicto que resolver

El coste es el tamaño del archivo, porque los diccionarios pequeños que un object stream comprimiría juntos ahora quedan sueltos. Si la salida compacta importa más que la latencia de la primera página, el trato está expuesto en object streams y actualizaciones incrementales en HotPDF. Los exportadores de Office tropiezan con la misma tensión desde el otro lado, que es por lo que algunos de ellos emiten el layout descrito en los cross-reference streams híbridos de los PDF generados por Office

La linealización también necesita PDF 1.2 o posterior. Con una Version menor, HotPDF promociona el documento a 1.2 al guardar. Con StrictVersionLock puesto lanza una excepción en su lugar, porque una versión que usted fijó a propósito no debería cambiar a sus espaldas

El techo de 4 GiB de la salida linealizada

Las tablas hint guardan offsets y longitudes en campos de 32 bits, así que un archivo linealizado no puede direccionar nada más allá de 4 GiB, y HotPDF se niega a producir uno en lugar de escribir offsets que den la vuelta. El techo viene de los anchos de campo que define el Anexo F

Diagrama de los tres puntos de control de HotPDF que miden tamaños de objeto, longitudes de sección de página y longitud final del fichero contra el límite de 4 GiB de los campos de 32 bits de la tabla de pistas de linearización en Delphi
Las medidas de objeto, de sección de página y de archivo completo se comprueban cada una contra el techo de 32 bits del campo hint antes de producir la salida

La comprobación corre en tres puntos, todos antes de que el primer byte llegue al destino: sobre cada objeto en cuanto se conoce su longitud serializada, sobre la longitud de cada sección de página mientras se construyen las entradas hint, y sobre la longitud final del archivo una vez dimensionada la tabla principal de referencias cruzadas. Unos offsets desbordados serían la peor manera de fallar: un visor que descarga el archivo completo puede ignorar los hints, mientras que el cliente de byte-ranges salta a los bytes equivocados, así que las pruebas de escritorio nunca reproducen la avería. Para salida de varios gigabytes, la ruta streaming de la Direct File API para flujos de trabajo con PDFs grandes es donde mirar

Comprobar si un archivo entrante está linealizado

THotPDF.IsLoadedLinearized reporta si el documento cargado se declara linealizado. La comprobación ocurre antes del parseo: HotPDF lee hasta 1024 bytes desde la posición cero del origen, encuentra el primer objeto y mira dentro de ese objeto un /Linearized con el valor 1. Los comentarios, las literal strings y las cadenas hex se saltan, así que el nombre dentro de una cadena no cuenta, y el escaneo se para en stream o endobj. Solo se acepta 1 o un equivalente como 1.0

La respuesta se cachea en el momento de la carga porque, para cuando el código de la aplicación pregunta, el parser ya movió la posición del stream y LoadFromFile ya soltó su stream de origen

El flag reporta lo que el archivo reclama. El Anexo F añade una segunda condición: /L tiene que coincidir con la longitud real del archivo, y una actualización incremental añadida tras la linealización rompe esa coincidencia, así que los lectores vuelven a tratar el archivo como ordinario. Un pipeline que acepta archivos de editores que añaden sus guardados puede comparar los dos números por su cuenta

// Ayudante propio, no forma parte de HotPDF: compara /L con el tamaño real
// Parseo simplificado; asume un único espacio tras /L
function DeclaredLengthMatches(const FileName: string): Boolean;
var
  FS: TFileStream;
  Head: AnsiString;
  ActualSize: Int64;
  P, Q: Integer;
begin
  FS := TFileStream.Create(FileName, fmOpenRead or fmShareDenyWrite);
  try
    ActualSize := FS.Size;
    SetLength(Head, 1024);
    SetLength(Head, FS.Read(Head[1], 1024));
  finally
    FS.Free;
  end;
  P := Pos(AnsiString('/L '), Head);
  Result := P > 0;
  if not Result then
    Exit;
  Inc(P, 3);
  Q := P;
  while (Q <= Length(Head)) and (Head[Q] in ['0'..'9']) do
    Inc(Q);
  Result := StrToInt64Def(string(Copy(Head, P, Q - P)), -1) = ActualSize;
end;

procedure InspectUpload(const FileName: string);
var
  PDF: THotPDF;
begin
  PDF := THotPDF.Create(nil);
  try
    if PDF.LoadFromFile(FileName) <= 0 then
      Exit;
    if not PDF.IsLoadedLinearized then
      Writeln('Not linearized')
    else if not DeclaredLengthMatches(FileName) then
      Writeln('Linearized, but modified after linearization')
    else
      Writeln('Ready for Fast Web View');
  finally
    PDF.Free;
  end;
end;

LinearizeOutput e IsLoadedLinearized forman parte del HotPDF Delphi Component para Delphi y C++Builder, y su página de producto documenta cómo interactúan con los cross-reference streams, los object streams y el bloqueo de versión