Når HotPDF Delphi Component loader en PDF 1.5-fil med LoadFromFile, parser den ikke objekterne pakket inde i /Type /ObjStm-containere. Den noterer, hvor hvert komprimeret medlem bor, og parser det først, når noget spørger til det. Den lazy invariant er det, der holder load-tiden proportional med det, du faktisk rører, og det er også grunden til, at en fuld omskrivning skal gøre ét ekstra stykke arbejde, før nogen bytes går ud: ekspandere hvert medlem, der stadig er uparset, for omskrivningen er ved at smide de containere væk, som medlemmerne bor i
Symptomet, der motiverede denne note, er let at beskrive og ubehageligt at debugge. Load en fil, hvis skrifttyper, farverum og structure tree bor i object streams, kør den gennem BeginDoc- og EndDoc-generationsparret, og outputtet åbner uden brok. Sideantallet er rigtigt, teksten er synlig på de sider, du spot-tjekker. Så åbner en kollega side 40, og brødteksten renderer i en substitueret font, eller kommandoen Extract Text returnerer skrald, hvor en ActualText-erstatning plejede at være. Intet crashede. Writeren serialiserede simpelthen et objekt, der aldrig blev loadet, og et uloadet objekt serialiserer som ingenting
Hvad holder LoadFromFile faktisk for et komprimeret objekt?
For hver type-2 cross-reference-entry holder LoadFromFile en lille record i FCompactObjects: objektnummeret, indekset af den indeholdende stream i containertabellen, medlemmets position inde i den stream og en ParsedObject-pointer, der starter som nil. Containeren selv lokaliseres, dekrypteres, hvis dokumentet er krypteret, og inflateres, men medlemskroppene efterlades som bytes. ISO 32000-1 §7.5.7 definerer containerlayoutet, der gør dette muligt: en header af objektnummer- og offset-par, derefter medlemskroppene konkateneret efter /First, så ethvert enkelt medlem kan skæres ud uden at røre sine naboer
EnsureCompressedObjectLoaded er den eneste vej, der forvandler en record til et objekt. Den finder recorden efter objektnummer, og hvis ParsedObject allerede er sat, returnerer den det cachede objekt og tæller et cache-hit. Ellers genindlæser den containeren, hvis den var evicted, beregner medlemmets byte-range ud fra offset-tabellen, giver parseren et zero-copy view af den skive og gemmer resultatet tilbage i recorden. Herfra er objektet indirekte, bærer sit rigtige objektnummer og er registreret i dokumentets objektindeks som ethvert objekt, der blev parset fra filkroppen. Catalog, info-dictionaryen, sidetræ-roden og sideobjekterne går denne vej ved load, fordi navigation behøver dem. Fonts, farverum, ExtGState-dictionaries og structure elements gør ikke, og de forbliver som records, indtil en render eller en omskrivning rører dem
Du kan overvære dette udefra. GetLoadedObjectStreamCacheInfo rapporterer, hvor mange containere der findes, hvor mange medlemmer der blev indekseret, og hvor mange af dem der er blevet parset indtil videre:
var
Pdf: THotPDF;
Info: THPDFObjectStreamCacheInfo;
begin
Pdf := THotPDF.Create(nil);
try
Pdf.LoadFromFile('tagged-report.pdf');
if Pdf.GetLoadedObjectStreamCacheInfo(Info) then
Writeln(Format('%d containers, %d members indexed, %d parsed so far',
[Info.ContainerCount, Info.IndexedObjectCount,
Info.MaterializedObjectCount]));
finally
Pdf.Free;
end;
end;
På en struktur-tung fil er det tredje tal en lille brøkdel af det andet lige efter load. Det hul er hele pointen med lazy loading, og det er også præcis det sæt af objekter, en fuld omskrivning må vende tilbage for
Hvorfor dropper en fuld omskrivning fonts, som en inkrementel gemning beholder?
En fuld omskrivning kasserer kildefilens /ObjStm- og /XRef-containere og re-serialiserer objektgrafen fra bunden, så ethvert medlem, hvis ParsedObject stadig er nil, ikke har nogen repræsentation tilbage i outputtet. En inkrementel opdatering har aldrig dette problem, for den appender nye objekter efter de originale bytes og lader de gamle containere ligge til den forrige cross-reference-sektion at adressere. Forskellen ligger ikke i, hvordan de to tilstande behandler fonts. Den ligger i, om de originale containere overlever til at blive læst af den næste viewer
Fixet bor i SaveToStream, serialisatoren som EndDoc driver, uanset om du sætter FileName eller OutputStream. Før den dispatcher til nogen writer-gren, gennemløber den FCompactObjects og kalder EnsureCompressedObjectLoaded på hver entry. Kan et medlem ikke loades, raise'r gemningen i stedet for at fortsætte, for en omskrivning, der lydløst dropper en font-dictionary, er værre end én, der stopper. Ekspansionen skal sidde på det niveau, over de klassiske, pakkede og lineariserede grene, og over den lineariserede vejs beskæring af genindlæste strukturelle streams. En tidligere version ekspanderede medlemmer kun inde i SaveLoadedDocument, hvilket dækkede loaded-document-ordforrådet og missede generationsordforrådet helt. LoadFromFile efterfulgt af BeginDoc, side-redigeringer og EndDoc gik direkte til writeren med hvert urørt medlem stadig uparset
// Begge omskrivningsordforråd ekspanderer nu kompakte medlemmer, før nogen writer kører.
// Loaded-document-vejen:
Pdf.LoadFromFile('quarterly.pdf');
Pdf.SaveLoadedDocument('quarterly-rewritten.pdf');
// Generationsvejen over en loadet fil:
Pdf.LoadFromFile('quarterly.pdf');
Pdf.FileName := 'quarterly-stamped.pdf';
Pdf.BeginDoc;
Pdf.CurrentPage.SetFont('Arial', [], 9);
Pdf.CurrentPage.TextOut(40, 20, 0, 'Reviewed 2026-09-11');
Pdf.EndDoc; // SaveToStream materialiserer hver FCompactObjects-entry først
Cachede medlemmer beholder, hvad end du gjorde ved dem. Et objekt, der blev parset, redigeret og markeret dirty før gemningen, returneres fra cachen med sine redigeringer, og et medlem, du slettede, beholder sin slettet-tilstand på tværs af gentagne gemninger. Ekspansionsgennemløbet er idempotent af konstruktion: det fylder kun nogensinde nil-pladser
Hvorfor pixel-tjek på tre sider misser ActualText-tilfældet
Structure elements er der, hvor denne bug gemmer sig længst. En ActualText-entry på en marked-content-sekvens, defineret i ISO 32000-1 §14.9.4, erstatter glypherne til ekstraktion og tilgængelighed men påvirker ikke rendering. Bor structure elementet i en object stream, og mister omskrivningen det, tegner siden stadig korrekt, de første, midterste og sidste sider sammenligner pixel for pixel med kilden, og regressionen viser sig først, når nogen kører tekstekstraktion eller en screen reader. En omskrivningstest, der kun renderer sider, er ikke en omskrivningstest for tagget PDF. Diff også den ekstraherede tekst og structure tree
Hvordan ændrer et tomt user password loadet?
Et tomt user password betyder stadig, at filen er krypteret, og object streams i sådan en fil er ciphertext, indtil file key'en er fundet. ISO 32000-1 §7.6.3.4 Algoritme 2 udleder den nøgle ud fra passwordet, /O-entryen, /P og den første dokumentidentifikator, og HotPDF skal køre den mod den tomme streng, før type-2-gennemløbet kan inflate én eneste container. Det er grunden til, at BeginDoc på et loadet krypteret dokument kalder DecryptLoadedDocument med et tomt password før noget andet: objektgrafen skal autentificeres og dekrypteres, før en omskrivning kan begynde, uanset om calleren har til hensigt at beskytte outputtet. Output-kryptering er en separat beslutning, drevet af callerens beskyttelsesindstillinger, og BeginDoc gendanner de indstillinger efter dekrypteringsgennemløbet, så et krypteret input ikke lydløst bliver til et krypteret output
Containerpolitikken læses fra /Encrypt-dictionaryen, før noget password prøves. For /V 1 og 2 er hver stream krypteret med file key'en. For crypt filters resolver HotPDF /StmF gennem /CF: en Identity-filter eller en /CFM på None betyder klartekst-containere, mens V2 og AESV2 betyder krypterede. Svaret lander i FReloadObjectStreamsEncrypted, og det betyder noget for ét specifikt tilfælde. Når containere er klartekst, men strenge ikke er, bærer medlemmerne krypterede strenge, som skal dekrypteres individuelt, så MaterializeMembersOfPlaintextObjectStreams ekspanderer hvert kompakt medlem foran den per-objekt dekrypteringsgennemløb. Den gør intet, når politikken endnu ikke er kendt, og intet, når containerne selv var krypteret, for medlemmer af en krypteret container blev allerede dekrypteret med den og må aldrig dekrypteres to gange
Hvad sker der, når en container ikke kan dekrypteres?
En container, der fejler dekryptering, sættes i karantæne, den er ikke fatal. Type-2-gennemløbet registrerer en THPDFObjStmQuarantineInfo-entry i FObjStmQuarantine med containerens objektnummer, en THPDFObjStmQuarantineReason, en diagnostisk streng og listen af medlemsobjektnumre, som cross-reference'en havde routet ind i den. osqrDecryptFailed raise'es for fire distinkte situationer: ingen crypt filter kunne resolves, AES-256- eller AES-GCM-dekrypten kastede, den gamle RC4- eller AES-128-dekrypt kastede, eller der findes slet ingen brugbar file key. Uafhængige containere fortsætter med at loade, så et dokument med én beskadiget container åbner stadig og renderer stadig hver side, der ikke afhænger af den
Karantænelisten overlever parser-fallback. Fejler den primære cross-reference-load, og HotPDF rekonstruerer objekttabellen ved at skanne filen, overlever encrypted-flaget fra første forsøg måske ikke den rekonstruktion, men karantæne-records gør. Det er grunden til, at BeginDoc tjekker karantænelisten snarere end encrypted-flaget: på et loadet dokument gennemløber den FObjStmQuarantine og raise'r på den første osqrDecryptFailed-entry, navngiver containeren og beder om en genindlæsning med et gyldigt password. En omskrivning, der fortsatte forbi det punkt, ville skrive de medlemmer, containeren skulle holde, som tomme objekter og rapportere succes. Du kan selv køre samme tjek, tidligere og med din egen politik, gennem de offentlige accessors:
var
Info: THPDFObjStmQuarantineInfo;
I: Integer;
begin
Pdf.LoadFromFile('vendor-form.pdf'); // tomt user password
for I := 0 to Pdf.GetLoadedQuarantinedObjStmCount - 1 do
if Pdf.GetLoadedQuarantinedObjStmInfo(I, Info) and
(Info.Reason = osqrDecryptFailed) then
raise Exception.CreateFmt(
'Object stream %d is unreadable (%s); %d members unresolved',
[Info.ContainerObjNum, String(Info.Diagnostic),
Length(Info.MemberObjNums)]);
// sikkert at omskrive herfra
end;
Andre karantæne-årsager dækker de ikke-kryptografiske fejle: en container, der ikke er en stream, en manglende dictionary, en ugyldig /N eller /First, en stream-størrelse uden for det accepterede område, en dekomprimeringsfejl, en /First, der peger forbi dataene, eller en medlemskrop, der dekodede men ikke parsede. De er værd at logge ved ingest, da hver enkelt navngiver de præcise medlemmer, du vil mangle downstream
Hvorfor behøver en omskrivning den originale numeriske token?
HotPDF gemmer hvert numerisk objekt som en Single, og en Single kan ikke reproducere kildeteksten af et reelt tal. ISO 32000-1 §7.3.3 lader en writer emitte 0.750000, .75 eller 0.75 for samme værdi, og ingen af dem overlever en roundtrip gennem 24-bit binær og en generisk formatter uændret. Værre, en værdi som 0.7 er slet ikke repræsentérbar i en Single; den parser til den nærmeste float, og at reformatere den float kan give 0.69999999 eller en afrundet nabo afhængigt af cifferløkken. På en fill-farve eller en /CA-transparenkonstant er det en forskel på én tælling i en 8-bit kanal, hvilket er nok til at stryge ved en pixelsammenligning mod kilden og, på gradient-grænser, nok til at se
THPDFNumericObject.RememberSourceToken løser dette for det uændrede tilfælde. Parseren kalder den med den rå token lige efter at have tildelt Value; metoden accepterer kun tokens lavet af cifre, højst ét decimalpunktum og et valgfrit indledende fortegn og gemmer tokenen sammen med den værdi, den svarede til, i FSourceValue. SourceToken-propertyen returnerer den gemte tekst kun, mens Value stadig er lig FSourceValue. Ændr tallet, og tokenen fordamper, så en ændret værdi altid går gennem den eksisterende formateringsvej og aldrig emitterer forældet tekst. SaveNumericObject tjekker SourceToken først og skriver den ordret, når den er til stede, og falder derefter igennem til integer-, farverumsreference- og brøk-grenene kun for tal, der blev oprettet eller redigeret i hukommelsen
Invarianten er lille og værd at sige klart: et tal, du ikke rørte, skrives med de bytes, det blev læst med, og et tal, du rørte, skrives af HotPDFs egen formatter. Kompakte medlemmer har gavn af dette på samme måde som kropsobjekter, da EnsureCompressedObjectLoaded kører den samme parser over medlemsskiven. Selve talformateringen, og dens uafhængighed af proceslokalet, er dækket i artiklen om locale-invariant PDF-talformatering i HotPDF
At teste en omskrivningsvej op mod object streams
Tre tjek fanger hver fejl beskrevet ovenfor, og ingen af dem kræver Acrobat. For det første, sammenlign IndexedObjectCount med MaterializedObjectCount efter gemningen; ved en fuld omskrivning skal de være ens, og hvert hul er et medlem, der blev droppet. For det andet, ekstrahér tekst og enumerér structure tree på begge filer, ikke bare render dem, så en mistet ActualText eller et mistet structure element viser sig som en diff. For det tredje, load outputtet med en frisk instans og assert at GetLoadedQuarantinedObjStmCount er nul, hvilket også beviser, at writeren ikke producerede en container, readeren ikke kan åbne. De crypt filter-kombinationer, der bestemmer FReloadObjectStreamsEncrypted, er lagt ud i StmF-, StrF- og EFF-policy-artiklen. Writersiden af denne historie, hvordan man emitterer object streams og hvornår man foretrækker en inkrementel opdatering frem for en omskrivning, ligger i guiden om object streams og incremental updates
Lazy medlemsindlæsning, pre-writer-ekspansionsgennemløbet, decrypt quarantine og source-token-bevarelse følger alle med i HotPDF Delphi Component til Delphi og C++Builder. Produktsiden linker API-referencen, hvis du vil spore GetLoadedObjectStreamCacheInfo og karantæne-accessorerne op mod din egen ingest-pipeline