Avaa Microsoft Wordin tai Excelin tuottama PDF-tiedosto, selaa sitä läpi, ja mikään ei näytä epätavalliselta. Lataa se Delphi-ohjelmaan, lue sivumäärä takaisin, ja luku on oikein. Tallenna se sitten uudelleen salauksen (encryption) ollessa päällä, ja työ epäonnistuu EListError-virheellä, tai tuloste avautuu varoittaen vaurioituneesta ristiviittauksesta (cross-reference). Tiedosto ei ollut koskaan korruptoitunut. Se on hybrid-reference-tiedosto (hybridiviittaustiedosto), ja juuri se rakenne, joka sallii viisitoista vuotta vanhan katseluohjelman avata sen, on rakenne, joka nujertaa lataimen (loader), joka lopettaa lukemisen liian aikaisin
Tämä on yksi yleisimmistä tavoista, jolla PDF-liukuhihna (PDF pipeline), joka on läpäissyt jokaisen sisäisen testin, kohtaa tiedoston, jota se ei pysty edestakaisin tallentamaan (round-trip). Syötteet oli kaikki luotu talon sisällä, joten ne eivät koskaan olleet hybridejä. Ensimmäinen hybriditiedosto saapuu sinä päivänä, kun asiakas edelleenlähettää laskentataulukosta viedyn (exported) laskun
Mitä Word ja Excel todellisuudessa kirjoittavat
ISO 32000-1 kuvailee hybrid-reference-asettelun (hybrid-reference layout) kohdassa §7.5.8.4. Sovellus, joka haluaa PDF 1.5 -ominaisuuksia kuten objektivirtoja (object streams), mutta haluaa samalla antaa PDF 1.4 -lukijan avata tiedoston, kirjoittaa ristiviitetiedot (cross-reference information) kahdesti. On olemassa klassinen ristiviitetaulukko, tasalevyiset ASCII-rivit, jotka päättivät jokaisen PDF-tiedoston versioon 1.4 asti, ja on olemassa ristiviitevirta (cross-reference stream), joka indeksoi loput. Klassisen osion traileri (trailer) kantaa /XRefStm-merkintää, jonka arvo on tuon virran tavusiirtymä (byte offset)
Työnjako on harkittu. Objektit, jotka vanhan lukijan on tavoitettava, muun muassa luettelo (catalog) ja sivupuu (page tree), ovat osoitettavissa (addressable) klassisesta taulukosta. Objektit, jotka on taiteltu pakattuihin objektivirtoihin (compressed object streams), on merkitty vapaiksi (free) klassisessa taulukossa tyypin f merkinnällä, joten 1.4-lukija hyppää suoraan niiden ohi eikä koskaan kompastu rakenteeseen, jota se ei pysty jäsentämään. Niiden todelliset sijainnit asuvat vain ristiviitevirrassa. Tällaisen tiedoston tunnusmerkki on sen häntä: lyhyt klassinen osio, usein vain xref jota seuraa 0 0 -alaotsikko, jonka traileri osoittaa /XRefStm-merkintään, jossa varsinainen palautusdata sijaitsee
Miksi oikea sivumäärä ei todista mitään
Koska luettelo (catalog) ja sivupuu (page tree) ovat tarkoituksella saavutettavissa klassisesta taulukosta, latain (loader), joka lukee vain kyseisen taulukon, löytää /Root-kansion, kävelee läpi sivupuun ja ilmoittaa oikean sivumäärän. Kaikki, mitä vanha lukija tarvitsee, on läsnä, joten tiedosto vaikuttaa terveeltä. Puuttuvat objektit ovat niitä, jotka on pakattu objektivirtoihin: AcroForm-kenttien sanakirjat, Tagged-PDF-rakenne-elementit, pitkä häntä pieniä sanakirjoja, joiden ei koskaan tarvinnut olla näkyvissä perintökatseluohjelmalle (legacy viewer)
Et huomaa puutetta, ennen kuin jokin koskettaa noita objekteja, ja täysi uudelleentallennus (resave) koskettaa niitä kaikkia. Asiakirjan läpikäynti sen uudelleensalaamiseksi tai uudelleenkirjoittamiseksi on nimenomaan se operaatio, joka pyytää jokaista objektinumeroa vuorollaan, minkä vuoksi oire nousee pintaan tallennushetkellä (save time) lataushetken (load time) sijaan, kaukana sen aiheuttajasta
Ansa on ilmaisin, joka näkee xref:n ja pysähtyy
Halpa tapa päättää kuinka tiedosto on indeksoitu, on seurata startxref-merkintää ja tarkastaa ensimmäiset tavut, joihin se osoittaa. Avainsana xref tarkoittaa klassista taulukkoa; virtaobjekti (stream object) tarkoittaa ristiviitevirtaa (cross-reference stream). Tuo testi on oikein mille tahansa tiedostolle, joka sitoutuu jompaankumpaan skeemaan. Se on väärin hybriditiedostolle, jonka startxref tähtää klassiseen osioon vain ja ainoastaan vanhojen lukijoiden tyydyttämiseksi, kun taas tuon osion trailerin /XRefStm on se paikka, johon suurin osa asiakirjasta on todellisuudessa indeksoitu. Ilmaisin (detector), joka palauttaa "classic" ensimmäisellä kohtaamallaan xref-sanalla, ei koskaan lue /XRefStm-merkintää, ja jokainen objekti, joka elää vain virrassa, muuttuu näkymättömäksi
var
Pdf: THotPDF;
PageCount: Integer;
begin
Pdf := THotPDF.Create(nil);
try
PageCount := Pdf.LoadFromFile('Invoice_XLS.pdf'); // count on oikein
// tutki tai muokkaa ladattua asiakirjaa tässä
Pdf.SaveLoadedDocument('Invoice_secured.pdf'); // kävelee läpi jokaisen objektin
finally
Pdf.Free;
end;
end;
Jos varhaisen poistumisen (early-exit) ilmaisin on paikallaan, lataus näyttää hyvältä ja uudelleentallennus on se kohta, jossa poissaolevat objektit ilmoittavat itsestään. Korjaus ei ole lukea enemmän tavuja alussa; se on tunnistaa hybriditraileri ja seurata /XRefStm-merkintää ennen kuin päätetään, että tiedosto on valmis
Yhdistämisjärjestys ei ole neuvoteltavissa
Kun molemmat indeksit on luettu, ne voidaan yhdistää (merge) vain yhteen suuntaan. Ristiviitevirta on yhdistettävä ensin, ja klassiset merkinnät (classic entries) täytetään sen ympärille. Syynä on muodon ytimessä oleva pieni huijaus. Hybriditiedosto merkitsee pakatut objektinsa (compressed objects) vapaiksi klassisessa taulukossa, jotta vanhat lukijat jättävät ne huomiotta. Latain, joka noudattaa ensimmäisenä-nähty-voittaa -käytäntöä (first-seen-wins policy) ja lukee klassisen taulukon ensin, tallentaa nuo objektinumerot vapaiksi ja sitten hylkää virran merkinnät, jotka todellisuudessa paikantavat ne, koska paikat on jo viety. Käännä järjestys ja virrasta tulevat tyypin 2 merkinnät, joista jokainen on objektivirran numero plus indeksi, voittavat ne paikat jotka niiden on tarkoituskin omistaa, ja klassiset merkinnät asettuvat niiden ympärille
Sama kuri suojaa siltä, että vanhempi versio (revision) herättäisi poistetun objektin henkiin. Inkrementaaliset päivitykset (incremental updates) ketjuutuvat taaksepäin /Prev-merkinnän kautta, ja tyypin 0 vapaa merkintä (free entry) on vartija (sentinel) sille, että tuoreempi osio on siirtänyt objektinumeron eläkkeelle. Ketjun myöhäisemmän, vanhemman osion ei saa antaa ylikirjoittaa tuota vartijaa vanhentuneella sijainnilla (stale location). Käsittele ensimmäisenä nähtyä (first-seen) arvovaltaisena (authoritative) vapaille merkeille, ja poistettu objekti pysyy poistettuna; käsittele sitä huolimattomasti, ja tiedoston oma historia elvyttää (reanimates) sisällön, jonka uusin revisio oli poistanut
Mitä tämä tarkoittaa HotPDF:ssä
Moottori (engine) ratkaisee (resolves) hybrid-reference-tiedostot puolestasi, ja se tekee sen jokaisella polulla, jonka on jäsennettävä ristiviitedataa. Lataa asiakirja kutsumalla LoadFromFile tai LoadFromStream, tee muutoksesi ja kutsu SaveLoadedDocument; tai suorita kertaluontoinen operaatio kuten EncryptFile, joka lukee syötteen ja kirjoittaa tulosteen. Kummassakin tapauksessa palautus lukee /XRefStm-merkinnän, yhdistää virta-osion (stream section) ennen klassisia merkintöjä, ja ratkaisee virroissa elävät objektit ennen kuin kirjoitus (write) luettelee (enumerates) ne. AES-256-salauksen (encryption) polku on se, missä ongelma ensimmäisen kerran näytti itsensä, koska asiakirjan salaaminen kirjoittaa uudelleen jokaisen objektin ja vaatii siten, että jokainen objekti on jo paikannettu (located)
// Kertaluonteinen: lue hybridi syöte, kirjoita AES-256 -salattu kopio
Pdf.EncryptFile('Letter_DOC.pdf', 'Letter_secured.pdf',
'owner-secret', '', aes256, [prPrint, prFillAnnotations]);
Yksityiskohta, joka on syytä painaa mieleen, sijaitsee ylävirtaan API:sta. Wordista, Excelistä, PowerPointista ja pitkästä listasta "Tallenna PDF:nä" -liukuhihnoja saapuvat tiedostot ovat rutiininomaisesti hybridejä, joten latain, jota käytät vain omaa generaattorisi tulostetta vastaan, ei ehkä koskaan kohtaa sellaista testauksessa. Kylvä testisetteihisi (fixtures) dokumentteja, jotka on viety aidoista Office-sovelluksista, ei vain tiedostoja joita oma koodisi on tuottanut
Epäilemäsi tiedoston tarkistaminen
Kaksi tarkastusta ratkaisee kysymyksen nopeasti. Avaa tiedosto heksanäkymässä (hex view) ja lue tavut viimeisen startxref-merkinnän jälkeen; hybriditiedosto näyttää lyhyen klassisen osion, jonka traileri-sanakirja (trailer dictionary) sisältää /XRefStm-merkinnän. Tai vertaa täyden jäsennyksen (full parse) raportoimaa objektimäärää siihen suurimpaan objektinumeroon, jonka /Size ilmoittaa trailerissa. Suuri ero tarkoittaa, että objekteja piileskelee virroissa, joita latain ei ole avannut, mikä on sama vajaus, joka muuttuu tallennushetken virheeksi myöhemmin
Tyypillisen Excel-viennin (export) häntä tekee ensimmäisestä tarkastuksesta konkreettisen. Kaikki viimeisen xref-avainsanan jälkeen on pelkkää ASCII-tekstiä, joten tunnusmerkki on luettavissa suoraan heksanäkymästä (siirtymät on vain havainnollistavia, huomautukset lisätty)
xref
0 0 % tyhjä klassinen alaotsikko: ei lainkaan rivejä
trailer
<< /Size 216 % yksi ohi korkeimman käytössä olevan objektinumeron
/Root 1 0 R
/Info 15 0 R
/ID [<5C9A...> <5C9A...>]
/XRefStm 87325 % ristiviitevirran tavusiirtymä (byte offset)
>>
startxref
88710 % osoittaa klassiseen osioon yllä
%%EOF
0 0 -alaotsikko on paljastava: klassinen taulukko, jossa on nolla merkintää, on olemassa vain trailerin kantamiseksi, ja traileri on olemassa pääasiassa sanoakseen /XRefStm 87325. Ilmaisin, joka pysähtyy xref-avainsanaan, on tässä vaiheessa nähnyt tyhjyyden indeksin. Kun mieluummin skriptaat tarkistuksen kuin arvioit sitä silmämääräisesti, merkki (marker) sijaitsee aina tiedoston viimeisen parin kilotavun sisällä, joten rajattu taaksepäin suuntautuva luku (bounded backward read) riittää
// Palauttaa /XRefStm-siirtymän tiedoston hännästä, tai -1, jos
// merkkiä ei ole (tiedosto ei ole hybridi, tai ei PDF lainkaan)
function FindXRefStm(const FileName: string): Int64;
var
FS: TFileStream;
Tail: AnsiString;
Len, P: Integer;
begin
Result := -1;
FS := TFileStream.Create(FileName, fmOpenRead or fmShareDenyWrite);
try
Len := 2048; // trailer asuu hännässä
if FS.Size < Len then
Len := Integer(FS.Size);
FS.Position := FS.Size - Len; // rajattu lukeminen taaksepäin: 2 KB max
SetLength(Tail, Len);
FS.ReadBuffer(Tail[1], Len);
finally
FS.Free;
end;
P := Pos(AnsiString('/XRefStm'), Tail);
if P = 0 then
Exit; // ei hybridimerkkiä hännässä
Inc(P, Length('/XRefStm'));
while (P <= Len) and (Tail[P] in [' ', #9, #13, #10]) do
Inc(P); // hyppää välilyönnit avaimen jälkeen
Result := 0;
while (P <= Len) and (Tail[P] in ['0'..'9']) do
begin
Result := Result * 10 + Ord(Tail[P]) - Ord('0');
Inc(P);
end;
end;
// Käyttö: ei-negatiivinen tulos nimeää tavun, josta virta alkaa
if FindXRefStm('Invoice_XLS.pdf') >= 0 then
Writeln('hybridiviittaustiedosto: uudelleentallennus vaatii /XRefStm-osion');
Käsittele koetinta (probe) esikarsintana (triage), ei jäsentäjänä (parser): se kertoo sinulle vain sen, mitkä tiedostot erässä ansaitsevat huomiota ennen uudelleentallennustyön ajamista, ei muuta. Se, mitä lataimen on sitten tehtävä löytämänsä siirtymän (offset) kanssa, osioketjun seuraaminen, virtamerkintöjen (stream entries) yhdistäminen ennen klassisia, ja vapaa-merkintä-vartijoiden (free-entry sentinels) kunnioittaminen, käydään läpi vaihe vaiheelta kumppaniartikkelissamme, joka käsittelee hybrid-reference-PDF:ien käsittelyä Office-sovelluksista
Kirjoittajan puoli tästä tarinasta, eli kuinka objektivirtoja ja pakattuja ristiviittauksia (compressed cross-references) tuotetaan ylipäätään, käsitellään artikkelissamme objektivirroista ja inkrementaalisista päivityksistä. Kun kyseessä oleva hybriditiedosto on lisäksi erittäin suuri, lataustekniikat Direct File API -läpikäynnissä suuria PDF-työnkulkuja varten antavat sinun tutkia sitä lukematta koko asiaa muistiin. Molemmat parittuvat luonnollisesti tässä kuvatun palautuksen (recovery) kanssa, joka toimitetaan osana HotPDF-komponenttia Delphille ja C++Builderille, rinnakkain muualla tässä blogissa käsiteltyjen lataus-, muokkaus-, salaus- ja allekirjoitus-API:en kanssa