Teknisk artikel

PDF-dispositioner og sidetilknytning i Delphi

Smid syv sider ud af en håndbog på 200 sider, og hvert bogmærke lander et sted forkert. Løsningen er ikke at genopbygge dispositionen ud fra en flad titelliste. PDFiumPas eksponerer TPdfOutlineEditor, som indlæser det rigtige dispositionstræ, lader dig flytte elementer og give dem nye mål og derefter kører ApplyPageMap for at flytte hver eksplicit destination gennem din sideplan

Hvorfor ødelægger sletning af sider hvert bogmærke?

Fordi et dispositionselement ikke gemmer et sidetal. Det gemmer en reference til et sideobjekt, og når sideobjekterne ændres, peger referencen enten på en side, der er flyttet, eller på ingenting. ISO 32000-1 §12.3.2.2 definerer en eksplicit destination som et array, hvis første element er en indirekte reference til en sideordbog, efterfulgt af et fit-navn som /Fit eller /XYZ. Slet siden, og du står tilbage med en hængende reference; omarrangér siderne, og referencen er stadig gyldig, men beskriver nu et andet kapitel. PDFiumPas opløser det array tilbage til et sidetal ved indlæsning, så TPdfOutlineItem.PageNumber giver dig et én-baseret sideindeks, der matcher den offentlige TPdf-API frem for et objektnummer. Det er hele pointen med abstraktionen: din omtilknytningslogik arbejder i samme koordinatsystem som den sideplan, du allerede byggede, da du opdelte, omarrangerede eller udlagde dokumentet. Hvis du er ved at bygge den plan, løber samme én-baserede konvention gennem opdeling af PDF-dokumenter i flere filer og gennem n-up-imposition og sideomarrangering

Dispositionen er et dobbelt linket træ, ikke en liste

Grunden til, at du ikke blot kan serialisere et fladt array af titler, er, at ISO 32000-1 §12.3.3 forbinder hvert dispositionselement med fem separate links: /Parent, /Prev, /Next, /First og /Last. At flytte et enkelt deltræ omskriver derfor den gamle forælder, den nye forælder, begge nabo-søskende på hver side af snittet og indsættelsespunktet samt den flyttede knudes forældrepeger. Tag en af dem forkert, og standardoverholdende læsere viser et afkortet træ eller looper. PDFiumPas holder redigeringstilstanden som et depth-first-array af TPdfOutlineItem-records med et stabilt heltals-Id, så et deltræ er et sammenhængende udsnit, og søskendekæden er afledt, aldrig håndvedligeholdt. TPdfOutlineEditor.Move løfter det udsnit, genindsætter det under den nye forælder ved det ønskede søskendeindeks og tildeler kun blokkens rod om. Den nægter også de to flyt, der ville ødelægge grafen: at flytte et element ind i dets eget deltræ og at nævne en forælder, der ikke findes

PDFiumPas-dispositionsredigering i Delphi: at flytte kapitel 3 ud af del I og under dokumentroden omskriver /Parent-pegeren på den flyttede knude plus /First og søskende-/Prev- og /Next-links omkring både snittet og indsættelsespunktet
Ét Move-kald omskriver forældrepegeren på det løftede deltræ og søskendelinksene på begge sider af snittet og indsættelsespunktet

Hvorfor er /Count fortegnet?

Fordi tegnet bærer den udfoldede tilstand, ikke størrelsen. En positiv /Count betyder, at elementet er åbent, og tallet er, hvor mange efterkommere der i øjeblikket er synlige; en negativ /Count betyder, at elementet er sammenklappet. PDFiumPas skriver efterkommerantallet for hvert element, der har børn, og negerer det, når IsOpen er False, og ved indlæsning læser den tilstanden tilbage som IsOpen := HasCount and (CountValue > 0). Dette er den mest almindelige håndrullede fejl i dispositionsskrivere: at udsende et utegnet antal og stille tvinge hele træet åbent

Hvordan PDFiumPas koder dispositionens udfoldelsestilstand i Delphi: en positiv /Count betyder, at elementet er åbent og tæller synlige efterkommere, en negativ /Count betyder sammenklappet, og et utegnet antal tvinger enhver læser til at udfolde hele træet
Tegnet af /Count er udfoldelsestilstanden, og størrelsen er antallet af synlige efterkommere, så et utegnet antal stille tvinger hele træet åbent
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);           // bliver andet barn af roden
    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 former, specifikationen tillader. Giv DestinationInAction som False, og PDFiumPas skriver et direkte /Dest-array; giv True, og den skriver en Go-To-handling, /A << /S /GoTo /D [ page ref suffix ] >>, efter ISO 32000-1 §12.6.4.2. Uanset hvad fjerner den først enhver eksisterende /Dest og /A fra elementet, så de to ikke kan eksistere samtidig og være uenige. Suffikset er /Fit som standard og skal begynde med et PDF-navn, hvilket er derfor, et tomt eller misdannet suffiks rejser straks i stedet for at frembringe et destinationsarray, ingen læser kan parse

Hvordan indtager ApplyPageMap en sideplan?

ApplyPageMap tager præcis det array, din sideplan allerede har valideret: NewPageNumbers, indekseret efter gammel side minus én, holdende det nye én-baserede sidetal eller nul, når siden ikke overlevede. Den går elementarrayet baglæns, så sletning af et deltræ aldrig invaliderer et indeks, den endnu ikke har besøgt, og den rapporterer, hvad den gjorde, gennem RemappedDestinationCount og RemovedDanglingItemCount

var
  NewPageNumbers: array of Integer;
  Report: TPdfOutlineValidationReport;
  I: Integer;
begin
  // Én post pr. side af det ORIGINALE dokument
  SetLength(NewPageNumbers, OriginalPageCount);
  for I := 0 to OriginalPageCount - 1 do
    NewPageNumbers[I] := 0;              // 0 == denne side blev droppet

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

  // True: slet hele det hængende deltræ. False: behold elementet, fjern dets mål
  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;

Flaget DeleteDangling beslutter politikken for en destination, der kortlagde til nul, og begge grene er bevidste. Med True sletter PDFiumPas elementet og hele dets deltræ, for en dispositionsknude, hvis mål forsvandt, leder normalt et kapitel, der forsvandt med det. Med False overlever elementet med sin titel og hierarki intakt, men med /Dest og /A fjernet, hvilket er det, du vil have, når et menneske skal give det et nyt mål i gennemgangen. Genuint misdannet input fejler stadig højlydt frem for at blive lappet: en negativ post eller en destination, der peger forbi slutningen af det leverede map, returnerer False med IssueKind sat til poviInvalidPageMap

Hvordan PDFiumPas ApplyPageMap omdirigerer PDF-bogmærker i Delphi: et page map indekseret efter gammel side minus én sender overlevende destinationer til deres nye sidetal, mens poster, der kortlægger til nul, enten slettes med deres deltræ eller berøves deres mål
Sidemappet indekseres efter gammel side minus én, og en nulpost sletter enten det hængende deltræ eller efterlader elementet med sit mål fjernet

Ugennemsigtige poster og den ærlige afvejning

Ikke hvert dispositionselement har et sidetal, PDFiumPas kan ræsonnere om. Tre slags bæres igennem urørte: navngivne destinationer, handlinger, der ikke er /S /GoTo, og ukendte ordbogsnøgler tilføjet af det, der frembragte filen. Disse indlæses med PageNumber lig nul, beholder deres originale bytes i elementet og skrives tilbage ordret, medmindre du eksplicit kalder Retarget på dem

  • En navngiven destination er en nøgle ind i dokumentets navnetræ, så at kortlægge den korrekt betyder at opløse træet og omskrive måleposten, ikke at gætte på dispositionsniveau
  • En /URI-, /Launch- eller JavaScript-handling har slet ingen sidesemantik og må ikke stille konverteres til en Go-To
  • Leverandørspecifikke nøgler og strukturerdestinationer bevares, for at droppe det, du ikke forstår, er sådan rundture taber data

Prisen er reel og værd at slå fast rent ud: ApplyPageMap springer disse elementer helt over, så et dokument, hvis bogmærker alle bruger navngivne destinationer, kommer gennem en sidesletning med sin disposition strukturelt gyldig og semantisk forældet. Det er det bevidste valg — et forældet link, en anmelder kan fange, slår et selvsikkert forkert, ingen bemærker. Hvis du triagerer indkommende filer, før du redigerer dem, vil et inventargennemløb i en PDF-intake-review-workbench fortælle dig, hvilke dokumenter der falder i den spand

Gemning: inkrementel revision, derefter en uafhængig genindlæsning

TPdfOutlineEditor.SaveIncremental tilføjer en sparsom inkrementel revision frem for at omskrive filen. Elementer, der blev indlæst, beholder deres originale indirekte objektreference inklusive den nøjagtige generation, så eksisterende krydsreferencer forbliver gyldige; kun elementer, du tilføjede, trækker et nyt nummer, allokeret fra én forbi revisionens maksimale objektnummer. Kataloget opdateres i samme revision, og en manglende /Outlines-post tilføjes til det, når kilden slet ingen disposition havde

Det, der sker efter skrivningen, er delen værd at kopiere. PDFiumPas genåbner destinationsstreamen med en fuldstændig uafhængig editor og sammenligner det genindlæste træ med det i hukommelsen — elementantal, titler, sidetal, destinationssuffikser, handling-versus-direkte-destination-form, stilarter, udfoldelsestilstand og forældrerelationer. Enhver uoverensstemmelse eller enhver indlæsningsfejl rydder destinationsstreamen og returnerer poviVerificationFailure i stedet for at række dig en plausibelt udseende fil. Krypterede kilder afvises på forhånd med poviEncryptedInput, da nye titler og destinationer frembringer strengindhold, der ikke kan frembringes ved at kopiere /Encrypt-traileren frem

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;

Behandl dispositionen som det, den er — en linket objektgraf med sine egne invarianter — og sletning af sider holder op med at være en bogmærkekatastrofe og bliver et page map, du rækker til ét metodekald. TPdfOutlineEditor, ApplyPageMap og den verificerede inkrementelle skriver følger med i PDFiumPas fra v3.98.0 til Delphi, C++Builder og Lazarus; du kan gennemse den fulde API og downloade en prøve på PDFium Delphi Component-produktsiden