Een getagd rapport waarvan de omslagpagina eruit is gesneden kan openen met het juiste aantal pagina's en toch op drie plekken stuk zijn: het bookmark "Contents" doet niets wanneer u erop klikt, de toegankelijkheidschecker meldt een structuurelement dat bij geen enkele pagina hoort, en een strenge validator geeft een verwijzing naar een vrij object aan. De paginaboom is in orde. ISO 32000-1 §7.7.3 vraagt alleen dat de pagina uit /Kids verdwijnt en dat /Count met één daalt, en een bestand kan aan die clausule voldoen terwijl de halve catalog nog naar de ontbrekende pagina wijst
Zeven structuren kunnen een verwijzing naar één pagina bevatten
Maar één daarvan is de paginaboom. De rest zit verspreid over de catalog, de outline, de logical structure en de annotaties van andere pagina's, en elke verwijzing lost op naar null zodra het paginaobject uit het bestand verdwenen is. Wat een viewer met die null doet is zijn eigen keuze, dus hetzelfde uitgedunde bestand kan in twee readers op verschillende manieren misgaan
- De name tree onder
/Names/Dests(§7.7.4, §12.3.2.3) mapt namen op destinationarrays waarvan het eerste element de pagina is - De
/Dests-dictionary van vóór 1.2, direct in de catalog, bevat hetzelfde soort arrays met een naam als key - Outline-items (§12.3.3) bereiken een pagina via een inline
/Destof via een/A-actie met/S /GoToen een/D-array - Structuurelementen (§14.7.2) dragen een
/Pg-key die de pagina noemt waar hun marked content op staat, en hun/K-kinderen kunnen marked-content-references en object references (§14.7.4.3) zijn die aan die pagina vastzitten - De
ParentTree(§14.7.4.4) mapt/StructParents-nummers van pagina's en annotaties terug naar structuurelementen, en een element kan daar leven zonder ooit op de/K-keten vanaf de root te verschijnen - Linkannotaties op andere pagina's (§12.5.6.5) dragen een
/Destof een/GoTo-actie die op de pagina mikt, en de catalog/OpenActionkan hetzelfde doen
Wat THotPDF.DeletePage doet voordat hij /Kids aanraakt
THotPDF.DeletePage(PageIndex) op een geladen document schoont eerst al die structuren op en herschrijft de paginaboom als laatste. De index is zero-based. Twee bewakingen grijpen in voordat er iets verandert: een index buiten bereik geeft Invalid page number, en een verzoek om de enige resterende pagina te verwijderen gooit ook een exception, want een /Pages-knooppunt met nul kinderen beschrijft geen geldig document
De volgorde van die ronde staat vast: de /Names /Dests-tree, de oude catalog /Dests-dictionary, /OpenAction, de outline, /StructTreeRoot samen met zijn ParentTree, en ten slotte de /Annots-arrays van elke pagina die blijft. Pas daarna wordt het paginaobject als verwijderd gemarkeerd, worden zijn widgetannotaties losgekoppeld van de AcroForm-veldboom, en worden /Kids, /Count en het /Parent van elke overlevende pagina herschreven. Aan de formkant wordt een parent-veld dat na het verdwijnen van zijn widget zonder kinderen achterblijft ook verwijderd, tot en met /Fields, zodat een uitgedund formulier geen veldentry overhoudt met niets op het scherm erachter
DeletePages neemt een 1-based rangestring zoals '1,3-5,7-', de notatie die de andere paginabewerkingen op een geladen document gebruiken, en verwijdert vanaf de hoogste geselecteerde pagina naar beneden. Van boven naar beneden werken houdt de nummers die u schreef geldig terwijl pagina's eronder wegvallen. Een bereik dat elke pagina bestrijkt gooit om dezelfde reden een exception waarom DeletePage de laatste weigert, en de functie geeft terug hoeveel pagina's hij heeft verwijderd
var
Pdf: THotPDF;
Removed: Integer;
begin
Pdf := THotPDF.Create(nil);
try
// LoadFromFile geeft terug hoeveel pagina's hij geladen heeft
if Pdf.LoadFromFile('tagged-report.pdf') > 0 then
begin
// Zero-based: pagina 0 is de omslag
Pdf.DeletePage(0);
// 1-based bereik, genummerd tegen het document zoals het nu is
Removed := Pdf.DeletePages('3-4,9');
Pdf.SaveLoadedDocument('tagged-report-trimmed.pdf');
end;
finally
Pdf.Free;
end;
end;
Namen verdwijnen, bookmarks verschuiven
Named destinations die op de verwijderde pagina mikken worden verwijderd. Bookmarks die erop mikten worden omgeleid. In de /Names /Dests-tree loopt de ronde elk knooppunt langs en toetst elke destination, in beide vormen die de spec toestaat, de kale array en de dictionary met een /D-key; het naam/waarde-paar gaat eruit wanneer het eerste element van de array de verwijderde pagina is. Een tussenknooppunt dat zonder /Names en zonder /Kids achterblijft wordt van zijn parent losgekoppeld, zodat de tree geen holle bladeren overhoudt. De oude catalog /Dests-dictionary krijgt dezelfde toets, key voor key, en een catalog /OpenAction die opende op de verwijderde pagina wordt weggehaald, waarmee de openingsweergave terugvalt op de default van de viewer
Een knooppunt dat entries verliest verliest ook zijn /Limits-array. De nieuwe laagste en hoogste keys worden niet herberekend, en viewers lossen namen ook zonder dat paar op. §7.9.6 schrijft /Limits wel voor op tussen- en bladknooppunten, dus als een preflight-stap in uw pipeline die regel streng afdwingt, draait u het uitgedunde bestand als onderdeel van dezelfde job door die stap
De outline wordt afgelopen via /First en /Next, met een bezochtlijst en een dieptelimiet van 128, zodat een corrupte /Next-keten die in zichzelf terugslingert de aanroep niet kan laten hangen. Voor elke /Dest-array of /GoTo-/D-array die op de verwijderde pagina mikt wordt het eerste element vervangen door de pagina die erop volgde, of door de pagina ervoor wanneer de verwijderde pagina de laatste was. De view-parameters na de paginareferentie blijven zoals ze waren, dus een /XYZ-jump houdt zijn left-, top- en zoomwaarden en past die toe op de nieuwe pagina
Die asymmetrie is een bewuste keuze. Een naam die naar niets oplost is legaal en ongevaarlijk; een GoTo die een ontbrekende destination noemt doet simpelweg niets. Een bookmark met een null-destination is erger: hij blijft in de zijbalk staan, lijkt klikbaar en faalt. Uitkomen op de dichtstbijzijnde overlevende pagina is van twee imperfecte uitkomsten de beste. Omleiden geldt alleen expliciete destinationarrays. Een outline-item waarvan het /Dest een naamstring is lost op via de name tree, en zodra de ronde die naam heeft weggehaald komt de bookmark nergens meer uit, dus documenten die intensief named bookmarks gebruiken verdienen na het verwijderen een check. De check hieronder leest namen met ResolveLoadedNamedDestination, wat alleen naar de /Names-array van het root-knooppunt kijkt; dat geeft een zinvol antwoord voor een vlakke name tree, terwijl een tree die in /Kids is gesplitst voor elke naam -1 teruggeeft. De structuur van de outline zelf, inclusief de niet voor de hand liggende /Count-semantiek, staat in de gids over het toevoegen van bookmarks en named destinations aan een geladen PDF
// Controleer het resultaat in plaats van het aan te nemen.
// Memo1 is een TMemo op uw eigen formulier.
Pdf.DeletePage(0);
// ResolveLoadedNamedDestination leest alleen de root-/Names-array,
// dus deze check is zinvol voor een vlakke name tree; op een tree
// die /Kids gebruikt geeft hij -1 terug voor elke naam, verwijderd of niet
if Pdf.ResolveLoadedNamedDestination('cover') = -1 then
Memo1.Lines.Add('"cover" is not resolvable from the root /Names array');
// Een bookmark die op de omslag mikte lost nu op naar de pagina
// die erop volgde, wat na de delete zero-based index 0 is
Memo1.Lines.Add(Format('Bookmark "Contents" -> page index %d',
[Pdf.GetLoadedBookmarkPageIndex('Contents')]));
De structure tree en de ParentTree snoeien
Structuurelementen die alleen bestaan vanwege de verwijderde pagina worden verwijderd, en elementen die meerdere pagina's overspannen verliezen hun /Pg-key maar houden hun kinderen. De ronde begint bij /StructTreeRoot, daalt af door /K tot een diepte van 128, en accepteert zowel de arrayvorm als de enkele-dictionaryvorm van /K die §14.7.2 toestaat. De kinderen van een element worden eerst gesnoeid voordat het element zelf wordt beoordeeld. Als het snoeien zijn /K leegmaakte, wordt het element verwijderd en laat zijn parent het vallen. Als zijn eigen /Pg de verwijderde pagina noemt maar het element heeft nog kinderen en een /P-parent, dan gaat alleen /Pg eruit, want /Pg op een element is slechts de standaardpagina voor marked-content-kinderen die hun eigen pagina niet noemen, en die kinderen kunnen net zo goed op andere pagina's leven. Een element waarvan het /Pg de verwijderde pagina is en waar niets onder hangt wordt helemaal verwijderd; marked-content-referencedictionaries waarvan het /Pg de pagina noemt vallen onder die laatste regel
De ParentTree krijgt dezelfde behandeling, en de reden is makkelijk te missen: een element kan bereikbaar zijn vanuit de ParentTree en nergens anders. Die number tree mapt /StructParents-integers op één element of een array van elementen. Elke waarde wordt gesnoeid met de regels hierboven, weggesnoeide waarden vallen uit hun arrays, een /Nums-paar waarvan de array leeg eindigt wordt verwijderd, en een knooppunt zonder /Nums en zonder /Kids wordt losgekoppeld. Deze ronde overslaan en de weesobjecten blijven via hun /Pg naar een vrijgegeven pagina wijzen. Voor wie tekst in tag-volgorde extraheert is dat direct te merken: tekstextractie in structuurorde loopt precies door deze trees, en een element met een null /Pg is een alinea die uit de leesorde valt
Waarom worden links op overlevende pagina's verwijderd in plaats van omgeleid?
Een linkannotatie op een pagina die blijft, waarvan de /Dest-array of /GoTo-actie naar de verwijderde pagina wijst, wordt uit de /Annots van die pagina verwijderd, en zijn tag gaat mee. De OBJR-dictionary waarvan het /Obj die annotatie noemde wordt uit zijn structuurelement gehaald; was die OBJR het enige kind van het element, dan verdwijnt het element ook, en wordt de ParentTree opnieuw gesnoeid. De OBJR laten staan zou §14.7.4.3 breken, die verwacht dat /Obj naar een echt object verwijst, en een PDF/UA-checker zou een Link-element zien waarvan de object reference nergens heen leidt
Bookmarks verschuiven; links niet. Een kruisverwijzing in de broodtekst die "zie pagina 3" zegt is fout zodra pagina 3 weg is, en hem stilletjes op pagina 4 richten maakt van een dode link een misleidende. Een bookmark die op het volgende hoofdstuk landt is navigatie; een link in de broodtekst is een uitspraak over de inhoud. Verwijderen is de veiligste default. Als uw workflow die links wil behouden, herschrijf dan hun destinations voordat u DeletePage aanroept, want na de aanroep zitten ze al uit de /Annots-arrays
Incrementele saves en de directe-kindvalkuil
DeletePage gedraagt zich hetzelfde tussen BeginIncrementalUpdate en SaveIncrementalUpdate. De aangehangen sectie bevat de objecten die de ronde veranderde plus free entries voor verwijderde objecten die een eigen objectnummer hadden, zoals de pagina en de verwijderde linkannotaties. Alles boven het originele %%EOF blijft byte voor byte gelijk, zoals §7.5.6 voorschrijft
Pdf := THotPDF.Create(nil);
try
Pdf.BeginIncrementalUpdate('tagged-report.pdf');
Pdf.DeletePage(0);
// Structuurelementen die een direct /MCR-kind kwijt zijn
// worden in herschreven vorm bijgevoegd, met een kortere /K-array
Pdf.SaveIncrementalUpdate('tagged-report-trimmed.pdf');
finally
Pdf.Free;
end;
Dit is precies de plek waar een handgeschreven snoeier de mist ingaat. Marked-content references (/Type /MCR) en object references (/Type /OBJR) zijn doorgaans directe dictionaries die in de /K-array van hun parent-element zitten. Een direct object heeft geen objectnummer en geen xref-entry, dus hij kan niet op zichzelf worden vrijgegeven. De voor de hand liggende zet is elk verwijderd kind toch vrij te geven, en elke schrijver die een direct object oplost naar het indirecte object dat hem bevat geeft dan de houder vrij, en dat is het behouden structuurelement. Dat element gaat als null het bestand uit, en getagde content op pagina's die nooit zijn aangeraakt verliest zijn tags. HotPDF stuurt een verwijderd object alleen naar de free list als het een objectnummer heeft; voor een direct kind markeert hij het bevattende element als gewijzigd, zodat het element wordt herschreven met het kind eruit en de free list schoon blijft
Dezelfde voorzichtigheid bepaalt wat DeletePage op een geladen document bewust niet vrijgeeft. Contentstreams, XObjects en de niet-widget-annotaties van de verwijderde pagina blijven als objecten staan, want een geladen bestand kan ze allemaal delen met een pagina die blijft en er is geen goedkope manier om op het moment van verwijderen het tegendeel te bewijzen. De paginaboomverwijzing weghalen is genoeg voor correctheid; de bytes die die objecten nog innemen zijn een aparte vraag, en de object dependency graph en retained-bytes-analyse is het gereedschap om te meten wat een uitgedund document nog draagt
DeletePage of DeleteLoadedPage?
Gebruik DeletePage voor elke paginaverwijdering waar een gebruiker om vroeg. THotPDF.DeleteLoadedPage(PageIndex) is de lichtgewicht variant: hij past dezelfde twee bewakingen toe, schuift de geladen paginalijst op, herschrijft /Kids en /Count, maakt de gerenderde paginacache ongeldig en vuurt OnLoadedDocumentModified af. Hij raakt de name tree, de outline, de structure tree of de annotaties van andere pagina's niet aan, en markeert het paginaobject niet als verwijderd. ComposeLoadedPagesNUp is het werk waar hij voor gemaakt is: hij voegt vers opgemaakte vellen toe die de bronpagina's als form XObjects tekenen en laat daarna elk origineel vallen met DeleteLoadedPage(0), want de vellen verwijzen naar de content van de pagina's en niet naar de paginaobjecten. Voor het klusje "pagina 7 uit dit contract halen" op een getagd, van bookmarks voorzien en onderling gelinkt bestand is DeletePage de aanroep die elke verwijzing consistent achterlaat, of u nu opslaat met SaveLoadedDocument of aanhangt met SaveIncrementalUpdate
Beide methoden, het op bereiken werkende DeletePages en het hierboven gebruikte incrementele updatepad, maken deel uit van de HotPDF Delphi Component, die geladen PDF's vanuit Delphi- en C++Builder-code bewerkt zonder een viewer-runtime op de machine