Technický článek

Líné členy object streamů a plné přepisy PDF v Delphi

Když HotPDF Delphi Component načítá PDF 1.5 soubor přes LoadFromFile, neparssuje objekty zabalené v kontejnerech /Type /ObjStm. Zaznamená, kde každý komprimovaný člen bydlí, a parsuje ho, jen když se někdo zeptá. Ta líná invarianta je to, co drží čas načtení úměrný tomu, čeho se skutečně dotknete, a je to taky důvod, proč musí plný přepis udělat jednu extra práci, než vyjdou jakékoli bajty: rozbalit každý člen, který je pořád neparsovaný, protože přepis se chystá zahodit kontejnery, ve kterých ti členové bydlí

Symptom, který k téhle poznámce vedl, se snadno popíše a nepříjemně debuguje. Načtěte soubor, jehož fonty, barevné prostory a strom struktur sedí v object streamech, protáhněte ho párem generování BeginDoc a EndDoc a výstup se otevře bez stížnosti. Počet stránek sedí, text je viditelný na stránkách, které zkontrolujete zběžně. Pak kolega otevře stránku 40 a text těla se vykreslí v náhradním fontu, nebo příkaz Extract Text vrátí smetí tam, kde dřív byla náhrada ActualText. Nic nepadlo. Writer prostě zserializoval objekt, který nebyl nikdy načtený, a nenačtený objekt se serializuje jako nic

Co LoadFromFile doopravdy drží pro komprimovaný objekt?

Pro každou cross-reference položku typu 2 si LoadFromFile drží malý záznam v FCompactObjects: číslo objektu, index obsahujícího streamu v tabulce kontejnerů, pozici člena uvnitř toho streamu a pointer ParsedObject, který startuje jako nil. Kontejner sám se najde, dešifruje, pokud je dokument šifrovaný, a rozbalí, ale těla členů se nechávají jako bajty. ISO 32000-1 §7.5.7 definuje rozložení kontejneru, které to umožňuje: hlavička párů číslo-objektu a offset, pak těla členů spojená za /First, takže jakýkoli jednotlivý člen se dá vykrájet, aniž by se dotkl jeho sousedů

EnsureCompressedObjectLoaded je jediná cesta, která mění záznam na objekt. Najde záznam podle čísla objektu a pokud je ParsedObject už nastavené, vrátí ten cachovaný objekt a počítá cache hit. Jinak znovu načte kontejner, pokud byl vyhozen, spočítá bajtový rozsah člena z offset tabulky, podá parseru zero-copy pohled na ten výsek a uloží výsledek zpátky do záznamu. Od té chvíle je objekt nepřímý, nese své reálné číslo objektu a je zaregistrovaný v indexu objektů dokumentu jako jakýkoli objekt parsovaný z těla souboru. Catalog, info slovník, kořen stromu stránek a objekty stránek jdou touto cestou v momentě načtení, protože je navigace potřebuje. Fonty, barevné prostory, slovníky ExtGState a strukturní elementy ne a zůstávají jako záznamy, dokud se jich nedotkne vykreslení stránky nebo přepis

Jak HotPDF Delphi Component ukládá komprimovaný člen, než se parsuje: záznam FCompactObjects drží číslo objektu, index kontejneru, index člena a nil pointer ParsedObject, zatímco EnsureCompressedObjectLoaded mění záznam na zaregistrovaný objekt přes cache hity, reloady kontejnerů, krájení z offset tabulky a zero-copy parsování
LoadFromFile nechává těla členů /ObjStm jako bajty a parsuje je, jen když se reader zeptá, takže čas načtení sleduje, čeho se dotknete — catalog a strom stránek přijdou brzy, zatímco fonty, barevné prostory a strukturní elementy zůstávají jako záznamy

Tohle můžete sledovat zvenčí. GetLoadedObjectStreamCacheInfo hlásí, kolik kontejnerů existuje, kolik členů bylo zaindexováno a kolik z nich bylo doposud parsováno:

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;

Na strukturově těžkém souboru je třetí číslo malý zlomek druhého těsně po načtení. Ta mezera je celý smysl lazy loadingu a je to taky přesně ta sada objektů, o které se plný přepis musí vrátit

Proč plný přepis zahodí fonty, které inkrementální uložení drží?

Plný přepis zahodí kontejnery /ObjStm a /XRef zdrojového souboru a znovu serializuje graf objektů od nuly, takže jakýkoli člen, jehož ParsedObject je pořád nil, nemá ve výstupu žádnou reprezentaci. Inkrementální update tenhle problém nikdy nemá, protože přidává nové objekty za původními bajty a nechává staré kontejnery na místě pro předchozí cross-reference sekci, která na ně adresuje. Rozdíl není v tom, jak ty dva módy zacházejí s fonty. Je v tom, zda původní kontejnery přežijí, aby je mohl číst další prohlížeč

Oprava bydlí v SaveToStream, serializéru, který řídí EndDoc bez ohledu na to, zda nastavíte FileName nebo OutputStream. Než se rozešle do kteréhokoli writeru, projde FCompactObjects a zavolá EnsureCompressedObjectLoaded na každou položku. Pokud se člen nedá načíst, uložení hodí výjimku místo pokračování, protože přepis, který potichu zahodí slovník fontu, je horší než takový, který se zastaví. Rozbalení musí sedět na té úrovni, nad klasickou, packed i linearized větví, a nad prořezáváním reloadovaných strukturních streamů, které dělá linearized cesta. Dřívější verze rozbalovala členy jen uvnitř SaveLoadedDocument, což pokrylo slovník načteného dokumentu a zcela minulo slovník generování. LoadFromFile následované BeginDoc, editami stránek a EndDoc šlo rovnou k writeru s každým nedotčeným členem pořád neparsovaným

Kde sedí rozbalení při plném přepisu HotPDF: SaveToStream protáhne každou položku FCompactObjects přes EnsureCompressedObjectLoaded, než se rozešle klasickému, packed nebo linearized writeru, takže jak slovník SaveLoadedDocument, tak slovník LoadFromFile plus BeginDoc plus EndDoc serializují plně parsované objekty místo nil záznamů
Inkrementální update přidává za původní bajty a drží staré kontejnery čitelné, ale plný přepis je zahodí — jeden rozbalovací průchod nad každou writer větví je to, co brání nenačtenému fontu nebo strukturnímu elementu serializovat se jako nic
// Oba slovníky přepisu teď rozbalují kompaktní členy, než běží kterýkoli writer.
// Cesta načteného dokumentu:
Pdf.LoadFromFile('quarterly.pdf');
Pdf.SaveLoadedDocument('quarterly-rewritten.pdf');

// Cesta generování přes načtený soubor:
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 nejdřív materializuje každou položku FCompactObjects

Cachovaní členové drží cokoli, co jste jim udělali. Objekt, který byl parsovaný, editovaný a označený dirty před uložením, se vrací z cache se svými editami a člen, který jste smazali, drží svůj stav smazání napříč opakovanými uloženími. Rozbalovací průchod je idempotentní konstrukcí: plní jen sloty nil

Proč pixelové kontroly na třech stránkách minou případ ActualText

Strukturní elementy jsou místo, kde se tenhle bug schovává nejdéle. Položka ActualText na marked-content sekvenci, definovaná v ISO 32000-1 §14.9.4, vyměňuje glyfy pro extrakci a accessibility, ale neovlivňuje vykreslení. Pokud strukturní element bydlí v object streamu a přepis ho ztratí, stránka se pořád kreslí správně, první, prostřední a poslední stránka se srovná pixel za pixel proti zdroji a regrese se ukáže, jen když někdo pustí text extrakci nebo screen reader. Přepisový test, který jen vykresluje stránky, není přepisový test pro tagované PDF. Diffujte k tomu extrahovaný text i strom struktur

Jak prázdné uživatelské heslo mění načtení?

Prázdné uživatelské heslo pořád znamená, že soubor je šifrovaný, a object streamy v takovém souboru jsou ciphertext, dokud se neobnoví klíč souboru. ISO 32000-1 §7.6.3.4 algoritmus 2 odvozuje ten klíč z hesla, položky /O, /P a prvního identifikátoru dokumentu a HotPDF ho musí pustit proti prázdnému řetězci, než může průchod typu 2 rozbalit jediný kontejner. Proto BeginDoc na načteném šifrovaném dokumentu volá DecryptLoadedDocument s prázdným heslem před čímkoli jiným: graf objektů musí být autentizovaný a dešifrovaný, než může přepis začít, bez ohledu na to, zda volající chce výstup chránit. Šifrování výstupu je oddělené rozhodnutí řízené ochrannými nastaveními volajícího a BeginDoc ta nastavení po dešifrovacím průchodu obnoví, takže šifrovaný vstup se potichu nezmění v šifrovaný výstup

Politika kontejnerů se čte ze slovníku /Encrypt, než se zkusí jakékoli heslo. Pro /V 1 a 2 je každý stream šifrovaný klíčem souboru. U crypt filtrů rozřešuje HotPDF /StmF přes /CF: filtr Identity nebo /CFM None znamená plaintextové kontejnery, zatímco V2 a AESV2 šifrované. Odpověď přistane v FReloadObjectStreamsEncrypted a na jednom konkrétním případě záleží. Když jsou kontejnery plaintextové, ale řetězce ne, nesou členové šifrované řetězce, které se musí dešifrovat jednotlivě, takže MaterializeMembersOfPlaintextObjectStreams rozbalí každý kompaktní člen před dešifrovacím průchodem po objektech. Nedělá nic, když politika ještě není známá, a nic, když samotné kontejnery byly šifrované, protože členové šifrovaného kontejneru byli už dešifrovaní s ním a nesmí se nikdy dešifrovat dvakrát

Co se stane, když se kontejner nedá dešifrovat?

Kontejner, který selže v dešifrování, jde do karantény, není fatální. Průchod typu 2 zaznamená položku THPDFObjStmQuarantineInfo v FObjStmQuarantine s číslem objektu kontejneru, THPDFObjStmQuarantineReason, diagnostickým řetězcem a seznamem čísel objektů členů, které na něj cross-reference směrovala. osqrDecryptFailed se hodí pro čtyři odlišné situace: nešlo rozřešit žádný crypt filtr, dešifrování AES-256 nebo AES-GCM hodilo, dešifrování legacy RC4 nebo AES-128 hodilo, nebo žádný použitelný klíč souboru neexistuje. Nezávislé kontejnery se načítají dál, takže dokument s jedním poškozeným kontejnerem se pořád otevře a pořád vykreslí každou stránku, která na něm nezávisí

Jak decrypt karanténa HotPDF funguje na načteném PDF: kontejner, jehož dešifrování hodí, se zaznamená jako THPDFObjStmQuarantineInfo s důvodem osqrDecryptFailed a čísly objektů svých členů, nezávislé kontejnery se načítají dál a BeginDoc hodí na první selhavé položce, než může přepis nahlásit úspěch
Karanténní záznamy přežijí parser fallback a BeginDoc je kontroluje podle jména místo podle příznaku šifrování, takže dokument s jedním poškozeným kontejnerem se pořád otevře, zatímco cesta přepisu se zastaví místo psaní prázdných objektů

Karanténní listina přežije parser fallback. Pokud selže primární načtení cross-referencí a HotPDF rekonstruuje tabulku objektů skenováním souboru, příznak šifrování z prvního pokusu tu rekonstrukci nemusí přežít, ale karanténní záznamy přežijí. Proto BeginDoc kontroluje karanténní listinu místo příznaku šifrování: na načteném dokumentu projde FObjStmQuarantine a hodí na první položce osqrDecryptFailed, jmenuje kontejner a žádá reload s platným heslem. Přepis, který by pokračoval za tenhle bod, by zapsal členy, které kontejner měl držet, jako prázdné objekty a nahlásil úspěch. Stejnou kontrolu můžete pustit sami, dřív a s vlastní politikou, přes veřejné accessory:

var
  Info: THPDFObjStmQuarantineInfo;
  I: Integer;
begin
  Pdf.LoadFromFile('vendor-form.pdf');   // prázdné uživatelské heslo
  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)]);
  // odsud je bezpečné přepisovat
end;

Ostatní karanténní důvody pokrývají nekryptografická selhání: kontejner, který není stream, chybějící slovník, neplatné /N nebo /First, velikost streamu mimo akceptovaný rozsah, selhání dekomprese, /First mířící za data nebo tělo člena, které se dekódovalo, ale neparsovalo. Ta stojí za logování při ingestu, protože každé jmenuje přesně ty členy, které vám downstream budou chybět

Proč přepis potřebuje originální numerický token?

HotPDF ukládá každý numerický objekt jako Single a Single nedokáže reprodukovat zdrojový text reálného čísla. ISO 32000-1 §7.3.3 dovoluje writeru emitovat 0.750000, .75 nebo 0.75 pro tutéž hodnotu a žádná z těch podob nepřežije round trip přes 24bitovou binárku a generický formatter beze změny. Horší je, že hodnota jako 0.7 není v Single reprezentovatelná vůbec; parsuje se na nejbližší float a přeformátování toho floatu může produkovat 0.69999999 nebo zaokrouhleného souseda podle digit smyčky. U barvy výplně nebo konstanty průhlednosti /CA je to rozdíl o jedničku v 8bitovém kanálu, což stačí na propadnutí pixelového srovnání proti zdroji a na hranicích gradientů stačí na to, abyste to viděli

THPDFNumericObject.RememberSourceToken řeší tohle pro needitovaný případ. Parser ho volá se surovým tokenem hned po přiřazení Value; metoda akceptuje jen tokeny složené z číslic, nejvýše jedné desetinné tečky a volitelného úvodního znaménka a ukládá token spolu s hodnotou, které odpovídal, do FSourceValue. Property SourceToken vrací uložený text jen tehdy, dokud Value stále rovná FSourceValue. Změňte číslo a token se vypaří, takže změněná hodnota vždycky jde stávající formátovací cestou a nikdy neemituje zaostalý text. SaveNumericObject kontroluje nejdřív SourceToken a zapisuje ho slovo od slova, když je přítomný, pak propadá k větvím celého čísla, reference barevného prostoru a zlomků jen pro čísla, která byla vytvořená nebo editovaná v paměti

Invarianta je malá a stojí za výslovnou řeč: číslo, kterého se nedotknete, se zapíše bajty, se kterými se četlo, a číslo, kterého se dotknete, píše HotPDF vlastní formatter. Kompaktní členové z toho těží stejně jako body objekty, protože EnsureCompressedObjectLoaded pouští nad výsekem člena téhož parseru. Samotné formátování čísel a jeho nezávislost na locale procesu rozebírá článek o locale-invariantním formátování PDF čísel v HotPDF

Testování přepisové cesty proti object streamům

Tři kontroly chytí každé selhání popsané výše a žádná nepotřebuje Acrobat. Za prvé, srovnejte IndexedObjectCount s MaterializedObjectCount po uložení; u plného přepisu se musí rovnat a jakákoli mezera je člen, který byl zahozen. Za druhé, extrahujte text a enumerujte strom struktur na obou souborech, ne jen vykreslete, takže ztracené ActualText nebo ztracený strukturní element se ukáže jako diff. Za třetí, načtěte výstup čerstvou instancí a assertujte, že GetLoadedQuarantinedObjStmCount je nula, což taky dokazuje, že writer neprodukoval kontejner, který reader nedokáže otevřít. Kombinace crypt filtrů, které rozhodují o FReloadObjectStreamsEncrypted, jsou rozložené v článku o politikách StmF, StrF a EFF. Writer strana příběhu, jak emitovat object streamy a kdy dát přednost inkrementálnímu updatu před přepisem, je v průvodci object streamy a inkrementálními updaty

Líné načítání členů, rozbalovací průchod před writerem, decrypt karanténa i uchovávání source tokenu všechny dodává HotPDF Delphi Component pro Delphi a C++Builder. Produktová stránka linkuje API referenci, pokud chcete trasovat GetLoadedObjectStreamCacheInfo a karanténní accessory proti vlastní ingest pipeline