Odborný článok

Pridávanie záložiek do existujúcich PDF v Delphi pomocou HotPDF

HotPDF pridáva záložky (bookmarks) do existujúceho PDF v Delphi prostredníctvom metódy AddLoadedOutline, ktorá vytvára položky osnovy (outline entries) na akejkoľvek úrovni vnorenia v načítanom dokumente, a číta aj zapisuje pomenované cieľové body (named destinations) pomocou ResolveLoadedNamedDestination a AddLoadedNamedDestination. Načítajte súbor, zostavte strom, zavolajte SaveLoadedDocument a bočný panel čítačky, ktorý bol predtým prázdny, teraz nesie funkčný obsah dokumentu

Scenár, ktorý toto všetko motivuje, je skľučujúco bežný. Zlúčite tucet zmlúv do jedného kontrolného balíka alebo spojíte tri príručky k produktom do jedného distribuovateľného súboru a výstup je štrukturálne v poriadku: každá stránka je prítomná, každé písmo neporušené. Potom ho však niekto otvorí v programe Acrobat a panel záložiek je prázdny. Dokument s rozsahom 400 strán bez navigácie je technicky kompletný, no v praxi nepoužiteľný. Až do verzie 2.347.0 dokázal HotPDF záložky v načítaných dokumentoch čítať, upravovať a mazať, ale nikdy nie vytvárať. Táto medzera je teraz vyplnená a tento článok vás prevedie novým rozhraním API a jedným úskalím PDF špecifikácie – kľúčom /Count, ktorý vás môže potrápiť, ak ho budete považovať za obyčajné počítadlo položiek

Čo je to vlastne strom osnovy (outline tree)

Osnova (outline) v PDF je obojstranne prepojený strom slovníkov (dictionaries), nie plochý zoznam, a táto štruktúra vysvetľuje každý parameter v rozhraní API. Norma ISO 32000-1 §12.3.3 ho definuje nasledovne: katalóg dokumentu odkazuje na koreň /Outlines, koreň ukazuje na svoje prvé a posledné deti pomocou kľúčov /First and /Last, súrodenci sú zreťazení cez /Next a /Prev, zatiaľ čo každá položka odkazuje späť nahor cez /Parent. Každá položka nesie reťazec /Title (názov) a zvyčajne aj /Dest (cieľ), ktorý hovorí, kam má kliknutie používateľa zaviesť

Cieľové body (destinations) sa dodávajú v dvoch variantoch a norma ISO 32000-1 §12.3.2 ich drží zámerne oddelené. Explicitný cieľ je vložené pole (inline array), napríklad [page /XYZ x y zoom]: priamy odkaz na objekt stránky plus špecifikácia zobrazenia. Pomenovaný cieľ namiesto toho ukladá reťazec názvu a samotný cieľ žije v katalógu dokumentu pod stromom názvov /Names /Dests name tree, čo predstavuje úroveň nepriameho smerovania (indirection), ktorá umožňuje mnohým odkazom zdieľať jeden cieľ a umožňuje cieľu pohybovať sa bez úpravy samotných odkazov. HotPDF píše explicitné ciele /XYZ pri vytváraní záložiek a poskytuje samostatné volania na čítanie a rozširovanie stromu názvov

Ako pridať záložky do existujúceho PDF v Delphi?

Po načítaní súboru cez LoadFromFile zavolajte metódu AddLoadedOutline(ParentIndex, DestPageIndex, Title, X, Y, Zoom): pre položku najvyššej úrovne odovzdajte ParentIndex = -1, alebo pre vnorenie pod existujúcu položku najvyššej úrovne odovzdajte jej 0-indexovaný index. Funkcia vytvorí koreň /Outlines, ak ho dokument ešte nemal, zostaví slovník položky, zapíše pole /Dest [page /XYZ x y zoom] ukazujúce na 0-indexovaný DestPageIndex a prepojí novú položku do reťazca súrodencov. V prípade úspechu vráti 0 a pri zlyhaní -1, napríklad ak je DestPageIndex mimo rozsahu

Pre usporiadanie je dôležitý jeden detail prepojenia: nová položka sa vloží na začiatok reťazca potomkov svojho rodiča ako nová hodnota /First. Takže ak pridáte položku „Kapitola 1“ a potom „Kapitola 2“ ako položky najvyššej úrovne, Kapitola 2 sa v bočnom paneli zobrazí nad Kapitolou 1 a Kapitola 1 sa posunie na index najvyššej úrovne 1. Praktickým vzorom je buď pridávať položky v opačnom poradí čítania, alebo pradať každého rodiča a okamžite naplniť jeho deti, kým sa stále nachádza na indexe 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;

Parametre X, Y a Zoom sa mapujú priamo na cieľ /XYZ a predvolene majú hodnotu 0. PDF umiestňuje počiatok súradníc do ľavého dolného rohu, takže Y = 792 mieri na hornú časť stránky formátu US Letter a podľa normy ISO 32000-1 nulová hodnota v pozícii /XYZ inštruuje prehliadač, aby zachoval svoje aktuálne nastavenie priblíženia, čo je takmer vždy presne to, čo chcete. Všetko sa odohráva na načítanom grafe objektov v pamäti; na disk sa nič nezapíše, kým sa nespustí SaveLoadedDocument, čo je rovnaký model úpravy a následného uloženia, aký používa API načítaného dokumentu pre prepisovanie názvu, autora a XMP metadát na načítaných PDF

Presmerovanie záložiek: skoky na stránku verzus akcie URI

Existujúce záložky je možné preusporiadať bez ich opätovného vytvárania a HotPDF poskytuje pre tieto dva prípady dve odlišné volania, pretože vnútorné štruktúry PDF sú naozaj rozdielne. Metóda SetLoadedOutlineDestination(Index, DestPageIndex, X, Y, Zoom) nahrádza /Dest položky najvyššej úrovne na indexe Index novým poľom /XYZ, čo je nástroj pre klasickú chybu po zlúčení, kedy každá záložka stále ukazuje na čísla stránok pôvodného súboru pred zlúčením. Metóda SetLoadedOutlineURI(Index, URI) robí niečo štrukturálne odlišné: pripojí slovník akcií /A s /S /URI, čím zmení záložku na webový odkaz namiesto skoku v rámci dokumentu

// 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');

Pri návrhu stromu držte tieto dva mechanizmy oddelené. Zápis /Dest slúži na čistú navigáciu vo vnútri dokumentu; akcia URI dokument úplne opustí a norma ISO 32000-1 očakáva, že položka osnovy bude navigovať cez jeden alebo druhý mechanizmus, nie cez oba súčasne. Vyhraďte preto metódu SetLoadedOutlineURI pre položky, ktoré ešte nenesú cieľovú stránku – napríklad pre položku „Domovská stránka produktu“ alebo „Nahlásiť problém“ na konci stromu, a nie pre zmenený nadpis kapitoly. Tu zapísaná akcia URI je rovnaká štruktúra /S /URI, aká sa používa pre anotácie odkazov v obsahu stránky, čo je podrobnejšie popísané v príručke o vytváraní hyperodkazov v dokumentoch PDF pomocou HotPDF

Ako fungujú pomenované ciele v načítanom PDF?

Pomenované ciele (named destinations) predstavujú stabilný kotevný mechanizmus podľa normy ISO 32000-1 §12.3.2.3: namiesto vkladania odkazu na stránku do každého odkazu si dokument udržiava mapovanie názvu na cieľ v strome názvov /Names /Dests v katalógu dokumentu a odkazy odkazujú na položky podľa názvu. Metóda ResolveLoadedNamedDestination(Name) vyhľadá názov v tomto strome a vráti 0-indexovaný index stránky, na ktorú ukazuje, prípadne -1, ak názov chýba. Rozlišovač (resolver) spracováva obe formy uloženia, ktoré špecifikácia povoľuje pre mapovanú hodnotu: čisté pole cieľa ako [page /XYZ x y zoom] aj formu slovníka, ktorý toto pole zabaľuje pod kľúč /D

Metóda AddLoadedNamedDestination(Name, DestPageIndex, X, Y) funguje opačne: zaregistruje novú kotvu v strome /Names /Dests, pričom vytvorí samotný strom, ak ho dokument nemal, a v prípade úspechu vráti True. Táto dvojica spoločne pokrýva pracovný postup, pri ktorom zlúčený dokument musí rešpektovať kotvy zapísané predchádzajúcimi nástrojmi a publikovať nové pre potreby nasledujúcich odkazov

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;

Úprimné limity: ciele, ktoré HotPDF zapisuje pre záložky aj pomenované kotvy, sú ciele typu /XYZ. Iné explicitné typy definované špecifikáciou, ako /Fit, /FitH, /FitB a ďalšie, tieto volania negenerujú, hoci /XYZ s nulovým priblížením pokrýva dominantný scenár použitia „prejdi na toto miesto, zachovaj priblíženie“. Metóda GetLoadedBookmarkPageIndex(Title), pohodlné vyhľadávanie, ktoré prijíma názov záložky a vracia jej cieľovú stránku, prehľadáva najvyššiu úroveň stromu osnovy, preto ju použite na overenie práve vytvorených položiek na úrovni kapitol a nie hlboko vnorených listov

Pasca /Count: počíta potomkov, nie položky

Kľúč /Count v koreni osnovy neznamená to, čo predpokladá väčšina vývojárov, a nesprávny predpoklad spôsobuje zlyhania testov, ktoré vyzerajú ako poškodenie dát. Norma ISO 32000-1 §12.3.3 definuje /Count ako celkový počet viditeľných potomkov na všetkých úrovniach, nie ako počet položiek najvyššej úrovne. Pri jednotlivej položke nesie význam aj znamienko: kladná hodnota znamená, že položka je otvorená a v bočnom paneli sa zobrazuje toľko potomkov, zatiaľ čo záporná hodnota znamená, že položka je zbalená a skrýva sa v nej |N| potomkov

Tento poznatok vyplynul z vlastnej regresnej sady testov HotPDF pri vývoji API pre záložky načítaného dokumentu. Testovací dokument obsahoval tri záložky najvyššej úrovne, z ktorých jedna mala potomka; po vymazaní jednej položky najvyššej úrovne oznámila funkcia GetLoadedOutlineCount čítajúca koreňový kľúč /Count hodnotu 1 namiesto očakávaných 2. Nič nebolo poškodené: počiatočná 3 nikdy nepredstavovala „tri položky najvyššej úrovne“ a správny prepočet musí prejsť reťazec najvyššej úrovne, akumulovať každý uzol plus jeho kladný /Count a preskočiť potomkov zbalených uzlov. Poučenie pre váš vlastný kód je priame: po štrukturálnych úpravách nikdy nerobte asertácie na delty kľúča /Count, pretože hodnota sa mení nelineárne s viditeľnými potomkami. Namiesto toho overujte štruktúru podľa obsahu

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

Kam to zapadá v sade nástrojov pre načítané dokumenty

Vytváranie záložiek a pomenovaných cieľov dopĺňa obraz, ktorý API načítaného dokumentu už nejaký čas skladá: rovnaký cyklus načítania, úpravy a uloženia už prepisuje metadáta dokumentu, pridáva odkazové anotácie a obojstranne prenáša anotácie cez import a export XFDF. Tento vzor je jednotný: LoadFromFile, sekvencia volaní *Loaded* voči pamäťovému grafu objektov a potom jedno volanie SaveLoadedDocument, čo uľahčuje začlenenie tvorby osnovy do existujúceho reťazca zlučovania alebo pečiatkovania ako ďalší krok pred uložením

API popísané v tomto článku, AddLoadedOutline, SetLoadedOutlineDestination, SetLoadedOutlineURI, ResolveLoadedNamedDestination, AddLoadedNamedDestination a GetLoadedBookmarkPageIndex, sa dodávajú v štandardnom komponente HotPDF Component pre Delphi a C++Builder, bez externých závislostí a bez potreby samostatného behového prostredia prehliadača