Articol tehnic

Ștergerea paginilor PDF în Delphi fără referințe atârnate

HotPDF Delphi Component șterge o pagină dintr-un PDF încărcat prin THotPDF.DeletePage, iar din versiunea 2.751.0 apelul acela curăță și fiecare referință de nivel de document care mai arată spre pagină: destinații cu nume din arborele /Names /Dests, dicționarul /Dests moștenit din catalog, acțiuni /GoTo de marcaj, elemente de structură sub /StructTreeRoot, ParentTree, intrări OBJR pentru adnotări și adnotări de link de pe paginile care rămân. Arborele de pagini este reconstruit ultimul, după ce nimic altceva nu mai poate ajunge la obiectul șters

Defectul pe care îl previne este ușor de reprodus și greu de diagnosticat. Ștergeți coperta unui raport etichetat, salvați și deschideți rezultatul: Acrobat arată numărul corect de pagini, dar marcajul „Contents” nu mai ajunge nicăieri, verificatorul de accesibilitate raportează un element de structură fără pagină, iar un validator strict listează o referință către un obiect eliberat. Nimic din arborele de pagini nu este greșit. Problema este că o pagină PDF nu este doar o frunză a lui /Pages; este o țintă spre care arată jumătate din catalog, iar scoaterea frunzei lasă fiecare dintre pointerii aceia atârnat

De ce nu este de ajuns să scoți o pagină din /Kids?

Pentru că ISO 32000-1 lasă cel puțin șapte structuri independente să țină o referință către un obiect de pagină, iar doar una dintre ele este arborele de pagini. Scoaterea paginii din /Kids și decrementarea lui /Count satisface §7.7.3, iar fiecare altă referință devine un pointer către un obiect care fie este eliberat în xref, fie pur și simplu lipsește din fișierul rescris. Un vizualizator care urmează unul dintre pointerii aceia primește null, iar ce face cu acel null ține de vizualizator

  • Arborele de nume de sub /Names /Dests (§7.7.4, §12.3.2.3) mapează nume la tablouri de destinație al căror prim element este pagina
  • Dicționarul /Dests de dinainte de 1.2, direct în catalog, ține același tip de tablouri indexate după nume
  • Elementele de outline (§12.3.3) ajung la o pagină fie printr-un /Dest inline, fie printr-o acțiune /A cu /S /GoTo și un tablou /D
  • Elementele de structură (§14.7.2) cară o cheie /Pg care numește pagina pe care trăiește conținutul lor marcat, iar copiii lor /K pot fi referințe de conținut marcat și referințe de obiect (§14.7.4.3) legate de pagina aceea
  • ParentTree (§14.7.4.4) mapează numerele /StructParents de pagină și de adnotare înapoi la elemente de structură, iar un element poate trăi acolo fără să apară deloc pe lanțul /K de la rădăcină
  • Adnotările de link de pe alte pagini (§12.5.6.5) cară un /Dest sau o acțiune /GoTo care țintește pagina, iar /OpenAction din catalog poate face la fel
De ce nu este de ajuns să scoți o pagină HotPDF din /Kids: ISO 32000-1 lasă arborele de nume /Names /Dests, dicționarul /Dests moștenit din catalog, elementele de outline, elementele de structură cu /Pg, ParentTree, adnotările de link și /OpenAction să țină toate o referință la același obiect de pagină, iar doar arborele de pagini este reconstruit
O pagină PDF este o țintă spre care arată jumătate din catalog: scoaterea frunzei mulțumește arborele de pagini, în timp ce fiecare alt pointer rezolvă la null, așa că un raport scurtat își pierde marcajul Contents și cade la verificarea de accesibilitate

Ce curăță THotPDF.DeletePage înainte de a atinge arborele de pagini?

THotPDF.DeletePage(PageIndex) pe un document încărcat rulează întâi întreaga baleiere de referințe, apoi marchează obiectul de pagină ca șters cu DeleteObj, desprinde eventualele adnotări de widget din arborele de câmpuri AcroForm, deplasează tabloul intern de pagini și în final apelează RebuildLoadedPageTree ca să rescrie /Kids, /Count și /Parent al fiecărei pagini care supraviețuiește. Baleierea vizitează catalogul într-o ordine fixă: arborele de nume /Names /Dests, dicționarul /Dests de stil vechi, /OpenAction, arborele de outline, /StructTreeRoot cu ParentTree al lui și, ultimele, tablourile /Annots ale fiecărei pagini care rămâne. Fiecare pas decide dacă o referință este scoasă, retargetată sau lăsată în pace, după ce permite specificația ca structura aceea să facă fără pagină. Două paze se aplică înainte de a rula ceva: DeletePage ridică Invalid page number pentru un index în afara intervalului și refuză să scoată ultima pagină, pentru că un nod /Pages cu zero copii nu este un PDF valid, iar DeletePages preia aceeași notație bazată pe 1 "1,3-5,7-" ca celelalte operații de pagină pe document încărcat și iterează de la cel mai mare index selectat în jos, ca indexurile pe care le-ați scris să rămână valide cât lucrează

Baleierea fixă de referințe pe care o rulează THotPDF.DeletePage înainte de a atinge arborele de pagini: pazele resping un index în afara intervalului sau ultima pagină, apoi /Names /Dests și /Dests moștenit sunt curățate, /OpenAction este aruncat, outline-urile sunt retargetate spre NearestRetainedPage, StructTreeRoot și ParentTree sunt curățate, link-urile de pe paginile păstrate sunt scoase, iar RebuildLoadedPageTree rulează ultimul
Fiecare structură primește tratamentul pe care îl permite specificația: numele dispar, marcajele aterizează pe cea mai apropiată pagină păstrată, elementele de structură își pierd /Pg sau dispar, iar rescrierea /Kids are loc abia după ce nimic altceva nu mai poate ajunge la obiectul șters
var
  Pdf: THotPDF;
begin
  Pdf := THotPDF.Create(nil);
  try
    if Pdf.LoadFromFile('tagged-report.pdf', '') > 0 then
    begin
      // Bazat pe zero: scoate coperta. Destinațiile cu nume,
      // marcajele, arborele de structură, ParentTree și
      // adnotările de link care arătau spre ea sunt curățate înainte
      // ca arborele /Pages să fie reconstruit.
      Pdf.DeletePage(0);
      // Sintaxă de interval bazată pe 1 pentru loturi, indexul cel mai mare
      // intern, ca indexurile anterioare să rămână valide.
      Pdf.DeletePages('3-4,9');
      Pdf.SaveLoadedDocument('tagged-report-trimmed.pdf');
    end;
  finally
    Pdf.Free;
  end;
end;

Cum sunt tratate diferit destinațiile cu nume și marcajele?

Destinațiile cu nume sunt scoase, iar marcajele sunt retargetate, pentru că un nume care nu mai există este un rezultat acceptabil, în timp ce un marcaj fără destinație este un defect vizibil. În arborele /Names /Dests, HotPDF parcurge fiecare nod, testează fiecare destinație, atât în forma de tablou simplu, cât și în forma de dicționar cu cheie /D, față de pagina ștearsă, și scoate perechea nume/valoare când primul element al tabloului este pagina aceea. Un nod ale cărui /Names și /Kids ajung amândouă goale este marcat șters și dezlegat de la părintele lui, așa că arborele nu păstrează niciodată frunze goale. Același test rulează peste dicționarul /Dests de stil vechi din catalog, iar /OpenAction din catalog este pur și simplu aruncat dacă se deschidea pe pagina ștearsă. O limită aici: când un nod din arborele de nume pierde intrări, HotPDF șterge perechea /Limits a nodului aceluia în loc să recalculeze noile chei minimă și maximă, iar deși vizualizatoarele rezolvă numele fără probleme și așa, un verificator strict de conformitate care citește ISO 32000-1 §7.9.6 poate semnala un nod non-rădăcină care nu are /Limits

Elementele de outline merg în cealaltă direcție. RetargetOutlineDestinations parcurge /First și /Next din rădăcina de outline, cu o listă de noduri vizitate și o limită de adâncime de 128, ca un arbore ciclic corupt să nu poată bloca apelul, iar pentru fiecare tablou /Dest sau tablou /D de acțiune /GoTo îndreptat spre pagină înlocuiește primul element cu NearestRetainedPage: pagina care a urmat celei șterse sau pagina dinaintea ei când pagina ștearsă era ultima. Parametrii de vizualizare de după referința de pagină sunt lăsați așa cum erau. Un marcaj care arăta spre o deschidere de capitol ștearsă aterizează deci pe prima pagină din ce rămâne, în loc să dispară din bara laterală, care este comportamentul pe care recenzenții îl așteaptă de la un document scurtat. Testul de destinație potrivește totuși doar tablourile explicite: un element de outline al cărui /Dest este un șir de nume care obișnuia să rezolve la pagina ștearsă nu este retargetat, pentru că intrarea din arborele de nume a dispărut și referința rezolvă acum la nimic, nu la un obiect eliberat, așa că vizualizatorul o tratează ca pe un marcaj mort. Mecanica arborelui de outline în sine, /First, /Next și semantica neevidentă a lui /Count, sunt acoperite în ghidul despre adăugarea de marcaje și destinații cu nume pe un PDF încărcat

// Verificați baleierea în loc să vă încredeți în ea.
Pdf.DeletePage(0);
if Pdf.ResolveLoadedNamedDestination('cover') = -1 then
  ShowMessage('Named destination "cover" was pruned');
// Un marcaj care țintea coperta rezolvă acum la
// pagina care a urmat (index bazat pe zero 0 după ștergere).
if Pdf.GetLoadedBookmarkPageIndex('Contents') = 0 then
  ShowMessage('Bookmark retargeted to the nearest retained page');

Ce se întâmplă cu arborele de structură și cu ParentTree?

Elementele de structură care există doar din cauza paginii șterse sunt scoase, iar elementele care se întind pe mai multe pagini își pierd cheia /Pg, dar își păstrează copiii. PruneStructureElement coboară pe lanțul /K din /StructTreeRoot până la o adâncime de 128, tratând atât forma de tablou, cât și forma de dicționar unic ale lui /K pe care le permite §14.7.2. Pentru fiecare element curăță întâi copiii, apoi evaluează elementul însuși: dacă curățarea i-a golit /K, elementul este marcat șters și părintele lui îl scoate. Dacă /Pg al elementului numește pagina ștearsă și elementul mai are copii plus un părinte /P, doar /Pg este scos, pentru că un /Pg pe un element este pagina implicită pentru copiii lui de conținut marcat, iar copiii aceia pot referi alte pagini în mod explicit. Doar un element al cărui /Pg este pagina ștearsă și care nu mai are nimic sub el este scos de tot

ParentTree primește același tratament, iar motivul este cel care a mușcat în timpul dezvoltării: un element de structură poate fi accesibil din ParentTree și de nicăieri altundeva. Arborele de numere mapează întregi /StructParents la un singur element sau la un tablou de elemente, iar PruneParentTreeNode rulează PruneStructureElement peste fiecare valoare pe care o găsește, scoate valorile care au fost curățate, șterge o pereche /Nums când tabloul ei de valori este gol și dezleagă un nod ale cărui /Nums și /Kids au dispărut amândouă. Curățarea doar a descendenților lui /K ar fi lăsat elementele acelea orfane arătând spre o pagină eliberată prin /Pg și spre referințe de conținut marcat eliberate prin copiii lor /MCR. Dacă extrageți text în ordine de structură, asta contează direct: extragerea de text în ordine de structură parcurge exact arborii aceștia, iar un element cu /Pg nul este un paragraf care iese în tăcere din ordinea de citire

Care adnotări de link de pe paginile care rămân sunt scoase?

Orice adnotare de link de pe o pagină păstrată al cărei tablou /Dest sau acțiune /GoTo arată spre pagina ștearsă este scoasă împreună cu proprietatea ei din arborele de structură. RemoveRetainedPageDestinationAnnotations parcurge tabloul /Annots al fiecărei pagini în afară de cea țintă, aplică același test de destinație folosit pentru outline-uri, marchează adnotarea potrivită ca ștearsă, o scoate din tablou și apoi apelează PruneAnnotationReferencesInStructureTree, ca dicționarul OBJR al cărui /Obj numea adnotarea aceea să fie scos din elementul lui de structură, cu elementul însuși scos dacă OBJR era singurul lui copil. Lăsarea OBJR la locul lui ar încălca §14.7.4.3, care cere ca /Obj să refere un obiect existent, și ar apărea într-o verificare PDF/UA ca un link etichetat fără nicio adnotare în spate. Observați asimetria față de marcaje: link-urile sunt scoase, nu retargetate. O referință încrucișată în corpul textului care spunea „vezi pagina 3” este greșită odată ce pagina 3 a dispărut, iar îndreptarea ei spre pagina 4 ar fi o minciună într-un fel în care un marcaj care aterizează pe cel mai apropiat capitol nu este, așa că dacă fluxul dumneavoastră are nevoie ca link-urile acelea să fie păstrate, retargetați-le singur înainte de a apela DeletePage

De ce nu trebuie niciodată înregistrat ca liber un /MCR sau OBJR scos?

Pentru că referințele de conținut marcat și referințele de obiect sunt de regulă dicționare directe în interiorul tabloului /K al elementului lor părinte, iar registrul de modificări incrementale rezolvă un obiect direct la cel mai apropiat obiect indirect care îl conține. Când RemoveArrayItem scoate un copil dintr-un tablou /K, eliberează obiectul din memorie doar dacă acesta era un THPDFLink sau o valoare ne-indirectă, iar MarkRemovedObject înregistrează un obiect în lista de libere doar când numărul lui de obiect este mai mare decât zero. Prima versiune a acestei baleieri nu făcea distincția aceea, iar efectul într-o salvare incrementală a fost exact ce este proiectat registrul să facă: RegisterIncrementalChange urca de la /MCR-ul direct până la rădăcina lui de tranzacție în graf, care era elementul de structură păstrat ce îl deținea, și scria elementul acela ca null. Un document care pierdea o pagină se întorcea cu conținutul etichetat de pe celelalte pagini devenit în tăcere neetichetat. Singura mișcare corectă pentru un copil direct este să marchezi containerul lui ca murdar prin TouchContainer, ca containerul să fie rescris, și să lași lista de libere în pace

De ce un copil /MCR sau OBJR scos nu trebuie niciodată înregistrat ca liber în HotPDF: registrul de modificări incrementale rezolvă un dicționar direct la cel mai apropiat container indirect, așa că prima versiune scria elementul de structură păstrat ca null și dezetichita în tăcere paginile care supraviețuiau, în timp ce TouchContainer rescrie acum containerul și lasă lista de libere în pace
Eliberarea copilului din memorie este rezervată pentru THPDFLink sau valori ne-indirecte și pentru numere de obiect mai mari decât zero, așa că o salvare incrementală adaugă doar containerele atinse și obiectul de pagină eliberat
// Actualizare incrementală: doar containerele atinse și
// obiectul de pagină eliberat ajung în secțiunea adăugată.
Pdf := THotPDF.Create(nil);
try
  Pdf.BeginIncrementalUpdate('tagged-report.pdf');
  Pdf.DeletePage(0);
  // Elementele de structură păstrate al căror /K a pierdut un /MCR direct
  // sunt rescrise în loc, niciodată scrise ca null.
  Pdf.SaveIncrementalUpdate('tagged-report-trimmed.pdf');
finally
  Pdf.Free;
end;

Aceeași prudență dă forma a ceea ce DeletePage nu eliberează în mod deliberat pe un document încărcat. Stream-urile de conținut, XObject-urile și adnotările care nu sunt widget-uri ale paginii șterse sunt lăsate ca obiecte, pentru că un fișier încărcat poate partaja oricare dintre ele cu o pagină care rămâne și nu există o cale ieftină de a dovedi contrariul la momentul ștergerii. Scoaterea referinței din arborele de pagini este de ajuns pentru corectitudine; octeții pe care obiectele acelea îi ocupă în continuare sunt o întrebare separată, iar graful de dependențe de obiecte și analiza octeților reținuți este instrumentul cu care se măsoară ce mai cară un document scurtat

DeletePage versus DeleteLoadedPage: pe care să îl apelați?

Apelați DeletePage pentru orice scoatere de pagină vizibilă utilizatorului și păstrați DeleteLoadedPage pentru cazul în care întregul document este rearanjat și nicio referință de nivel de document nu merită păstrată. THotPDF.DeleteLoadedPage(PageIndex), adăugat în versiunea 2.508.0, este varianta ușoară: deplasează tabloul intern de pagini, apelează RebuildLoadedKidsArray ca să rescrie /Kids și /Count, invalidează cache-ul de pagini randate și declanșează OnLoadedDocumentModified. Nu parcurge arborele de nume, outline-urile, arborele de structură sau adnotările altor pagini și nu marchează obiectul de pagină ca șters. Acesta este instrumentul potrivit în interiorul impunerii N-up, unde HotPDF adaugă foi proaspăt compuse și apoi scoate fiecare pagină originală cu DeleteLoadedPage(0): paginile sursă sunt înlocuite în bloc, iar conținutul foii referă resursele lor, nu obiectele de pagină. Pentru treaba obișnuită de „scoate pagina 7 din contractul acesta”, DeletePage este singurul apel care lasă un document etichetat, cu marcaje și cu referințe încrucișate destul de consistent ca să treacă de un validator, atât într-o rescriere completă prin SaveLoadedDocument, cât și într-o actualizare incrementală prin SaveIncrementalUpdate. Ambele metode sunt livrate în HotPDF Delphi Component pentru Delphi și C++Builder, fără a fi nevoie de vreun runtime de vizualizator extern sau de vreo dependență