Teknisk artikel

Ta bort PDF-sidor i Delphi utan dinglande referenser

HotPDF Delphi Component tar bort en sida ur en inläst PDF genom THotPDF.DeletePage, och sedan version 2.751.0 rensar det anropet också bort varje dokumentnivåreferens som fortfarande pekar på sidan: namngivna destinationer i /Names-/Dests-trädet, den äldre katalogordboken /Dests, bokmärkens /GoTo-åtgärder, strukturelement under /StructTreeRoot, ParentTree, OBJR-poster för annotationer, och länkannotationer på överlevande sidor. Sidträdet byggs om sist, efter att ingenting annat kan nå det borttagna objektet

Felet detta förhindrar är lätt att återskapa och svårt att diagnosticera. Ta bort omslagssidan i en taggad rapport, spara, och öppna resultatet: Acrobat visar rätt sidantal, men bokmärket "Contents" landar nu ingenstans, tillgänglighetskontrollen rapporterar ett strukturelement utan sida, och en strikt validator listar en referens till ett fritt objekt. Ingenting i sidträdet är fel. Problemet är att en PDF-sida inte bara är ett löv i /Pages; den är ett mål som halva katalogen pekar på, och att ta bort lövet lämnar var och en av de pekarna dinglande

Varför räcker det inte att ta bort en sida från /Kids?

Därför att ISO 32000-1 låter minst sju oberoende strukturer hålla en referens till ett sidobjekt, och bara en av dem är sidträdet. Att släppa sidan från /Kids och minska /Count uppfyller §7.7.3, och varje annan referens blir en pekare till ett objekt som antingen frigörs i xref:en eller helt enkelt saknas i den omskrivna filen. En viewer som följer en av de pekarna får null, och vad den gör med den nullen är upp till viewern

  • Namnträdet under /Names /Dests (§7.7.4, §12.3.2.3) mappar namn till destinationsarrayer vars första element är sidan
  • Ordboken /Dests från före 1.2, direkt i katalogen, håller samma sorts arrayer nycklade på namn
  • Dispositionsposter (§12.3.3) når en sida antingen genom en inline /Dest eller genom en /A-åtgärd med /S /GoTo och en /D-array
  • Strukturelement (§14.7.2) bär en /Pg-nyckel som namnger sidan deras markerade innehåll lever på, och deras /K-barn kan vara markerade innehållsreferenser och objektreferenser (§14.7.4.3) knutna till den sidan
  • ParentTree (§14.7.4.4) mappar sid- och annotationsnumren /StructParents tillbaka till strukturelement, och ett element kan leva där utan att alls förekomma på /K-kedjan från roten
  • Länkannotationer på andra sidor (§12.5.6.5) bär en /Dest- eller /GoTo-åtgärd riktad mot sidan, och katalogens /OpenAction kan göra samma sak
Varför det inte räcker att ta bort en HotPDF-sida från /Kids: ISO 32000-1 låter namnträdet /Names /Dests, den äldre katalogordboken /Dests, dispositionsposter, strukturelement med /Pg, ParentTree, länkannotationer och /OpenAction alla hålla en referens till samma sidobjekt, och bara sidträdet byggs om
En PDF-sida är ett mål som halva katalogen pekar på: att släppa lövet uppfyller sidträdet medan varje annan pekare löses upp till null, så en trimmad rapport tappar sitt Contents-bokmärke och faller på sin tillgänglighetskontroll

Vad rensar THotPDF.DeletePage innan den rör sidträdet?

THotPDF.DeletePage(PageIndex) på ett inläst dokument kör hela referenssvepet först, markerar sedan sidobjektet som borttaget med DeleteObj, lossar eventuella widget-annotationer från AcroForm-fältträdet, skjuter det interna sidarrayen, och anropar till sist RebuildLoadedPageTree för att skriva om /Kids, /Count och varje överlevande sidas /Parent. Svepet besöker katalogen i en fast ordning: namnträdet /Names /Dests, ordboken /Dests av gammal stil, /OpenAction, dispositionsträdet, /StructTreeRoot med sin ParentTree, och sist /Annots-arrayerna på varje sida som blir kvar. Varje steg avgör om en referens ska tas bort, riktas om eller lämnas i fred utifrån vad specifikationen tillåter den strukturen att göra utan sidan. Två vakter gäller innan något av det körs: DeletePage kastar Invalid page number för ett index utanför intervallet och vägrar ta bort den sista sidan, eftersom en /Pages-nod med noll barn inte är en giltig PDF, medan DeletePages tar samma ettbaserade notation "1,3-5,7-" som de andra sidoperationerna på inlästa dokument och itererar från det högsta valda indexet och nedåt så att indexen du skrev förblir giltiga medan den arbetar

Det fasta referenssvepet som THotPDF.DeletePage kör innan sidträdet rörs: vakter avvisar ett index utanför intervallet eller den sista sidan, sedan rensas /Names /Dests och den äldre /Dests, /OpenAction släpps, dispositioner riktas om till NearestRetainedPage, StructTreeRoot och ParentTree rensas, länkar på kvarvarande sidor tas bort, och RebuildLoadedPageTree körs sist
Varje struktur får den behandling specifikationen tillåter: namn försvinner, bokmärken landar på närmaste kvarvarande sida, strukturelement tappar /Pg eller försvinner, och /Kids skrivs om först när ingenting annat kan nå det borttagna objektet
var
  Pdf: THotPDF;
begin
  Pdf := THotPDF.Create(nil);
  try
    if Pdf.LoadFromFile('tagged-report.pdf', '') > 0 then
    begin
      // Nollbaserat: ta bort omslagssidan. Namngivna destinationer,
      // bokmärken, strukturträdet, ParentTree och länk-
      // annotationer som pekade på den rensas innan
      // /Pages-trädet byggs om.
      Pdf.DeletePage(0);
      // Ettbaserad intervallsyntax för batcher, högsta indexet först
      // internt så att tidigare index förblir giltiga.
      Pdf.DeletePages('3-4,9');
      Pdf.SaveLoadedDocument('tagged-report-trimmed.pdf');
    end;
  finally
    Pdf.Free;
  end;
end;

Hur hanteras namngivna destinationer och bokmärken olika?

Namngivna destinationer tas bort och bokmärken riktas om, eftersom ett namn som inte längre finns är ett acceptabelt utfall medan ett bokmärke utan destination är en synlig defekt. I trädet /Names /Dests går HotPDF genom varje nod, testar varje destination, både i den bara arrayformen och i ordboksformen med en /D-nyckel, mot den borttagna sidan, och tar bort namn/värde-paret när det första elementet i arrayen är den sidan. En nod vars /Names och /Kids båda slutar tomma markeras som borttagen och kopplas loss från sin förälder, så trädet behåller aldrig ihåliga löv. Samma test körs över katalogordboken /Dests av gammal stil, och katalogens /OpenAction släpps helt enkelt om den öppnade på den borttagna sidan. En gräns här: när en namnträdnod tappar poster tar HotPDF bort nodens /Limits-par i stället för att räkna fram de nya lägsta och högsta nycklarna, och även om viewers löser upp namn fint utan den kan en strikt konformitetskontroll som läser ISO 32000-1 §7.9.6 flagga en icke-rotnod som saknar /Limits

Dispositionsposter går åt andra hållet. RetargetOutlineDestinations korsar /First och /Next från dispositionsroten, med en besökslista och en djupgräns på 128 så att ett trasigt cykliskt träd inte kan hänga anropet, och för varje /Dest-array eller /GoTo-åtgärd med en /D-array riktad mot sidan ersätter den första elementet med NearestRetainedPage: sidan som följde efter den borttagna, eller sidan före den när den borttagna sidan var sist. Vyparametrarna efter sidreferensen lämnas som de var. Ett bokmärke som pekade på ett borttaget kapitelöppning landar därför på första sidan av det som återstår i stället för att försvinna ur sidopanelen, vilket är beteendet granskare förväntar sig av ett trimmat dokument. Destinationstestet matchar dock bara explicita arrayer: en dispositionspost vars /Dest är en namnsträng som brukade lösa upp till den borttagna sidan riktas inte om, eftersom namnträdsposten är borta och referensen nu löses upp till ingenting i stället för till ett frigjort objekt, så viewern behandlar den som ett dött bokmärke. Mekaniken i dispositionsträdet självt, /First, /Next och den icke-uppenbara semantiken för /Count, täcks i guiden till att lägga till bokmärken och namngivna destinationer i en inläst PDF

// Verifiera svepet i stället för att lita på det.
Pdf.DeletePage(0);
if Pdf.ResolveLoadedNamedDestination('cover') = -1 then
  ShowMessage('Named destination "cover" was pruned');
// Ett bokmärke som siktade på omslaget löses nu upp till
// sidan som följde efter det (nollbaserat index 0 efter borttagningen).
if Pdf.GetLoadedBookmarkPageIndex('Contents') = 0 then
  ShowMessage('Bookmark retargeted to the nearest retained page');

Vad händer med strukturträdet och ParentTree?

Strukturelement som bara finns på grund av den borttagna sidan tas bort, och element som spänner över flera sidor tappar sin /Pg-nyckel men behåller sina barn. PruneStructureElement går nedåt i /K-kedjan från /StructTreeRoot till ett djup av 128, och hanterar både arrayformen och den enstaka ordboksformen av /K som §14.7.2 tillåter. För varje element rensar den först barnen och utvärderar sedan elementet självt: om rensningen tömde dess /K markeras elementet som borttaget och dess förälder släpper det. Om elementets egen /Pg namnger den borttagna sidan och elementet fortfarande har barn plus en /P-förälder tas bara /Pg bort, eftersom en /Pg på ett element är standardsidan för dess markerade innehållsbarn och de barnen kan referera till andra sidor explicit. Bara ett element vars /Pg är den borttagna sidan och som inte har något kvar under sig tas bort helt

ParentTree får samma behandling, och skälet är det som bet under utvecklingen: ett strukturelement kan vara nåbart från ParentTree och ingen annanstans. Nummerträdet mappar /StructParents-heltal till antingen ett enstaka element eller en array av element, och PruneParentTreeNode kör PruneStructureElement över varje värde den hittar, tar bort värden som rensades bort, raderar ett /Nums-par när dess värdearray är tom, och kopplar loss en nod vars /Nums och /Kids båda är borta. Att bara rensa ättlingarna till /K skulle ha lämnat de föräldralösa elementen pekande på en frigjord sida genom /Pg och på frigjorda markerade innehållsreferenser genom sina /MCR-barn. Om du extraherar text i strukturordning spelar det direkt roll: textutdragning i strukturordning går precis genom de här träden, och ett element med en null-/Pg är ett stycke som tyst faller ur läsordningen

Vilka länkannotationer på överlevande sidor tas bort?

Varje länkannotation på en kvarvarande sida vars /Dest-array eller /GoTo-åtgärd pekar på den borttagna sidan tas bort tillsammans med sitt strukturträdsägande. RemoveRetainedPageDestinationAnnotations går genom /Annots-arrayen på varje sida utom målet, tillämpar samma destinationstest som används för dispositioner, markerar en matchande annotation som borttagen, släpper den ur arrayen, och anropar sedan PruneAnnotationReferencesInStructureTree så att OBJR-ordboken vars /Obj namngav den annotationen tas bort från sitt strukturelement, med elementet självt borttaget om OBJR:n var dess enda barn. Att lämna OBJR:n kvar skulle bryta mot §14.7.4.3, som kräver att /Obj refererar till ett befintligt objekt, och skulle dyka upp i en PDF/UA-kontroll som en taggad länk utan annotation bakom sig. Notera asymmetrin mot bokmärken: länkar tas bort, inte riktas om. En korsreferens i brödtexten som sade "se sidan 3" är fel när sidan 3 är borta, och att peka den på sidan 4 skulle vara en lögn på ett sätt som ett bokmärke som landar på närmaste kapitel inte är, så om ditt arbetsflöde behöver de länkarna bevarade får du rikta om dem själv innan du anropar DeletePage

Varför får en borttagen /MCR eller /OBJR aldrig registreras som fri?

Därför att markerade innehållsreferenser och objektreferenser oftast är direkta ordböcker inuti sin förälders /K-array, och registret för inkrementella ändringar löser upp ett direkt objekt till närmaste indirekta objekt som innehåller det. När RemoveArrayItem släpper ett barn ur en /K-array frigör den det i minnet bara om det var en THPDFLink eller ett icke-indirekt värde, och MarkRemovedObject registrerar ett objekt för frilistan bara när dess objektnummer är större än noll. Den första versionen av det här svepet gjorde inte den åtskillnaden, och effekten i en inkrementell sparning var exakt vad registret är konstruerat för att göra: RegisterIncrementalChange gick från den direkta /MCR:n upp till sin graftransaktionsrot, vilket var det kvarvarande strukturelementet som ägde den, och skrev ut det elementet som null. Ett dokument som tappade en sida kom tillbaka med det taggade innehållet på de andra sidorna tyst otaggat. Det enda korrekta draget för ett direkt barn är att markera sin behållare smutsig genom TouchContainer så att behållaren skrivs om, och att lämna frilistan i fred

Varför ett borttaget /MCR- eller OBJR-barn aldrig får registreras som fritt i HotPDF: registret för inkrementella ändringar löser upp en direkt ordbok till närmaste indirekta behållare, så den första versionen skrev ut det kvarvarande strukturelementet som null och otaggade tyst de överlevande sidorna, medan TouchContainer nu skriver om behållaren och lämnar frilistan i fred
Att frigöra barnet i minnet är reserverat för THPDFLink eller icke-indirekta värden och för objektnummer större än noll, så en inkrementell sparning lägger bara till de berörda behållarna och det frigjorda sidobjektet
// Inkrementell uppdatering: bara de berörda behållarna och det
// frigjorda sidobjektet hamnar i det tillagda avsnittet.
Pdf := THotPDF.Create(nil);
try
  Pdf.BeginIncrementalUpdate('tagged-report.pdf');
  Pdf.DeletePage(0);
  // Kvarvarande strukturelement vars /K tappade en direkt /MCR
  // skrivs om på plats, aldrig ut som null.
  Pdf.SaveIncrementalUpdate('tagged-report-trimmed.pdf');
finally
  Pdf.Free;
end;

Samma försiktighet formar vad DeletePage medvetet inte frigör på ett inläst dokument. Content streams, XObjects och den borttagna sidans icke-widget-annotationer lämnas som objekt, eftersom en inläst fil kan dela vilket som helst av dem med en sida som blir kvar och det inte finns något billigt sätt att bevisa motsatsen vid borttagningstillfället. Att ta bort sidträdsreferensen räcker för korrektheten; byten de objekten fortfarande upptar är en separat fråga, och objektberoendegrafen och analysen av kvarhållna byte är verktyget för att mäta vad ett trimmat dokument fortfarande bär med sig

DeletePage kontra DeleteLoadedPage: vilken ska du anropa?

Anropa DeletePage för varje användarsynlig sidborttagning, och spara DeleteLoadedPage för fallet där hela dokumentet flödas om och ingen dokumentnivåreferens är värd att behålla. THotPDF.DeleteLoadedPage(PageIndex), tillagd i version 2.508.0, är den lätta varianten: den skjuter det interna sidarrayen, anropar RebuildLoadedKidsArray för att skriva om /Kids och /Count, ogiltigförklarar cachen för renderade sidor och avfyrar OnLoadedDocumentModified. Den går inte genom namnträdet, dispositionerna, strukturträdet eller andra sidors annotationer, och den markerar inte sidobjektet som borttaget. Det är rätt verktyg inuti N-up-impositionering, där HotPDF lägger till nyskapade ark och sedan släpper varje originalsida med DeleteLoadedPage(0): källsidorna ersätts i bulk, och arkens innehåll refererar till deras resurser snarare än till sidobjekten. För det vanliga jobbet "ta bort sidan 7 ur det här kontraktet" är DeletePage det enda anropet som lämnar ett taggat, bokmärkt och korslänkat dokument tillräckligt konsekvent för att passera en validator, både vid en full omskrivning genom SaveLoadedDocument och vid en inkrementell uppdatering genom SaveIncrementalUpdate. Båda metoderna levereras i HotPDF Delphi Component för Delphi och C++Builder, utan krav på eller beroende av en extern viewer-runtime