Műszaki cikk

PDF-oldalak törlése Delphiben lógó referenciák nélkül

A HotPDF Delphi Component a THotPDF.DeletePage-pel töröl egy oldalt egy betöltött PDF-ből, és a 2.751.0 verzió óta ez a hívás kitakarítja az összes dokumentumszintű hivatkozást is, ami még az oldalra mutat: a /Names /Dests fában lévő nevesített célokat, a régi katalógus /Dests szótárát, a könyvjelzők /GoTo akcióit, a /StructTreeRoot alatti struktúraelemeket, a ParentTree-t, az annotációk OBJR bejegyzéseit és a megmaradó oldalak linkannotációit. Az oldalfát utoljára építi újra, miután semmi más nem érheti el a törölt objektumot

A hiba, amit ez megelőz, könnyen reprodukálható és nehezen diagnosztizálható. Töröld egy taggelt jelentés borítólapját, mentsd el, és nyisd meg az eredményt: az Acrobat a helyes oldalszámot mutatja, a „Contents” könyvjelző viszont most sehova nem érkezik meg, az akadálymentesítési ellenőrző egy oldal nélküli struktúraelemről számol be, egy szigorú validátor pedig egy szabad objektumra mutató hivatkozást sorol fel. Az oldalfában semmi nem hibás. A probléma az, hogy egy PDF-oldal nem csak a /Pages egy levele; ez egy célpont, amire a katalógus fele mutat, és a levél eltávolítása mindegyik pointert lógva hagyja

Miért nem elég egy oldalt eltávolítani a /Kids-ből?

Mert az ISO 32000-1 legalább hét független struktúrának engedi meg, hogy egy oldalobjektumra hivatkozzon, és ezek közül csak egy az oldalfa. Az oldal elhagyása a /Kids-ből és a /Count csökkentése kielégíti a §7.7.3-at, minden más hivatkozás viszont egy olyan objektumra mutató pointerré válik, ami vagy felszabadul az xrefben, vagy egyszerűen hiányzik az újraírt fájlból. Egy megjelenítő, ami követi az egyik ilyen pointert, null-t kap, és hogy mit tesz azzal a null-lal, az a megjelenítőn múlik

  • A /Names /Dests alatti névfa (§7.7.4, §12.3.2.3) neveket képez le célponttömbökre, amiknek az első eleme az oldal
  • Az 1.2 előtti /Dests szótár közvetlenül a katalógusban ugyanilyen, névvel kulcsolt tömböket tart
  • A vázlatpontok (§12.3.3) egy oldalt vagy egy beágyazott /Dest-en, vagy egy /A akción keresztül érnek el, /S /GoTo-val és egy /D tömbbel
  • A struktúraelemek (§14.7.2) egy /Pg kulcsot hordoznak, ami megnevezi az oldalt, amin a megjelölt tartalmuk él, a /K gyerekeik pedig megjelölt-tartalom referenciák és objektumreferenciák (§14.7.4.3) lehetnek, amik ahhoz az oldalhoz kötődnek
  • A ParentTree (§14.7.4.4) az oldal- és annotáció-/StructParents számokat képezi vissza struktúraelemekre, és egy elem ott is élhet anélkül, hogy egyáltalán megjelenne a gyökérből induló /K láncon
  • A többi oldal linkannotációi (§12.5.6.5) egy /Dest-et vagy /GoTo akciót hordoznak, ami az oldalra céloz, és a katalógus /OpenAction-je ugyanezt teheti
Miért nem elég egy HotPDF-oldalt eltávolítani a /Kids-ből: az ISO 32000-1 megengedi, hogy a /Names /Dests névfa, a régi katalógus /Dests szótára, a vázlatelemek /Pg-vel, a ParentTree, a linkannotációk és az /OpenAction mind ugyanarra az oldalobjektumra hivatkozzanak, és csak az oldalfa épül újra
Egy PDF-oldal olyan célpont, amire a katalógus fele mutat: a levél eldobása kielégíti az oldalfát, miközben minden más pointer null-ra oldódik fel, így egy megkurtított jelentés elveszíti a Contents könyvjelzőjét és elbukik az akadálymentesítési ellenőrzésen

Mit tisztít meg a THotPDF.DeletePage, mielőtt az oldalfához hozzányúlna?

A THotPDF.DeletePage(PageIndex) egy betöltött dokumentumon először lefuttatja a teljes referenciaseprést, majd a DeleteObj-jal töröltnek jelöli az oldalobjektumot, leválasztja a widget-annotációkat az AcroForm mezőfáról, eltolja a belső oldaltömböt, és végül meghívja a RebuildLoadedPageTree-t, hogy újraírja a /Kids-et, a /Count-ot és minden megmaradó oldal /Parent mezőjét. A seprés fix sorrendben járja be a katalógust: a /Names /Dests névfát, a régi stílusú /Dests szótárt, az /OpenAction-t, a vázlatfát, a /StructTreeRoot-ot a ParentTree-jével, és végül minden megmaradó oldal /Annots tömbjét. Minden lépés eldönti, hogy egy hivatkozás törlődik, átirányul vagy békén marad, aszerint, hogy a specifikáció mit enged meg annak a struktúrának az oldal nélkül. Két őr érvényes, mielőtt bármi elindul: a DeletePage Invalid page number hibát dob egy tartományon kívüli indexre, és megtagadja az utolsó oldal eltávolítását, mert egy nulla gyerekes /Pages csomópont nem érvényes PDF, a DeletePages pedig ugyanazt az egyalapú "1,3-5,7-" jelölést veszi, mint a többi betöltött dokumentumos oldalművelet, és a legmagasabb kijelölt indextől lefelé iterál, hogy az általad leírt indexek érvényesek maradjanak, amíg dolgozik

A fix referenciaseprés, amit a THotPDF.DeletePage futtat, mielőtt az oldalfához hozzányúlna: az őrök visszautasítják a tartományon kívüli indexet vagy az utolsó oldalt, majd a /Names /Dests és a régi /Dests kimetélődik, az /OpenAction eldobódik, a vázlatok a NearestRetainedPage-re irányulnak át, a StructTreeRoot és a ParentTree kimetélődik, a megmaradó oldalak linkjei törlődnek, és végül a RebuildLoadedPageTree fut le
Minden struktúra azt a kezelést kapja, amit a specifikáció megenged: a nevek eltűnnek, a könyvjelzők a legközelebbi megmaradt oldalra érkeznek meg, a struktúraelemek elveszítik a /Pg-jüket vagy eltűnnek, a /Kids újraírása pedig csak azután történik meg, hogy semmi más nem érheti el a törölt objektumot
var
  Pdf: THotPDF;
begin
  Pdf := THotPDF.Create(nil);
  try
    if Pdf.LoadFromFile('tagged-report.pdf', '') > 0 then
    begin
      // Nulla alapú: dobjuk el a borítólapot. A nevesített célok,
      // a könyvjelzők, a struktúrafa, a ParentTree és a rá mutató
      // linkannotációk mind kimetélődnek, mielőtt az /Pages
      // fa újraépülne.
      Pdf.DeletePage(0);
      // Egyalapú tartományszintaxis kötegekhez, belül a legmagasabb
      // indextől, hogy a korábbi indexek érvényesek maradjanak.
      Pdf.DeletePages('3-4,9');
      Pdf.SaveLoadedDocument('tagged-report-trimmed.pdf');
    end;
  finally
    Pdf.Free;
  end;
end;

Miben tér el a nevesített célok és a könyvjelzők kezelése?

A nevesített célok törlődnek, a könyvjelzők pedig átirányulnak, mert egy név, ami már nem létezik, elfogadható kimenet, egy cél nélküli könyvjelző viszont látható hiba. A /Names /Dests fában a HotPDF bejár minden csomópontot, minden célpontot megvizsgál — mind a csupasz tömbös formában, mind a /D kulcsos szótáros formában — a törölt oldal ellen, és eltávolítja a név/érték párt, amikor a tömb első eleme az az oldal. Egy csomópontot, aminek a /Names és a /Kids mezője is kiürül, töröltnek jelöl, és lecsatol a szülőjéről, így a fa soha nem tart üres leveleket. Ugyanez a vizsgálat fut le a régi stílusú katalógus /Dests szótáron, a katalógus /OpenAction-jét pedig egyszerűen eldobja, ha az a törölt oldalra nyílt. Egy határvonal itt: amikor egy névfacsomópont elveszíti a bejegyzéseit, a HotPDF az adott csomópont /Limits párját törli ahelyett, hogy újraszámolná az új legalacsonyabb és legmagasabb kulcsot, és bár a megjelenítők nélküle is szépen feloldják a neveket, egy szigorú megfelelőség-ellenőrző, ami az ISO 32000-1 §7.9.6-ot olvassa, jelezhet egy nem gyökér csomópontot, amiből hiányzik a /Limits

A vázlatpontok a másik irányba mennek. A RetargetOutlineDestinations a /First és /Next mentén járja be a vázlat gyökerétől a fát, egy látott-listával és 128-as mélységkorláttal, hogy egy sérült, ciklikus fa ne akaszthassa meg a hívást, és minden olyan /Dest tömb vagy /GoTo akció /D tömb esetében, ami az oldalra céloz, az első elemet a NearestRetainedPage-re cseréli: arra az oldalra, ami a törölt után következett, vagy az előtte lévőre, ha a törölt oldal volt az utolsó. Az oldalhivatkozás utáni nézetparaméterek úgy maradnak, ahogy voltak. Egy könyvjelző, ami egy törölt fejezetnyitóra mutatott, ezért a maradék első oldalára érkezik meg ahelyett, hogy eltűnne az oldalsávból, és pontosan ezt várják a lektorok egy megkurtított dokumentumtól. A célpontvizsgálat viszont csak az explicit tömbökre illeszkedik: egy vázlatelem, aminek a /Dest mezője egy névstring, ami korábban a törölt oldalra oldódott fel, nem irányul át, mert a névfabejegyzés eltűnt, és a hivatkozás most nem egy felszabadított objektumra, hanem semmire oldódik fel, így a megjelenítő halott könyvjelzőként kezeli. Magának a vázlatfának a mechanikáját, a /First, a /Next és a nem nyilvánvaló /Count szemantikát a könyvjelzők és nevesített célok betöltött PDF-hez való hozzáadásáról szóló útmutató tárgyalja

// Ellenőrizd a seprést ahelyett, hogy megbíznál benne.
Pdf.DeletePage(0);
if Pdf.ResolveLoadedNamedDestination('cover') = -1 then
  ShowMessage('Named destination "cover" was pruned');
// Egy könyvjelző, ami a borítóra célzott, most az utána
// következő oldalra oldódik fel (a törlés után 0-s index).
if Pdf.GetLoadedBookmarkPageIndex('Contents') = 0 then
  ShowMessage('Bookmark retargeted to the nearest retained page');

Mi történik a struktúrafával és a ParentTree-vel?

Azok a struktúraelemek, amik csak a törölt oldal miatt léteznek, eltávolításra kerülnek, a több oldalon átnyúló elemek pedig elveszítik a /Pg kulcsukat, de megtartják a gyerekeiket. A PruneStructureElement a /StructTreeRoot-tól ereszkedik le a /K láncon 128-as mélységig, kezelve a /K tömbös és egyetlen szótáras formáját is, amit a §14.7.2 megenged. Minden elemnél először a gyerekeket metszi, majd magát az elemet értékeli: ha a metszés kiürítette a /K-ját, az elem töröltnek jelölődik, és a szülője eldobja. Ha az elem saját /Pg mezője a törölt oldalt nevezi meg, és az elemnek még vannak gyerekei meg egy /P szülője, csak a /Pg törlődik, mert egy elemen lévő /Pg a megjelölt-tartalom gyerekei számára az alapértelmezett oldal, és azok a gyerekek explicit módon más oldalakra is hivatkozhatnak. Csak az az elem tűnik el egyenesen, aminek a /Pg-je a törölt oldal, és ami alatt nem maradt semmi

A ParentTree ugyanezt a kezelést kapja, és az ok az, ami a fejlesztés közben megmart minket: egy struktúraelem elérhető lehet a ParentTree-ből, és sehonnan máshonnan. A numbers tree a /StructParents egészeket vagy egyetlen elemre, vagy elemek tömbjére képezi le, a PruneParentTreeNode pedig lefuttatja a PruneStructureElement-et minden általa talált értéken, eltávolítja a kimetélt értékeket, töröl egy /Nums párt, amikor az értéktömbje üres, és lecsatol egy csomópontot, aminek a /Nums és a /Kids mezője is eltűnt. Ha csak a /K leszármazottait metszettük volna, azok az árva elemek a /Pg-n keresztül egy felszabadított oldalra, a /MCR gyerekeiken keresztül pedig felszabadított megjelölt-tartalom referenciákra mutatnának. Ha struktúrasorrendben nyersz ki szöveget, ez közvetlenül számít: a struktúrasorrendű szövegkinyerés pontosan ezeket a fákat járja, és egy null /Pg-jű elem egy olyan bekezdés, ami csendben kiesik az olvasási sorrendből

Mely linkannotációk törlődnek a megmaradó oldalakon?

Minden olyan linkannotáció egy megmaradó oldalon, aminek a /Dest tömbje vagy /GoTo akciója a törölt oldalra mutat, eltávolításra kerül a struktúrafabeli tulajdonával együtt. A RemoveRetainedPageDestinationAnnotations bejárja minden oldal /Annots tömbjét a célon kívül, alkalmazza ugyanazt a célpontvizsgálatot, amit a vázlatoknál, töröltnek jelöl egy illeszkedő annotációt, eldobja a tömbből, majd meghívja a PruneAnnotationReferencesInStructureTree-et, hogy az a OBJR szótár, aminek az /Obj mezője azt az annotációt nevezte meg, eltűnjön a struktúraeleméből, maga az elem pedig eltűnjön, ha az OBJR volt az egyetlen gyereke. Az OBJR helyben hagyása megsértené a §14.7.4.3-at, ami megköveteli, hogy az /Obj egy létező objektumra hivatkozzon, és egy PDF/UA-ellenőrzésben egy annotáció nélküli taggelt linkként jelenne meg. Vedd észre az aszimmetriát a könyvjelzőkkel: a linkek törlődnek, nem irányulnak át. Egy szövegtörzsben lévő kereszthivatkozás, ami azt mondta, „lásd a 3. oldalt”, hibás lesz, amint a 3. oldal eltűnt, és a 4. oldalra mutatni hazugság lenne olyan módon, ahogy egy szomszédos fejezetre érkező könyvjelző nem az; ha tehát a munkafolyamatodnak meg kell őriznie ezeket a linkeket, irányítsd át őket magad, mielőtt a DeletePage-et hívod

Miért nem szabad egy eltávolított /MCR-t vagy /OBJR-t szabadként regisztrálni?

Mert a megjelölt-tartalom referenciák és az objektumreferenciák rendszerint közvetlen szótárak a szülőelemük /K tömbjében, az inkrementális változásregisztráció pedig egy közvetlen objektumot a legközelebbi őt tartalmazó indirekt objektumra old fel. Amikor a RemoveArrayItem eldob egy gyereket egy /K tömbből, az in-memory objektumot csak akkor szabadítja fel, ha az egy THPDFLink vagy egy nem indirekt érték volt, a MarkRemovedObject pedig csak akkor regisztrál egy objektumot a szabad listára, ha az objektumszáma nagyobb nullánál. A seprés első verziója nem tette meg ezt a különbséget, és a hatás egy inkrementális mentésben pontosan az volt, amire a regisztráció készült: a RegisterIncrementalChange a közvetlen /MCR-től felment a gráftranzakció-gyökeréig, ami az őt birtokló megmaradt struktúraelem volt, és azt az elemet null-ként írta ki. Egy dokumentum, ami elveszített egy oldalt, úgy jött vissza, hogy a többi oldalon lévő taggelt tartalom csendben taggelatlanná vált. Az egyetlen helyes lépés egy közvetlen gyereknél az, hogy a TouchContainer-en keresztül piszkosnak jelöljük a konténerét, hogy a konténer újraíródjon, a szabad listát pedig békén hagyjuk

Miért nem szabad egy eltávolított /MCR vagy OBJR gyereket szabadként regisztrálni a HotPDF-ben: az inkrementális változásregisztráció egy közvetlen szótárat a legközelebbi indirekt konténerre old fel, így az első verzió a megmaradt struktúraelemeket null-ként írta ki és csendben taggelatlanná tette a túlélő oldalakat, a TouchContainer pedig most újraírja a konténert és békén hagyja a szabad listát
Az in-memory gyerek felszabadítása a THPDFLink és a nem indirekt értékek, valamint a nullánál nagyobb objektumszámok számára van fenntartva, így egy inkrementális mentés csak az érintett konténereket és a felszabadított oldalobjektumot fűzi hozzá
// Inkrementális frissítés: csak az érintett konténerek és
// a felszabadított oldalobjektum kerül a hozzáfűzött szakaszba.
Pdf := THotPDF.Create(nil);
try
  Pdf.BeginIncrementalUpdate('tagged-report.pdf');
  Pdf.DeletePage(0);
  // Azok a megmaradt struktúraelemek, amiknek a /K-ja elveszített
  // egy közvetlen /MCR-t, helyben íródnak újra, soha nem null-ként.
  Pdf.SaveIncrementalUpdate('tagged-report-trimmed.pdf');
finally
  Pdf.Free;
end;

Ugyanez az óvatosság formálja azt is, amit a DeletePage szándékosan nem szabadít fel egy betöltött dokumentumon. A törölt oldal tartalom-streamjei, XObjectjei és nem widget annotációi objektumként megmaradnak, mert egy betöltött fájl bármelyiküket megoszthatja egy megmaradó oldallal, és törléskor nincs olcsó módja ennek cáfolatára. Az oldalfa-hivatkozás eltávolítása elég a helyességhez; hogy azok az objektumok mekkora helyet foglalnak még, az külön kérdés, és a az objektum-függőségi gráf és a megtartott bájtok elemzése az eszköz annak mérésére, mit hordoz még egy megkurtított dokumentum

DeletePage vagy DeleteLoadedPage: melyiket hívd?

A DeletePage-et hívd minden felhasználó felé irányuló oldaltörléshez, a DeleteLoadedPage-et pedig tartsd meg arra az esetre, amikor az egész dokumentumot újrarendezed, és egyetlen dokumentumszintű hivatkozás sem érdemes a megtartásra. A THotPDF.DeleteLoadedPage(PageIndex), ami a 2.508.0 verzióban érkezett, a könnyű változat: eltolja a belső oldaltömböt, meghívja a RebuildLoadedKidsArray-t a /Kids és a /Count újraírásához, érvényteleníti a renderelt oldal cache-t, és elsüti az OnLoadedDocumentModified eseményt. Nem járja be a névfát, a vázlatokat, a struktúrafát vagy a többi oldal annotációit, és nem jelöli töröltnek az oldalobjektumot. Ez a helyes eszköz az N-up imposition belsejében, ahol a HotPDF frissen összeállított íveket fűz hozzá, majd minden eredeti oldalt eldob DeleteLoadedPage(0)-val: a forrásoldalak teljes egészükben cserélődnek, az ív tartalma pedig azok erőforrásaira hivatkozik, nem az oldalobjektumokra. A hétköznapi „távolítsd el a 7. oldalt ebből a szerződésből” feladathoz a DeletePage az egyetlen hívás, ami elég konzisztensen hagy egy taggelt, könyvjelzőzött, kereszthivatkozott dokumentumot ahhoz, hogy átmenjen egy validátoron, ugyanúgy egy teljes újraírásban a SaveLoadedDocument-en keresztül, mint egy inkrementális frissítésben a SaveIncrementalUpdate-en keresztül. Mindkét metódus a HotPDF Delphi Component része Delphihez és C++Builderhez, külső megjelenítő-futtatókörnyezet vagy függőség nélkül