Tehnički članak

Brisanje PDF stranica u Delphi-ju bez visećih referenci

HotPDF Delphi Component briše stranicu iz učitanog PDF-a kroz THotPDF.DeletePage, i od verzije 2.751.0 taj poziv čisti i svaku referencu na nivou dokumenta koja još pokazuje na tu stranicu: imenovana odredišta u stablu /Names /Dests, nasleđeni /Dests rečnik kataloga, /GoTo akcije obeleživača, elemente strukture pod /StructTreeRoot, ParentTree, OBJR unose za anotacije, i link anotacije na preostalim stranicama. Stablo stranica se ponovo gradi poslednje, kada ništa drugo ne može da dosegne obrisani objekat

Kvar koji se time sprečava lako je reprodukovati i teško dijagnostikovati. Obrišite naslovnu stranicu označenog izveštaja, sačuvajte i otvorite rezultat: Acrobat prikazuje ispravan broj stranica, ali obeleživač „Contents“ sada ne vodi nikuda, provera pristupačnosti prijavljuje element strukture bez stranice, a strogi validator navodi referencu na slobodan objekat. U stablu stranica ništa nije pogrešno. Problem je u tome što PDF stranica nije samo list stabla /Pages; ona je cilj na koji pokazuje pola kataloga, i uklanjanje tog lista ostavlja svaki od tih pokazivača da visi

Zašto uklanjanje stranice iz /Kids nije dovoljno?

Zato što ISO 32000-1 dopušta da najmanje sedam nezavisnih struktura drži referencu na objekat stranice, a samo jedna od njih je stablo stranica. Uklanjanje stranice iz /Kids i smanjenje /Count zadovoljava §7.7.3, a svaka druga referenca postaje pokazivač na objekat koji je ili oslobođen u xref-u ili prosto odsutan iz prepisanog fajla. Pregledač koji prati jedan od tih pokazivača dobija null, a šta će sa tim null-om uraditi zavisi od pregledača

  • Stablo imena pod /Names /Dests (§7.7.4, §12.3.2.3) mapira imena na nizove odredišta čiji je prvi element stranica
  • Rečnik /Dests iz vremena pre 1.2, direktno u katalogu, drži istu vrstu nizova pod ključem imena
  • Stavke obeleživača (§12.3.3) stižu do stranice bilo kroz ugrađeni /Dest bilo kroz /A akciju sa /S /GoTo i /D nizom
  • Elementi strukture (§14.7.2) nose ključ /Pg koji imenuje stranicu na kojoj živi njihov označeni sadržaj, a njihova deca /K mogu biti reference na označeni sadržaj i reference na objekte (§14.7.4.3) vezane za tu stranicu
  • ParentTree (§14.7.4.4) mapira brojeve /StructParents stranica i anotacija natrag u elemente strukture, a element može da živi tamo bez toga da se uopšte pojavi na lancu /K od korena
  • Link anotacije na drugim stranicama (§12.5.6.5) nose /Dest ili /GoTo akciju koja cilja tu stranicu, a i /OpenAction kataloga može da radi isto
Zašto uklanjanje HotPDF stranice iz /Kids nije dovoljno: ISO 32000-1 dopušta da stablo imena /Names /Dests, nasleđeni rečnik /Dests u katalogu, stavke obeleživača, elementi strukture sa /Pg, ParentTree, link anotacije i /OpenAction svi drže referencu na isti objekat stranice, a samo se stablo stranica ponovo gradi
PDF stranica je cilj na koji pokazuje pola kataloga: uklanjanje lista zadovoljava stablo stranica dok se svaki drugi pokazivač razrešava u null, pa skraćeni izveštaj gubi obeleživač Contents i pada na proveri pristupačnosti

Šta THotPDF.DeletePage čisti pre nego što dotakne stablo stranica?

THotPDF.DeletePage(PageIndex) na učitanom dokumentu prvo izvršava celo čišćenje referenci, zatim označava objekat stranice kao obrisan kroz DeleteObj, odvaja widget anotacije od stabla polja AcroForm-a, pomera interni niz stranica, i na kraju poziva RebuildLoadedPageTree da prepiše /Kids, /Count i /Parent svake preostale stranice. Čišćenje obilazi katalog fiksnim redosledom: stablo imena /Names /Dests, stari /Dests rečnik, /OpenAction, stablo obeleživača, /StructTreeRoot sa svojim ParentTree, i na kraju nizove /Annots svake stranice koja ostaje. Svaki korak odlučuje da li se referenca uklanja, preusmerava ili ostavlja na miru prema tome šta specifikacija dopušta toj strukturi bez te stranice. Dva čuvara važe pre svega toga: DeletePage podiže Invalid page number za indeks van opsega i odbija da ukloni poslednju stranicu, jer /Pages čvor bez ijednog deteta nije validan PDF, dok DeletePages prima istu notaciju "1,3-5,7-" sa bazom jedan kao i ostale operacije nad stranicama učitanog dokumenta i ide od najvišeg izabranog indeksa naniže da indeksi koje ste napisali ostanu validni dok radi

Fiksno čišćenje referenci koje THotPDF.DeletePage izvršava pre nego što se stablo stranica dotakne: čuvari odbijaju indeks van opsega ili poslednju stranicu, zatim se /Names /Dests i nasleđeni /Dests čiste, /OpenAction izbacuje, obeleživači se preusmeravaju na NearestRetainedPage, StructTreeRoot i ParentTree se čiste, linkovi preostalih stranica uklanjaju, a RebuildLoadedPageTree ide poslednji
Svaka struktura dobija tretman koji specifikacija dopušta: imena nestaju, obeleživači sležu na najbližu zadržanu stranicu, elementi strukture gube /Pg ili nestaju, a prepisivanje /Kids se dešava tek kada ništa drugo ne može da dosegne obrisani objekat
var
  Pdf: THotPDF;
begin
  Pdf := THotPDF.Create(nil);
  try
    if Pdf.LoadFromFile('tagged-report.pdf', '') > 0 then
    begin
      // Baza nula: izbaci naslovnu stranicu. Imenovana odredišta,
      // obeleživači, stablo strukture, ParentTree i link
      // anotacije koje su pokazivale na nju čiste se pre nego
      // što se stablo /Pages ponovo izgradi.
      Pdf.DeletePage(0);
      // Notacija opsega sa bazom jedan za grupe, interno prvo
      // najviši indeks da raniji indeksi ostanu validni.
      Pdf.DeletePages('3-4,9');
      Pdf.SaveLoadedDocument('tagged-report-trimmed.pdf');
    end;
  finally
    Pdf.Free;
  end;
end;

Kako se imenovana odredišta i obeleživači tretiraju različito?

Imenovana odredišta se uklanjaju a obeleživači preusmeravaju, jer je ime koje više ne postoji prihvatljiv ishod dok je obeleživač bez odredišta vidljiv defekt. U stablu /Names /Dests HotPDF obilazi svaki čvor, testira svako odredište, i u golog niza i u obliku rečnika sa ključem /D, prema obrisanoj stranici, i uklanja par ime/vrednost kada je prvi element niza ta stranica. Čvor kojem i /Names i /Kids ostanu prazni označava se kao obrisan i odvezuje od roditelja, pa stablo nikada ne drži šuplje listove. Isti test prolazi i kroz stari /Dests rečnik kataloga, a /OpenAction kataloga se prosto izbacuje ako je otvarao obrisanu stranicu. Jedna granica ovde: kada čvor stabla imena izgubi unose, HotPDF briše par /Limits tog čvora umesto da ponovo izračuna novi najniži i najviši ključ, i iako pregledači razrešavaju imena bez problema i bez toga, strogi proverivač usklađenosti koji čita ISO 32000-1 §7.9.6 može da označi čvor koji nije koren a nema /Limits

Stavke obeleživača idu drugim putem. RetargetOutlineDestinations prolazi kroz /First i /Next od korena obeleživača, sa listom posećenih i ograničenjem dubine od 128 da pokvareno ciklično stablo ne može da blokira poziv, i za svaki /Dest niz ili /D niz /GoTo akcije usmeren na tu stranicu zamenjuje prvi element sa NearestRetainedPage: stranicom koja je sledila obrisanu, ili stranicom pre nje kada je obrisana stranica bila poslednja. Parametri prikaza posle reference na stranicu ostaju kakvi su bili. Obeleživač koji je pokazivao na obrisani početak poglavlja zato sleće na prvu stranicu onoga što ostaje, umesto da nestane iz bočne trake, što je ponašanje koje recenzenti očekuju od skraćenog dokumenta. Test odredišta ipak pokriva samo eksplicitne nizove: stavka obeleživača čiji je /Dest string imena koji je nekada razrešavao na obrisanu stranicu nije preusmerena, jer je unos u stablu imena nestao i referenca sada ne razrešava ni u šta umesto u oslobođeni objekat, pa je pregledač tretira kao mrtav obeleživač. Mehanika samog stabla obeleživača, /First, /Next i ne baš očigledna semantika /Count, obrađena je u vodiču za dodavanje obeleživača i imenovanih odredišta u učitan PDF

// Proveri čišćenje umesto da mu veruješ na reč.
Pdf.DeletePage(0);
if Pdf.ResolveLoadedNamedDestination('cover') = -1 then
  ShowMessage('Named destination "cover" was pruned');
// Obeleživač koji je ciljao naslovnu sada razrešava na
// stranicu koja je sledila (indeks 0 sa bazom nula posle brisanja).
if Pdf.GetLoadedBookmarkPageIndex('Contents') = 0 then
  ShowMessage('Bookmark retargeted to the nearest retained page');

Šta se dešava sa stablom strukture i ParentTree-om?

Elementi strukture koji postoje samo zbog obrisane stranice uklanjaju se, a elementi koji se protežu kroz nekoliko stranica gube svoj ključ /Pg ali zadržavaju svoju decu. PruneStructureElement spušta se niz lanac /K od /StructTreeRoot do dubine 128, obrađujući i oblik niza i oblik jednog rečnika za /K koji §14.7.2 dopušta. Za svaki element prvo čisti decu, pa procenjuje sam element: ako je čišćenje ispraznilo njegov /K, element se označava kao obrisan i roditelj ga izbacuje. Ako /Pg samog elementa imenuje obrisanu stranicu a element i dalje ima decu plus roditelja /P, uklanja se samo /Pg, jer je /Pg na elementu podrazumevana stranica za njegovu decu označenog sadržaja i ta deca mogu eksplicitno referencirati druge stranice. Samo element čiji je /Pg obrisana stranica i pod kojim nije ostalo ništa uklanja se u potpunosti

ParentTree dobija isti tretman, a razlog je onaj koji je zapeo tokom razvoja: element strukture može biti dohvatljiv iz ParentTree-a i nigde drugde. Number tree mapira /StructParents cele brojeve u jedan element ili niz elemenata, a PruneParentTreeNode pokreće PruneStructureElement nad svakom vrednošću koju nađe, uklanja vrednosti koje su očišćene, briše par /Nums kada je njegov niz vrednosti prazan, i odvezuje čvor kojem su i /Nums i /Kids nestali. Čišćenje samo potomaka /K ostavilo bi te osirotele elemente da pokazuju na oslobođenu stranicu kroz /Pg i na oslobođene reference označenog sadržaja kroz svoju decu /MCR. Ako izvlačite tekst u redosledu strukture, to je direktno važno: izvlačenje teksta u redosledu strukture prolazi upravo kroz ta stabla, a element sa null /Pg je pasus koji tiho ispada iz redosleda čitanja

Koje link anotacije na preostalim stranicama se uklanjaju?

Svaka link anotacija na zadržanoj stranici čiji /Dest niz ili /GoTo akcija pokazuje na obrisanu stranicu uklanja se zajedno sa svojim vlasništvom u stablu strukture. RemoveRetainedPageDestinationAnnotations prolazi kroz niz /Annots svake stranice osim ciljne, primenjuje isti test odredišta kao za obeleživače, označava odgovarajuću anotaciju kao obrisanu, izbacuje je iz niza, i zatim poziva PruneAnnotationReferencesInStructureTree da se OBJR rečnik čiji /Obj imenuje tu anotaciju ukloni iz svog elementa strukture, a sam element ukloni ako je OBJR bio njegovo jedino dete. Ostavljanje OBJR-a na mestu kršilo bi §14.7.4.3, koji zahteva da /Obj referencira postojeći objekat, i pojavilo bi se u PDF/UA proveri kao označena veza bez anotacije iza sebe. Primetite asimetriju sa obeleživačima: linkovi se uklanjaju, ne preusmeravaju. Unakrsna referenca u tekstu koja je govorila „vidi stranicu 3“ netačna je kada stranica 3 nestane, a usmeravanje na stranicu 4 bila bi laž na način na koji obeleživač koji sleće na najbliže poglavlje nije, pa ako vaš radni tok zahteva da ti linkovi ostanu, preusmerite ih sami pre poziva DeletePage

Zašto uklonjeni /MCR ili /OBJR nikada ne smeju da budu registrovani kao slobodni?

Zato što su reference na označeni sadržaj i reference na objekte obično direktni rečnici unutar niza /K svog roditeljskog elementa, a registar inkrementalnih izmena razrešava direktan objekat na najbliži indirektni objekat koji ga sadrži. Kada RemoveArrayItem izbaci dete iz niza /K, on oslobađa objekat u memoriji samo ako je bio THPDFLink ili ne-indirektna vrednost, a MarkRemovedObject registruje objekat za listu slobodnih samo kada je njegov broj objekta veći od nule. Prva verzija ovog čišćenja nije pravila tu razliku, a efekat u inkrementalnom čuvanju bio je upravo ono za šta je registar dizajniran: RegisterIncrementalChange išao je od direktnog /MCR nagore do svog korena transakcije grafa, a to je bio zadržani element strukture koji ga je posedovao, i taj element je upisao kao null. Dokument koji je izgubio jednu stranicu vratio se sa označenim sadržajem na ostalim stranicama koji je tiho postao neoznačen. Jedini ispravan potez za direktno dete je da se njegov kontejner označi kao prljav kroz TouchContainer da bi se kontejner prepisao, a listu slobodnih ostaviti na miru

Zašto uklonjeno /MCR ili OBJR dete nikada ne sme da bude registrovano kao slobodno u HotPDF-u: registar inkrementalnih izmena razrešava direktan rečnik na najbliži indirektni kontejner, pa je prva verzija zadržani element strukture upisala kao null i tiho skinula oznaku sa preostalih stranica, dok TouchContainer sada prepisuje kontejner i listu slobodnih ostavlja na miru
Oslobađanje deteta u memoriji rezervisano je za THPDFLink ili ne-indirektne vrednosti i za brojeve objekata veće od nule, pa inkrementalno čuvanje dodaje samo dirane kontejnere i oslobođeni objekat stranice
// Inkrementalno ažuriranje: samo dirani kontejneri i
// oslobođeni objekat stranice sležu u dodatak.
Pdf := THotPDF.Create(nil);
try
  Pdf.BeginIncrementalUpdate('tagged-report.pdf');
  Pdf.DeletePage(0);
  // Zadržani elementi strukture čiji je /K izgubio direktni /MCR
  // prepisuju se na mestu, nikada se ne upisuju kao null.
  Pdf.SaveIncrementalUpdate('tagged-report-trimmed.pdf');
finally
  Pdf.Free;
end;

Ista opreznost oblikuje ono što DeletePage namerno ne oslobađa na učitanom dokumentu. Content stream-ovi, XObject-i i ne-widget anotacije obrisane stranice ostaju kao objekti, jer učitan fajl može da deli bilo koji od njih sa stranicom koja ostaje i nema jeftinog načina da se u trenutku brisanja dokaže suprotno. Uklanjanje reference iz stabla stranica dovoljno je za ispravnost; bajtovi koje ti objekti još zauzimaju su odvojeno pitanje, a graf zavisnosti objekata i analiza zadržanih bajtova je alat za merenje onoga što skraćeni dokument još nosi

DeletePage naspram DeleteLoadedPage: šta pozvati?

Pozovite DeletePage za svako brisanje stranice okrenuto korisniku, a DeleteLoadedPage sačuvajte za slučaj kada se ceo dokument ponovo slaže i nijedna referenca na nivou dokumenta nije vredna čuvanja. THotPDF.DeleteLoadedPage(PageIndex), dodat u verziji 2.508.0, je lagana varijanta: pomera interni niz stranica, poziva RebuildLoadedKidsArray da prepiše /Kids i /Count, invalidira cache renderovanih stranica i aktivira OnLoadedDocumentModified. Ne obilazi stablo imena, obeleživače, stablo strukture ni anotacije drugih stranica, i ne označava objekat stranice kao obrisan. To je pravi alat unutar N-up imposition-a, gde HotPDF dodaje sveže složene tabake a zatim izbacuje svaku originalnu stranicu sa DeleteLoadedPage(0): izvorne stranice se zamenjuju u celosti, a sadržaj tabaka referencira njihove resurse, a ne objekte stranica. Za običan posao „ukloni stranicu 7 iz ovog ugovora“, DeletePage je jedini poziv koji ostavlja označen, obeležen i unakrsno povezan dokument dovoljno konzistentnim da prođe validator, i u potpunom prepisivanju kroz SaveLoadedDocument i u inkrementalnom ažuriranju kroz SaveIncrementalUpdate. Oba metoda se isporučuju u HotPDF Delphi Component za Delphi i C++Builder, bez potrebe za bilo kakvim eksternim viewer runtime-om ili zavisnošću