Artículo técnico

Índice disperso de objetos PDF en Delphi con PDFiumPas

Quieres un diccionario de un PDF de 2 GB y la herramienta primero expande toda la tabla de referencias cruzadas en un array dimensionado por el tráiler /Size. PDFiumPas sustituye ese paso por un índice de objetos disperso y perezoso: conserva solo los descriptores de sección xref, resuelve un único número de objeto bajo demanda a través de ventanas acotadas, y cachea justo las entradas que realmente tocaste

La forma antigua de este código en FPdfCompress era honesta pero cara. ApplyDefaultOpenAction leía el archivo completo en un único TBytes, y después asignaba un array denso TPdfActiveXrefEntries con una casilla por número de objeto hasta /Size. Dos cosas fallaban a escala. El coste de lectura crecía linealmente con el tamaño del documento incluso cuando el llamador quería cuatro diccionarios, y el array denso chocaba con el presupuesto del parser: TPdfParserResourceBudget.Default fija MaxObjects en 4.000.000, así que un archivo perfectamente válido cuyo número de objeto más alto se sitúa por encima de ese techo se rechazaba por un argumento de memoria en lugar de uno de corrección

El índice de objetos disperso y perezoso de PDFiumPas en Delphi comparado con un array denso de referencias cruzadas: la ruta densa lee el archivo entero y asigna una casilla por número de objeto hasta el tamaño del tráiler, mientras que la ruta dispersa conserva solo descriptores de sección
Solo los descriptores permanecen en memoria, las entradas permanecen en el archivo, y toda lectura pasa por una ventana acotada de un mebibyte

¿Por qué la API pública de PDFium no responde a esta pregunta?

Porque la información existe dentro de PDFium pero nunca cruza la frontera C. CPDF_Parser mantiene internamente la tabla de referencias cruzadas, la pertenencia a flujos de objetos y la precedencia de revisiones, y sin embargo las cabeceras publicadas no exponen punto de entrada que tome un número de objeto y devuelva su offset en bruto, su generación, qué revisión ganó o en qué ObjStm vive. El lado del guardado está igualmente cerrado: FPDF_SaveAsCopy y FPDF_SaveWithVersion solo te entregan un callback de escritura secuencial. Cualquier parche a nivel de bytes sobre un catálogo tras un guardado nativo tiene por tanto que construirse en la capa Pascal, razón por la que PDFiumPas analiza estas estructuras por sí mismo en lugar de reutilizar la DLL

¿Qué conserva realmente en memoria el índice disperso?

Descriptores, no entradas. Para una tabla clásica (ISO 32000-1 §7.5.4) un TPdfSparseXrefSubsection almacena el primer número de objeto, el recuento de objetos, el offset en bytes donde empiezan las filas de entrada y el ancho de entrada medido. Las entradas mismas permanecen en el archivo. El ancho se mide desde la primera fila en lugar de suponerse 20 bytes, porque los productores discrepan sobre los finales de línea; PDFiumPas acepta de 18 a 64 y rechaza cualquier cosa fuera de esa banda, junto con toda subsección cuyo recuento declarado se extendería más allá del final del flujo. Para un flujo de referencias cruzadas (§7.5.8) la sección contiene los tres anchos de campo /W, cada uno acotado de 0 a 8, los pares /Index aplanados, y los bytes de entrada decodificados, cuya longitud esperada se calcula a partir de /W y /Index antes de inflar un solo byte

Todo el índice lo construye Initialize desde una ventana final de 1 MiB como mucho, que es donde se encuentra startxref, y toda lectura de objeto posterior usa una ventana de objeto de 1 MiB. El techo del flujo en bruto es 64 MiB y una única línea xref no puede exceder 1024 bytes. Si has leído nuestra nota sobre validar flujos de objetos y de referencias cruzadas con PDFiumPas, la misma disciplina de anchos de campo aplica aquí, solo que ahora se usa para direccionar una entrada en lugar de auditar una tabla entera

uses
  FPdfCompress;

var
  Source: TFileStream;
  Revision: TPdfSparseRevisionInfo;
begin
  Source := TFileStream.Create(FileName, fmOpenRead or fmShareDenyWrite);
  try
    { recorre solo startxref, la cadena /Prev y el catálogo }
    if ReadPdfSparseRevisionInfo(Source, Revision) then
    begin
      Writeln('root      ', Revision.RootObjectNumber, ' ',
        Revision.RootGeneration);
      Writeln('max obj   ', Revision.MaximumObjectNumber);
      Writeln('xref str  ', Revision.UsesXrefStream);
      Writeln('encrypted ', Revision.HasEncrypt);
      Writeln(string(Revision.CatalogDictionary));
    end;
  finally
    Source.Free;
  end;
end;

¿Cómo alcanza una búsqueda a un objeto?

Por aritmética, en ambos diseños. Una subsección clásica tiene filas de ancho fijo, así que la dirección de una entrada es el inicio de la subsección más el offset del objeto por el ancho medido; PDFiumPas lee entonces esa única línea, analiza el offset de diez dígitos y la generación de cinco dígitos, comprueba la generación contra el techo 65535 de §7.5.4, y clasifica la palabra clave final como axkDirect o axkFree. Un flujo de referencias cruzadas necesita un paso más porque las subsecciones /Index están concatenadas en la carrera de bytes decodificada, así que el índice acumula los recuentos de las subsecciones precedentes antes de multiplicar por el ancho /W sumado. El tipo 1 rinde un offset, el tipo 2 rinde un número de flujo de objetos y un índice de miembro, y cualquier otra cosa se convierte en axkUnknown en lugar de una adivinanza

{ tabla clásica, ISO 32000-1 sección 7.5.4 }
EntryOffset := Subsection.EntryOffset +
  Int64(ObjectNumber - Subsection.FirstObject) * Subsection.EntryWidth;

{ flujo de referencias cruzadas, ISO 32000-1 sección 7.5.8 }
EntryWidth := Section.Widths[0] + Section.Widths[1] + Section.Widths[2];
EntryPosition := Integer((PriorCount + ObjectNumber -
  Section.IndexValues[I]) * EntryWidth);

Nada en ninguna de las dos rutas es proporcional a /Size. Ese es el punto entero de la reescritura: el valor de tamaño del tráiler se arrastra como metadato y se usa al escribir la revisión incremental, pero nunca gobierna una asignación. El conjunto de regresiones lo fija con un accesorio cuyo árbol de páginas vive en el objeto 1.000.000.000 y 1.000.000.001 bajo un tráiler que declara /Size 1000000002. La antigua implementación densa rechazaba ese archivo; el índice disperso resuelve ambas referencias y conserva el tamaño declarado en el tráiler de salida

Cómo resuelve PDFiumPas un número de objeto en Delphi: una tabla de referencias cruzadas clásica multiplica el ancho de fila medido, mientras que un flujo de referencias cruzadas acumula los recuentos de las subsecciones precedentes antes de multiplicar los anchos de campo sumados del array /W
Ambas búsquedas son aritmética pura, así que ninguna es proporcional al recuento de objetos declarado en el tráiler

Revisiones híbridas, cadenas /Prev y las guardas a su alrededor

La precedencia de revisiones es donde un índice perezoso ingenuo se equivoca. PDFiumPas recorre la cadena desde startxref en orden de más nuevo a más antiguo y detiene una búsqueda en la primera sección que responde, lo que reproduce la regla de precedencia sin materializar una tabla fusionada. Los archivos de referencia híbrida (§7.5.8.4) se gestionan dentro de la rama clásica: cuando el tráiler lleva un /XRefStm, la sección del flujo suplementario se registra antes de la sección clásica que lo referenció, de modo que los objetos comprimidos invisibles para la tabla llana siguen encontrándose mientras las entradas clásicas conservan su categoría. Las revisiones más antiguas se siguen después por /Prev

Dos guardas acotan ese recorrido, y ambas importan en archivos dañados. Todo offset visitado se registra, así que un /Prev que apunta de vuelta a la cadena termina en lugar de girar, y la profundidad de traversal está limitada por MaxRecursionDepth, que vale 1024 por defecto. El indicador de cifrado se acumula a través de toda la cadena en lugar de leerse solo del tráiler más nuevo, porque un documento cuyo último tráiler omite /Encrypt puede seguir cifrado más atrás; los llamadores que añaden revisiones confían en ese indicador para negarse a escribir objetos en texto plano en un archivo cifrado

Cómo recorre PDFiumPas una cadena de revisiones PDF híbrida en Delphi: las secciones se registran de más nuevo a más antiguo desde startxref, una sección XRefStm suplementaria va por delante de la tabla clásica que la nombró, y el recorrido /Prev está acotado por offsets visitados y un techo de profundidad
Una búsqueda se detiene en la primera sección que responde, lo que reproduce la precedencia de revisiones sin materializar jamás una tabla fusionada

Entradas de tipo 2: por qué el flujo de objetos espera

Una entrada de tipo 2 nombra un flujo de objetos, y PDFiumPas no toca ese flujo hasta que un llamador pide un miembro suyo. Cuando por fin lo hace, se verifica /Type /ObjStm, se comprueba /N contra el presupuesto de objetos y /First contra el techo de bytes decodificados, y se verifica la cordura de /N contra /First ya que cada par de cabecera necesita al menos cuatro bytes. Solo entonces se infla el flujo, y el escaneo de cabecera se detiene en el miembro solicitado y su sucesor en lugar de construir una tabla completa de miembros. Se retiene un flujo de objetos decodificado a la vez, que es el compromiso correcto cuando una rama del árbol de páginas se agrupa en un único ObjStm; nuestro artículo sobre decodificación de flujos de objetos y predictores en Delphi cubre lo que ocurre dentro de ese paso de inflado (§7.5.7)

var
  Reader: TPdfSparseDictionaryReader;
  Generation: Integer;
  Dict: AnsiString;
begin
  { un índice retenido, muchas lecturas conscientes de la generación }
  Reader := TPdfSparseDictionaryReader.Create(Source);
  try
    if Reader.Valid and
       Reader.ReadLatestDictionary(PageObjectNumber, Generation, Dict) then
      HandlePage(PageObjectNumber, Generation, Dict);
  finally
    Reader.Free;  { Source sigue siendo tuyo }
  end;
end;

Dónde la caché deja de hacer promesas

El índice es una instantánea, y merece la pena ser franco al respecto. Las secciones se analizan una vez en Initialize; si el flujo subyacente se modifica después, toda entrada cacheada está caducada y la clase no se enterará. TPdfSparseDictionaryReader retiene el índice por el tiempo de vida del origen propiedad del llamador, que es exactamente lo que quiere un recorrido recursivo sobre un árbol de páginas y exactamente lo que no debes hacer a través de una reescritura. La caché de entradas es un array plano buscado linealmente y también almacena resultados negativos, así que unos cientos de búsquedas son baratas y unos cientos de miles no lo son. ReadDictionary exige una coincidencia exacta de generación mientras que ReadLatestDictionary resuelve la activa, y la diferencia es deliberada: la resolución de referencias necesita la primera, la inspección del catálogo necesita la segunda. Donde estos límites no pueden honrarse, las unidades circundantes recaen en el parser legado de archivo completo en lugar de estrechar el conjunto de archivos que aún funcionan, un patrón que también usamos para el streaming bajo demanda de PDF grandes

Las regresiones entre compiladores cubren el mismo comportamiento en las tres cadenas de herramientas, incluyendo una aserción de que un origen de 2 MiB nunca ve una sola lectura mayor de 1 MiB. Si mantienes código Delphi, C++Builder o Lazarus que toca la estructura PDF directamente y estás cansado de pagar costes de parseo de archivo completo por cuatro diccionarios, el índice disperso y la costura pública a su alrededor se incluyen en el PDFiumPas Delphi PDFium Component