Teknisk artikel

PDF-outline-redigering och sidommappning i Delphi

Släng sju sidor ur en handbok på 200 sidor och landar varje bokmärke på fel ställe. Lösningen är inte att bygga om outline-trädet från en platt titellista. PDFiumPas exponerar TPdfOutlineEditor, som laddar det verkliga outline-trädet, låter dig flytta och omrikta poster och sedan kör ApplyPageMap för att föra varje explicit destination genom din sidplan

Varför förstör sidborttagning alla bokmärken?

Eftersom en outline-post inte lagrar ett sidnummer. Den lagrar en referens till ett sidobjekt, och när sidobjekten ändras pekar referensen antingen på en sida som flyttat eller på ingenting alls. ISO 32000-1 §12.3.2.2 definierar en explicit destination som en array vars första element är en indirekt referens till en sidordlista, följt av ett passningsnamn som /Fit eller /XYZ. Ta bort sidan och står du kvar med en hängande referens; omordna sidorna och är referensen fortfarande giltig men beskriver nu ett annat kapitel. PDFiumPas löser tillbaka arrayen till ett sidnummer vid inläsning, så TPdfOutlineItem.PageNumber ger dig ett entalsbaserat sidindex som matchar det publika TPdf-API:t i stället för ett objektnummer. Det är hela poängen med abstraktionen: din ommappningslogik arbetar i samma koordinatsystem som den sidplan du redan byggde när du delade, omordnade eller impositionerade dokumentet. Om du håller på att bygga den planen löper samma ental-indexkonvention genom att dela upp PDF-dokument i flera filer och genom n-up-imposition och sidoomordning

Outline-trädet är ett dubbellänkat träd, inte en lista

Skälet till att du inte helt enkelt kan serialisera en platt array av titlar är att ISO 32000-1 §12.3.3 kopplar in varje outline-post i fem separata länkar: /Parent, /Prev, /Next, /First och /Last. Att flytta ett enda delträd skriver därför om den gamla föräldern, den nya föräldern, båda grannarna på varsin sida om snittet och insättningspunkten, och förälderpekaren hos den flyttade noden själv. Får du en av dem fel visar konforma läsare ett avkapat träd, eller loopar. PDFiumPas håller redigeringstillståndet som en djup-först-array av TPdfOutlineItem-poster med ett stabilt heltals-Id, så ett delträd är ett sammanhängande utsnitt och syskonkedjan härleds, aldrig handunderhållen. TPdfOutlineEditor.Move lyfter utsnittet, återinfogar det under den nya föräldern vid det begärda syskonindexet och omtilldelar bara roten i blocket. Den vägrar också de två flyttar som skulle korrumpera grafen: att flytta en post in i sitt eget delträd, och att namnge en förälder som inte finns

PDFiumPas outline-redigering i Delphi: att flytta kapitel 3 ur del I och under dokumentroten skriver om /Parent-pekaren hos den flyttade noden plus /First- och syskonlänkarna /Prev och /Next runt både snittet och insättningspunkten
Ett enda Move-anrop skriver om förälderpekaren hos det lyfta delträdet och syskonlänkarna på båda sidor om snittet och insättningspunkten

Varför är /Count signerat?

Eftersom tecknet bär expanderat-läget, inte storleken. Ett positivt /Count betyder att posten är öppen och talet är hur många ättlingar som för tillfället syns; ett negativt /Count betyder att posten är hopfälld. PDFiumPas skriver ättlingsantalet för varje post som har barn och negerar det när IsOpen är False, och vid inläsning läser det tillbaka läget som IsOpen := HasCount and (CountValue > 0). Det här är det vanligaste hemmabyggda felet i outline-skrivare: att avge ett osignerat värde och tyst tvinga hela trädet öppet

Hur PDFiumPas kodar outline-expansion i Delphi: ett positivt /Count betyder att posten är öppen och räknar synliga ättlingar, ett negativt /Count betyder hopfälld, och ett osignerat värde tvingar varje läsare att expandera hela trädet
Tecknet på /Count är expanderat-läget och beloppet är antalet synliga ättlingar, så ett osignerat värde tyst tvingar hela trädet öppet
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 andra barnet till 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 ett negativt /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 hanterar båda former specifikationen tillåter. Skicka DestinationInAction som False och PDFiumPas skriver en direkt /Dest-array; skicka True och skriver den en Go-To-aktion, /A << /S /GoTo /D [ page ref suffix ] >>, enligt ISO 32000-1 §12.6.4.2. I båda fallen tar den först bort alla befintliga /Dest och /A från posten så att de två inte kan samexistera och säga emot varandra. Suffixet standardiseras till /Fit och måste börja med ett PDF-namn, vilket är skälet till att ett tomt eller illformat suffix kastar omedelbart i stället för att producera en destinationsarray ingen läsare kan tolka

Hur konsumerar ApplyPageMap en sidplan?

ApplyPageMap tar exakt den array din sidplan redan validerat: NewPageNumbers, indexerad med gammal sida minus ett, som håller det nya entalssidnumret eller noll när den sidan inte överlevde. Den går baklänges genom postarrayen så att borttagning av ett delträd aldrig ogiltigförklarar ett index den ännu inte besökt, och den rapporterar vad den gjorde genom RemappedDestinationCount och RemovedDanglingItemCount

var
  NewPageNumbers: array of Integer;
  Report: TPdfOutlineValidationReport;
  I: Integer;
begin
  // En post per sida i det URSPRUNGLIGA dokumentet
  SetLength(NewPageNumbers, OriginalPageCount);
  for I := 0 to OriginalPageCount - 1 do
    NewPageNumbers[I] := 0;              // 0 == den här sidan kasserades

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

  // True: ta bort hela det hängande delträdet. False: behåll posten, ta bort 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;

Flaggan DeleteDangling avgör policyn för en destination som mappades till noll, och båda grenarna är avsiktliga. Med True tar PDFiumPas bort posten och hela dess delträd, eftersom en outline-nod vars mål försvann vanligtvis inleder ett kapitel som försvann med den. Med False överlever posten med titeln och hierarkin intakta men utan /Dest och /A, vilket är vad du vill när en människa ska rikta om den i granskning. Genuint illformad indata felar fortfarande högljutt i stället för att lappas: en negativ post eller en destination som pekar bortom slutet på den medföljande kartan returnerar False med IssueKind satt till poviInvalidPageMap

Hur PDFiumPas ApplyPageMap omdirigerar PDF-bokmärken i Delphi: en sidkarta indexerad med gammal sida minus ett skickar överlevande destinationer till sina nya sidnummer, medan poster som mappas till noll antingen tas bort med sitt delträd eller berövas sitt mål
Sidkartan indexeras med gammal sida minus ett, och en nollpost tar antingen bort det hängande delträdet eller lämnar posten utan mål

Ogenomskinliga poster, och den ärliga kompromissen

Inte varje outline-post har ett sidnummer PDFiumPas kan resonera om. Tre slag förs vidare orörda: namngivna destinationer, aktioner som inte är /S /GoTo, och okända ordlistenycklar tillagda av vad det nu var som producerade filen. Dessa laddas med PageNumber lika med noll, behåller sina ursprungliga bytes i posten och skrivs tillbaka ordagrant om du inte uttryckligen anropar Retarget på dem

  • En namngiven destination är en nyckel in i dokumentets namnträd, så att mappa om den korrekt innebär att lösa trädet och skriva om målposten, inte att gissa på outline-nivå
  • En /URI-, /Launch- eller JavaScript-aktion har inga sid-semantiker alls och får inte tyst konverteras till en Go-To
  • Leverantörsspecifika nycklar och strukturella destinationer bevaras, eftersom att kasta det man inte förstår är hur rundresor förlorar data

Kostnaden är verklig och värd att säga rakt ut: ApplyPageMap hoppar helt över de posterna, så ett dokument vars bokmärken alla använder namngivna destinationer kommer igenom en sidborttagning med sin outline strukturellt giltig och semantiskt inaktuell. Det är det avsiktliga valet — en inaktuell länk en granskare kan fånga slår en självsäkert fel länk ingen märker. Om du triagerar inkommande filer innan du redigerar dem kommer ett inventeringssvep i en PDF-intagsgranskningsbank att tala om vilka dokument som hamnar i den hinken

Spara: inkrementell revision, sedan en oberoende omladdning

TPdfOutlineEditor.SaveIncremental fogar in en gles inkrementell revision i stället för att skriva om filen. Poster som laddades behåller sin ursprungliga indirekta objektreferens inklusive den exakta generationen, så befintliga korsreferenser förblir giltiga; bara poster du lagt till drar ett nytt nummer, allokerat från ett över revisionens högsta objektnummer. Katalogen uppdateras i samma revision, och en saknad /Outlines-post läggs till i den när källan saknade outline överhuvudtaget

Det som händer efter skrivningen är den del värd att kopiera. PDFiumPas öppnar destinationsströmmen på nytt med en helt oberoende editor och jämför det omladdade trädet mot det i minnet — postantal, titlar, sidnummer, destinationssuffix, aktion-mot-direkt destinationsform, stilar, expanderat-läge och förälderrelationer. Varje missmatch, eller varje inläsningsfel, rensar destinationsströmmen och returnerar poviVerificationFailure i stället för att ge dig en trovärdigt utseende fil. Krypterade källor vägras direkt med poviEncryptedInput, eftersom nya titlar och destinationer skapar stränginnehåll som inte kan produceras genom att kopiera /Encrypt-trailern vidare

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;

Behandla outline som det den är — en länkad objektgraf med sina egna invarianter — och upphör sidborttagning att vara en bokmärkeskatastrof och blir i stället en sidkarta du överlämnar till ett metodanrop. TPdfOutlineEditor, ApplyPageMap och den verifierade inkrementella skrivaren skeppas i PDFiumPas från v3.98.0 för Delphi, C++Builder och Lazarus; du kan granska hela API:t och ladda ner en utvärdering på sidan för PDFiumPas Delphi PDFium-komponent