Knihovna HotPDF přidává záložky do existujícího PDF v Delphi pomocí metody AddLoadedOutline, která vytváří položky osnovy na jakékoli úrovni vnoření v načteném dokumentu, a čte i zapisuje pojmenované cíle prostřednictvím metod ResolveLoadedNamedDestination a AddLoadedNamedDestination. Načtěte soubor, sestavte strom, zavolejte SaveLoadedDocument a postranní panel čtečky, který byl dříve prázdný, bude nyní obsahovat funkční obsah dokumentu
Scénář, který k tomuto řešení motivuje, je bohužel velmi běžný. Sloučíte tucet smluv do jednoho revizního balíčku nebo spojíte tři produktové manuály do jednoho distribuovatelného souboru a výstup je strukturálně v pořádku: každá stránka je přítomna, každé písmo neporušeno. Pak jej ale někdo otevře v programu Acrobat a panel záložek je prázdný. Dokument o 400 stránkách bez navigace je sice technicky kompletní, ale v praxi nepoužitelný. Až do verze 2.347.0 dokázala knihovna HotPDF záložky v načtených dokumentech číst, upravovat a mazat, ale nikdy je neuměla vytvářet. Tato mezera je nyní uzavřena a tento článek vás provede novým rozhraním API a jednou záludností specifikace PDF, klíčem /Count, který vás potrápí, pokud jej budete považovat za pouhé počítadlo položek
Co je to vlastně strom osnovy
Osnova (outline) PDF je obousměrně vázaný strom slovníků, nikoli plochý seznam, a tato struktura vysvětluje každý parametr v rozhraní API. Norma ISO 32000-1 §12.3.3 ji definuje následovně: katalog dokumentu ukazuje na kořen /Outlines, kořen odkazuje na své první a poslední podřízené prvky pomocí /First a /Last, sourozenci jsou zřetězeni přes /Next a /Prev, přičemž každá položka ukazuje zpět nahoru prostřednictvím /Parent. Každá položka nese řetězec /Title a obvykle také /Dest, který určuje, kam má kliknutí na ni čtenáře přenést
Cíle (destinations) existují ve dvou variantách a norma ISO 32000-1 §12.3.2 je záměrně udržuje oddělené. An explicit destination is an inline array such as [page /XYZ x y zoom]: a direct reference to a page object plus a view specification. A named destination instead stores a name string, and the actual target lives in the document catalog under the /Names /Dests name tree, one level of indirection that lets many links share one target and lets the target move without touching the links. HotPDF writes explicit /XYZ destinations when it creates bookmarks, and gives you separate calls for reading and extending the name tree
Cíle (destinations) existují ve dvou variantách a norma ISO 32000-1 §12.3.2 je záměrně udržuje oddělené. Explicitní cíl je vložené pole, jako například [page /XYZ x y zoom]: přímý odkaz na objekt stránky plus specifikace zobrazení. Pojmenovaný cíl naproti tomu ukládá pojmenovaný řetězec a skutečný cíl žije v katalogu dokumentu pod stromem názvů /Names /Dests, což je úroveň nepřímého adresování, která umožňuje mnoha odkazům sdílet jeden cíl a umožňuje cíli přesun, aniž by bylo nutné upravovat samotné odkazy. HotPDF zapisuje při vytváření záložek explicitní cíle /XYZ a poskytuje samostatná volání pro čtení a rozšiřování stromu názvů
Jak v Delphi přidat záložky do existujícího PDF?
Po volání LoadFromFile zavolejte AddLoadedOutline(ParentIndex, DestPageIndex, Title, X, Y, Zoom): předejte ParentIndex = -1 pro položku nejvyšší úrovně, nebo index (od nuly) existující položky nejvyšší úrovně pro vnoření pod ni. Funkce vytvoří kořen /Outlines, pokud jej dokument ještě neměl, sestaví slovník položky, zapíše pole /Dest [page /XYZ x y zoom] ukazující na indexovanou stránku DestPageIndex (indexováno od nuly) a zařadí novou položku do sourozeneckého řetězce. Při úspěchu vrátí 0 a při selhání -1, například když je DestPageIndex mimo rozsah
Pro pořadí je důležitý jeden detail řazení: nová položka se vkládá na začátek řetězce potomků svého rodiče jako nový prvek /First. Pokud tedy přidáte „Kapitolu 1“ a poté „Kapitolu 2“ jako položky nejvyšší úrovně, Kapitola 2 se v postranním panelu zobrazí nad Kapitolou 1 a Kapitola 1 se posune na index 1 nejvyšší úrovně. V praxi se proto položky přidávají buď v opačném pořadí čtení, nebo se přidá každý rodič a okamžitě se naplní jeho potomci, dokud se rodič nachází na indexu 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;
Parametry X, Y a Zoom se mapují přímo na cíl /XYZ a všechny mají výchozí hodnotu 0. PDF umísťuje počátek souřadnic do levého dolního rohu, takže Y = 792 cílí na horní okraj stránky formátu US Letter, a podle normy ISO 32000-1 nulová hodnota v poli /XYZ říká prohlížeči, aby zachoval své aktuální nastavení přiblížení, což je přesně to, co ve většině případů chcete. Vše probíhá v načteném objektovém grafu v paměti; na disk se nic nezapíše, dokud se nespustí SaveLoadedDocument, což je stejný model úpravy a následného uložení, jaký rozhraní API pro načtené dokumenty používá k přepisování názvu, autora a metadat XMP v načtených souborech PDF
Změna cíle záložek: skoky na stránky versus akce URI
Stávající záložky lze přesměrovat bez jejich opětovného vytváření a HotPDF poskytuje pro tyto dva případy dvě odlišná volání, protože struktury PDF pod nimi jsou zcela rozdílné. Metoda SetLoadedOutlineDestination(Index, DestPageIndex, X, Y, Zoom) nahrazuje /Dest u položky nejvyšší úrovně na indexu Index nový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. Metoda SetLoadedOutlineURI(Index, URI) provádí strukturálně odlišnou operaci: připojuje slovník akce /A s /S /URI, čímž mění záložku na webový odkaz namísto skoku uvnitř 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');
Při navrhování stromu udržujte tyto dva mechanismy oddělené. Cíl /Dest is pure navigation inside the document; a URI action leaves the document entirely, and ISO 32000-1 expects an outline entry to navigate through one mechanism or the other, not both. So reserve SetLoadedOutlineURI for entries that do not already carry a page destination, a "Product homepage" or "Report an issue" leaf at the end of the tree rather than a converted chapter heading. The URI action written here is the same /S /URI construct used for link annotations on page content, covered in more depth in the guide to creating hyperlinks in PDF documents with HotPDF
Jak fungují pojmenované cíle v načteném PDF?
Pojmenované cíle představují stabilní kotevní mechanismus podle normy ISO 32000-1 §12.3.2.3: namísto vkládání odkazu na stránku do každého odkazu udržuje dokument mapování názvů na cíle ve stromu názvů /Names /Dests katalogu a odkazy se na položky odkazují jménem. Metoda ResolveLoadedNamedDestination(Name) vyhledá název v tomto stromu a vrátí index stránky (od nuly), na kterou ukazuje, nebo -1, pokud název chybí. Převaděč zpracovává obě formy ukládání, které specifikace pro mapovanou hodnotu povoluje: čisté pole cíle jako [page /XYZ x y zoom] i formu slovníku, která pole obaluje pod klíčem /D
Metoda AddLoadedNamedDestination(Name, DestPageIndex, X, Y) funguje opačně: registruje novou kotvu ve stromu /Names /Dests, přičemž strom sama vytvoří, pokud jej dokument dosud neměl, a při úspěchu vrátí True. Tato dvojice metod pokrývá pracovní postup, kdy sloučený dokument musí respektovat kotvy zapsané předchozími nástroji a publikovat nové kotvy, na které mohou cílit následující odkazy
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;
Upřímná omezení: cíle, které HotPDF zapisuje pro záložky i pojmenované kotvy, jsou cíle typu /XYZ. Ostatní explicitní typy definované specifikací, jako /Fit, /FitH, /FitB a podobně, tato volání negenerují, ačkoli /XYZ s nulovým přiblížením pokrývá nejčastější případ použití „přejdi na toto místo, zachovej mé přiblížení“. A metoda GetLoadedBookmarkPageIndex(Title), pohodlné vyhledávání, které na základě názvu záložky vrací její cílovou stránku, prohledává pouze nejvyšší úroveň stromu osnovy, takže ji použijte k ověření vytvořených položek na úrovni kapitol a nikoli pro hluboce vnořené záložky
Past /Count: počítá potomky, nikoli položky
Klíč /Count v kořenu osnovy neznamená to, co předpokládá většina vývojářů, a nesprávný předpoklad vede k selhání testů, která vypadají jako poškození dat. Norma ISO 32000-1 §12.3.3 definuje /Count jako celkový počet viditelných potomků na všech úrovních, nikoli jako počet položek nejvyšší úrovně. Znaménko u jednotlivých položek má také svůj význam: kladná hodnota znamená, že položka je rozbalená a v postranním panelu se zobrazuje daný počet potomků, zatímco záporná hodnota znamená, že položka je sbalená a potomci v počtu |N| jsou v ní skryti
Na to se přišlo v rámci regresních testů HotPDF při vytváření API pro záložky načtených dokumentů. Testovací dokument obsahoval tři záložky nejvyšší úrovně, z nichž jedna měla podřízenou položku; po smazání jedné položky nejvyšší úrovně nahlásila metoda GetLoadedOutlineCount, která čte kořenový /Count, hodnotu 1 namísto očekávané 2. Nic nebylo poškozeno: počáteční hodnota 3 nikdy neznamenala „tři položky nejvyšší úrovně“ a správný přepočet musí projít řetězec nejvyšší úrovně a sčítat každý uzel plus jeho kladný /Count, přičemž přeskupuje potomky sbalených uzlů. Poučení pro váš kód je jasné: po strukturálních úpravách se nikdy nespoléhejte na rozdíly v hodnotě /Count, protože tato hodnota se mění nelineárně s viditelnými potomky. Místo toho ověřte strukturu podle 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 sadě nástrojů pro načtené dokumenty
Vytváření záložek a pojmenovaných cílů doplňuje skládačku, kterou rozhraní API pro načtené dokumenty již nějakou dobu sestavuje: stejný cyklus načtení-úpravy-uložení již přepisuje metadata dokumentu, přidává anotace odkazů a přenáší anotace tam i zpět prostřednictvím importu a exportu XFDF. Postup je jednotný: LoadFromFile, sekence volání *Loaded* nad objektovým grafem v paměti a poté jedno volání SaveLoadedDocument, což umožňuje snaddo zařadit vytváření osnovy do stávajícího procesu slučování nebo razítkování jako další krok před uložením
Zde probíraná rozhraní API AddLoadedOutline, SetLoadedOutlineDestination, SetLoadedOutlineURI, ResolveLoadedNamedDestination, AddLoadedNamedDestination, and GetLoadedBookmarkPageIndex se dodávají ve standardním produktu HotPDF Component pro Delphi a C++Builder, bez externích závislostí a bez nutnosti samostatného runtime prohlížeče