Teknisk artikkel

Slett PDF-sider i Delphi uten dinglende referanser

HotPDF Delphi Component sletter en side fra en lastet PDF gjennom THotPDF.DeletePage, og siden versjon 2.751.0 rydder det kallet også bort hver dokumentnivåreferanse som fortsatt peker på siden: navngitte destinasjoner i /Names-/Dests-treet, den gamle katalogordboken /Dests, bokmerkehandlinger /GoTo, strukturelementer under /StructTreeRoot, ParentTree, OBJR-oppføringer for annotasjoner og lenkeannotasjoner på overlevende sider. Sidetreet bygges opp igjen til slutt, etter at ingenting annet kan nå det slettede objektet

Feilen dette forhindrer, er lett å reprodusere og vanskelig å diagnostisere. Slett forsiden på en tagget rapport, lagre, og åpne resultatet: Acrobat viser riktig sidetall, men bokmerket «Contents» lander nå ingen steder, tilgjengelighetssjekken rapporterer et strukturelement uten side, og en streng validator lister en referanse til et ledig objekt. Ingenting i sidetreet er galt. Problemet er at en PDF-side ikke bare er et blad i /Pages; den er et mål som halve katalogen peker på, og å fjerne bladet etterlater hver av disse pekerne dinglende

Hvorfor er det ikke nok å fjerne en side fra /Kids?

Fordi ISO 32000-1 lar minst sju uavhengige strukturer holde en referanse til et sideobjekt, og bare én av dem er sidetreet. Å droppe siden fra /Kids og dekrementere /Count tilfredsstiller §7.7.3, og hver annen referanse blir en peker til et objekt som enten er frigjort i xref-en eller rett og slett mangler i den omskrevne filen. Et visningsprogram som følger en av disse pekerne, får null, og hva det gjør med den null-en, er opp til visningsprogrammet

  • Navnetreet under /Names /Dests (§7.7.4, §12.3.2.3) mapper navn til destinasjonsarrayer der første element er siden
  • Ordboken /Dests fra før 1.2, direkte i katalogen, holder samme type arrayer med navn som nøkkel
  • Bokmerkeelementer (§12.3.3) når en side enten gjennom en innebygd /Dest eller gjennom en /A-handling med /S /GoTo og en /D-array
  • Strukturelementer (§14.7.2) bærer en /Pg-nøkkel som navngir siden der det markerte innholdet deres lever, og /K-barna deres kan være markerte innholdsreferanser og objektreferanser (§14.7.4.3) knyttet til den siden
  • ParentTree (§14.7.4.4) mapper side- og annotasjonsnummerene /StructParents tilbake til strukturelementer, og et element kan ligge der uten å dukke opp på /K-kjeden fra roten i det hele tatt
  • Lenkeannotasjoner på andre sider (§12.5.6.5) bærer en /Dest eller en /GoTo-handling som retter seg mot siden, og katalogens /OpenAction kan gjøre det samme
Hvorfor det ikke er nok å fjerne en HotPDF-side fra /Kids: ISO 32000-1 lar navnetreet /Names /Dests, den gammeldagse katalogordboken /Dests, bokmerkeelementer, strukturelementer med /Pg, ParentTree, lenkeannotasjoner og /OpenAction alle holde en referanse til det samme sideobjektet, og bare sidetreet bygges opp igjen
En PDF-side er et mål som halve katalogen peker på: å droppe bladet tilfredsstiller sidetreet mens hver annen peker løser seg til null, så en trimmet rapport mister Contents-bokmerket og stryker i tilgjengelighetssjekken

Hva rydder THotPDF.DeletePage opp før den rører sidetreet?

THotPDF.DeletePage(PageIndex) på et lastet dokument kjører hele referansesveipingen først, merker deretter sideobjektet som slettet med DeleteObj, kobler eventuelle widget-annotasjoner fra AcroForm-feltreet, forskyver den interne sidearrayen, og kaller til slutt RebuildLoadedPageTree for å skrive om /Kids, /Count og hver overlevende sides /Parent. Sveipingen besøker katalogen i en fast rekkefølge: navnetreet /Names /Dests, den gammeldagse /Dests-ordboken, /OpenAction, bokmerketreet, /StructTreeRoot med sin ParentTree, og til slutt /Annots-arrayene til hver side som blir igjen. Hvert trinn bestemmer om en referanse fjernes, rettes mot noe annet, eller får være i fred, ut fra hva spesifikasjonen tillater at den strukturen gjør uten siden. To vakter gjelder før noe av dette kjører: DeletePage reiser Invalid page number for en indeks utenfor området og nekter å fjerne den siste siden, fordi en /Pages-node med null barn ikke er en gyldig PDF, mens DeletePages tar samme en-baserte "1,3-5,7-"-notasjon som de andre sideoperasjonene på lastede dokumenter og itererer fra den høyeste valgte indeksen og nedover, så indeksene du skrev, forblir gyldige mens den arbeider

Den faste referansesveipingen THotPDF.DeletePage kjører før sidetreet røres: vakter avviser en indeks utenfor området eller den siste siden, deretter ryddes /Names /Dests og den gammeldagse /Dests, /OpenAction droppes, bokmerker rettes mot NearestRetainedPage, StructTreeRoot og ParentTree ryddes, lenker på bevarte sider fjernes, og RebuildLoadedPageTree kjører til slutt
Hver struktur får den behandlingen spesifikasjonen tillater: navn forsvinner, bokmerker lander på nærmeste bevarte side, strukturelementer mister /Pg eller forsvinner, og omskrivningen av /Kids skjer først etter at ingenting annet kan nå det slettede objektet
var
  Pdf: THotPDF;
begin
  Pdf := THotPDF.Create(nil);
  try
    if Pdf.LoadFromFile('tagged-report.pdf', '') > 0 then
    begin
      // Nullbasert: dropp forsiden. Navngitte destinasjoner,
      // bokmerker, strukturtreet, ParentTree og lenke-
      // annotasjoner som pekte på den, ryddes bort før
      // /Pages-treet bygges opp igjen.
      Pdf.DeletePage(0);
      // En-basert områdesyntaks for batcher, høyeste indeks
      // først internt så tidligere indekser forblir gyldige.
      Pdf.DeletePages('3-4,9');
      Pdf.SaveLoadedDocument('tagged-report-trimmed.pdf');
    end;
  finally
    Pdf.Free;
  end;
end;

Hvordan håndteres navngitte destinasjoner og bokmerker forskjellig?

Navngitte destinasjoner fjernes og bokmerker rettes mot nye mål, fordi et navn som ikke lenger finnes, er et akseptabelt utfall, mens et bokmerke uten destinasjon er en synlig defekt. I /Names /Dests-treet går HotPDF gjennom hver node, tester hver destinasjon, både i den bare arrayformen og i ordboksformen med en /D-nøkkel, mot den slettede siden, og fjerner navn/verdi-paret når første element i arrayen er den siden. En node der både /Names og /Kids ender tomme, merkes som slettet og kobles av fra forelderen, så treet aldri beholder hule blader. Den samme testen kjøres over den gammeldagse katalogordboken /Dests, og katalogens /OpenAction droppes rett og slett hvis den åpnet på den slettede siden. Én grense her: når en navnetrenode mister oppføringer, sletter HotPDF nodens /Limits-par i stedet for å regne ut den nye laveste og høyeste nøkkelen, og selv om visningsprogrammer løser navn fint uten den, kan en streng samsvarssjekker som leser ISO 32000-1 §7.9.6 flagge en ikke-rot-node som mangler /Limits

Bokmerkeelementer går motsatt vei. RetargetOutlineDestinations traverserer /First og /Next fra bokmerkeroten, med en besøktliste og en dybdegrense på 128 så et korrupt syklisk tre ikke kan henge kallet, og for hver /Dest-array eller /GoTo-handling med /D-array som er rettet mot siden, erstatter den første elementet med NearestRetainedPage: siden som fulgte den slettede, eller siden før den når den slettede siden var sist. Visningsparametrene etter sidereferansen får være som de var. Et bokmerke som pekte på et slettet kapittelåpning, lander derfor på første side av det som gjenstår, i stedet for å forsvinne fra sidelinjen, og det er oppførselen korrekturlesere forventer av et trimmet dokument. Destinasjonstesten matcher likevel bare eksplisitte arrayer: et bokmerkeelement der /Dest er en navnestreng som før løste seg til den slettede siden, rettes ikke mot noe nytt mål, fordi navnetreoppføringen er borte og referansen nå ikke løser seg til noe i stedet for til et frigjort objekt, så visningsprogrammet behandler den som et dødt bokmerke. Mekanikken i selve bokmerketreet, /First, /Next og den lite opplagte /Count-semantikken, er dekket i guiden til å legge til bokmerker og navngitte destinasjoner i en lastet PDF

// Verifiser sveipingen i stedet for å stole på den.
Pdf.DeletePage(0);
if Pdf.ResolveLoadedNamedDestination('cover') = -1 then
  ShowMessage('Named destination "cover" was pruned');
// Et bokmerke som pekte på forsiden, løser nå til
// siden som fulgte den (nullbasert indeks 0 etter slettingen).
if Pdf.GetLoadedBookmarkPageIndex('Contents') = 0 then
  ShowMessage('Bookmark retargeted to the nearest retained page');

Hva skjer med strukturtreet og ParentTree?

Strukturelementer som bare finnes på grunn av den slettede siden, fjernes, og elementer som spenner over flere sider, mister /Pg-nøkkelen men beholder barna sine. PruneStructureElement går ned /K-kjeden fra /StructTreeRoot til en dybde på 128, og håndterer både arrayformen og enkeltordboksformen av /K som §14.7.2 tillater. For hvert element rydder den først barna, og vurderer deretter elementet selv: hvis ryddingen tømte /K, merkes elementet som slettet og forelderen dropper det. Hvis elementets egen /Pg navngir den slettede siden og elementet fortsatt har barn pluss en /P-forelder, fjernes bare /Pg, fordi en /Pg på et element er standardsiden for dets markerte innholdsbarn, og de barna kan referere til andre sider eksplisitt. Bare et element hvis /Pg er den slettede siden og som ikke har noe igjen under seg, fjernes helt

ParentTree får samme behandling, og grunnen er den som bet under utviklingen: et strukturelement kan være nåbart fra ParentTree og ingen andre steder. Talltreet mapper /StructParents-heltall til enten et enkelt element eller en array av elementer, og PruneParentTreeNode kjører PruneStructureElement over hver verdi den finner, fjerner verdier som ble ryddet bort, sletter et /Nums-par når verdien er en tom array, og kobler av en node der både /Nums og /Kids er borte. Å bare rydde etterkommerne av /K ville ha latt de foreldreløse elementene peke på en frigjort side gjennom /Pg og på frigjorte markerte innholdsreferanser gjennom /MCR-barna sine. Hvis du trekker ut tekst i strukturrekkefølge, betyr det direkte noe: tekstuttrekk i strukturrekkefølge går nettopp gjennom disse trærne, og et element med en null /Pg er et avsnitt som stille faller ut av leserekkefølgen

Hvilke lenkeannotasjoner på overlevende sider fjernes?

Enhver lenkeannotasjon på en bevart side der /Dest-arrayen eller /GoTo-handlingen peker på den slettede siden, fjernes sammen med strukturtre-tilhørigheten sin. RemoveRetainedPageDestinationAnnotations går gjennom /Annots-arrayen til hver side bortsett fra målet, bruker den samme destinasjonstesten som for bokmerker, merker en samsvarende annotasjon som slettet, dropper den fra arrayen, og kaller deretter PruneAnnotationReferencesInStructureTree så OBJR-ordboken der /Obj navnga den annotasjonen, fjernes fra strukturelementet sitt, med elementet selv fjernet hvis OBJR-en var dets eneste barn. Å la OBJR-en stå ville bryte §14.7.4.3, som krever at /Obj refererer til et eksisterende objekt, og ville dukke opp i en PDF/UA-sjekk som en tagget lenke uten annotasjon bak. Merk asymmetrien med bokmerker: lenker fjernes, de rettes ikke mot nye mål. En kryssreferanse i brødteksten som sa «se side 3», er feil når side 3 er borte, og å peke den på side 4 ville være en løgn på en måte som et bokmerke som lander på nærmeste kapittel ikke er, så hvis arbeidsflyten din trenger at de lenkene bevares, må du rette dem mot nye mål selv før du kaller DeletePage

Hvorfor må en fjernet /MCR eller /OBJR aldri registreres som ledig?

Fordi markerte innholdsreferanser og objektreferanser vanligvis er direkte ordbøker inne i foreldreelementets /K-array, og registeret for inkrementelle endringer løser et direkte objekt opp til det nærmeste indirekte objektet som inneholder det. Når RemoveArrayItem dropper et barn fra en /K-array, frigjør den det objektet i minnet bare hvis det var en THPDFLink eller en ikke-indirekte verdi, og MarkRemovedObject registrerer et objekt for den ledige listen bare når objektnummeret er større enn null. Den første versjonen av denne sveipingen gjorde ikke den forskjellen, og effekten i en inkrementell lagring var nøyaktig det registeret er laget for å gjøre: RegisterIncrementalChange gikk fra den direkte /MCR-en opp til sin graftransaksjonsrot, som var det bevarte strukturelementet som eide den, og skrev det elementet ut som null. Et dokument som mistet én side, kom tilbake med tagget innhold på de andre sidene stille utagget. Det eneste riktige trekket for et direkte barn er å merke beholderen som skitten gjennom TouchContainer så beholderen skrives på nytt, og la den ledige listen være i fred

Hvorfor et fjernet /MCR- eller OBJR-barn aldri må registreres som ledig i HotPDF: registeret for inkrementelle endringer løser en direkte ordbok opp til nærmeste indirekte beholder, så den første versjonen skrev det bevarte strukturelementet ut som null og utagget stille overlevende sider, mens TouchContainer nå skriver om beholderen og lar den ledige listen være i fred
Å frigjøre barnet i minnet er forbeholdt THPDFLink eller ikke-indirekte verdier og objektnumre større enn null, så en inkrementell lagring bare tilføyer de berørte beholderne og det frigjorte sideobjektet
// Inkrementell oppdatering: bare de berørte beholderne og
// det frigjorte sideobjektet havner i den tilføyde seksjonen.
Pdf := THotPDF.Create(nil);
try
  Pdf.BeginIncrementalUpdate('tagged-report.pdf');
  Pdf.DeletePage(0);
  // Bevarte strukturelementer hvis /K mistet en direkte /MCR
  // skrives om på stedet, aldri ut som null.
  Pdf.SaveIncrementalUpdate('tagged-report-trimmed.pdf');
finally
  Pdf.Free;
end;

Samme forsiktighet former det DeletePage bevisst ikke frigjør på et lastet dokument. Innholdsstrømmer, XObjects og de ikke-widget-annotasjonene på den slettede siden blir liggende som objekter, fordi en lastet fil kan dele hvilken som helst av dem med en side som blir igjen, og det finnes ingen billig måte å bevise det motsatte på slettetidspunktet. Å fjerne sidetre-referansen er nok for korrekthet; bytene de objektene fortsatt opptar, er et eget spørsmål, og objektavhengighetsgrafen og analysen av beholdte byte er verktøyet for å måle hva et trimmet dokument fortsatt bærer med seg

DeletePage mot DeleteLoadedPage: hvilken bør du kalle?

Kall DeletePage for enhver sidesletting brukeren ser, og reserver DeleteLoadedPage for tilfellet der hele dokumentet reflowes og ingen dokumentnivåreferanse er verdt å beholde. THotPDF.DeleteLoadedPage(PageIndex), lagt til i versjon 2.508.0, er den lette varianten: den forskyver den interne sidearrayen, kaller RebuildLoadedKidsArray for å skrive om /Kids og /Count, ugyldiggjør cachen for gjengitte sider og fyrer OnLoadedDocumentModified. Den går ikke gjennom navnetreet, bokmerkene, strukturtreet eller annotasjonene på andre sider, og den merker ikke sideobjektet som slettet. Det er det riktige verktøyet inne i N-up-imposisjon, der HotPDF legger til nylig sammensatte ark og deretter dropper hver originalside med DeleteLoadedPage(0): kildesidene erstattes i sin helhet, og arkinnholdet refererer til ressursene deres snarere enn til sideobjektene. For den vanlige jobben «fjern side 7 fra denne kontrakten» er DeletePage det eneste kallet som etterlater et tagget, bokmerket og krysslenket dokument som er konsistent nok til å passere en validator, både i en full omskrivning gjennom SaveLoadedDocument og i en inkrementell oppdatering gjennom SaveIncrementalUpdate. Begge metodene leveres i HotPDF Delphi Component for Delphi og C++Builder, uten krav om noen ekstern visningsruntime eller avhengighet