Technický článek

Přidání záložek do existujících PDF v Delphi s HotPDF

HotPDF přidává záložky do existujícího PDF v Delphi pomocí AddLoadedOutline, která vytváří položky osnovy na libovolné úrovni vnoření v načteném dokumentu, a pojmenované cíle čte a zapisuje přes ResolveLoadedNamedDestination a AddLoadedNamedDestination. Načtěte soubor, sestavte strom, zavolejte SaveLoadedDocument a postranní panel čtečky, který býval prázdný, nyní nese funkční obsah

Scénář, který to všechno motivuje, je skličujícím způsobem běžný. Sloučíte tucet smluv do jednoho balíčku k revizi nebo sešijete tři produktové příručky do jediného distribuovatelného souboru a výstup je strukturálně v pořádku: každá stránka přítomna, každý font neporušený. Pak jej někdo otevře v Acrobatu a panel záložek je prázdný. Dokument o 400 stránkách bez navigace je technicky kompletní a prakticky nepoužitelný, a až do verze 2.347.0 uměl HotPDF záložky v načtených dokumentech číst, upravovat a mazat, ale nikdy je vytvářet. Tato mezera je nyní uzavřena a tento článek prochází nový povrch API a jeden kus PDF tajemna, klíč /Count, který vás kousne, pokud jej budete považovat za prostý čítač položek

Co strom osnovy ve skutečnosti je

Osnova PDF je obousměrně provázaný strom slovníků, ne plochý seznam, a tato struktura vysvětluje každý parametr v API. Definuje ji ISO 32000-1 §12.3.3: katalog dokumentu ukazuje na kořen /Outlines, kořen ukazuje na své první a poslední dítě přes /First a /Last, sourozenci se řetězí přes /Next a /Prev a každá položka ukazuje zpět nahoru přes /Parent. Každá položka nese řetězec /Title a obvykle i /Dest, který říká, kam má kliknutí čtenáře zavést

Cíle existují ve dvou podobách a ISO 32000-1 §12.3.2 je záměrně drží odděleně. Explicitní cíl je vložené pole jako [page /XYZ x y zoom]: přímý odkaz na objekt stránky plus specifikace pohledu. Pojmenovaný cíl místo toho ukládá řetězec s názvem a skutečný cíl žije v katalogu dokumentu ve stromu názvů /Names /Dests, což je jedna úroveň nepřímosti, která umožňuje mnoha odkazům sdílet jeden cíl a cíli se přesunout, aniž by se odkazů kdokoli dotkl. HotPDF při vytváření záložek zapisuje explicitní cíle /XYZ a pro čtení a rozšiřování stromu názvů vám dává samostatná volání

Diagram HotPDF obousměrně provázaného stromu osnovy PDF, který AddLoadedOutline sestavuje: katalog dokumentu ukazuje na kořen Outlines, First a Last kořene dosahují na krajní položky, sourozenci se řetězí přes Next a Prev, každá položka ukládá Parent a strom Names Dests vyhodnocuje pojmenované cíle
Osnova je obousměrně provázaný strom — kořen drží /First a /Last, sourozenci se řetězí přes /Next a /Prev, každá položka ukládá /Parent a každý cíl kliknutí cestuje v poli /Dest [page /XYZ]

Jak přidat záložky do existujícího PDF v Delphi?

Po LoadFromFile zavolejte AddLoadedOutline(ParentIndex, DestPageIndex, Title, X, Y, Zoom): předejte ParentIndex = -1 pro položku nejvyšší úrovně, nebo index existující položky nejvyšší úrovně počítaný od nuly, pod kterou chcete vnořit. Funkce vytvoří kořen /Outlines, pokud jej dokument nikdy neměl, sestaví slovník položky, zapíše pole /Dest [page /XYZ x y zoom] ukazující na DestPageIndex počítaný od nuly a napojí novou položku do řetězce sourozenců. Při úspěchu vrací 0 a při selhání -1, například když je DestPageIndex mimo rozsah

Na pořadí má vliv jeden detail napojení: nová položka se vkládá na začátek řetězce dětí svého rodiče jako nový /First. Pokud tedy přidáte „Chapter 1“ a poté „Chapter 2“ jako položky nejvyšší úrovně, Chapter 2 se v postranním panelu objeví nad Chapter 1 a Chapter 1 se posune na index nejvyšší úrovně 1. Praktický vzor je buď přidávat položky v obráceném pořadí čtení, nebo přidat každého rodiče a okamžitě naplnit jeho děti, dokud ještě sedí na indexu 0

var
  Pdf: THotPDF;
begin
  Pdf := THotPDF.Create(nil);
  try
    if Pdf.LoadFromFile('merged-manual.pdf', '') > 0 then
    begin
      // Kapitoly přidávejte v obráceném pořadí čtení: každá nová
      // položka nejvyšší úrovně se stane /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 je nyní na indexu nejvyšší úrovně 0; sekce vnořte
      // pod ni (děti se také vkládají na začátek, proto obrácené pořadí).
      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;

Parametry X, Y a Zoom se mapují přímo na cíl /XYZ a všechny mají výchozí hodnotu 0. PDF umisťuje počátek souřadnic do levého dolního rohu, takže Y = 792 míří na horní okraj stránky US Letter, a podle ISO 32000-1 nulová hodnota v pozici /XYZ říká prohlížeči, aby ponechal své aktuální nastavení, což je přesně to, co u přiblížení téměř vždy chcete. Vše se děje na načteném grafu objektů v paměti; disku se nic nedotkne, dokud neproběhne SaveLoadedDocument, tedy stejný model upravit-a-pak-uložit, který API pro načtené dokumenty používá pro přepisování názvu, autora a metadat XMP v načtených PDF

Přesměrování záložek: skoky na stránky versus akce URI

Existující záložky lze přepojit bez jejich opětovného vytváření a HotPDF dává těmto dvěma případům dvě odlišná volání, protože konstrukce PDF pod nimi jsou skutečně odlišné. SetLoadedOutlineDestination(Index, DestPageIndex, X, Y, Zoom) nahradí /Dest položky nejvyšší úrovně na indexu Index čerstvým polem /XYZ, což je nástroj pro klasické selhání po sloučení, kdy každá záložka stále ukazuje na čísla stránek původního souboru před sloučením. SetLoadedOutlineURI(Index, URI) dělá něco strukturálně jiného: připojí slovník akce /A s /S /URI, čímž záložku promění ve webový odkaz místo skoku uvnitř dokumentu

Dva způsoby, jimiž HotPDF přepojuje existující záložku: SetLoadedOutlineDestination zapisuje pole Dest XYZ uvnitř dokumentu pro skoky na stránky, zatímco SetLoadedOutlineURI připojuje slovník akce A s S URI, který otevře webovou stránku
SetLoadedOutlineDestination přepisuje pole /Dest pro skoky uvnitř dokumentu, což je oprava pro záložky po sloučení stále ukazující na stránky před sloučením, zatímco SetLoadedOutlineURI vymění akci /A /S /URI vyhrazenou pro koncové položky, které dokument opouštějí
// Sloučení posunulo vše o 10 stránek: přesměrujte
// třetí záložku nejvyšší úrovně na stránku 12 (od nuly 11).
Pdf.SetLoadedOutlineDestination(2, 11, 0, 792, 0);

// Proměňte poslední položku v externí odkaz.
Pdf.SetLoadedOutlineURI(3, 'https://www.loslab.com/');

Pdf.SaveLoadedDocument('retargeted.pdf');

Při návrhu stromu oba mechanismy důsledně rozlišujte. /Dest je čistá navigace uvnitř dokumentu; akce URI dokument úplně opouští a ISO 32000-1 očekává, že položka osnovy naviguje jedním mechanismem, nebo druhým, ne oběma. Vyhraďte proto SetLoadedOutlineURI pro položky, které ještě nenesou cíl na stránku, tedy koncovou položku „Product homepage“ nebo „Report an issue“ na konci stromu, nikoli převedený nadpis kapitoly. Zde zapsaná akce URI je stejná konstrukce /S /URI, jaká se používá pro anotace odkazů v obsahu stránky, podrobněji popsaná v průvodci vytvářením hypertextových odkazů v dokumentech PDF pomocí HotPDF

Jak fungují pojmenované cíle v načteném PDF?

Pojmenované cíle jsou mechanismus stabilních kotev podle ISO 32000-1 §12.3.2.3: místo vkládání odkazu na stránku do každého odkazu dokument udržuje mapu název-cíl ve stromu názvů /Names /Dests v katalogu a odkazy se na položky odvolávají názvem. ResolveLoadedNamedDestination(Name) vyhledá název v tomto stromu a vrátí index stránky počítaný od nuly, na kterou ukazuje, nebo -1, pokud název chybí. Resolver zvládá obě formy uložení, které specifikace pro mapovanou hodnotu povoluje: holé pole cíle jako [page /XYZ x y zoom] i slovníkovou formu, která pole obaluje pod klíčem /D

AddLoadedNamedDestination(Name, DestPageIndex, X, Y) jde opačným směrem: zaregistruje novou kotvu ve stromu /Names /Dests, přičemž strom sám vytvoří, pokud jej dokument nemá, a při úspěchu vrátí True. Dohromady tato dvojice pokrývá pracovní postup, kdy sloučený dokument musí respektovat kotvy zapsané nástroji výše v řetězci a publikovat nové, na které mohou mířit navazující odkazy

var
  PageIdx: Integer;
begin
  // Respektujte kotvu, kterou jiný nástroj zapsal do /Names /Dests.
  PageIdx := Pdf.ResolveLoadedNamedDestination('chapter.3.figures');
  if PageIdx >= 0 then
    Pdf.AddLoadedOutline(-1, PageIdx, 'Chapter 3: Figures', 0, 792, 0);

  // Publikujte novou kotvu, na kterou mohou mířit jiné odkazy.
  if Pdf.AddLoadedNamedDestination('appendix.glossary', 42, 0, 792) then
    Pdf.SaveLoadedDocument('anchored.pdf');
end;

Poctivé hranice: cíle, které HotPDF zapisuje, u záložek i pojmenovaných kotev, jsou cíle /XYZ. Ostatní explicitní typy, které specifikace definuje, /Fit, /FitH, /FitB a spol., tato volání neemitují, ačkoli /XYZ s nulovým přiblížením pokrývá dominantní případ použití „přejdi na toto místo, zachovej mé přiblížení“. A GetLoadedBookmarkPageIndex(Title), pohodlné vyhledání, které přijme název záložky a vrátí její cílovou stránku, prochází nejvyšší úroveň stromu osnovy, takže jej používejte k ověření položek na úrovni kapitol, které jste právě vytvořili, nikoli hluboko vnořených listů

Past klíče /Count: počítá potomky, ne položky

Klíč /Count na kořeni osnovy neznamená to, co většina vývojářů předpokládá, a chybný předpoklad vede k selháním testů, která vypadají jako poškození dat. ISO 32000-1 §12.3.3 definuje /Count jako celkový počet viditelných potomků na všech úrovních, nikoli počet položek nejvyšší úrovně. U jednotlivé položky nese význam i znaménko: kladná hodnota znamená, že položka je rozbalená a tolik potomků se zobrazuje v postranním panelu, zatímco záporná hodnota znamená, že položka je sbalená a |N| potomků je uvnitř skryto

Toto vyplynulo z vlastní regresní sady HotPDF během vývoje API pro záložky v načtených dokumentech. Testovací dokument obsahoval tři záložky nejvyšší úrovně, jednu s dítětem; po smazání jedné položky nejvyšší úrovně hlásila GetLoadedOutlineCount, která čte kořenový /Count, hodnotu 1 místo očekávaných 2. Nic nebylo poškozeno: počáteční 3 nikdy nebyly „tři položky nejvyšší úrovně“ a správný přepočet musí projít řetězec nejvyšší úrovně, sčítat každý uzel plus jeho kladný /Count a přeskakovat potomky sbalených uzlů. Poučení pro váš vlastní kód je přímé: po strukturálních úpravách nikdy neověřujte rozdíly /Count, protože hodnota se s viditelnými potomky mění nelineárně. Strukturu místo toho ověřujte podle obsahu

HotPDF: sémantika /Count v osnovách PDF: kladný Count označuje rozbalenou položku, jejíž děti zůstávají viditelné a započítané, záporný Count potomky sbalí a smazání jedné záložky nejvyšší úrovně posune kořenový počet ze čtyř na tři, nikoli o jednu za řádek
Kořenový /Count sčítá viditelné potomky na všech úrovních, takže o jeho pohybu rozhoduje znaménko a vnoření — ověření rozdílu po mazání selže a poctivou kontrolou je ověření přes GetLoadedBookmarkPageIndex podle názvu
// Po úpravách ověřujte vyhledáním podle názvu, ne rozdíly /Count.
if Pdf.GetLoadedBookmarkPageIndex('Chapter 1: Installation') = 0 then
  ShowMessage('Outline verified');

Kam to zapadá v sadě nástrojů pro načtené dokumenty

Vytváření záložek a pojmenovaných cílů dokresluje obraz, který API pro načtené dokumenty už nějakou dobu doplňuje: stejný cyklus načíst-upravit-uložit už přepisuje metadata dokumentu, přidává anotace odkazů a provádí obousměrný přenos anotací přes import a export XFDF. Vzor je jednotný, LoadFromFile, posloupnost volání *Loaded* nad grafem objektů v paměti a pak jedno SaveLoadedDocument, což umožňuje snadno začlenit sestavení osnovy do existujícího pipeline slučování nebo razítkování jako jeden další krok před uložením

Zde probíraná API, AddLoadedOutline, SetLoadedOutlineDestination, SetLoadedOutlineURI, ResolveLoadedNamedDestination, AddLoadedNamedDestination a GetLoadedBookmarkPageIndex, jsou součástí standardní komponenty HotPDF pro Delphi pro Delphi a C++Builder, bez externích závislostí a bez nutnosti samostatného runtime prohlížeče