Teknisk artikkel

Late objektstrømmedlemmer og full PDF-omskriving i Delphi

Når HotPDF Delphi Component laster en PDF 1.5-fil med LoadFromFile, parser den ikke objektene som ligger pakket inne i /Type /ObjStm-beholdere. Den registrerer hvor hvert komprimerte medlem ligger, og parser det først når noe ber om det. Denne late invarianten er det som holder lastetiden proporsjonal med det du faktisk rører, og den er også grunnen til at en full omskriving må gjøre én ekstra jobb før noen byte går ut: utvide hvert medlem som fortsatt ikke er parset, fordi omskrivingen er i ferd med å kaste beholderne de medlemmene bor i

Symptomet som motiverte dette notatet, er lett å beskrive og ubehagelig å feilsøke. Last en fil der fonter, fargerom og strukturtre ligger i objektstrømmer, kjør den gjennom generasjonsparet BeginDoc og EndDoc, og utdataene åpner seg uten klager. Sidetallet er riktig, teksten er synlig på sidene du stikkprøver. Så åpner en kollega side 40, og brødteksten gjengis i en erstattet font, eller kommandoen Extract Text returnerer vås der en ActualText-erstatning pleide å være. Ingenting krasjet. Skriveren serialiserte rett og slett et objekt som aldri ble lastet, og et objekt som ikke er lastet, serialiseres som ingenting

Hva beholder LoadFromFile egentlig for et komprimert objekt?

For hver kryssreferanseoppføring av type 2 beholder LoadFromFile en liten record i FCompactObjects: objektnummeret, indeksen til den inneholdende strømmen i beholdertabellen, medlemmets posisjon inne i den strømmen, og en ParsedObject-peker som starter som nil. Selve beholderen lokaliseres, dekrypteres hvis dokumentet er kryptert, og blåses opp, men medlemskroppene blir liggende som byte. ISO 32000-1 §7.5.7 definerer beholderlayouten som gjør dette mulig: en header med par av objektnummer og offset, deretter medlemskroppene sammenkjede etter /First, så ethvert enkelt medlem kan skjæres ut uten å røre naboene sine

EnsureCompressedObjectLoaded er den eneste veien som gjør en record om til et objekt. Den finner recorden etter objektnummer, og hvis ParsedObject allerede er satt, returnerer den det hurtiglagrede objektet og teller et cachetreff. Ellers laster den beholderen på nytt hvis den var kastet ut, regner ut medlemmets byteområde fra offsettabellen, gir parseren et nullkopi-visning av det utsnittet, og lagrer resultatet tilbake i recorden. Fra da av er objektet indirekte, bærer sitt virkelige objektnummer, og er registrert i dokumentets objektindeks som ethvert objekt som ble parset fra filkroppen. Katalogen, info-ordboken, sidetreroten og sideobjektene går gjennom denne veien ved lasting fordi navigasjon trenger dem. Fonter, fargerom, ExtGState-ordbøker og strukturelementer gjør det ikke, og de forblir records til en sidegjengivelse eller en omskriving rører dem

Hvordan HotPDF Delphi Component lagrer et komprimert medlem før det parses: FCompactObjects-recorden beholder objektnummeret, beholderindeksen, medlemsindeksen og en nil ParsedObject-peker, mens EnsureCompressedObjectLoaded gjør en record om til et registrert objekt gjennom cachetreff, omlasting av beholderen, utsnitt fra offsettabellen og nullkopi-parsing
LoadFromFile lar /ObjStm-medlemskropper ligge som byte og parser dem først når en leser ber om det, så lastetiden følger det du rører — katalogen og sidetreet kommer tidlig, mens fonter, fargerom og strukturelementer forblir records

Du kan se dette fra utsiden. GetLoadedObjectStreamCacheInfo rapporterer hvor mange beholdere som finnes, hvor mange medlemmer som ble indeksert, og hvor mange av dem som er parset så langt:

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 tallet en liten brøkdel av det andre rett etter lasting. Gapet er hele poenget med lat lasting, og det er også nøyaktig settet av objekter en full omskriving må gå tilbake for

Hvorfor dropper en full omskriving fonter som en inkrementell lagring beholder?

En full omskriving kaster kildefilens /ObjStm- og /XRef-beholdere og serialiserer objektgrafen på nytt fra grunnen av, så ethvert medlem der ParsedObject fortsatt er nil, har ingen representasjon igjen i utdataene. En inkrementell oppdatering har aldri dette problemet, fordi den tilføyer nye objekter etter originalbytene og lar de gamle beholderne ligge for den forrige kryssreferanseseksjonen å adressere. Forskjellen ligger ikke i hvordan de to modusene behandler fonter. Den ligger i hvorvidt originalbeholderne overlever til å leses av det neste visningsprogrammet

Fiksen ligger i SaveToStream, serialisereren som EndDoc driver enten du setter FileName eller OutputStream. Før den sender videre til noen skrivergren, går den gjennom FCompactObjects og kaller EnsureCompressedObjectLoaded på hver oppføring. Hvis et medlem ikke kan lastes, reiser lagringen et unntak i stedet for å fortsette, fordi en omskriving som stille dropper en fontordbok, er verre enn en som stopper. Utvidelsen må ligge på det nivået, over de klassiske, pakkede og lineariserte grenene, og over den lineariserte rutenes rydding av omlastede strukturelle strømmer. En tidligere versjon utvidet medlemmene bare inne i SaveLoadedDocument, som dekket lastet-dokument-vokabularet og bommet fullstendig på generasjonsvokabularet. LoadFromFile etterfulgt av BeginDoc, sidenedigeringer og EndDoc gikk rett til skriveren med hvert urørte medlem fortsatt uparset

Hvor utvidelsen ved full omskriving i HotPDF ligger: SaveToStream går gjennom hver FCompactObjects-oppføring via EnsureCompressedObjectLoaded før den sender videre til den klassiske, pakkede eller lineariserte skriveren, så både SaveLoadedDocument-vokabularet og LoadFromFile-pluss-BeginDoc-pluss-EndDoc-vokabularet serialiserer fullt parsede objekter i stedet for nil-recorder
En inkrementell oppdatering tilføyer etter originalbytene og holder de gamle beholderne lesbare, men en full omskriving forkaster dem — én utvidelsesrunde over hver skrivergren er det som hindrer en ulastet font eller et strukturelement i å serialiseres som ingenting
// Begge omskrivingsvokabularene utvider nå kompakte medlemmer før noen skriver kjører.
// Lastet-dokument-stien:
Pdf.LoadFromFile('quarterly.pdf');
Pdf.SaveLoadedDocument('quarterly-rewritten.pdf');

// Generasjonsstien over en lastet 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-oppføring først

Hurtiglagrede medlemmer beholder det du gjorde med dem. Et objekt som ble parset, redigert og merket skitten før lagringen, returneres fra cachen med endringene sine, og et medlem du slettet, beholder slettestatusen sin på tvers av gjentatte lagringer. Utvidelsesrunden er idempotent av konstruksjon: den fyller bare nil-spor

Hvorfor bommer pikselkontroller på tre sider på ActualText-tilfellet

Strukturelementer er der denne feilen gjemmer seg lengst. En ActualText-oppføring på en markert innholdssekvens, definert i ISO 32000-1 §14.9.4, erstatter glyfene for uttrekk og tilgjengelighet, men påvirker ikke gjengivelsen. Hvis strukturelementet ligger i en objektstrøm og omskrivingen mister det, tegnes siden fortsatt riktig, første, midterste og siste side sammenlignes piksel for piksel mot kilden, og regresjonen viser seg først når noen kjører tekstuttrekk eller en skjermleser. En omskrivingstest som bare gjengir sider, er ikke en omskrivingstest for tagget PDF. Diff både den uttrukne teksten og strukturtreet

Hvordan endrer et tomt brukerpassord lastingen?

Et tomt brukerpassord betyr fortsatt at filen er kryptert, og objektstrømmer i en slik fil er chiffrat til filnøkkelen er gjenopprettet. ISO 32000-1 §7.6.3.4 algoritme 2 utleder den nøkkelen fra passordet, oppføringen /O, /P og dokumentets første identifikator, og HotPDF må kjøre den mot den tomme strengen før type-2-runden kan blåse opp en eneste beholder. Det er derfor BeginDoc på et lastet kryptert dokument kaller DecryptLoadedDocument med et tomt passord før noe annet: objektgrafen må autentiseres og dekrypteres før en omskriving kan begynne, uansett om kalleren har tenkt å beskytte utdataene. Utdata-kryptering er en egen beslutning, drevet av kallerens beskyttelsesinnstillinger, og BeginDoc gjenoppretter de innstillingene etter dekrypteringsrunden så et kryptert inndata ikke stille blir et kryptert utdata

Beholderpolitikken leses fra /Encrypt-ordboken før noe passord prøves. For /V 1 og 2 er hver strøm kryptert med filnøkkelen. For crypt filter løser HotPDF opp /StmF gjennom /CF: et Identity-filter eller en /CFM på None betyr klartekstbeholdere, mens V2 og AESV2 betyr krypterte. Svaret havner i FReloadObjectStreamsEncrypted, og det betyr noe for ett bestemt tilfelle. Når beholderne er klartekst men strengene ikke er det, bærer medlemmene krypterte strenger som må dekrypteres enkeltvis, så MaterializeMembersOfPlaintextObjectStreams utvider hvert kompakte medlem før dekrypteringsrunden per objekt. Den gjør ingenting når politikken ennå ikke er kjent, og ingenting når beholderne selv var krypterte, fordi medlemmer av en kryptert beholder allerede ble dekryptert sammen med den og aldri må dekrypteres to ganger

Hva skjer når en beholder ikke kan dekrypteres?

En beholder som feiler i dekrypteringen, settes i karantene i stedet for å være fatal. Type-2-runden registrerer en THPDFObjStmQuarantineInfo-oppføring i FObjStmQuarantine med beholderens objektnummer, en THPDFObjStmQuarantineReason, en diagnostisk streng og listen over medlemsobjektnumre kryssreferansen hadde rutet inn i den. osqrDecryptFailed reises for fire forskjellige situasjoner: ingen crypt filter kunne løses, AES-256- eller AES-GCM-dekrypteringen kastet et unntak, den eldre RC4- eller AES-128-dekrypteringen kastet et unntak, eller det finnes ingen brukbar filnøkkel i det hele tatt. Uavhengige beholdere fortsetter å lastes, så et dokument med én skadet beholder åpner seg fortsatt og gjengir fortsatt hver side som ikke er avhengig av den

Hvordan dekrypteringskarantenen i HotPDF fungerer på en lastet PDF: en beholder der dekrypteringen kaster unntak, registreres som en THPDFObjStmQuarantineInfo med årsaken osqrDecryptFailed og medlemmets objektnumre, uavhengige beholdere fortsetter å lastes, og BeginDoc reiser unntak på den første feilende oppføringen før en omskriving kan rapportere suksess
Karantenerecorder overlever parserens fallback, og BeginDoc sjekker dem ved navn i stedet for via det krypterte flagget, så et dokument med én skadet beholder fortsatt åpner seg mens omskrivingsveien stopper i stedet for å skrive tomme objekter

Karantenelisten overlever parserens fallback. Hvis den primære kryssreferanselastingen feiler og HotPDF rekonstruerer objekttabellen ved å skanne filen, overlever kanskje ikke det krypterte flagget fra det første forsøket den rekonstruksjonen, men karantenerecordene gjør det. Det er derfor BeginDoc sjekker karantenelisten i stedet for det krypterte flagget: på et lastet dokument går den gjennom FObjStmQuarantine og reiser unntak på den første osqrDecryptFailed-oppføringen, navngir beholderen og ber om en omlasting med et gyldig passord. En omskriving som fortsatte forbi det punktet, ville skrevet medlemmene beholderen skulle holdt, som tomme objekter og rapportert suksess. Du kan kjøre den samme kontrollen selv, tidligere og med din egen policy, gjennom de offentlige tilgangsmetodene:

var
  Info: THPDFObjStmQuarantineInfo;
  I: Integer;
begin
  Pdf.LoadFromFile('vendor-form.pdf');   // tomt brukerpassord
  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)]);
  // trygt å skrive om herfra
end;

Andre karantenegrunner dekker de ikke-kryptografiske feilene: en beholder som ikke er en strøm, en manglende ordbok, en ugyldig /N eller /First, en strømstørrelse utenfor det aksepterte området, en dekomprimeringsfeil, en /First som peker forbi dataene, eller en medlemskropp som dekodet men ikke ble parset. De er verdt å logge ved inntak, siden hver av dem navngir nøyaktig de medlemmene du vil mangle nedstrøms

Hvorfor trenger en omskriving den opprinnelige numeriske tokenen?

HotPDF lagrer hvert numerisk objekt som en Single, og en Single kan ikke gjengi kildeteksten til et reelt tall. ISO 32000-1 §7.3.3 lar en skriver skrive 0.750000, .75 eller 0.75 for samme verdi, og ingen av dem overlever en rundtur gjennom 24-bits binær og en generisk formatter uendret. Verre: en verdi som 0.7 er ikke representerbar i en Single i det hele tatt; den parses til nærmeste float, og å reformatere den floaten kan gi 0.69999999 eller en avrundet nabo avhengig av sifferløkken. På en fyllfarge eller en /CA-transparenskonstant er det en forskjell på én enhet i en 8-bits kanal, som er nok til å feile en piksel-sammenligning mot kilden og, på gradientoverganger, nok til å bli synlig

THPDFNumericObject.RememberSourceToken løser dette for det uendrede tilfellet. Parseren kaller den med den rå tokenen rett etter at Value er tilordnet; metoden godtar bare tokener som består av sifre, høyst ett desimalpunkt og et valgfritt fortegn, og lagrer tokenen sammen med verdien den svarte til i FSourceValue. Egenskapen SourceToken returnerer den lagrede teksten bare så lenge Value fortsatt er lik FSourceValue. Endrer du tallet, fordamper tokenen, så en endret verdi går alltid gjennom den eksisterende formateringsveien og skriver aldri ut foreldet tekst. SaveNumericObject sjekker SourceToken først og skriver den ordrett når den finnes, og faller deretter gjennom til heltalls-, fargeromsreferanse- og brøkgrenene bare for tall som ble opprettet eller redigert i minnet

Invarianten er liten og verdt å si rett ut: et tall du ikke rørte, skrives med bytene det ble lest med, og et tall du rørte, skrives av HotPDF sin egen formatter. Kompakte medlemmer har samme nytte av dette som objekter i filkroppen, siden EnsureCompressedObjectLoaded kjører den samme parseren over medlemsutsnittet. Selve tallformateringen, og dens uavhengighet av prosessens locale, er dekket i artikkelen om locale-uavhengig tallformatering i HotPDF

Å teste en omskrivingsvei mot objektstrømmer

Tre kontroller fanger hver feil beskrevet over, og ingen av dem krever Acrobat. For det første: sammenlign IndexedObjectCount med MaterializedObjectCount etter lagringen; ved en full omskriving må de være like, og ethvert gap er et medlem som ble droppet. For det andre: trekk ut tekst og list opp strukturtreet i begge filene, ikke bare gjengi dem, så en tapt ActualText eller et tapt strukturelement dukker opp som en diff. For det tredje: last utdataene med en fersk instans og påstå at GetLoadedQuarantinedObjStmCount er null, noe som også beviser at skriveren ikke produserte en beholder leseren ikke kan åpne. Kombinasjonene av crypt filter som avgjør FReloadObjectStreamsEncrypted, er lagt ut i artikkelen om StmF-, StrF- og EFF-policyer. Skriversiden av denne historien, hvordan objektstrømmer skrives ut og når en inkrementell oppdatering er å foretrekke fremfor en omskriving, står i guiden til objektstrømmer og inkrementelle oppdateringer

Lat medlemslasting, utvidelsesrunden før skriveren, dekrypteringskarantene og bevaring av kildetoken leveres alle i HotPDF Delphi Component for Delphi og C++Builder. Produktsiden lenker API-referansen hvis du vil spore GetLoadedObjectStreamCacheInfo og karantenetilgangsmetodene mot din egen inntakspipeline