Technisch artikel

PDF-pagina verwijderen in Delphi zonder dangling pointers

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 /Dest of via een /A-actie met /S /GoTo en 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 /Dest of een /GoTo-actie die op de pagina mikt, en de catalog /OpenAction kan hetzelfde doen
Waarom een HotPDF-pagina uit /Kids halen niet genoeg is: ISO 32000-1 staat toe dat de /Names /Dests-name tree, de oude catalog /Dests-dictionary, outline-items, structuurelementen met /Pg, de ParentTree, linkannotaties en /OpenAction allemaal naar hetzelfde paginaobject verwijzen, en alleen de paginaboom wordt herbouwd
Een PDF-pagina is een doel waar de halve catalog naar wijst: het blad weghalen voldoet aan de paginaboom terwijl elke andere pointer naar null oplost, dus een uitgedund rapport verliest zijn Contents-bookmark en zakt voor zijn toegankelijkheidscheck

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

De vaste verwijzingsronde die THotPDF.DeletePage draait voordat de paginaboom wordt aangeraakt: bewakingen weigeren een index buiten bereik of de laatste pagina, dan worden /Names /Dests en de oude /Dests gesnoeid, /OpenAction weggehaald, outline-destinations verplaatst naar de dichtstbijzijnde pagina die blijft, StructTreeRoot en ParentTree gesnoeid, links op overlevende pagina's verwijderd, en /Kids, /Count en /Parent als laatste herschreven
Elke structuur krijgt de behandeling die de spec toestaat: namen verdwijnen, bookmarks landen op de dichtstbijzijnde pagina die blijft, structuurelementen verliezen /Pg of verdwijnen, en de /Kids-herschrijving gebeurt pas als niets anders het verwijderde object nog kan bereiken

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

Waarom een verwijderd /MCR- of OBJR-kind in een incrementele update nooit als vrij geregistreerd mag worden: het is een directe dictionary in de /K-array van zijn element, dus het vrijgeven ervan zou betekenen dat het bevattende structuurelement wordt vrijgegeven, dat dan als null wordt weggeschreven en overlevende pagina's van tags berooft, terwijl de juiste zet de container herschrijft en de free list met rust laat
Alleen objecten met een eigen objectnummer gaan naar de free list, dus een incrementele save voegt de herschreven containers plus free entries toe voor de pagina en de andere verwijderde indirecte objecten

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