Teknisk artikel

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

HotPDF lägger till bokmärken i en befintlig PDF i Delphi via AddLoadedOutline, som skapar bokmärkesposter på valfri nästlingsnivå i ett inläst dokument, och programmet läser och skriver namngivna destinationer via ResolveLoadedNamedDestination och AddLoadedNamedDestination. Läs in filen, bygg trädet, anropa SaveLoadedDocument, så innehåller läsarens sidofält som tidigare var tomt nu en fungerande innehållsförteckning

Scenariot som motiverar allt detta är deprimerande vanligt. Du slår samman ett dussin kontrakt till ett enda granskningspaket, eller sammanfogar tre produktmanualer till en enda distribuerbar fil, och resultatet är strukturellt bra: varje sida finns där, varje typsnitt är intakt. Sedan öppnar någon filen i Acrobat och bokmärkespanelen är tom. Ett dokument på 400 sidor utan navigering är tekniskt sett komplett men i praktiken 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. Denna lucka är nu fylld, och den här artikeln går igenom det nya API-gränssnittet och den del av PDF-mystiken, nyckeln /Count, som kommer att ställa till problem om du behandlar den som en enkel posträknare

Vad bokmärkessträdet faktiskt är

Ett PDF-bokmärke (outline) är ett dubbelkopplat träd av ordlistor (dictionaries), inte en platt lista, och denna struktur förklarar varje parameter i API:et. ISO 32000-1 §12.3.3 definierar det: dokumentkatalogen pekar på en /Outlines-rot, roten pekar på sina första och sista underordnade element via /First och /Last, och syskon länkas samman via /Next och /Prev, medan varje post pekar tillbaka uppåt via /Parent. Varje post innehåller en /Title-sträng och vanligtvis en destination, /Dest, som anger vart läsaren ska förflyttas när de klickar på den

Destinationstyper

Destinationer finns i två varianter, och ISO 32000-1 §12.3.2 håller dem avsiktligt åtskilda. En explicit destination är en infogad array (inline array) såsom [page /XYZ x y zoom]: en direkt referens till ett sidobjekt plus en vy-specifikation. En namngiven destination lagrar istället en namnsträng, och det faktiska målet lever i dokumentkatalogen under namnträdet /Names /Dests. Denna indirektion gör att många länkar kan dela samma mål och att målet kan flyttas utan att länkarna behöver röras. HotPDF skriver explicita /XYZ-destinationer när programmet skapar bokmärken, och ger dig separata anrop för att läsa och bygga ut namnträdet

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

Anropa AddLoadedOutline(ParentIndex, DestPageIndex, Title, X, Y, Zoom) efter LoadFromFile: ange ParentIndex = -1 för en post på toppnivå, eller det nollbaserade indexet för en befintlig post på toppnivå för att nästla den därunder. Funktionen skapar roten /Outlines om dokumentet inte hade någon sedan tidigare, bygger ordlistan för posten, 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 om DestPageIndex är utanför tillåtet intervall

En detalj i länkningen är viktig för ordningsföljden: en ny post infogas i början av sin förälders kedja av underordnade poster, som en ny /First. Så om du lägger till "Kapitel 1" och sedan "Kapitel 2" as top-level entries, kommer Kapitel 2 att visas ovanför Kapitel 1 i sidofältet, och Kapitel 1 har flyttats 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 omedelbart fylla dess underordnade poster medan den fortfarande ligger på index 0

var
  Pdf: THotPDF;
begin
  Pdf := THotPDF.Create(nil);
  try
    if Pdf.LoadFromFile('merged-manual.pdf', '') > 0 then
    begin
      // Add chapters in reverse reading order: each new
      // top-level entry becomes /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 is now top-level index 0; nest sections
      // under it (children also prepend, so reverse order).
      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 direkt till destinationen /XYZ och har alla 0 som standardvärde. PDF placerar koordinatsystemets nollpunkt i det nedre vänstra hörnet, so Y = 792 pekar mot toppen av en sida i US Letter-storlek. Enligt ISO 32000-1 talar ett nollvärde i ett /XYZ-fält om för visaren att den ska behålla sin nuvarande inställning, vilket är precis vad du vill ha för zoom i nästan alla fall. Allt sker på den inlästa objektgrafen i minnet; ingenting skrivs till disken förrän SaveLoadedDocument körs – samma modell med "redigera och spara sedan" som API:et for inlästa dokument använder för att skriva om titel, författare och XMP-metadata i inlästa PDF-filer

Ändra mål för bokmärken: sidhopp kontra URI-åtgärder

Befintliga bokmärken kan kopplas om utan att de behöver skapas på nytt. HotPDF ger dessa två fall två olika anrop eftersom de underliggande PDF-konstruktionerna är helt olika. SetLoadedOutlineDestination(Index, DestPageIndex, X, Y, Zoom) ersätter destinationen /Dest för toppnivåposten på Index med en ny /XYZ-array. Detta är verktyget för att åtgärda det klassiska problemet efter en sammanslagning, där varje bokmärke fortfarande pekar på sidnumren i den ursprungliga filen. SetLoadedOutlineURI(Index, URI) gör något strukturellt annorlunda: det kopplar en åtgärdsordlista /A med /S /URI, vilket gör bokmärket till en webblänk istället for ett hopp inuti dokumentet

// The merge shifted everything by 10 pages: repoint the
// third top-level bookmark at page 12 (zero-based 11).
Pdf.SetLoadedOutlineDestination(2, 11, 0, 792, 0);

// Turn the last entry into an external link.
Pdf.SetLoadedOutlineURI(3, 'https://www.loslab.com/');

Pdf.SaveLoadedDocument('retargeted.pdf');

Håll isär de båda mekanismerna när du designar bokmärkessträdet. En /Dest är ren navigering inuti dokumentet; en URI-åtgärd lämnar dokumentet helt och hållet, och ISO 32000-1 förväntar sig att en bokmärkespost navigerar genom antingen den ena eller den andra mekanismen, inte båda. Reservera därför SetLoadedOutlineURI för poster som inte redan har en sid-destination – till exempel en länk till en "Produkts hemsida" eller "Rapportera ett problem" i slutet av trädet, snarare än en konverterad kapitelrubrik. URI-åtgärden som skrivs här är samma /S /URI-konstruktion som används for länkningskommentarer på sidinnehåll, vilket beskrivs mer i detalj i guiden för 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 enligt ISO 32000-1 §12.3.2.3: istället för att bädda in en sidreferens i varje länk behåller dokumentet en mappning från namn till destination i katalogens namnträd /Names /Dests, och länkarna refererar till poster med hjälp av 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. Resolvern hanterar båda lagringsformerna som specifikationen tillåter för det mappade värdet: en ren destinations-array såsom [page /XYZ x y zoom] och ordlisteformen som omsluter arrayen under en /D-nyckel

AddLoadedNamedDestination(Name, DestPageIndex, X, Y) gör tvärtom: den registrerar ett nytt ankare i trädet /Names /Dests (och skapar själva trädet om dokumentet saknar ett) samt returnerar True vid framgång. Tillsammans täcker paret arbetsflödet där ett sammanslaget dokument måste respektera ankare som skrivits av uppströmsverktyg samt publicera nya som nedströmslänkar kan peka på

var
  PageIdx: Integer;
begin
  // Honor an anchor another tool wrote into /Names /Dests.
  PageIdx := Pdf.ResolveLoadedNamedDestination('chapter.3.figures');
  if PageIdx >= 0 then
    Pdf.AddLoadedOutline(-1, PageIdx, 'Chapter 3: Figures', 0, 792, 0);

  // Publish a new anchor for other links to target.
  if Pdf.AddLoadedNamedDestination('appendix.glossary', 42, 0, 792) then
    Pdf.SaveLoadedDocument('anchored.pdf');
end;

De ärliga begränsningarna: destinationerna som HotPDF skriver, både för bokmärken och namngivna ankare, är /XYZ-destinationer. De andra explicita typerna som specifikationen definierar, såsom /Fit, /FitH, /FitB med flera, skapas inte av dessa anrop, även om /XYZ med noll zoom täcker det dominerande användningsfallet "gå till den här platsen, behåll min zoom". Funktionen GetLoadedBookmarkPageIndex(Title), den praktiska sökningen som tar en bokmärkestitel och returnerar dess destinationssida, skannar endast toppnivån i bokmärkessträdet. Använd den därför för att verifiera de kapitelposter du just har skapat, snarare än djupt nästlade underbokmärken

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

Nyckeln /Count på bokmärkesroten betyder inte vad de flesta utvecklare antar, och det felaktiga antagandet leder till testmisslyckanden som ser ut som datakorruption. ISO 32000-1 §12.3.3 definierar /Count som det totala antalet synliga avkomlingar på alla nivåer, inte antalet poster på toppnivå. På en enskild post har även tecknet en betydelse: ett positivt värde innebär att posten är öppen och att så många avkomlingar visas i sidofältet, medan ett negativt värde innebär att posten är komprimerad (stängd) och att |N| avkomlingar är dolda inuti den

Detta upptäcktes i HotPDFs egna regressionstester under uppbyggnaden av bokmärkes-API:erna for inlästa dokument. Ett testdokument innehöll tre bokmärken på toppnivå, varav ett hade ett underliggande bokmärke. Efter att ha tagit bort en toppnivåpost rapporterade GetLoadedOutlineCount, som läser rotens /Count, 1 istället för förväntade 2. Inget var korrupt: den ursprungliga trean hade aldrig varit "tre toppnivåposter" från början. Den korrekta omberäkningen måste stega igenom toppnivåkedjan och ackumulera varje nod plus dess positiva /Count, med undantag för avkomlingar till stängda noder. Lärdomen för din egen kod är direkt: gör aldrig antaganden (asserts) om förändringar i /Count efter strukturella ändringar, eftersom värdet rör sig icke-linjärt med synliga avkomlingar. Verifiera strukturen genom innehåll istället

// After edits, verify by title lookup, not by /Count deltas.
if Pdf.GetLoadedBookmarkPageIndex('Chapter 1: Installation') = 0 then
  ShowMessage('Outline verified');

Var detta passar in i verktygsuppsättningen för inlästa dokument

Skapandet av bokmärken och namngivna destinationer kompletterar en bild som API:et för inlästa dokument har byggt upp under en tid: samma cykel med att läsa in, redigera och spara skriver redan om dokumentmetadata, lägger till länkningskommentarer och skickar kommentarer fram och tillbaka via import och export av XFDF. Mönstret är enhetligt: LoadFromFile, en sekvens av *Loaded*-anrop mot objektgrafen i minnet, och sedan ett anrop till SaveLoadedDocument. Detta gör det enkelt att lägga till bokmärkesskapande i ett befintligt arbetsflöde för sammanslagning eller stämpling som ett sista steg före sparandet

De API:er som diskuteras här, AddLoadedOutline, SetLoadedOutlineDestination, SetLoadedOutlineURI, ResolveLoadedNamedDestination, AddLoadedNamedDestination och GetLoadedBookmarkPageIndex, levereras i standardversionen av HotPDF Component för Delphi och C++Builder, med no external dependencies and no separate viewer runtime required