diff --git a/crates/typst-library/src/meta/heading.rs b/crates/typst-library/src/meta/heading.rs index d70bc0a9e..00b939939 100644 --- a/crates/typst-library/src/meta/heading.rs +++ b/crates/typst-library/src/meta/heading.rs @@ -78,7 +78,11 @@ pub struct HeadingElem { /// ``` pub supplement: Smart>, - /// Whether the heading should appear in the outline. + /// Whether the heading should appear in the [outline]($func/outline). + /// + /// Note that this property, if set to `{true}`, ensures the heading is + /// also shown as a bookmark in the exported PDF's outline (when exporting + /// to PDF). To change that behavior, use the `bookmarked` property. /// /// ```example /// #outline() @@ -93,6 +97,29 @@ pub struct HeadingElem { #[default(true)] pub outlined: bool, + /// Whether the heading should appear as a bookmark in the exported PDF's + /// outline. Doesn't affect other export formats, such as PNG. + /// + /// The default value of `{auto}` indicates that the heading will only + /// appear in the exported PDF's outline if its `outlined` property is set + /// to `{true}`, that is, if it would also be listed in Typst's + /// [outline]($func/outline). Setting this property to either + /// `{true}` (bookmark) or `{false}` (don't bookmark) bypasses that + /// behavior. + /// + /// ```example + /// #heading[Normal heading] + /// This heading will be shown in + /// the PDF's bookmark outline. + /// + /// #heading(bookmarked: false)[Not bookmarked] + /// This heading won't be + /// bookmarked in the resulting + /// PDF. + /// ``` + #[default(Smart::Auto)] + pub bookmarked: Smart, + /// The heading's title. #[required] pub body: Content, @@ -111,6 +138,7 @@ impl Synthesize for HeadingElem { self.push_numbering(self.numbering(styles)); self.push_supplement(Smart::Custom(Some(Supplement::Content(supplement)))); self.push_outlined(self.outlined(styles)); + self.push_bookmarked(self.bookmarked(styles)); Ok(()) } diff --git a/crates/typst/src/export/pdf/outline.rs b/crates/typst/src/export/pdf/outline.rs index 539647ebf..89d01135a 100644 --- a/crates/typst/src/export/pdf/outline.rs +++ b/crates/typst/src/export/pdf/outline.rs @@ -3,22 +3,80 @@ use std::num::NonZeroUsize; use pdf_writer::{Finish, Ref, TextStr}; use super::{AbsExt, PdfContext, RefExt}; -use crate::geom::Abs; +use crate::geom::{Abs, Smart}; use crate::model::Content; /// Construct the outline for the document. #[tracing::instrument(skip_all)] pub fn write_outline(ctx: &mut PdfContext) -> Option { let mut tree: Vec = vec![]; + + // Stores the level of the topmost skipped ancestor of the next bookmarked + // heading. A skipped heading is a heading with 'bookmarked: false', that + // is, it is not added to the PDF outline, and so is not in the tree. + // Therefore, its next descendant must be added at its level, which is + // enforced in the manner shown below. + let mut last_skipped_level = None; for heading in ctx.introspector.query(&item!(heading_func).select()) { let leaf = HeadingNode::leaf((*heading).clone()); - let mut children = &mut tree; - while children.last().map_or(false, |last| last.level < leaf.level) { - children = &mut children.last_mut().unwrap().children; - } + if leaf.bookmarked { + let mut children = &mut tree; - children.push(leaf); + // Descend the tree through the latest bookmarked heading of each + // level until either: + // - you reach a node whose children would be brothers of this + // heading (=> add the current heading as a child of this node); + // - you reach a node with no children (=> this heading probably + // skipped a few nesting levels in Typst, or one or more ancestors + // of this heading weren't bookmarked, so add it as a child of this + // node, which is its deepest bookmarked ancestor); + // - or, if the latest heading(s) was(/were) skipped + // ('bookmarked: false'), then stop if you reach a node whose + // children would be brothers of the latest skipped heading + // of lowest level (=> those skipped headings would be ancestors + // of the current heading, so add it as a 'brother' of the least + // deep skipped ancestor among them, as those ancestors weren't + // added to the bookmark tree, and the current heading should not + // be mistakenly added as a descendant of a brother of that + // ancestor.) + // + // That is, if you had a bookmarked heading of level N, a skipped + // heading of level N, a skipped heading of level N + 1, and then + // a bookmarked heading of level N + 2, that last one is bookmarked + // as a level N heading (taking the place of its topmost skipped + // ancestor), so that it is not mistakenly added as a descendant of + // the previous level N heading. + // + // In other words, a heading can be added to the bookmark tree + // at most as deep as its topmost skipped direct ancestor (if it + // exists), or at most as deep as its actual nesting level in Typst + // (not exceeding whichever is the most restrictive depth limit + // of those two). + while children.last().map_or(false, |last| { + last_skipped_level.map_or(true, |l| last.level < l) + && last.level < leaf.level + }) { + children = &mut children.last_mut().unwrap().children; + } + + // Since this heading was bookmarked, the next heading, if it is a + // child of this one, won't have a skipped direct ancestor (indeed, + // this heading would be its most direct ancestor, and wasn't + // skipped). Therefore, it can be added as a child of this one, if + // needed, following the usual rules listed above. + last_skipped_level = None; + children.push(leaf); + } else if last_skipped_level.map_or(true, |l| leaf.level < l) { + // Only the topmost / lowest-level skipped heading matters when you + // have consecutive skipped headings (since none of them are being + // added to the bookmark tree), hence the condition above. + // This ensures the next bookmarked heading will be placed + // at most as deep as its topmost skipped ancestors. Deeper + // ancestors do not matter as the nesting structure they create + // won't be visible in the PDF outline. + last_skipped_level = Some(leaf.level); + } } if tree.is_empty() { @@ -48,6 +106,7 @@ pub fn write_outline(ctx: &mut PdfContext) -> Option { struct HeadingNode { element: Content, level: NonZeroUsize, + bookmarked: bool, children: Vec, } @@ -55,6 +114,10 @@ impl HeadingNode { fn leaf(element: Content) -> Self { HeadingNode { level: element.expect_field::("level"), + // 'bookmarked' set to 'auto' falls back to the value of 'outlined'. + bookmarked: element + .expect_field::>("bookmarked") + .unwrap_or_else(|| element.expect_field::("outlined")), element, children: Vec::new(), } diff --git a/tests/typ/meta/outline.typ b/tests/typ/meta/outline.typ index 9f45b2f32..73b771429 100644 --- a/tests/typ/meta/outline.typ +++ b/tests/typ/meta/outline.typ @@ -30,7 +30,8 @@ fn main() { ==== Deep Stuff Ok ... -#set heading(numbering: "(I)") +// Ensure 'bookmarked' option doesn't affect the outline +#set heading(numbering: "(I)", bookmarked: false) = #text(blue)[Zusammen]fassung #lorem(10)