Tekninen artikkeli

ObjStm-jäsenten laiska lataus ja PDF-täysuudelleenkirjoitus

Kun HotPDF Delphi Component lataa PDF 1.5 -tiedoston LoadFromFile-kutsulla, se ei jäsennä /Type /ObjStm -säiliöiden sisään pakattuja objekteja. Se kirjaa ylös, missä kukin pakattu jäsen elää, ja jäsentää sen vasta kun jokin sitä pyytää. Juuri tuo laiska invariantti pitää latausajan verrannollisena siihen, mitä oikeasti kosketat, ja se on myös syy siihen, miksi täysuudelleenkirjoituksella on yksi ylimääräinen tehtävä ennen kuin tavuja lähtee ulos: laajenna jokainen yhä jäsentämätön jäsen, koska uudelleenkirjoitus on juuri heittämässä pois ne säiliöt, joissa ne jäsenet elävät

Oire, joka tämän muistiinpanon laukaisi, on helppo kuvata ja epämiellyttävä debugata. Lataa tiedosto, jonka fontit, väriavaruudet ja jäsennyspuu istuvat objektistreameissa, aja se BeginDoc- ja EndDoc -generointiparin läpi, niin tuloste avautuu valittamatta. Sivumäärä on oikea, teksti näkyy sivuilla joita pistokoemaisesti katsot. Sitten kollega avaa sivun 40 ja leipäteksti renderöityy korvatulla fontilla, tai Extract Text -komento palauttaa roskaa siinä missä ennen oli ActualText-korvaus. Mikään ei kaatunut. Kirjoittaja vain serialisoi objektin, jota ei koskaan ladattu, ja lataamaton objekti serialisoituu tyhjänä

Mitä LoadFromFile oikeastaan tallettaa pakatusta objektista?

Jokaiselle tyypin 2 ristiviitemerkinnälle LoadFromFile pitää pientä tietuetta FCompactObjects-kokoelmassa: objektinumero, säiliön sisältävän streamin indeksi säiliötaulukossa, jäsenen sijainti tuon streamin sisällä sekä ParsedObject-osoitin, joka alkaa nil-arvosta. Itse säiliö paikannetaan, puretaan salauksesta jos asiakirja on salattu, ja inflatoidaan, mutta jäsenten rungot jätetään tavuiksi. ISO 32000-1 §7.5.7 määrittelee säiliön asettelun, joka tekee tämän mahdolliseksi: otsikko, jossa on objektinumeron ja siirtymän pareja, ja sitten jäsenten rungot peräkkäin /First-arvon jälkeen, joten mikä tahansa yksittäinen jäsen voidaan leikata irti naapureihinsa koskematta

EnsureCompressedObjectLoaded on ainoa polku, joka muuttaa tietueen objektiksi. Se etsii tietueen objektinumeron perusteella, ja jos ParsedObject on jo asetettu, se palauttaa tuon välimuistissa olevan objektin ja laskee välimuistiosuman. Muussa tapauksessa se lataa säiliön uudelleen, jos se on häädetty, laskee jäsenen tavualueen siirtymätaulukosta, antaa jäsentimelle nollakopioivan näkymän tuosta siivusta ja tallentaa tuloksen takaisin tietueeseen. Siitä eteenpäin objekti on epäsuora, kantaa oikeaa objektinumeroaan ja on rekisteröity asiakirjan objekti-indeksiin kuin mikä tahansa tiedoston rungosta jäsennetty objekti. Luettelo, tietosanakirja, sivupuun juuri ja sivuobjektit kulkevat tämän polun läpi latausvaiheessa, koska navigointi tarvitsee niitä. Fontit, väriavaruudet, ExtGState-sanakirjat ja rakennealkiot eivät, ja ne pysyvät tietueina kunnes sivun renderöinti tai uudelleenkirjoitus koskee niihin

Miten HotPDF Delphi Component tallettaa pakatun jäsenen ennen sen jäsentämistä: FCompactObjects-tietue pitää objektinumeron, säiliöindeksin, jäsenindeksin ja nil-arvoisen ParsedObject-osoittimen, kun taas EnsureCompressedObjectLoaded muuttaa tietueen rekisteröidyksi objektiksi välimuistiosumien, säiliön uudelleenlatausten, siirtymätaulukon siivutuksen ja nollakopioivan jäsennyksen kautta
LoadFromFile jättää /ObjStm-jäsenten rungot tavuiksi ja jäsentää ne vasta kun lukija pyytää, joten latausaika seuraa sitä mitä kosketat — luettelo ja sivupuu saapuvat aikaisin, kun taas fontit, väriavaruudet ja rakennealkiot pysyvät tietueina

Tätä voi seurata ulkopuolelta. GetLoadedObjectStreamCacheInfo kertoo, kuinka monta säiliötä on olemassa, kuinka monta jäsentä indeksoitiin ja kuinka moni niistä on toistaiseksi jäsennetty:

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;

Rakennepainotteisella tiedostolla kolmas luku on pieni murto-osa toisesta heti latauksen jälkeen. Tuo ero on koko laiskan latauksen pointti, ja se on myös täsmälleen se objektijoukko, jonka perään täysuudelleenkirjoituksen on palattava

Miksi täysuudelleenkirjoitus pudottaa fontit, jotka inkrementaalinen tallennus säilyttää?

Täysuudelleenkirjoitus hylkää lähdetiedoston /ObjStm- ja /XRef-säiliöt ja serialisoi objektigraafin alusta asti, joten millään jäsenellä, jonka ParsedObject on yhä nil, ei ole enää esitystä tulosteessa. Inkrementaalisessa päivityksessä tätä ongelmaa ei koskaan ole, koska se liittää uudet objektit alkuperäisten tavujen perään ja jättää vanhat säiliöt paikalleen edellisen ristiviiteosion osoitettaviksi. Ero ei ole siinä, miten nämä kaksi tilaa kohtelevat fontteja. Se on siinä, jäävätkö alkuperäiset säiliöt henkiin seuraavan katselimen luettaviksi

Korjaus asuu SaveToStream-metodissa, siinä serialisoijassa jota EndDoc ajaa riippumatta siitä, asetatko FileName- vai OutputStream-kentän. Ennen kuin se hajauttaa mihinkään kirjoittajahaaraan, se käy läpi FCompactObjects-kokoelman ja kutsuu EnsureCompressedObjectLoaded-metodia jokaiselle merkinnälle. Jos jäsentä ei voi ladata, tallennus nostaa virheen sen sijaan että jatkaisi, koska uudelleenkirjoitus, joka pudottaa fonttisanakirjan hiljaa, on huonompi kuin sellainen joka pysähtyy. Laajennuksen on sijaittava tuolla tasolla, klassisen, pakatun ja linearisoidun haaran yläpuolella sekä linearisoidun reitin uudelleenladattujen rakennevirtojen karsinnan yläpuolella. Aiempi versio laajensi jäsenet vain SaveLoadedDocument-metodin sisällä, mikä kattoi ladatun asiakirjan sanaston ja ohitti generointisanaston kokonaan. LoadFromFile ja sen perään BeginDoc, sivumuokkaukset ja EndDoc menivät suoraan kirjoittajalle jokaisen koskemattoman jäsenen ollessa yhä jäsentämätön

Missä HotPDF:n täysuudelleenkirjoituksen laajennus sijaitsee: SaveToStream käy jokaisen FCompactObjects-merkinnän läpi EnsureCompressedObjectLoaded-kutsulla ennen hajauttamista klassiseen, pakattuun tai linearisoituun kirjoittajaan, joten sekä SaveLoadedDocument-sanasto että LoadFromFile- ja BeginDoc- ja EndDoc-sanasto serialisoivat täysin jäsennettyjä objekteja nil-tietueiden sijaan
Inkrementaalinen päivitys liittää perään alkuperäisten tavujen jälkeen ja pitää vanhat säiliöt luettavina, mutta täysuudelleenkirjoitus hylkää ne — yksi laajennuskierros jokaisen kirjoittajahaaran yläpuolella estää lataamattoman fontin tai rakennealkion serialisoitumisen tyhjänä
// Molemmat uudelleenkirjoitussanastot laajentavat nyt pakatut jäsenet ennen kuin yksikään kirjoittaja ajaa.
// Ladatun asiakirjan polku:
Pdf.LoadFromFile('quarterly.pdf');
Pdf.SaveLoadedDocument('quarterly-rewritten.pdf');

// Generointipolku ladatun tiedoston yli:
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 materialisoi ensin jokaisen FCompactObjects-merkinnän

Välimuistissa olevat jäsenet pitävät sen, mitä niille teit. Objekti, joka jäsennettiin, muokattiin ja merkittiin likaiseksi ennen tallennusta, palautetaan välimuistista muokkauksineen, ja poistamasi jäsen pitää poistotilansa toistuvien tallennusten yli. Laajennuskierros on rakenteellisesti idempotentti: se täyttää aina vain nil-paikkoja

Miksi pikselitarkistukset kolmella sivulla eivät nappaa ActualText-tapausta

Rakennealkiot ovat se paikka, jossa tämä bugi piiloutuu pisimpään. Merkityn sisällön sekvenssissä oleva ActualText-merkintä, joka määritellään ISO 32000-1 §14.9.4:ssä, korvaa glyyfit poimintaa ja saavutettavuutta varten mutta ei vaikuta renderöintiin. Jos rakennealkio elää objektistreamissa ja uudelleenkirjoitus hukkaa sen, sivu piirtyy silti oikein, ensimmäinen, keskimmäinen ja viimeinen sivu vertautuvat pikseli pikseliltä lähteeseen, ja regressio näkyy vasta kun joku ajaa tekstinpoiminnan tai ruudunlukijan. Uudelleenkirjoitustesti, joka vain renderöi sivuja, ei ole merkittyä PDF:ää koskeva uudelleenkirjoitustesti. Vertaile myös poimittu teksti ja jäsennyspuu

Miten tyhjä käyttäjäsalasana muuttaa latausta?

Tyhjä käyttäjäsalasana tarkoittaa silti, että tiedosto on salattu, ja objektistreamit ovat sellaisessa tiedostossa salatekstiä kunnes tiedostoavain on palautettu. ISO 32000-1 §7.6.3.4 algoritmi 2 johtaa tuon avaimen salasanasta, /O-merkinnästä, /P-arvosta ja ensimmäisestä asiakirjatunnisteesta, ja HotPDF:n on ajettava se tyhjää merkkijonoa vasten ennen kuin tyypin 2 läpikäynti voi inflatoida yhtäkään säiliötä. Sen takia BeginDoc kutsuu ladatulla salatulla asiakirjalla DecryptLoadedDocument-metodia tyhjällä salasanalla ennen mitään muuta: objektigraafi on todennettava ja purettava salauksesta ennen kuin uudelleenkirjoitus voi alkaa, riippumatta siitä aikooko kutsuja suojata tulosteen. Tulosteen salaus on erillinen päätös, jota ohjaavat kutsujan suojausasetukset, ja BeginDoc palauttaa nuo asetukset salauksenpurkukierroksen jälkeen, jottei salattu syöte muutu hiljaa salatuksi tulosteeksi

Säiliöpolitiikka luetaan /Encrypt-sanakirjasta ennen kuin yhtäkään salasanaa kokeillaan. Arvoilla /V 1 ja 2 jokainen stream on salattu tiedostoavaimella. Krypt-suodattimien osalta HotPDF ratkaisee /StmF-arvon /CF-sanakirjan kautta: Identity-suodatin tai /CFM-arvo None tarkoittaa selväkielisiä säiliöitä, kun taas V2 ja AESV2 tarkoittavat salattuja. Vastaus päätyy FReloadObjectStreamsEncrypted-kenttään, ja sillä on merkitystä yhdessä tietyssä tapauksessa. Kun säiliöt ovat selväkielisiä mutta merkkijonot eivät, jäsenet kantavat salattuja merkkijonoja, jotka on purettava yksitellen, joten MaterializeMembersOfPlaintextObjectStreams laajentaa jokaisen pakatun jäsenen ennen objektikohtaista salauksenpurkukierrosta. Se ei tee mitään, kun politiikka ei ole vielä tiedossa, eikä mitään, kun säiliöt itsessään oli salattu, koska salatun säiliön jäsenet on jo purettu sen mukana eikä niitä saa koskaan purkaa kahdesti

Mitä tapahtuu, kun säiliötä ei voi purkaa salauksesta?

Säiliö, jonka salauksenpurkku epäonnistuu, asetetaan karanteeniin eikä se ole kohtalokasta. Tyypin 2 läpikäynti tallentaa THPDFObjStmQuarantineInfo-merkinnän FObjStmQuarantine-kokoelmaan säiliön objektinumerolla, THPDFObjStmQuarantineReason-syyllä, diagnostiikkamerkkijonolla sekä luettelolla niistä jäsenobjektien numeroista, jotka ristiviite oli ohjannut siihen. osqrDecryptFailed nostetaan neljässä eri tilanteessa: yhtään krypt-suodatinta ei voitu ratkaista, AES-256- tai AES-GCM-purku heitti poikkeuksen, perinteinen RC4- tai AES-128-purku heitti poikkeuksen, tai käyttökelpoista tiedostoavainta ei ole lainkaan. Riippumattomat säiliöt jatkavat latautumista, joten asiakirja, jossa on yksi vahingoittunut säiliö, avautuu edelleen ja renderöi edelleen jokaisen sivun, joka ei ole siitä riippuvainen

Miten HotPDF:n salauksenpurun karanteeni toimii ladatussa PDF:ssä: säiliö, jonka salauksenpurkku heittää poikkeuksen, tallennetaan THPDFObjStmQuarantineInfo-merkintänä osqrDecryptFailed-syyllä ja jäsenobjektiensa numeroilla, riippumattomat säiliöt jatkavat latautumista, ja BeginDoc nostaa virheen ensimmäisestä epäonnistuneesta merkinnästä ennen kuin uudelleenkirjoitus ehtii ilmoittaa onnistumisesta
Karanteenimerkinnät selviävät jäsentimen varamenettelystä, ja BeginDoc tarkistaa ne nimeltä mainiten eikä salauslipun perusteella, joten asiakirja, jossa on yksi vahingoittunut säiliö, avautuu edelleen, kun taas uudelleenkirjoituspolku pysähtyy tyhjien objektien kirjoittamisen sijaan

Karanteenilista selviää jäsentimen varamenettelystä. Jos ensisijainen ristiviitteen lataus epäonnistuu ja HotPDF rekonstruoi objektitaulukon skannaamalla tiedoston, ensimmäisen yrityksen salauslippu ei välttämättä selviä tuosta rekonstruktiosta, mutta karanteenimerkinnät selviävät. Sen takia BeginDoc tarkistaa karanteenilistan eikä salauslippua: ladatulla asiakirjalla se käy läpi FObjStmQuarantine-kokoelman ja nostaa virheen ensimmäisestä osqrDecryptFailed-merkinnästä nimeämällä säiliön ja pyytämällä uudelleenlatausta kelvollisella salasanalla. Uudelleenkirjoitus, joka etenisi tuon pisteen ohi, kirjoittaisi ne jäsenet, jotka säiliön oli määrä sisältää, tyhjinä objekteina ja ilmoittaisi onnistumisesta. Voit ajaa saman tarkistuksen itse, aikaisemmin ja omalla politiikallasi, julkisten käsittelijöiden kautta:

var
  Info: THPDFObjStmQuarantineInfo;
  I: Integer;
begin
  Pdf.LoadFromFile('vendor-form.pdf');   // tyhjä käyttäjäsalasana
  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)]);
  // tästä eteenpäin uudelleenkirjoitus on turvallinen
end;

Muut karanteenisyytt kattavat ei-kryptografiset viat: säiliö, joka ei ole stream, puuttuva sanakirja, virheellinen /N tai /First, hyväksytyn alueen ulkopuolella oleva streamin koko, pakkauksen purun epäonnistuminen, datan ohi osoittava /First tai jäsenen runko, joka purkautui mutta ei jäsentynyt. Ne kannattaa lokittaa vastaanotossa, sillä jokainen niistä nimeää täsmälleen ne jäsenet, jotka sinulta jäävät puuttumaan alavirrassa

Miksi uudelleenkirjoitus tarvitsee alkuperäisen numeerisen tokenin?

HotPDF tallentaa jokaisen numeerisen objektin Single-arvona, eikä Single pysty toistamaan reaaliluvun lähdetekstiä. ISO 32000-1 §7.3.3 sallii kirjoittajan tuottaa samalle arvolle muodot 0.750000, .75 tai 0.75, eikä yksikään niistä selviä muuttumattomana edestakaisesta matkasta 24-bittisen binääriesityksen ja yleiskäyttöisen muotoilijan läpi. Pahempaa: arvo kuten 0.7 ei ole Single-arvossa esitettävissä lainkaan; se jäsentyy lähimmäksi liukuluvuksi, ja tuon liukuluvun uudelleenmuotoilu voi tuottaa muodon 0.69999999 tai pyöristetyn naapurin numeroluupista riippuen. Täyttövärissä tai /CA-läpinäkyvyysvakiossa se on yhden askelen ero 8-bittisessä kanavassa, mikä riittää kaatamaan pikselivertailun lähdettä vasten ja liukuvärien rajoilla riittää näkymään

THPDFNumericObject.RememberSourceToken ratkaisee tämän muokkaamattoman tapauksen osalta. Jäsennin kutsuu sitä raa'alla tokenilla heti Value-arvon sijoittamisen jälkeen; metodi hyväksyy vain tokeneita, jotka koostuvat numeroista, enintään yhdestä desimaalipisteestä ja valinnaisesta etumerkistä, ja tallentaa tokenin yhdessä sitä vastanneen arvon kanssa FSourceValue-kenttään. SourceToken-ominaisuus palauttaa tallennetun tekstin vain niin kauan kuin Value on yhä yhtä suuri kuin FSourceValue. Muuta lukua, niin token haihtuu, joten muokattu arvo kulkee aina olemassa olevan muotoilupolun läpi eikä koskaan tuota vanhentunutta tekstiä. SaveNumericObject tarkistaa SourceToken-arvon ensin ja kirjoittaa sen sellaisenaan kun se on läsnä, ja putoaa sitten kokonaisluku-, väriavaruusviite- ja murtolukuharoihin vain niiden lukujen osalta, jotka luotiin tai muokattiin muistissa

Invariantti on pieni ja kannattaa todeta suoraan: luku, johon et koskenut, kirjoitetaan niillä tavuilla joilla se luettiin, ja luku, johon koskit, kirjoitetaan HotPDF:n omalla muotoilijalla. Pakatut jäsenet hyötyvät tästä samalla tavalla kuin rungon objektit, koska EnsureCompressedObjectLoaded ajaa saman jäsentimen jäsenen siivun yli. Itse lukujen muotoilu ja sen riippumattomuus prosessin locale-asetuksesta on käsitelty artikkelissa locale-riippumattomasta PDF-lukujen muotoilusta HotPDF:ssä

Uudelleenkirjoituspolun testaaminen objektistreameja vasten

Kolme tarkistusta nappaa jokaisen yllä kuvatun vian, eikä yksikään niistä vaadi Acrobatia. Ensimmäinen: vertaa IndexedObjectCount-arvoa MaterializedObjectCount-arvoon tallennuksen jälkeen; täysuudelleenkirjoituksessa niiden on oltava yhtä suuret, ja mikä tahansa ero on pudotettu jäsen. Toinen: poimi teksti ja luettele jäsennyspuu molemmista tiedostoista, älä vain renderöi niitä, jotta kadonnut ActualText tai kadonnut rakennealkio näkyy diffinä. Kolmas: lataa tuloste tuoreella instanssilla ja varmista, että GetLoadedQuarantinedObjStmCount on nolla, mikä myös todistaa, ettei kirjoittaja tuottanut säiliötä, jota lukija ei voi avata. Krypt-suodatinyhdistelmät, jotka ratkaisevat FReloadObjectStreamsEncrypted-arvon, on esitetty StmF-, StrF- ja EFF-politiikka-artikkelissa. Tämän tarinan kirjoittajapuoli, eli miten objektistreamit tuotetaan ja milloin inkrementaalinen päivitys kannattaa valita uudelleenkirjoituksen sijaan, on objektistream- ja inkrementaalipäivitysoppaassa

Laiska jäsenlataus, kirjoittajaa edeltävä laajennuskierros, salauksenpurun karanteeni ja lähdetokenin säilytys toimitetaan kaikki HotPDF Delphi Componentissa Delphille ja C++Builderille. Tuotesivulla on linkki rajapintaviitteeseen, jos haluat jäljittää GetLoadedObjectStreamCacheInfo-kutsun ja karanteenikäsittelijät omaa vastaanottoputkeasi vasten