Teknisk artikel

Lägg till bokmärken i befintliga PDF-filer med HotPDF

HotPDF lägger till bokmärken i en befintlig PDF i Delphi genom AddLoadedOutline, som skapar dispositionsposter på valfri nästlingsnivå i ett inläst dokument, och den läser och skriver namngivna destinationer genom ResolveLoadedNamedDestination och AddLoadedNamedDestination. Läs in filen, bygg trädet, anropa SaveLoadedDocument, och läsarens sidopanel som brukade vara tom bär nu en fungerande innehållsförteckning

Scenariot som motiverar allt det här är nedslående vanligt. Du slår ihop ett dussin avtal till ett granskningspaket, eller syr samman tre produktmanualer till en enda distribuerbar fil, och utdatan är strukturellt felfri: varje sida på plats, varje typsnitt intakt. Sedan öppnar någon den i Acrobat och bokmärkespanelen är tom. Ett dokument på 400 sidor utan navigering är tekniskt komplett och praktiskt taget oanvändbart, och fram till version 2.347.0 kunde HotPDF läsa, ändra och ta bort bokmärken i inlästa dokument men aldrig skapa dem. Den luckan är nu stängd, och den här artikeln går igenom den nya API-ytan och den enda biten PDF-esoterik, nyckeln /Count, som kommer att bita dig om du behandlar den som en enkel posträknare

Vad dispositionsträdet faktiskt är

En PDF-disposition är ett dubbellänkat träd av ordböcker, inte en platt lista, och den strukturen förklarar varje parameter i API:et. ISO 32000-1 §12.3.3 definierar den: dokumentkatalogen pekar på en /Outlines-rot, roten pekar på sina första och sista barn via /First och /Last, och syskon kedjas genom /Next och /Prev medan varje post pekar uppåt genom /Parent. Varje post bär en /Title-sträng och, vanligtvis, en /Dest som säger vart en klickning ska ta läsaren

Destinationer kommer i två smaker, och ISO 32000-1 §12.3.2 håller dem medvetet åtskilda. En explicit destination är en inbäddad array som [page /XYZ x y zoom]: en direkt referens till ett sidobjekt plus en vyspecifikation. En namngiven destination lagrar istället en namnsträng, och det verkliga målet bor i dokumentkatalogen under namnträdet /Names /Dests, ett lager av indirektion som låter många länkar dela ett mål och låter målet flytta sig utan att länkarna rörs. HotPDF skriver explicita /XYZ-destinationer när den skapar bokmärken, och ger dig separata anrop för att läsa och utöka namnträdet

HotPDF-diagram över det dubbellänkade PDF-dispositionsträd som AddLoadedOutline bygger: dokumentkatalogen pekar på Outlines-roten, rotens First och Last når ändposterna, syskon kedjas genom Next och Prev medan varje post lagrar Parent, och namnträdet Names Dests löser upp namngivna destinationer
Dispositionen är ett dubbellänkat träd — roten håller /First och /Last, syskon kedjas genom /Next och /Prev, varje post lagrar /Parent, och varje klickmål åker i en /Dest [page /XYZ]-array

Hur lägger du till bokmärken i en befintlig PDF i Delphi?

Anropa AddLoadedOutline(ParentIndex, DestPageIndex, Title, X, Y, Zoom) efter LoadFromFile: skicka ParentIndex = -1 för en post på toppnivå, eller det nollbaserade indexet för en befintlig toppnivåpost för att nästla under den. Funktionen skapar /Outlines-roten om dokumentet aldrig hade någon, bygger postordboken, skriver en /Dest [page /XYZ x y zoom]-array som pekar på det nollbaserade DestPageIndex, och länkar in den nya posten i syskonkedjan. Den returnerar 0 vid framgång och -1 vid misslyckande, till exempel när DestPageIndex ligger utanför intervallet

En länkningsdetalj spelar roll för ordningen: en ny post infogas först i sin förälders barnkedja, som den nya /First. Så om du lägger till ”Chapter 1” och sedan ”Chapter 2” som toppnivåposter hamnar Chapter 2 ovanför Chapter 1 i sidopanelen, och Chapter 1 har förskjutits till toppnivåindex 1. Det praktiska mönstret är antingen att lägga till poster i omvänd läsordning, eller att lägga till varje förälder och genast fylla på dess barn medan den fortfarande sitter på index 0

var
  Pdf: THotPDF;
begin
  Pdf := THotPDF.Create(nil);
  try
    if Pdf.LoadFromFile('merged-manual.pdf', '') > 0 then
    begin
      // Lägg till kapitel i omvänd läsordning: varje ny
      // toppnivåpost blir /First (index 0).
      Pdf.AddLoadedOutline(-1, 40, 'Chapter 2: Configuration', 0, 792, 0);
      Pdf.AddLoadedOutline(-1, 0, 'Chapter 1: Installation', 0, 792, 0);
      // Chapter 1 är nu toppnivåindex 0; nästla avsnitt
      // under den (barn läggs också först, så omvänd ordning).
      Pdf.AddLoadedOutline(0, 12, 'License activation', 0, 792, 0);
      Pdf.AddLoadedOutline(0, 3, 'System requirements', 0, 792, 0);
      Pdf.SaveLoadedDocument('merged-manual-toc.pdf');
    end;
  finally
    Pdf.Free;
  end;
end;

Parametrarna X, Y och Zoom mappar rakt mot /XYZ-destinationen och har alla 0 som standard. PDF lägger koordinatorigo i det nedre vänstra hörnet, så Y = 792 siktar på överkanten av en US Letter-sida, och enligt ISO 32000-1 säger ett nollvärde i en /XYZ-plats åt visaren att behålla sin nuvarande inställning, vilket är precis vad du vill ha för zoom i nästan varje fall. Allt sker på den inlästa objektgrafen i minnet; ingenting rör disken förrän SaveLoadedDocument körs, samma redigera-sedan-spara-modell som API:et för inlästa dokument använder för omskrivning av titel, författare och XMP-metadata i inlästa PDF-filer

Att rikta om bokmärken: sidhopp kontra URI-åtgärder

Befintliga bokmärken kan kopplas om utan att återskapas, och HotPDF ger de två fallen två skilda anrop eftersom PDF-konstruktionerna under dem verkligen är olika. SetLoadedOutlineDestination(Index, DestPageIndex, X, Y, Zoom) ersätter /Dest för toppnivåposten på Index med en färsk /XYZ-array, verktyget för det klassiska misslyckandet efter en sammanslagning där varje bokmärke fortfarande pekar på sidnumren i originalfilen före sammanslagningen. SetLoadedOutlineURI(Index, URI) gör något strukturellt annorlunda: den fäster en /A-åtgärdsordbok med /S /URI, vilket förvandlar bokmärket till en webblänk istället för ett hopp inuti dokumentet

Två sätt som HotPDF kopplar om ett befintligt bokmärke: SetLoadedOutlineDestination skriver en Dest XYZ-array inom dokumentet för sidhopp, medan SetLoadedOutlineURI fäster en A-åtgärdsordbok med S URI som öppnar en webbsida
SetLoadedOutlineDestination skriver om /Dest-arrayen för hopp inom dokumentet, botemedlet mot bokmärken efter sammanslagning som fortfarande pekar på sidor före sammanslagningen, medan SetLoadedOutlineURI byter in en /A /S /URI-åtgärd reserverad för lövposter som lämnar dokumentet
// Sammanslagningen förskjöt allt med 10 sidor: rikta om det
// tredje toppnivåbokmärket till sida 12 (nollbaserat 11).
Pdf.SetLoadedOutlineDestination(2, 11, 0, 792, 0);

// Gör den sista posten till en extern länk.
Pdf.SetLoadedOutlineURI(3, 'https://www.loslab.com/');

Pdf.SaveLoadedDocument('retargeted.pdf');

Håll de två mekanismerna isär när du utformar trädet. En /Dest är ren navigering inuti dokumentet; en URI-åtgärd lämnar dokumentet helt, och ISO 32000-1 förväntar sig att en dispositionspost navigerar genom den ena mekanismen eller den andra, inte båda. Så reservera SetLoadedOutlineURI för poster som inte redan bär en siddestination, ett löv som ”Product homepage” eller ”Report an issue” i slutet av trädet snarare än en omvandlad kapitelrubrik. URI-åtgärden som skrivs här är samma /S /URI-konstruktion som används för länkannoteringar på sidinnehåll, vilket täcks djupare i guiden till att skapa hyperlänkar i PDF-dokument med HotPDF

Hur fungerar namngivna destinationer i en inläst PDF?

Namngivna destinationer är mekanismen för stabila ankare i ISO 32000-1 §12.3.2.3: istället för att bädda in en sidreferens i varje länk håller dokumentet en karta från namn till destination i katalogens namnträd /Names /Dests, och länkar hänvisar till poster med namn. ResolveLoadedNamedDestination(Name) slår upp ett namn i det trädet och returnerar det nollbaserade sidindex det pekar på, eller -1 om namnet saknas. Uppslagaren hanterar båda lagringsformerna som specifikationen tillåter för det mappade värdet: en naken destinationsarray som [page /XYZ x y zoom] och ordboksformen som lindar arrayen under en /D-nyckel

AddLoadedNamedDestination(Name, DestPageIndex, X, Y) går åt andra hållet: den registrerar ett nytt ankare i trädet /Names /Dests, skapar trädet självt när dokumentet saknar ett, och returnerar True vid framgång. Tillsammans täcker paret arbetsflödet där ett sammanslaget dokument måste respektera ankare som uppströmsverktyg skrivit, och publicera nya som nedströmslänkar kan sikta på

var
  PageIdx: Integer;
begin
  // Respektera ett ankare ett annat verktyg skrev in i /Names /Dests.
  PageIdx := Pdf.ResolveLoadedNamedDestination('chapter.3.figures');
  if PageIdx >= 0 then
    Pdf.AddLoadedOutline(-1, PageIdx, 'Chapter 3: Figures', 0, 792, 0);

  // Publicera ett nytt ankare som andra länkar kan sikta på.
  if Pdf.AddLoadedNamedDestination('appendix.glossary', 42, 0, 792) then
    Pdf.SaveLoadedDocument('anchored.pdf');
end;

De ärliga gränserna: destinationerna HotPDF skriver, för bokmärken såväl som namngivna ankare, är /XYZ-destinationer. De andra explicita typerna som specifikationen definierar, /Fit, /FitH, /FitB och deras släktingar, avges inte av de här anropen, även om /XYZ med noll zoom täcker det dominerande användningsfallet ”gå till det här stället, behåll min zoom”. Och GetLoadedBookmarkPageIndex(Title), den bekväma uppslagningen som tar en bokmärkestitel och returnerar dess destinationssida, söker igenom dispositionsträdets toppnivå, så använd den för att verifiera de kapitelnivåposter du just skapat snarare än djupt nästlade löv

Fällan med /Count: den räknar ättlingar, inte poster

Nyckeln /Count på dispositionsroten betyder inte vad de flesta utvecklare antar, och det felaktiga antagandet ger testfel som ser ut som datakorruption. ISO 32000-1 §12.3.3 definierar /Count som det totala antalet synliga ättlingar på alla nivåer, inte antalet toppnivåposter. På en enskild post bär tecknet också betydelse: ett positivt värde betyder att posten är öppen och att så många ättlingar visas i sidopanelen, medan ett negativt värde betyder att posten är hopfälld och att |N| ättlingar är dolda inuti den

Det här kom fram ur HotPDF:s egen regressionssvit medan bokmärkes-API:erna för inlästa dokument byggdes. Ett testdokument höll tre toppnivåbokmärken, ett med ett barn; efter att en toppnivåpost tagits bort rapporterade GetLoadedOutlineCount, som läser rotens /Count, 1 istället för de förväntade 2. Ingenting var korrupt: den ursprungliga trean hade aldrig varit ”tre toppnivåposter” från början, och den korrekta omräkningen måste gå igenom toppnivåkedjan och ackumulera varje nod plus dess positiva /Count, och hoppa över ättlingar till hopfällda noder. Lärdomen för din egen kod är direkt: hävda aldrig något om deltan i /Count efter strukturella redigeringar, eftersom värdet rör sig icke-linjärt med synliga ättlingar. Verifiera strukturen efter innehåll istället

HotPDF: /Count-semantiken i PDF-dispositioner: en positiv Count markerar en öppen post vars barn förblir synliga och räknade, en negativ Count viker undan ättlingar, och att ta bort ett toppnivåbokmärke flyttar rotens räknare från fyra till tre snarare än med ett per rad
Rotens /Count summerar synliga ättlingar på alla nivåer, så tecken och nästling avgör hur den rör sig — en delta-assertion efter borttagningar misslyckas, och verifiering med GetLoadedBookmarkPageIndex på titel är den ärliga kontrollen
// Verifiera efter redigeringar med titeluppslagning, inte med /Count-deltan.
if Pdf.GetLoadedBookmarkPageIndex('Chapter 1: Installation') = 0 then
  ShowMessage('Outline verified');

Var det här passar in i verktygslådan för inlästa dokument

Skapandet av bokmärken och namngivna destinationer fullbordar en bild som API:et för inlästa dokument har fyllt i ett tag: samma cykel av inläsning, redigering och sparande skriver redan om dokumentmetadata, lägger till länkannoteringar och tar annoteringar på rundtur genom XFDF-import och -export. Mönstret är enhetligt, LoadFromFile, en följd av *Loaded*-anrop mot objektgrafen i minnet, sedan ett SaveLoadedDocument, vilket gör det enkelt att vika in dispositionsbyggandet i en befintlig sammanslagnings- eller stämplingspipeline som ytterligare ett steg före sparandet

API:erna som diskuteras här, AddLoadedOutline, SetLoadedOutlineDestination, SetLoadedOutlineURI, ResolveLoadedNamedDestination, AddLoadedNamedDestination och GetLoadedBookmarkPageIndex, levereras i standardversionen av HotPDF Delphi Component för Delphi och C++Builder, utan externa beroenden och utan krav på en separat visarkörtid