Tehnički članak

Brisanje PDF stranica u Delphiju bez visećih referenci

HotPDF Delphi Component briše stranicu iz učitanog PDF-a kroz THotPDF.DeletePage, a od verzije 2.751.0 taj poziv čisti i svaku referencu na razini dokumenta koja još pokazuje na tu stranicu: imenovane destinacije u stablu /Names /Dests, naslijeđeni rječnik /Dests u katalogu, /GoTo akcije oznaka, strukturne elemente pod /StructTreeRoot, ParentTree, OBJR unose za napomene i link napomene na preživjelim stranicama. Stablo stranica obnavlja se posljednje, nakon što ništa drugo ne može doći do izbrisanog objekta

Kvar koji se time sprječava lako je reproducirati, a teško dijagnosticirati. Obrišite naslovnu stranicu označenog izvještaja, spremite ga i otvorite rezultat: Acrobat pokazuje točan broj stranica, ali oznaka "Contents" sada ne vodi nikamo, provjera pristupačnosti prijavljuje strukturni element bez stranice, a strogi validator navodi referencu na oslobođeni objekt. Ništa u stablu stranica 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 barem sedam neovisnih struktura drži referencu na objekt stranice, a samo je jedna od njih stablo stranica. Izbacivanje stranice iz /Kids i smanjenje /Count zadovoljava §7.7.3, a svaka druga referenca postaje pokazivač na objekt koji je ili oslobođen u xrefu ili jednostavno odsutan iz prepisane datoteke. Preglednik koji slijedi jedan od tih pokazivača dobiva null, a što će s tim nullom učiniti, stvar je preglednika

  • Stablo imena pod /Names /Dests (§7.7.4, §12.3.2.3) preslikava imena u nizove destinacija čiji je prvi element stranica
  • Rječnik /Dests iz razdoblja prije 1.2, izravno u katalogu, drži istu vrstu nizova ključanih imenom
  • Stavke outlinea (§12.3.3) dolaze do stranice ili kroz inline /Dest ili kroz /A akciju s /S /GoTo i nizom /D
  • Strukturni elementi (§14.7.2) nose ključ /Pg koji imenuje stranicu na kojoj živi njihov označeni sadržaj, a njihova djeca /K mogu biti reference označenog sadržaja i objektne reference (§14.7.4.3) vezane na tu stranicu
  • ParentTree (§14.7.4.4) preslikava brojeve /StructParents stranica i napomena natrag u strukturne elemente, a element može živjeti ondje bez da se uopće pojavljuje na lancu /K od korijena
  • Link napomene na drugim stranicama (§12.5.6.5) nose /Dest ili /GoTo akciju usmjerenu na tu stranicu, a /OpenAction u katalogu može učiniti isto
Zašto uklanjanje HotPDF stranice iz /Kids nije dovoljno: ISO 32000-1 dopušta da stablo imena /Names /Dests, naslijeđeni rječnik /Dests u katalogu, stavke outlinea, strukturni elementi s /Pg, ParentTree, link napomene i /OpenAction svi drže referencu na isti objekt stranice, a obnavlja se samo stablo stranica
PDF stranica je cilj na koji pokazuje pola kataloga: izbacivanje lista zadovoljava stablo stranica, dok se svaki drugi pokazivač razrješava u null, pa skraćeni izvještaj gubi svoju oznaku Contents i pada na provjeri pristupačnosti

Što THotPDF.DeletePage počisti prije nego dotakne stablo stranica?

THotPDF.DeletePage(PageIndex) na učitanom dokumentu prvo odrađuje cijeli prolaz kroz reference, zatim označava objekt stranice kao izbrisan s DeleteObj, odvaja sve widget napomene od stabla polja AcroForma, pomiče interni array stranica i konačno poziva RebuildLoadedPageTree da prepiše /Kids, /Count i /Parent svake preživjele stranice. Prolaz obilazi katalog u fiksnom redoslijedu: stablo imena /Names /Dests, starinski rječnik /Dests, /OpenAction, stablo outlinea, /StructTreeRoot s njegovim ParentTree, i na kraju nizove /Annots svake stranice koja ostaje. Svaki korak odlučuje hoće li se referenca ukloniti, preusmjeriti ili ostaviti na miru, prema tome što specifikacija toj strukturi dopušta bez te stranice. Prije svega toga vrijede dvije zaštite: DeletePage podiže Invalid page number za indeks izvan raspona i odbija ukloniti posljednju stranicu, jer /Pages čvor bez djece nije valjan PDF, dok DeletePages prima istu 1-baznu notaciju "1,3-5,7-" kao i ostale operacije nad stranicama učitanog dokumenta i iterira od najvišeg odabranog indeksa prema dolje, pa indeksi koje ste napisali ostaju valjani dok radi

Fiksni prolaz kroz reference koji THotPDF.DeletePage odrađuje prije nego se dotakne stablo stranica: zaštite odbijaju indeks izvan raspona ili posljednju stranicu, zatim se čiste /Names /Dests i naslijeđeni /Dests, /OpenAction se odbacuje, outline se preusmjerava na NearestRetainedPage, StructTreeRoot i ParentTree se čiste, linkovi preživjelih stranica uklanjaju, a RebuildLoadedPageTree ide posljednji
Svaka struktura dobiva tretman koji specifikacija dopušta: imena nestaju, oznake slijeću na najbližu zadržanu stranicu, strukturni elementi gube /Pg ili nestaju, a prepisivanje /Kids događa se tek kad ništa drugo ne može doći do izbrisanog objekta
var
  Pdf: THotPDF;
begin
  Pdf := THotPDF.Create(nil);
  try
    if Pdf.LoadFromFile('tagged-report.pdf', '') > 0 then
    begin
      // Nulto bazirano: izbaci naslovnu stranicu. Imenovane destinacije,
      // oznake, structure tree, ParentTree i link
      // napomene koje su pokazivale na nju čiste se prije nego se
      // stablo /Pages obnovi.
      Pdf.DeletePage(0);
      // 1-bazna sintaksa raspona za skupne operacije, najviši indeks prvi
      // interno, pa raniji indeksi ostaju valjani.
      Pdf.DeletePages('3-4,9');
      Pdf.SaveLoadedDocument('tagged-report-trimmed.pdf');
    end;
  finally
    Pdf.Free;
  end;
end;

Kako se imenovane destinacije i oznake obrađuju različito?

Imenovane destinacije uklanjaju se, a oznake se preusmjeravaju, jer je ime koje više ne postoji prihvatljiv ishod, dok je oznaka bez odredišta vidljiv defekt. U stablu /Names /Dests HotPDF obilazi svaki čvor, testira svaku destinaciju, i u golu obliku niza i u obliku rječnika s ključem /D, prema izbrisanoj stranici, i uklanja par ime/vrijednost kad je prvi element niza ta stranica. Čvor kojemu i /Names i /Kids na kraju ostanu prazni označava se kao izbrisan i odvezuje od svog roditelja, pa stablo nikad ne drži šuplje listove. Isti test ide i preko starinskog rječnika /Dests u katalogu, a /OpenAction u katalogu jednostavno se odbacuje ako se otvarao na izbrisanoj stranici. Jedna granica ovdje: kad čvor stabla imena izgubi unose, HotPDF briše par /Limits tog čvora umjesto da ponovno izračuna novi najniži i najviši ključ, i dok preglednici bez toga sasvim dobro razrješavaju imena, strogi conformance checker koji čita ISO 32000-1 §7.9.6 može označiti nekorijenski čvor kojemu nedostaje /Limits

Stavke outlinea idu u drugom smjeru. RetargetOutlineDestinations obilazi /First i /Next od korijena outlinea, s popisom posjećenih i ograničenjem dubine od 128 da oštećeno ciklično stablo ne može objesiti poziv, i za svaki niz /Dest ili niz /D akcije /GoTo usmjeren na stranicu zamjenjuje prvi element s NearestRetainedPage: stranicom koja je slijedila izbrisanu, ili onom prije nje kad je izbrisana stranica bila posljednja. Parametri prikaza nakon reference na stranicu ostaju kakvi su bili. Oznaka koja je pokazivala na izbrisani početak poglavlja zato slijeće na prvu stranicu onoga što ostaje, umjesto da nestane iz bočne trake, što je ponašanje koje recenzenti od skraćenog dokumenta i očekuju. Test destinacija ipak prepoznaje samo izričite nizove: stavka outlinea čiji je /Dest string s imenom koje se nekad razrješavalo u izbrisanu stranicu ne preusmjerava se, jer je unos u stablu imena nestao i referenca se sada razrješava u ništa, a ne u oslobođeni objekt, pa je preglednik tretira kao mrtvu oznaku. Mehanika samog stabla outlinea, /First, /Next i ne tako očita semantika /Count, obrađena je u vodiču za dodavanje oznaka i imenovanih destinacija u učitanom PDF-u

// Provjerite prolaz umjesto da mu vjerujete.
Pdf.DeletePage(0);
if Pdf.ResolveLoadedNamedDestination('cover') = -1 then
  ShowMessage('Named destination "cover" was pruned');
// Oznaka koja je ciljala naslovnu stranicu sada se razrješava u
// stranicu koja je slijedila (nulto baziran indeks 0 nakon brisanja).
if Pdf.GetLoadedBookmarkPageIndex('Contents') = 0 then
  ShowMessage('Bookmark retargeted to the nearest retained page');

Što se događa sa structure treeom i ParentTreeom?

Strukturni elementi koji postoje samo zbog izbrisane stranice uklanjaju se, a elementi koji se protežu kroz nekoliko stranica gube svoj ključ /Pg, ali zadržavaju djecu. PruneStructureElement spušta se lancima /K od /StructTreeRoot do dubine 128, rukujući i oblikom niza i oblikom jednog rječnika za /K koje §14.7.2 dopušta. Za svaki element prvo čisti djecu, zatim procjenjuje sam element: ako je čišćenje ispraznilo njegov /K, element se označava izbrisanim i njegov roditelj ga izbacuje. Ako /Pg samog elementa imenuje izbrisanu stranicu, a element još ima djecu plus roditelja /P, uklanja se samo /Pg, jer je /Pg na elementu zadana stranica za njegovu djecu označenog sadržaja, a ta djeca mogu izričito referencirati druge stranice. Samo element čiji je /Pg izbrisana stranica i pod kojim nije ostalo ništa uklanja se u cijelosti

ParentTree dobiva isti tretman, a razlog je onaj koji je zapeo tijekom razvoja: strukturni element može biti dohvatljiv iz ParentTree i nigdje drugdje. Number tree preslikava cijele brojeve /StructParents u jedan element ili niz elemenata, a PruneParentTreeNode vrti PruneStructureElement nad svakom vrijednošću koju nađe, uklanja vrijednosti koje su očišćene, briše par /Nums kad mu je niz vrijednosti prazan i odvezuje čvor kojemu su i /Nums i /Kids nestali. Čišćenje samo potomaka /K ostavilo bi te osirotjene elemente da pokazuju na oslobođenu stranicu kroz /Pg i na oslobođene reference označenog sadržaja kroz svoju djecu /MCR. Ako izvlačite tekst u strukturnom redoslijedu, to je izravno važno: izvlačenje teksta u strukturnom redoslijedu prolazi upravo kroz ta stabla, a element s null /Pg je odlomak koji tiho ispada iz redoslijeda čitanja

Koje se link napomene na preživjelim stranicama uklanjaju?

Svaka link napomena na zadržanoj stranici čiji /Dest niz ili /GoTo akcija pokazuje na izbrisanu stranicu uklanja se zajedno sa svojim vlasništvom u structure treeu. RemoveRetainedPageDestinationAnnotations obilazi niz /Annots svake stranice osim ciljne, primjenjuje isti test destinacija koji se koristi za outline, označava podudarnu napomenu izbrisanom, izbacuje je iz niza i zatim poziva PruneAnnotationReferencesInStructureTree, pa se OBJR rječnik čiji je /Obj imenovao tu napomenu uklanja iz svog strukturnog elementa, a sam element uklanja ako je OBJR bio njegovo jedino dijete. Ostaviti OBJR na mjestu kršilo bi §14.7.4.3, koji zahtijeva da /Obj referencira postojeći objekt, i pojavilo bi se u PDF/UA provjeri kao označeni link bez napomene iza sebe. Primijetite asimetriju s oznakama: linkovi se uklanjaju, a ne preusmjeravaju. Unakrsna referenca u tijelu teksta koja je govorila "vidi stranicu 3" pogrešna je kad stranica 3 nestane, a usmjeriti je na stranicu 4 bila bi laž na način na koji oznaka koja slijeće na najbliže poglavlje nije, pa ako vaš workflow treba te linkove sačuvati, preusmjerite ih sami prije poziva DeletePage

Zašto se uklonjeni /MCR ili /OBJR nikad ne smije registrirati kao slobodan?

Zato što su reference označenog sadržaja i objektne reference obično izravni rječnici unutar niza /K svog roditeljskog elementa, a registar inkrementalnih promjena razrješava izravni objekt u najbliži neizravni objekt koji ga sadrži. Kad RemoveArrayItem izbaci dijete iz niza /K, oslobađa objekt u memoriji samo ako je bio THPDFLink ili neizravna vrijednost, a MarkRemovedObject registrira objekt za listu slobodnih samo kad je njegov broj objekta veći od nule. Prva verzija ovog prolaza nije pravila tu razliku, a učinak pri inkrementalnom spremanju bio je točno ono za što je registar dizajniran: RegisterIncrementalChange hodao je od izravnog /MCR prema gore do svog korijena transakcije grafa, a to je bio zadržani strukturni element koji ga je posjedovao, i zapisao je taj element kao null. Dokument koji je izgubio jednu stranicu vratio se s označenim sadržajem na ostalim stranicama tiho neoznačenim. Jedini ispravan potez za izravno dijete je označiti njegov kontejner prljavim kroz TouchContainer da se kontejner prepiše, i ostaviti listu slobodnih na miru

Zašto se uklonjeno dijete /MCR ili OBJR nikad ne smije registrirati kao slobodno u HotPDF-u: registar inkrementalnih promjena razrješava izravni rječnik u najbliži neizravni kontejner, pa je prva verzija zapisala zadržani strukturni element kao null i tiho skinula oznake s preživjelih stranica, dok TouchContainer sada prepisuje kontejner i ostavlja listu slobodnih na miru
Oslobađanje djeteta u memoriji rezervirano je za THPDFLink ili neizravne vrijednosti i za brojeve objekata veće od nule, pa inkrementalno spremanje dodaje samo dodirnute kontejnere i oslobođeni objekt stranice
// Inkrementalno ažuriranje: samo dodirnuti kontejneri i
// oslobođeni objekt stranice dospijevaju u dodanu sekciju.
Pdf := THotPDF.Create(nil);
try
  Pdf.BeginIncrementalUpdate('tagged-report.pdf');
  Pdf.DeletePage(0);
  // Zadržani strukturni elementi čiji je /K izgubio izravni /MCR
  // prepisuju se na mjestu, nikad se ne zapisuju kao null.
  Pdf.SaveIncrementalUpdate('tagged-report-trimmed.pdf');
finally
  Pdf.Free;
end;

Ista opreznost oblikuje i ono što DeletePage namjerno ne oslobađa na učitanom dokumentu. Content streamovi, XObjecti i ne-widget napomene izbrisane stranice ostaju kao objekti, jer učitana datoteka može bilo koji od njih dijeliti sa stranicom koja ostaje i nema jeftinog načina da se to dokaže u trenutku brisanja. Uklanjanje reference iz stabla stranica dovoljno je za ispravnost; bajtovi koje ti objekti još zauzimaju zasebno su pitanje, a graf ovisnosti objekata i analiza zadržanih bajtova alat je za mjerenje što skraćeni dokument još nosi

DeletePage ili DeleteLoadedPage: koji pozvati?

Pozovite DeletePage za svako uklanjanje stranice prema korisniku, a DeleteLoadedPage čuvajte za slučaj kad se cijeli dokument prelama iznova i nijedna referenca na razini dokumenta nije vrijedna čuvanja. THotPDF.DeleteLoadedPage(PageIndex), dodan u verziji 2.508.0, lagana je varijanta: pomiče interni array stranica, poziva RebuildLoadedKidsArray da prepiše /Kids i /Count, invalidira cache renderiranih stranica i aktivira OnLoadedDocumentModified. Ne obilazi stablo imena, outline, structure tree ni napomene drugih stranica, i ne označava objekt stranice izbrisanim. To je pravi alat unutar N-up impositiona, gdje HotPDF dodaje svježe složene listove i zatim izbacuje svaku izvornu stranicu s DeleteLoadedPage(0): izvorne stranice zamjenjuju se u cijelosti, a sadržaj lista referencira njihove resurse, a ne objekte stranica. Za običan posao "ukloni stranicu 7 iz ovog ugovora", DeletePage jedini je poziv koji označeni, oznakama opremljen i unakrsno povezan dokument ostavlja dovoljno konzistentnim da prođe validator, i pri punom prepisivanju kroz SaveLoadedDocument i pri inkrementalnom ažuriranju kroz SaveIncrementalUpdate. Obje metode isporučuju se u HotPDF Delphi Component za Delphi i C++Builder, bez potrebe za vanjskim viewer runtimeom ili ovisnošću