Teknisk artikkel

Redigere PDF-disposisjoner og omkartlegge sider i Delphi

Fjern syv sider fra en håndbok på 200 sider, og hvert bokmerke lander et sted feil. Fiksen er ikke å gjenoppbygge disposisjonen fra en flat titelliste. PDFiumPas eksponerer TPdfOutlineEditor, som laster det ekte disposisjonstreet, lar deg flytte og retargete elementer, og kjører deretter ApplyPageMap for å flytte hver eksplisitt destinasjon gjennom sideplanen din

Hvorfor ødelegger sletting av sider hvert bokmerke?

Fordi et disposisjonselement ikke lagrer et sidetall. Det lagrer en referanse til et sideobjekt, og når sideobjektene endres, peker referansen enten på en side som flyttet seg eller på ingenting i det hele tatt. ISO 32000-1 §12.3.2.2 definerer en eksplisitt destinasjon som en array hvis første element er en indirekte referanse til en sideordliste, etterfulgt av et fit-navn som /Fit eller /XYZ. Slett siden, og du sitter igjen med en dinglende referanse; omorganiser sidene, og referansen er fortsatt gyldig, men beskriver nå et annet kapittel. PDFiumPas løser den arrayen tilbake til et sidetall når den laster, så TPdfOutlineItem.PageNumber gir deg en én-basert sideindeks som matcher det offentlige TPdf-API-et snarere enn et objektnummer. Det er hele poenget med abstraksjonen: omkartleggingslogikken din virker i samme koordinatsystem som sideplanen du allerede bygde da du splittet, omorganiserte eller la ut dokumentet. Hvis du bygger den planen, går den samme én-baserte konvensjonen gjennom splitting av PDF-dokumenter i flere filer og gjennom n-up utlegging og sideomorganisering

Disposisjonen er et dobbeltlenket tre, ikke en liste

Grunnen til at du ikke rett og slett kan serialisere en flat array av titler, er at ISO 32000-1 §12.3.3 kobler hvert disposisjonselement inn i fem separate lenker: /Parent, /Prev, /Next, /First og /Last. Å flytte ett enkelt subtre omskriver derfor den gamle forelderen, den nye forelderen, begge nabosøskenene på hver side av snittet og innsettingspunktet, og forelderpekeren til den flyttede noden selv. Få én av dem feil, og konforme lesere viser et avkuttet tre, eller looper. PDFiumPas holder redigeringstilstanden som en dybde-først array av TPdfOutlineItem-poster med en stabil heltalls-Id, så et subtre er en sammenhengende skive og søskenkjeden er avledet, aldri håndholdt. TPdfOutlineEditor.Move løfter den skiven, setter den inn igjen under den nye forelderen ved den etterspurte søskenindeksen, og omtildeler bare roten av blokken. Den avslår også de to flyttingene som ville korruptere grafen: å flytte et element inn i dets eget subtre, og å navngi en forelder som ikke finnes

PDFiumPas disposisjonsredigering i Delphi: å flytte kapittel 3 ut av del I og under dokumentroten omskriver /Parent-pekeren til den flyttede noden pluss /First og søsken-/Prev- og /Next-lenkene rundt både snittet og innsettingspunktet
Ét Move-kall omskriver forelderpekeren til det løftede subtreet og søskenlenkene på begge sider av snittet og innsettingspunktet

Hvorfor er /Count signert?

Fordi tegnet bærer den utvidede tilstanden, ikke størrelsen. En positiv /Count betyr at elementet er åpent, og tallet er hvor mange etterkommere som for øyeblikket er synlige; en negativ /Count betyr at elementet er sammenslått. PDFiumPas skriver etterkommerantallet for hvert element som har barn, og negaterer det når IsOpen er False, og ved innlasting leser den tilstanden tilbake som IsOpen := HasCount and (CountValue > 0). Dette er den vanligste håndrullede feilen i disposisjonsskrivere: å emittiere et usignert antall og stille tvinge hele treet åpent

Slik koder PDFiumPas disposisjonsutvidelsestilstand i Delphi: en positiv /Count betyr at elementet er åpent og teller synlige etterkommere, en negativ /Count betyr sammenslått, og et usignert antall tvinger hver leser til å utvide hele treet
Tegnet til /Count er utvidelsestilstanden og størrelsen er synlig etterkommerantall, så et usignert antall tvinger stille hele treet åpent
var
  Source, Dest: TMemoryStream;
  Editor: TPdfOutlineEditor;
  Options: TPdfOutlineEditOptions;
  Report: TPdfOutlineValidationReport;
  RootId, ChapterId: Integer;
begin
  Source := TMemoryStream.Create;
  Dest := TMemoryStream.Create;
  Editor := nil;
  try
    Source.LoadFromFile('handbook.pdf');
    Options := TPdfOutlineEditOptions.Default;   // MaxItems 100000, MaxDepth 64
    if not TPdfOutlineEditor.TryLoad(Source, Options, Editor, Report) then
      raise Exception.Create(Report.ErrorMessage);

    RootId := Editor[0].Id;
    ChapterId := Editor[2].Id;

    Editor.Move(ChapterId, RootId, 1);           // blir andre barn av roten
    Editor.SetTitle(ChapterId, 'Appendix B');
    Editor.SetStyle(ChapterId, [posBold, posItalic]);
    Editor.SetColor(ChapterId, 0.25, 0.5, 0.75);
    Editor.SetExpanded(RootId, False);           // skriver en negativ /Count
    Editor.Retarget(ChapterId, 12, '/XYZ 10 20 1');

    if not Editor.SaveIncremental(Source, Dest, Report) then
      raise Exception.Create(Report.ErrorMessage);
    Dest.SaveToFile('handbook-edited.pdf');
  finally
    Editor.Free;
    Dest.Free;
    Source.Free;
  end;
end;

Retarget håndterer begge formene spesifikasjonen tillater. Send DestinationInAction som False og PDFiumPas skriver en direkte /Dest-array; send True og den skriver en Go-To-handling, /A << /S /GoTo /D [ page ref suffix ] >>, per ISO 32000-1 §12.6.4.2. Uansett stripper den først enhver eksisterende /Dest og /A fra elementet, slik at de to ikke kan eksistere side om side og uenige. Suffiksen har standard /Fit og må begynne med et PDF-navn, og det er derfor en tom eller misdannet suffiks kaster umiddelbart i stedet for å produsere en destinasjonsarray ingen leser kan parse

Hvordan konsumerer ApplyPageMap en sideplan?

ApplyPageMap tar nøyaktig arrayen sideplanen din allerede validerte: NewPageNumbers, indeksert ved gammel side minus én, som holder det nye én-baserte sidetallet eller null når den siden ikke overlevde. Den går element-arrayen baklengs slik at å slette et subtre aldri ugyldiggjør en indeks den ennå ikke har besøkt, og den rapporterer hva den gjorde gjennom RemappedDestinationCount og RemovedDanglingItemCount

var
  NewPageNumbers: array of Integer;
  Report: TPdfOutlineValidationReport;
  I: Integer;
begin
  // Én oppføring per side av ORIGINAL-dokumentet
  SetLength(NewPageNumbers, OriginalPageCount);
  for I := 0 to OriginalPageCount - 1 do
    NewPageNumbers[I] := 0;              // 0 == denne siden ble droppet

  NewPageNumbers[0] := 1;                // gammel side 1 -> ny side 1
  NewPageNumbers[1] := 2;
  NewPageNumbers[9] := 3;                // gammel side 10 -> ny side 3

  // True: slett hele det dinglende subtreet. False: behold elementet, strip målet
  if not Editor.ApplyPageMap(NewPageNumbers, True, Report) then
    raise Exception.Create(Report.ErrorMessage);

  WriteLn(Format('%d remapped, %d dangling items removed',
    [Report.RemappedDestinationCount, Report.RemovedDanglingItemCount]));
end;

DeleteDangling-flagget bestemmer politikken for en destinasjon som mappet til null, og begge greinene er bevisste. Med True sletter PDFiumPas elementet og dets hele subtre, fordi en disposisjonsnode hvis mål forsvant, leder vanligvis et kapittel som forsvant med det. Med False overlever elementet med tittel og hierarki intakt, men med sin /Dest og /A fjernet, som er det du vil når et menneske skal retargete det i gjennomgang. Genuin misdannet inndata feiler fortsatt høyt i stedet for å lappes: en negativ oppføring eller en destinasjon som peker forbi slutten av det oppgitte kartet, returnerer False med IssueKind satt til poviInvalidPageMap

Slik omdirigerer PDFiumPas ApplyPageMap PDF-bokmerker i Delphi: et sidekart indeksert ved gammel side minus én sender overlevende destinasjoner til sine nye sidetall, mens oppføringer som mapper til null enten slettes med subtreet sitt eller strippes for målet sitt
Sidekartet er indeksert ved gammel side minus én, og en null-oppføring sletter enten det dinglende subtreet eller lar elementet stå med målet strippet

Opake elementer, og den ærlige avveiningen

Ikke hvert disposisjonselement har et sidetall PDFiumPas kan resonnere om. Tre typer bæres gjennom uberørt: navngitte destinasjoner, handlinger som ikke er /S /GoTo, og ukjente ordlistenøkler lagt til av hva som helst som produserte filen. Disse lastes med PageNumber lik null, beholder sine opprinnelige byte i elementet, og skrives tilbake ordrett med mindre du eksplisitt kaller Retarget på dem

  • En navngitt destinasjon er en nøkkel inn i dokumentnavnetreet, så å omkartlegge den korrekt betyr å løse treet og omskrive måloppføringen, ikke å gjette på disposisjonsnivå
  • En /URI-, /Launch- eller JavaScript-handling har ingen side-semantikk i det hele tatt og må ikke stille konverteres til en Go-To
  • Leverandørspesifikke nøkler og strukturdestinasjoner bevares fordi å slippe det du ikke forstår, er slik rundturer mister data

Kostnaden er reell og verdt å si klart ut: ApplyPageMap hopper over disse elementene fullstendig, så et dokument hvis bokmerker alle bruker navngitte destinasjoner, kommer gjennom en sidesletting med disposisjonen strukturelt gyldig og semantisk foreldet. Det er det bevisste valget — en foreldet lenke en korrekturleser kan fange, slår en selvsikkert feil en ingen legger merke til. Hvis du triagerer innkommende filer før du redigerer dem, vil et inventarpass i en PDF-intake gjennomgangsarbeidsbenk fortelle deg hvilke dokumenter som faller i den bøtta

Lagring: inkrementell revisjon, deretter en uavhengig gjeninnlasting

TPdfOutlineEditor.SaveIncremental tilføyer en sparsom inkrementell revisjon i stedet for å omskrive filen. Elementer som ble lastet beholder sin opprinnelige indirekte objektreferanse inkludert den eksakte generasjonen, så eksisterende kryssreferanser forblir gyldige; bare elementer du la til, trekker et ferskt nummer, tildelt fra én forbi revisjonens maksimale objektnummer. Katalogen oppdateres i samme revisjon, og en manglende /Outlines-oppføring legges til den når kilden ikke hadde noen disposisjon i det hele tatt

Hva som skjer etter skrivingen, er delen verdt å kopiere. PDFiumPas gjenåpner målet strømmen med en fullstendig uavhengig editor og sammenligner det gjeninnlastede treet mot det i minnet — elementantall, titler, sidetall, destinasjonssuffikser, handling-versus-direkte destinasjonsform, stiler, utvidelsestilstand og foreldrerelasjoner. Enhver mismatch, eller enhver innlastingsfeil, tømmer målet strømmen og returnerer poviVerificationFailure i stedet for å gi deg en plausibel-seende fil. Krypterte kilder avslås på forhånd med poviEncryptedInput, siden nye titler og destinasjoner lager strenginnhold som ikke kan produseres ved å kopiere /Encrypt-traileren fremover

if not Editor.SaveIncremental(Source, Dest, Report) then
  case Report.IssueKind of
    poviEncryptedInput:
      Log('Source is encrypted; outline editing needs an unprotected copy');
    poviInvalidDestination:
      Log(Format('Item %d %d targets a missing page',
        [Report.ObjectNumber, Report.Generation]));
    poviVerificationFailure:
      Log('Reload check rejected the written revision: ' + Report.ErrorMessage);
  else
    Log(Report.ErrorMessage);
  end;

Behandle disposisjonen som det den er — en lenket objektgraf med sine egne invarianter — og sidesletting slutter å være en bokmerke-katastrofe og blir et sidekart du gir til ett metodekall. TPdfOutlineEditor, ApplyPageMap og den verifiserte inkrementelle skriveren kommer i PDFiumPas fra v3.98.0 for Delphi, C++Builder og Lazarus; du kan gjennomgå hele API-et og laste ned en prøveversjon på PDFium Delphi Component produktsiden