Technisch artikel

Lazy objectstream-leden en volledige PDF-herschrijvingen

Wanneer de HotPDF Delphi Component een PDF 1.5-bestand laadt met LoadFromFile, parst hij de objecten die in /Type /ObjStm-containers zitten niet. Hij legt vast waar elk gecomprimeerd lid woont en parst het pas wanneer iets erom vraagt. Die lazy invariant is wat de laadtijd evenredig houdt met wat je werkelijk aanraakt, en het is ook de reden waarom een volledige herschrijving één extra klus moet doen voordat er bytes naar buiten gaan: elk lid uitpakken dat nog niet geparst is, want de herschrijving staat op het punt de containers weg te gooien waarin die leden wonen

Het symptoom dat deze notitie uitlokte is makkelijk te beschrijven en onaangenaam om te debuggen. Laad een bestand waarvan de fonts, kleurruimtes en structure tree in objectstreams zitten, haal het door het generatiepaar BeginDoc en EndDoc, en de output opent zonder klagen. Het aantal pagina's klopt, de tekst is zichtbaar op de pagina's die je steekproefsgewijs bekijkt. Dan opent een collega pagina 40 en rendert de broodtekst in een vervangen font, of geeft de opdracht Extract Text onzin terug waar vroeger een ActualText-vervanging stond. Niets crashte. De writer serialiseerde simpelweg een object dat nooit was geladen, en een niet-geladen object serialiseert als niets

Wat bewaart LoadFromFile nu eigenlijk voor een gecomprimeerd object?

Voor elke type-2 cross-reference-entry bewaart LoadFromFile een klein record in FCompactObjects: het objectnummer, de index van de bevattende stream in de containertabel, de positie van het lid binnen die stream, en een ParsedObject-pointer die op nil begint. De container zelf wordt gelokaliseerd, ontsleuteld als het document versleuteld is, en geïnflateerd, maar de lichamen van de leden blijven bytes. ISO 32000-1 §7.5.7 definieert de containerlay-out die dit mogelijk maakt: een header van objectnummer- en offsetparen, en dan de lichamen van de leden aaneengeschakeld na /First, zodat elk afzonderlijk lid eruit kan worden gesneden zonder zijn buren aan te raken

EnsureCompressedObjectLoaded is het enige pad dat een record in een object verandert. Het zoekt het record op objectnummer, en als ParsedObject al gezet is geeft het dat gecachte object terug en telt het een cachehit. Anders laadt het de container opnieuw als die was weggeduwd, berekent het het bytereeks van het lid uit de offstettabel, geeft het de parser een zero-copy view van dat stuk, en schrijft het resultaat terug in het record. Vanaf dat moment is het object indirect, draagt het zijn echte objectnummer, en is het in de objectindex van het document geregistreerd zoals elk object dat uit de bestandsbody is geparst. De catalog, de info-dictionary, de paginaboomroot en de paginaobjecten gaan bij het laden door dit pad omdat navigatie ze nodig heeft. Fonts, kleurruimtes, ExtGState-dictionaries en structuurelementen niet, en die blijven records tot een paginarendering of een herschrijving ze aanraakt

Hoe de HotPDF Delphi Component een gecomprimeerd lid bewaart voordat het wordt geparst: het FCompactObjects-record houdt het objectnummer, de containerindex, de lidindex en een nil ParsedObject-pointer bij, terwijl EnsureCompressedObjectLoaded een record via cachehits, herladen containers, offset-tabel-slicing en zero-copy parsen in een geregistreerd object verandert
LoadFromFile laat de lichamen van /ObjStm-leden als bytes liggen en parst ze pas wanneer een reader erom vraagt, dus de laadtijd volgt wat je aanraakt — catalog en paginaboom komen vroeg, terwijl fonts, kleurruimtes en structuurelementen records blijven

U kunt dit van buitenaf volgen. GetLoadedObjectStreamCacheInfo meldt hoeveel containers er zijn, hoeveel leden er geïndexeerd zijn, en hoeveel daarvan tot nu toe zijn geparst:

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;

Op een bestand met veel structuur is het derde getal direct na het laden een kleine fractie van het tweede. Dat verschil is het hele punt van lazy loading, en het is ook precies de verzameling objecten waar een volledige herschrijving voor terug moet

Waarom laat een volledige herschrijving fonts vallen die een incrementele save behoudt?

Een volledige herschrijving gooit de /ObjStm- en /XRef-containers van het bronbestand weg en serialiseert de objectgraaf vanaf nul opnieuw, dus elk lid waarvan ParsedObject nog nil is heeft geen representatie meer in de output. Een incrementele update heeft dit probleem nooit, want die hangt nieuwe objecten achter de originele bytes en laat de oude containers staan zodat de vorige cross-referencesectie ze kan adresseren. Het verschil zit niet in hoe de twee modi fonts behandelen. Het zit erin of de originele containers overleven om door de volgende viewer gelezen te worden

De fix zit in SaveToStream, de serializer die EndDoc aanstuurt of u nu FileName of OutputStream zet. Voordat hij naar een writertak dispatcht loopt hij door FCompactObjects en roept hij EnsureCompressedObjectLoaded aan op elke entry. Als een lid niet geladen kan worden, gooit de save een fout in plaats van door te gaan, want een herschrijving die stilzwijgend een fontdictionary laat vallen is erger dan een die stopt. De expansie moet op dat niveau zitten, boven de klassieke, packed en linearized takken, en boven het snoeien van herladen structurele streams in de linearized route. Een eerdere versie pakte leden alleen binnen SaveLoadedDocument uit, wat het vocabulaire van geladen documenten dekte en het generatievocabulaire volledig miste. LoadFromFile gevolgd door BeginDoc, paginabewerkingen en EndDoc ging rechtstreeks naar de writer met elk onaangeroerd lid nog ongeparst

Waar de volledige-herschrijvingsexpansie van HotPDF zit: SaveToStream loopt elke FCompactObjects-entry door EnsureCompressedObjectLoaded voordat hij naar de klassieke, packed of linearized writer dispatcht, zodat zowel het SaveLoadedDocument-vocabulaire als het vocabulaire LoadFromFile plus BeginDoc plus EndDoc volledig geparste objecten serialiseert in plaats van nil-records
Een incrementele update hangt achter de originele bytes aan en houdt de oude containers leesbaar, maar een volledige herschrijving gooit ze weg — één expansieronde boven elke writertak is wat voorkomt dat een niet-geladen font of structuurelement als niets serialiseert
// Beide herschrijfvocabulaires pakken nu compacte leden uit voordat er een writer loopt.
// Pad voor geladen documenten:
Pdf.LoadFromFile('quarterly.pdf');
Pdf.SaveLoadedDocument('quarterly-rewritten.pdf');

// Generatiepad over een geladen bestand:
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 materialiseert eerst elke FCompactObjects-entry

Gecachte leden houden wat je ermee deed. Een object dat vóór de save is geparst, bewerkt en dirty gemarkeerd komt met zijn bewerkingen uit de cache, en een lid dat u verwijderde houdt zijn verwijderde status over herhaalde saves heen. De expansieronde is idempotent per constructie: hij vult alleen ooit nil-slots

Waarom pixelchecks op drie pagina's het ActualText-geval missen

Structuurelementen zijn waar deze bug het langst verborgen blijft. Een ActualText-entry op een marked-content-sequentie, gedefinieerd in ISO 32000-1 §14.9.4, vervangt de glyphs voor extractie en toegankelijkheid maar raakt de rendering niet. Woont het structuurelement in een objectstream en verliest de herschrijving het, dan tekent de pagina nog steeds correct, vergelijken de eerste, middelste en laatste pagina pixel voor pixel met de bron, en komt de regressie pas boven water wanneer iemand tekstextractie of een screenreader draait. Een herschrijftest die alleen pagina's rendert is geen herschrijftest voor getagde PDF. Diff ook de geëxtraheerde tekst en de structure tree

Hoe verandert een leeg gebruikerswachtwoord het laden?

Een leeg gebruikerswachtwoord betekent nog steeds dat het bestand versleuteld is, en objectstreams in zo'n bestand zijn ciphertext tot de filesleutel is hersteld. ISO 32000-1 §7.6.3.4 Algoritme 2 leidt die sleutel af uit het wachtwoord, de /O-entry, /P en de eerste documentidentifier, en HotPDF moet dat algoritme op de lege string draaien voordat de type-2-ronde ook maar één container kan inflaten. Daarom roept BeginDoc op een geladen versleuteld document DecryptLoadedDocument aan met een leeg wachtwoord, vóór al het andere: de objectgraaf moet geauthenticeerd en ontsleuteld zijn voordat een herschrijving kan beginnen, ongeacht of de aanroeper de output wil beschermen. Outputversleuteling is een aparte beslissing, gestuurd door de beschermingsinstellingen van de aanroeper, en BeginDoc herstelt die instellingen na de decryptronde zodat een versleutelde input niet stilzwijgend een versleutelde output wordt

Het containerbeleid wordt uit de /Encrypt-dictionary gelezen voordat er een wachtwoord wordt geprobeerd. Voor /V 1 en 2 wordt elke stream met de filesleutel versleuteld. Bij crypt filters lost HotPDF /StmF op via /CF: een Identity-filter of een /CFM van None betekent plaintext containers, terwijl V2 en AESV2 versleutelde betekenen. Het antwoord landt in FReloadObjectStreamsEncrypted, en dat is om één specifiek geval belangrijk. Wanneer containers plaintext zijn maar strings niet, dragen de leden versleutelde strings die afzonderlijk ontsleuteld moeten worden, dus pakt MaterializeMembersOfPlaintextObjectStreams elk compact lid uit vóór de ontsleutelronde per object. Het doet niets wanneer het beleid nog niet bekend is en niets wanneer de containers zelf versleuteld waren, want leden van een versleutelde container zijn al met die container ontsleuteld en mogen nooit twee keer worden ontsleuteld

Wat gebeurt er wanneer een container niet ontsleuteld kan worden?

Een container waarvan het ontsleutelen mislukt gaat in quarantaine, en dat is niet fataal. De type-2-ronde legt een THPDFObjStmQuarantineInfo-entry vast in FObjStmQuarantine met het objectnummer van de container, een THPDFObjStmQuarantineReason, een diagnostische string, en de lijst met objectnummers van leden die de cross-reference erheen had gerouteerd. osqrDecryptFailed wordt gemeld voor vier verschillende situaties: er kon geen cryptfilter worden opgelost, het ontsleutelen met AES-256 of AES-GCM gooide een fout, het ontsleutelen met de oude RC4 of AES-128 gooide een fout, of er bestaat helemaal geen bruikbare filesleutel. Onafhankelijke containers blijven laden, dus een document met één beschadigde container opent nog steeds en rendert nog steeds elke pagina die er niet van afhangt

Hoe de decrypt-quarantaine van HotPDF werkt op een geladen PDF: een container waarvan het ontsleutelen een fout gooit wordt vastgelegd als THPDFObjStmQuarantineInfo met de reden osqrDecryptFailed en zijn objectnummers van leden, onafhankelijke containers blijven laden, en BeginDoc gooit een fout op de eerste mislukte entry voordat een herschrijving succes kan melden
Quarantainerecords overleven de parserfallback en BeginDoc controleert ze bij naam in plaats van via de encrypted-flag, dus een document met één beschadigde container opent nog steeds terwijl het herschrijfpad stopt in plaats van lege objecten te schrijven

De quarantainelijst overleeft de parserfallback. Als het primaire cross-reference laden mislukt en HotPDF de objecttabel reconstrueert door het bestand te scannen, overleeft de encrypted-flag van de eerste poging die reconstructie misschien niet, maar de quarantainerecords wel. Daarom controleert BeginDoc de quarantainelijst in plaats van de encrypted-flag: op een geladen document loopt hij door FObjStmQuarantine en gooit een fout op de eerste osqrDecryptFailed-entry, met vermelding van de container en het verzoek om te herladen met een geldig wachtwoord. Een herschrijving die voorbij dat punt door zou gaan, zou de leden die de container hoorde te bevatten als lege objecten wegschrijven en succes melden. U kunt dezelfde check zelf eerder doen en met uw eigen beleid, via de publieke accessors:

var
  Info: THPDFObjStmQuarantineInfo;
  I: Integer;
begin
  Pdf.LoadFromFile('vendor-form.pdf');   // leeg gebruikerswachtwoord
  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)]);
  // vanaf hier veilig om te herschrijven
end;

Andere quarantaineredenen dekken de niet-cryptografische fouten: een container die geen stream is, een ontbrekende dictionary, een ongeldige /N of /First, een streamgrootte buiten het geaccepteerde bereik, een decompressiefout, een /First die voorbij de data wijst, of een lid waarvan het lichaam wel decodeerde maar niet parste. Die zijn het loggen waard bij de ingest, want elk ervan noemt de exacte leden die u stroomafwaarts zult missen

Waarom heeft een herschrijving het originele numerieke token nodig?

HotPDF bewaart elk numeriek object als een Single, en een Single kan de brontekst van een reëel getal niet reproduceren. ISO 32000-1 §7.3.3 staat een writer toe 0.750000, .75 of 0.75 voor dezelfde waarde uit te schrijven, en geen daarvan overleeft een roundtrip door 24-bits binair en een generieke formatter onveranderd. Erger nog, een waarde als 0.7 is helemaal niet representeerbaar in een Single; hij parst naar de dichtstbijzijnde float, en die float opnieuw formatteren kan 0.69999999 opleveren of een afgeronde buur, afhankelijk van de cijferlus. Op een vulkleur of een /CA-transparantieconstante is dat een verschil van één stap in een 8-bits kanaal, wat genoeg is om een pixelvergelijking met de bron te laten zakken en, op gradiëntgrenzen, genoeg om het te zien

THPDFNumericObject.RememberSourceToken lost dit op voor het ongewijzigde geval. De parser roept het aan met het ruwe token direct nadat Value is toegewezen; de methode accepteert alleen tokens van cijfers, hoogstens één decimale punt en een optioneel voorloopteken, en bewaart het token samen met de waarde waarmee het overeenkwam in FSourceValue. De property SourceToken geeft de bewaarde tekst alleen terug zolang Value nog gelijk is aan FSourceValue. Verander het getal en het token verdampt, dus een gewijzigde waarde gaat altijd door het bestaande formatteringspad en schrijft nooit verouderde tekst uit. SaveNumericObject controleert eerst SourceToken en schrijft die woordelijk uit wanneer hij aanwezig is, en valt daarna alleen voor getallen die in geheugen zijn aangemaakt of bewerkt door naar de integer-, kleurruimteverwijzing- en fractionele takken

De invariant is klein en verdient het ronduit te worden gezegd: een getal dat u niet hebt aangeraakt wordt geschreven met de bytes waarmee het is gelezen, en een getal dat u wel hebt aangeraakt wordt geschreven door de eigen formatter van HotPDF. Compacte leden profiteren hiervan net als bodyobjecten, want EnsureCompressedObjectLoaded draait dezelfde parser over het stuk van het lid. De getalformattering zelf, en haar onafhankelijkheid van de proceslocale, staat in het artikel over locale-invariante PDF-getalformattering in HotPDF

Een herschrijfpad testen tegen objectstreams

Drie checks vangen elke hierboven beschreven fout, en geen ervan heeft Acrobat nodig. Ten eerste: vergelijk IndexedObjectCount met MaterializedObjectCount na de save; bij een volledige herschrijving moeten ze gelijk zijn, en elk verschil is een lid dat is laten vallen. Ten tweede: extraheer tekst en doorloop de structure tree van beide bestanden, en rendere ze niet alleen, zodat een verloren ActualText of een verloren structuurelement als diff opduikt. Ten derde: laad de output met een verse instantie en stel vast dat GetLoadedQuarantinedObjStmCount nul is, wat ook bewijst dat de writer geen container heeft geproduceerd die de reader niet kan openen. De cryptfiltercombinaties die FReloadObjectStreamsEncrypted bepalen staan in het artikel over het StmF-, StrF- en EFF-beleid. De writerkant van dit verhaal, hoe je objectstreams uitschrijft en wanneer je een incrementele update boven een herschrijving verkiest, staat in de gids over object streams en incrementele updates

Lazy laden van leden, de expansieronde vóór de writer, decrypt-quarantaine en het bewaren van brontekst-tokens zitten allemaal in de HotPDF Delphi Component voor Delphi en C++Builder. De productpagina linkt de API-referentie als u GetLoadedObjectStreamCacheInfo en de quarantaine-accessors tegen uw eigen ingest-pipeline wilt nalopen