Teknisk artikel

Lata objektströmsmedlemmar och full omskrivning i Delphi

När HotPDF Delphi Component laddar en PDF 1.5-fil med LoadFromFile tolkar den inte objekten som är packade inuti /Type /ObjStm-behållare. Den registrerar var varje komprimerad medlem ligger och tolkar den först när något frågar efter den. Den lata invarianten är vad som håller inläsningstiden proportionell mot vad du faktiskt rör, och den är också skälet till att en full omskrivning måste göra ett extra jobb innan några byte går ut: expandera varje medlem som fortfarande är otolkad, eftersom omskrivningen är på väg att kasta bort behållarna som de medlemmarna lever i

Symptomet som motiverade den här anteckningen är lätt att beskriva och obehagligt att felsöka. Ladda en fil vars typsnitt, färgrymder och strukturträd ligger i objektströmmar, kör den genom generationsparet BeginDoc och EndDoc, och utdatan öppnas utan klagomål. Sidantalet stämmer, texten syns på de sidor du stickprovar. Sedan öppnar en kollega sida 40 och brödtexten renderas i ett ersatt typsnitt, eller kommandot Extract Text returnerar skräp där en ActualText-ersättning brukade finnas. Ingenting kraschade. Skrivaren serialiserade helt enkelt ett objekt som aldrig hade laddats, och ett oladdat objekt serialiseras som ingenting

Vad behåller LoadFromFile egentligen för ett komprimerat objekt?

För varje cross-reference-post av typ 2 behåller LoadFromFile en liten record i FCompactObjects: objektnumret, indexet för den innehållande strömmen i behållartabellen, medlemmens position inuti den strömmen, och en ParsedObject-pekare som börjar som nil. Behållaren själv lokaliseras, dekrypteras om dokumentet är krypterat, och blåses upp, men medlemskropparna lämnas som byte. ISO 32000-1 §7.5.7 definierar behållarlayouten som gör detta möjligt: en header med par av objektnummer och offset, sedan medlemskropparna sammanfogade efter /First, så vilken enskild medlem som helst kan skäras ut utan att röra sina grannar

EnsureCompressedObjectLoaded är den enda vägen som förvandlar en record till ett objekt. Den hittar recorden via objektnummer, och om ParsedObject redan är satt returnerar den det cachade objektet och räknar en cache-träff. Annars laddar den om behållaren om den hade vräkts ut, räknar fram medlemmens byteintervall från offsettabellen, ger parsern en nollkopierande vy av den skivan, och lagrar resultatet tillbaka i recorden. Från och med då är objektet indirekt, bär sitt riktiga objektnummer, och är registrerat i dokumentets objektindex som vilket objekt som helst som parsades från filkroppen. Katalogen, info-ordboken, sidträdets rot och sidobjekten går genom den här vägen vid inläsning eftersom navigeringen behöver dem. Typsnitt, färgrymder, ExtGState-ordböcker och strukturelement gör det inte, och de förblir records tills en sidrendering eller en omskrivning rör dem

Hur HotPDF Delphi Component lagrar en komprimerad medlem innan den tolkas: FCompactObjects-recorden håller objektnummer, behållarindex, medlemsindex och en nil ParsedObject-pekare, medan EnsureCompressedObjectLoaded förvandlar en record till ett registrerat objekt genom cache-träffar, omladdning av behållare, skivning via offsettabellen och nollkopierande parsning
LoadFromFile lämnar /ObjStm-medlemskroppar som byte och tolkar dem först när en läsare frågar, så inläsningstiden följer vad du rör — katalog och sidträd kommer tidigt medan typsnitt, färgrymder och strukturelement förblir records

Du kan se detta utifrån. GetLoadedObjectStreamCacheInfo rapporterar hur många behållare som finns, hur många medlemmar som indexerades, och hur många av dem som har tolkats så här långt:

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 strukturtung fil är det tredje talet en liten bråkdel av det andra direkt efter inläsningen. Den klyftan är hela poängen med lat inläsning, och den är också precis den uppsättning objekt som en full omskrivning måste gå tillbaka efter

Varför tappar en full omskrivning typsnitt som en inkrementell sparning behåller?

En full omskrivning kastar bort källfilens /ObjStm- och /XRef-behållare och serialiserar om objektgrafen från grunden, så varje medlem vars ParsedObject fortfarande är nil har ingen representation kvar i utdatan. En inkrementell uppdatering har aldrig det problemet, eftersom den lägger till nya objekt efter originalbyten och lämnar de gamla behållarna på plats för det tidigare cross-reference-avsnittet att adressera. Skillnaden ligger inte i hur de två lägena behandlar typsnitt. Den ligger i huruvida originalbehållarna överlever för att läsas av nästa viewer

Fixen sitter i SaveToStream, serialiseraren som EndDoc driver vare sig du sätter FileName eller OutputStream. Innan den skickar vidare till någon skrivargren går den genom FCompactObjects och anropar EnsureCompressedObjectLoaded på varje post. Om en medlem inte kan laddas kastar sparningen i stället för att fortsätta, eftersom en omskrivning som tyst tappar en typsnittsordbok är sämre än en som stoppar. Expansionen måste ligga på den nivån, ovanför de klassiska, packade och linjäriserade grenarna, och ovanför den linjäriserade rutens gallring av omladdade strukturella strömmar. En tidigare version expanderade medlemmar bara inuti SaveLoadedDocument, vilket täckte vokabulären för inlästa dokument och missade generationsvokabulären helt. LoadFromFile följt av BeginDoc, sidredigeringar och EndDoc gick rakt till skrivaren med varje orörd medlem fortfarande otolkad

Var HotPDF-expansionen vid full omskrivning sitter: SaveToStream går genom varje FCompactObjects-post via EnsureCompressedObjectLoaded innan den skickar vidare till den klassiska, packade eller linjäriserade skrivaren, så både vokabulären SaveLoadedDocument och vokabulären LoadFromFile plus BeginDoc plus EndDoc serialiserar fullt tolkade objekt i stället för nil-records
En inkrementell uppdatering lägger till efter originalbyten och håller de gamla behållarna läsbara, men en full omskrivning kastar bort dem — en expansionsomgång ovanför varje skrivargren är vad som hindrar ett oladdat typsnitt eller strukturelement från att serialiseras som ingenting
// Båda omskrivningsvokabulärerna expanderar nu kompakta medlemmar innan någon skrivare körs.
// Vägen för inlästa dokument:
Pdf.LoadFromFile('quarterly.pdf');
Pdf.SaveLoadedDocument('quarterly-rewritten.pdf');

// Generationsvägen över en inläst 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 materialiserar varje FCompactObjects-post först

Cachade medlemmar behåller vad du än gjorde med dem. Ett objekt som parsades, redigerades och markerades smutsigt före sparningen returneras från cachen med sina redigeringar, och en medlem du raderade behåller sitt raderingstillstånd över upprepade sparningar. Expansionsomgången är idempotent till sin konstruktion: den fyller bara någonsin nil-platser

Varför missar pixelkontroller på tre sidor fallet med ActualText

Strukturelement är där den här buggen gömmer sig längst. En ActualText-post på en sekvens av markerat innehåll, definierad i ISO 32000-1 §14.9.4, ersätter glyferna för textutdragning och tillgänglighet men påverkar inte renderingen. Om strukturelementet ligger i en objektström och omskrivningen tappar det, ritas sidan fortfarande korrekt, första, mitten- och sista sidan matchar pixel för pixel mot källan, och regressionen visar sig bara när någon kör textutdragning eller en skärmläsare. Ett omskrivningstest som bara renderar sidor är inte ett omskrivningstest för taggad PDF. Diffa den utdragna texten och strukturträdet också

Hur förändrar ett tomt användarlösenord inläsningen?

Ett tomt användarlösenord betyder fortfarande att filen är krypterad, och objektströmmar i en sådan fil är chiffertext tills filnyckeln har återställts. ISO 32000-1 §7.6.3.4 algoritm 2 härleder den nyckeln från lösenordet, posten /O, /P och den första dokumentidentifieraren, och HotPDF måste köra den mot den tomma strängen innan typ-2-omgången kan blåsa upp en enda behållare. Det är därför BeginDoc på ett inläst krypterat dokument anropar DecryptLoadedDocument med ett tomt lösenord före allt annat: objektgrafen måste autentiseras och dekrypteras innan en omskrivning kan börja, oavsett om anroparen tänker skydda utdatan. Utdatakryptering är ett separat beslut, styrt av anroparens skyddsinställningar, och BeginDoc återställer de inställningarna efter dekrypteringsomgången så att en krypterad indata inte tyst blir en krypterad utdata

Behållarpolicyn läses från /Encrypt-ordboken innan något lösenord prövas. För /V 1 och 2 krypteras varje ström med filnyckeln. För crypt filters löser HotPDF upp /StmF genom /CF: ett Identity-filter eller en /CFM på None betyder klartextbehållare, medan V2 och AESV2 betyder krypterade sådana. Svaret landar i FReloadObjectStreamsEncrypted, och det spelar roll för ett specifikt fall. När behållarna är klartext men strängarna inte är det bär medlemmarna krypterade strängar som måste dekrypteras individuellt, så MaterializeMembersOfPlaintextObjectStreams expanderar varje kompakt medlem före dekrypteringsomgången per objekt. Den gör ingenting när policyn ännu inte är känd och ingenting när behållarna själva var krypterade, eftersom medlemmar i en krypterad behållare redan dekrypterades med den och aldrig får dekrypteras två gånger

Vad händer när en behållare inte kan dekrypteras?

En behållare som misslyckas med dekrypteringen sätts i karantän, inte dödligt. Typ-2-omgången registrerar en THPDFObjStmQuarantineInfo-post i FObjStmQuarantine med behållarens objektnummer, en THPDFObjStmQuarantineReason, en diagnostisk sträng, och listan över de medlemsobjektnummer som cross-reference-tabellen hade dirigerat in i den. osqrDecryptFailed reses för fyra olika situationer: inget crypt filter kunde lösas upp, AES-256- eller AES-GCM-dekrypteringen kastade, den äldre RC4- eller AES-128-dekrypteringen kastade, eller ingen användbar filnyckel finns alls. Oberoende behållare fortsätter att läsas in, så ett dokument med en skadad behållare öppnas fortfarande och renderar fortfarande varje sida som inte är beroende av den

Hur HotPDF-dekrypteringskarantänen fungerar på en inläst PDF: en behållare vars dekryptering kastar registreras som en THPDFObjStmQuarantineInfo med orsaken osqrDecryptFailed och sina medlemsobjektnummer, oberoende behållare fortsätter läsas in, och BeginDoc kastar på den första misslyckade posten innan en omskrivning kan rapportera framgång
Karantänposter överlever parserns återfall och BeginDoc kontrollerar dem vid namn i stället för via den krypterade flaggan, så ett dokument med en skadad behållare öppnas fortfarande medan omskrivningsvägen stoppar i stället för att skriva tomma objekt

Karantänlistan överlever parserns återfall. Om den primära inläsningen av cross-referenser misslyckas och HotPDF rekonstruerar objekttabellen genom att skanna filen kan den krypterade flaggan från första försöket inte överleva den rekonstruktionen, men karantänposterna gör det. Det är därför BeginDoc kontrollerar karantänlistan i stället för den krypterade flaggan: på ett inläst dokument går den genom FObjStmQuarantine och kastar på den första osqrDecryptFailed-posten, namnger behållaren och ber om en omladdning med ett giltigt lösenord. En omskrivning som fortsatte förbi den punkten skulle skriva medlemmarna som behållaren var tänkt att hålla som tomma objekt och rapportera framgång. Du kan köra samma kontroll själv, tidigare och med din egen policy, genom de publika åtkomstmetoderna:

var
  Info: THPDFObjStmQuarantineInfo;
  I: Integer;
begin
  Pdf.LoadFromFile('vendor-form.pdf');   // tomt användarlösenord
  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)]);
  // säkert att skriva om härifrån
end;

Andra karantänorsaker täcker de icke-kryptografiska felen: en behållare som inte är en ström, en saknad ordbok, ett ogiltigt /N eller /First, en strömstorlek utanför det accepterade intervallet, ett dekomprimeringsfel, ett /First som pekar förbi datan, eller en medlemskropp som avkodades men inte parsades. De är värda att logga vid intag, eftersom var och en namnger exakt de medlemmar du kommer att sakna nedströms

Varför behöver en omskrivning den ursprungliga numeriska token?

HotPDF lagrar varje numeriskt objekt som en Single, och en Single kan inte återge källtexten för ett reellt tal. ISO 32000-1 §7.3.3 låter en skrivare skicka ut 0.750000, .75 eller 0.75 för samma värde, och ingen av dem överlever en rundtur genom 24 bitars binär representation och en generisk formatterare oförändrad. Värre: ett värde som 0.7 går inte att representera i en Single alls; det tolkas till närmaste flyttal, och att formatera om det flyttalet kan ge 0.69999999 eller en avrundad granne beroende på sifferloopen. På en fyllnadsfärg eller en /CA-transparenskonstant är det en skillnad på ett steg i en 8-bitarskanal, vilket räcker för att falla i en pixeljämförelse mot källan och, vid gradientkanter, räcker för att synas

THPDFNumericObject.RememberSourceToken löser detta för det omodifierade fallet. Parsern anropar den med det råa tokenet direkt efter att ha tilldelat Value; metoden accepterar bara token som består av siffror, högst en decimalpunkt och ett valfritt inledande tecken, och lagrar tokenet tillsammans med värdet det motsvarade i FSourceValue. Egenskapen SourceToken returnerar den lagrade texten bara så länge Value fortfarande är lika med FSourceValue. Ändra talet och tokenet förångas, så ett modifierat värde går alltid genom den befintliga formateringsvägen och skickar aldrig ut inaktuell text. SaveNumericObject kontrollerar SourceToken först och skriver den ordagrant när den finns, och faller sedan igenom till grenarna för heltal, färgrymdsreferens och bråktal bara för tal som skapades eller redigerades i minnet

Invarianten är liten och värd att uttala rakt ut: ett tal du inte rörde skrivs med de byte det lästes med, och ett tal du rörde skrivs av HotPDF:s egen formatterare. Kompakta medlemmar drar nytta av detta på samma sätt som objekt i filkroppen, eftersom EnsureCompressedObjectLoaded kör samma parser över medlemskivan. Talformateringen i sig, och dess oberoende av processens locale, täcks i artikeln om locale-invariant PDF-nummerformatering i HotPDF

Att testa en omskrivningsväg mot objektströmmar

Tre kontroller fångar varje fel som beskrivs ovan, och ingen av dem kräver Acrobat. För det första: jämför IndexedObjectCount mot MaterializedObjectCount efter sparningen; vid en full omskrivning måste de vara lika, och varje klyfta är en medlem som tappades. För det andra: extrahera text och räkna upp strukturträdet i båda filerna, inte bara rendera dem, så att en förlorad ActualText eller ett förlorat strukturelement dyker upp som en diff. För det tredje: ladda utdatan med en färsk instans och hävda att GetLoadedQuarantinedObjStmCount är noll, vilket också bevisar att skrivaren inte producerade en behållare som läsaren inte kan öppna. Crypt filter-kombinationerna som avgör FReloadObjectStreamsEncrypted läggs ut i artikeln om StmF-, StrF- och EFF-policyer. Skrivarsidan av den här historien, hur man skickar ut objektströmmar och när en inkrementell uppdatering är att föredra framför en omskrivning, finns i guiden till objektströmmar och inkrementella uppdateringar

Lat medlemsinläsning, expansionsomgången före skrivaren, dekrypteringskarantän och bevarande av källtokens levereras alla i HotPDF Delphi Component för Delphi och C++Builder. Produktsidan länkar API-referensen om du vill spåra GetLoadedObjectStreamCacheInfo och karantänåtkomstmetoderna mot din egen intagspipeline