Quieren un diccionario de un PDF de 2 GB y la herramienta primero expande toda la tabla de referencias cruzadas en un arreglo dimensionado por el /Size del trailer. PDFiumPas reemplaza ese paso con un índice de objetos disperso y perezoso: conserva solo los descriptores de secciones xref, resuelve un solo número de objeto a demanda a través de ventanas acotadas, y cachea apenas las entradas que realmente tocaron
La forma vieja de este código en FPdfCompress era honesta pero cara. ApplyDefaultOpenAction leía el archivo completo a un TBytes, luego asignaba un arreglo denso TPdfActiveXrefEntries con una casilla por número de objeto hasta /Size. Dos cosas fallaban a escala. El costo de lectura crecía linealmente con el tamaño del documento incluso cuando el llamador quería cuatro diccionarios, y el arreglo 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 quedara por encima de ese techo era rechazado por un argumento de memoria en lugar de uno de corrección
¿Por qué la API pública de PDFium no responde 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 membresía de object streams y la precedencia de revisiones, sin embargo los headers publicados no exponen punto de entrada que tome un número de objeto y devuelva su offset crudo, su generación, qué revisión ganó o en qué ObjStm vive. El lado de guardado está igual de cerrado: FPDF_SaveAsCopy y FPDF_SaveWithVersion solo les entregan un callback de escritura secuencial. Cualquier parche a nivel de bytes a un catálogo después de un guardado nativo tiene por tanto que construirse en la capa Pascal, razón por la que PDFiumPas parsea estas estructuras por sí mismo en lugar de reutilizar la DLL
¿Qué mantiene realmente en memoria el índice disperso?
Descriptores, no entradas. Para una tabla clásica (ISO 32000-1 §7.5.4) un TPdfSparseXrefSubsection guarda el primer número de objeto, el conteo de objetos, el offset de byte donde comienzan las filas de entradas y el ancho de entrada medido. Las entradas mismas permanecen en el archivo. El ancho se mide desde la primera fila en lugar de asumirse de 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 cualquier subsección cuyo conteo declarado correría más allá del final del stream. Para un cross-reference stream (§7.5.8) la sección guarda los tres anchos de campo /W, cada uno restringido de 0 a 8, los pares /Index aplanados, y los bytes de entradas decodificados, cuya longitud esperada se calcula de /W y /Index antes de inflar un solo byte
El índice entero lo construye Initialize desde una ventana final de a lo más 1 MiB, que es donde se encuentra startxref, y cada lectura subsecuente de objetos usa una ventana de objeto de 1 MiB. El techo del stream crudo es 64 MiB y una línea xref individual no puede exceder 1024 bytes. Si leyeron nuestra nota sobre validar object y cross-reference streams 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 un solo objeto?
Por aritmética, en ambos layouts. 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 entonces lee esa única línea, parsea el offset de diez dígitos y la generación de cinco dígitos, verifica la generación contra el techo 65535 de §7.5.4, y clasifica la palabra clave final como axkDirect o axkFree. Un cross-reference stream necesita un paso más porque las subsecciones /Index están concatenadas en la corrida de bytes decodificada, así que el índice acumula los conteos de las subsecciones precedentes antes de multiplicar por el ancho /W sumado. El tipo 1 arroja un offset, el tipo 2 arroja un número de object stream y un índice de miembro, y cualquier otra cosa se convierte en axkUnknown en lugar de una suposición
{ tabla clásica, ISO 32000-1 sección 7.5.4 }
EntryOffset := Subsection.EntryOffset +
Int64(ObjectNumber - Subsection.FirstObject) * Subsection.EntryWidth;
{ cross-reference stream, 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 todo el punto de la reescritura: el valor de tamaño del trailer se lleva adelante como metadato y se usa al escribir la revisión incremental, pero jamás maneja una asignación. La suite de regresión lo fija con un fixture cuyo árbol de páginas vive en los objetos 1,000,000,000 y 1,000,000,001 bajo un trailer que declara /Size 1000000002. La implementación densa vieja rechazaba ese archivo; el índice disperso resuelve ambas referencias y conserva el tamaño declarado en el trailer de salida
Revisiones híbridas, cadenas /Prev y las guardas alrededor
La precedencia de revisiones es donde un índice perezoso ingenuo se equivoca. PDFiumPas recorre la cadena desde startxref en orden del más nuevo primero 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 manejan dentro de la rama clásica: cuando el trailer lleva un /XRefStm, la sección de stream suplementaria se registra antes de la sección clásica que la nombró, así que los objetos comprimidos invisibles para la tabla plana aún se encuentran mientras las entradas clásicas conservan su puesto. Las revisiones más viejas se siguen entonces a través de /Prev
Dos guardas acotan ese recorrido, y ambas importan en archivos dañados. Cada offset visitado se registra, así que un /Prev que apunte de vuelta a la cadena termina en lugar de girar, y la profundidad de traversal se limita por MaxRecursionDepth, que por defecto es 1024. El flag de cifrado se acumula a través de toda la cadena en lugar de leerse solo del trailer más nuevo, porque un documento cuyo trailer más reciente omite /Encrypt puede seguir cifrado más atrás; los llamadores que añaden revisiones confían en ese flag para rechazar escribir objetos en texto plano en un archivo cifrado
Entradas tipo 2: por qué el object stream espera
Una entrada tipo 2 nombra un object stream, y PDFiumPas no toca ese stream hasta que un llamador pide un miembro de él. Cuando por fin lo hace, /Type /ObjStm se verifica, /N se verifica contra el presupuesto de objetos y /First contra el techo de bytes decodificados, y /N se verifica de sensatez contra /First ya que cada par de encabezado necesita al menos cuatro bytes. Solo entonces se infla el stream, y el escaneo de encabezado se detiene en el miembro solicitado y su sucesor en lugar de construir una tabla completa de miembros. Se retiene un object stream decodificado a la vez, que es el trade correcto cuando una rama del árbol de páginas se agrupa en un solo ObjStm; nuestro artículo sobre decodificación de object streams y predictores en Delphi cubre lo que pasa dentro de ese paso de inflate (§7.5.7)
var
Reader: TPdfSparseDictionaryReader;
Generation: Integer;
Dict: AnsiString;
begin
{ un índice retenido, muchas lecturas conscientes de 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 suyo }
end;
end;
Dónde la caché deja de hacer promesas
El índice es una instantánea, y vale ser franco con eso. Las secciones se parsean una vez en Initialize; si el stream subyacente se modifica después, cada entrada cacheada está obsoleta y la clase no se va a enterar. TPdfSparseDictionaryReader sostiene el índice por el tiempo de vida que el llamador le da a la fuente, que es exactamente lo que quiere un recorrido recursivo sobre un árbol de páginas y exactamente lo que no deben hacer a través de una reescritura. La caché de entradas es un arreglo plano buscado linealmente y también almacena resultados negativos, así que unas cuantas centenas de búsquedas son baratas y unas cuantas centenas de miles no. ReadDictionary exige 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 retroceden al parser legacy 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 a demanda de PDFs grandes
Las regresiones multi-compilador cubren el mismo comportamiento en las tres toolchains, incluyendo una aserción de que una fuente de 2 MiB jamás ve una sola lectura mayor que 1 MiB. Si mantienen código Delphi, C++Builder o Lazarus que toca la estructura PDF directamente y están cansados de pagar costos de parseo de archivo completo por cuatro diccionarios, el índice disperso y la costura pública alrededor se entregan en el PDFiumPas Delphi PDFium component