summaryrefslogtreecommitdiff
path: root/crates
diff options
context:
space:
mode:
authorLaurenz <laurmaedje@gmail.com>2025-01-23 12:50:51 +0100
committerGitHub <noreply@github.com>2025-01-23 11:50:51 +0000
commit52ee33a275063369673d8802fb820db3825a661f (patch)
treee4f8ca45e7b9efd608babba5aed85042f8994a08 /crates
parent1bd8ff0e0fa7966f4bd2a4426241781bed168df7 (diff)
Rework outline (#5735)
Diffstat (limited to 'crates')
-rw-r--r--crates/typst-library/src/layout/repeat.rs2
-rw-r--r--crates/typst-library/src/math/equation.rs37
-rw-r--r--crates/typst-library/src/model/figure.rs59
-rw-r--r--crates/typst-library/src/model/heading.rs54
-rw-r--r--crates/typst-library/src/model/outline.rs803
-rw-r--r--crates/typst-library/src/model/terms.rs11
-rw-r--r--crates/typst-utils/src/lib.rs9
7 files changed, 581 insertions, 394 deletions
diff --git a/crates/typst-library/src/layout/repeat.rs b/crates/typst-library/src/layout/repeat.rs
index e423410a..9579f185 100644
--- a/crates/typst-library/src/layout/repeat.rs
+++ b/crates/typst-library/src/layout/repeat.rs
@@ -10,7 +10,7 @@ use crate::layout::{BlockElem, Length};
/// Space may be inserted between the instances of the body parameter, so be
/// sure to adjust the [`justify`]($repeat.justify) parameter accordingly.
///
-/// Errors if there no bounds on the available space, as it would create
+/// Errors if there are no bounds on the available space, as it would create
/// infinite content.
///
/// # Example
diff --git a/crates/typst-library/src/math/equation.rs b/crates/typst-library/src/math/equation.rs
index a9173c43..1e346280 100644
--- a/crates/typst-library/src/math/equation.rs
+++ b/crates/typst-library/src/math/equation.rs
@@ -229,35 +229,20 @@ impl Refable for Packed<EquationElem> {
}
impl Outlinable for Packed<EquationElem> {
- fn outline(
- &self,
- engine: &mut Engine,
- styles: StyleChain,
- ) -> SourceResult<Option<Content>> {
- if !self.block(StyleChain::default()) {
- return Ok(None);
- }
- let Some(numbering) = self.numbering() else {
- return Ok(None);
- };
-
- // After synthesis, this should always be custom content.
- let mut supplement = match (**self).supplement(StyleChain::default()) {
- Smart::Custom(Some(Supplement::Content(content))) => content,
- _ => Content::empty(),
- };
+ fn outlined(&self) -> bool {
+ self.block(StyleChain::default()) && self.numbering().is_some()
+ }
+ fn prefix(&self, numbers: Content) -> Content {
+ let supplement = self.supplement();
if !supplement.is_empty() {
- supplement += TextElem::packed("\u{a0}");
+ supplement + TextElem::packed('\u{a0}') + numbers
+ } else {
+ numbers
}
+ }
- let numbers = self.counter().display_at_loc(
- engine,
- self.location().unwrap(),
- styles,
- numbering,
- )?;
-
- Ok(Some(supplement + numbers))
+ fn body(&self) -> Content {
+ Content::empty()
}
}
diff --git a/crates/typst-library/src/model/figure.rs b/crates/typst-library/src/model/figure.rs
index 52dca966..ce7460c9 100644
--- a/crates/typst-library/src/model/figure.rs
+++ b/crates/typst-library/src/model/figure.rs
@@ -156,6 +156,7 @@ pub struct FigureElem {
pub scope: PlacementScope,
/// The figure's caption.
+ #[borrowed]
pub caption: Option<Packed<FigureCaption>>,
/// The kind of figure this is.
@@ -305,7 +306,7 @@ impl Synthesize for Packed<FigureElem> {
));
// Fill the figure's caption.
- let mut caption = elem.caption(styles);
+ let mut caption = elem.caption(styles).clone();
if let Some(caption) = &mut caption {
caption.synthesize(engine, styles)?;
caption.push_kind(kind.clone());
@@ -331,7 +332,7 @@ impl Show for Packed<FigureElem> {
let mut realized = self.body.clone();
// Build the caption, if any.
- if let Some(caption) = self.caption(styles) {
+ if let Some(caption) = self.caption(styles).clone() {
let (first, second) = match caption.position(styles) {
OuterVAlignment::Top => (caption.pack(), realized),
OuterVAlignment::Bottom => (realized, caption.pack()),
@@ -423,46 +424,26 @@ impl Refable for Packed<FigureElem> {
}
impl Outlinable for Packed<FigureElem> {
- fn outline(
- &self,
- engine: &mut Engine,
- styles: StyleChain,
- ) -> SourceResult<Option<Content>> {
- if !self.outlined(StyleChain::default()) {
- return Ok(None);
- }
-
- let Some(caption) = self.caption(StyleChain::default()) else {
- return Ok(None);
- };
-
- let mut realized = caption.body.clone();
- if let (
- Smart::Custom(Some(Supplement::Content(mut supplement))),
- Some(Some(counter)),
- Some(numbering),
- ) = (
- (**self).supplement(StyleChain::default()).clone(),
- (**self).counter(),
- self.numbering(),
- ) {
- let numbers = counter.display_at_loc(
- engine,
- self.location().unwrap(),
- styles,
- numbering,
- )?;
-
- if !supplement.is_empty() {
- supplement += TextElem::packed('\u{a0}');
- }
-
- let separator = caption.get_separator(StyleChain::default());
+ fn outlined(&self) -> bool {
+ (**self).outlined(StyleChain::default())
+ && (self.caption(StyleChain::default()).is_some()
+ || self.numbering().is_some())
+ }
- realized = supplement + numbers + separator + caption.body.clone();
+ fn prefix(&self, numbers: Content) -> Content {
+ let supplement = self.supplement();
+ if !supplement.is_empty() {
+ supplement + TextElem::packed('\u{a0}') + numbers
+ } else {
+ numbers
}
+ }
- Ok(Some(realized))
+ fn body(&self) -> Content {
+ self.caption(StyleChain::default())
+ .as_ref()
+ .map(|caption| caption.body.clone())
+ .unwrap_or_default()
}
}
diff --git a/crates/typst-library/src/model/heading.rs b/crates/typst-library/src/model/heading.rs
index db131afe..00931c81 100644
--- a/crates/typst-library/src/model/heading.rs
+++ b/crates/typst-library/src/model/heading.rs
@@ -1,7 +1,7 @@
use std::num::NonZeroUsize;
use ecow::eco_format;
-use typst_utils::NonZeroExt;
+use typst_utils::{Get, NonZeroExt};
use crate::diag::{warning, SourceResult};
use crate::engine::Engine;
@@ -13,8 +13,8 @@ use crate::html::{attr, tag, HtmlElem};
use crate::introspection::{
Count, Counter, CounterUpdate, Locatable, Locator, LocatorLink,
};
-use crate::layout::{Abs, Axes, BlockBody, BlockElem, Em, HElem, Length, Region};
-use crate::model::{Numbering, Outlinable, ParElem, Refable, Supplement};
+use crate::layout::{Abs, Axes, BlockBody, BlockElem, Em, HElem, Length, Region, Sides};
+use crate::model::{Numbering, Outlinable, Refable, Supplement};
use crate::text::{FontWeight, LocalName, SpaceElem, TextElem, TextSize};
/// A section heading.
@@ -264,10 +264,6 @@ impl Show for Packed<HeadingElem> {
realized = numbering + spacing + realized;
}
- if indent != Abs::zero() && !html {
- realized = realized.styled(ParElem::set_hanging_indent(indent.into()));
- }
-
Ok(if html {
// HTML's h1 is closer to a title element. There should only be one.
// Meanwhile, a level 1 Typst heading is a section heading. For this
@@ -294,8 +290,17 @@ impl Show for Packed<HeadingElem> {
HtmlElem::new(t).with_body(Some(realized)).pack().spanned(span)
}
} else {
- let realized = BlockBody::Content(realized);
- BlockElem::new().with_body(Some(realized)).pack().spanned(span)
+ let block = if indent != Abs::zero() {
+ let body = HElem::new((-indent).into()).pack() + realized;
+ let inset = Sides::default()
+ .with(TextElem::dir_in(styles).start(), Some(indent.into()));
+ BlockElem::new()
+ .with_body(Some(BlockBody::Content(body)))
+ .with_inset(inset)
+ } else {
+ BlockElem::new().with_body(Some(BlockBody::Content(realized)))
+ };
+ block.pack().spanned(span)
})
}
}
@@ -351,32 +356,21 @@ impl Refable for Packed<HeadingElem> {
}
impl Outlinable for Packed<HeadingElem> {
- fn outline(
- &self,
- engine: &mut Engine,
- styles: StyleChain,
- ) -> SourceResult<Option<Content>> {
- if !self.outlined(StyleChain::default()) {
- return Ok(None);
- }
-
- let mut content = self.body.clone();
- if let Some(numbering) = (**self).numbering(StyleChain::default()).as_ref() {
- let numbers = Counter::of(HeadingElem::elem()).display_at_loc(
- engine,
- self.location().unwrap(),
- styles,
- numbering,
- )?;
- content = numbers + SpaceElem::shared().clone() + content;
- };
-
- Ok(Some(content))
+ fn outlined(&self) -> bool {
+ (**self).outlined(StyleChain::default())
}
fn level(&self) -> NonZeroUsize {
(**self).resolve_level(StyleChain::default())
}
+
+ fn prefix(&self, numbers: Content) -> Content {
+ numbers
+ }
+
+ fn body(&self) -> Content {
+ self.body.clone()
+ }
}
impl LocalName for Packed<HeadingElem> {
diff --git a/crates/typst-library/src/model/outline.rs b/crates/typst-library/src/model/outline.rs
index 84661c1c..0db056e4 100644
--- a/crates/typst-library/src/model/outline.rs
+++ b/crates/typst-library/src/model/outline.rs
@@ -1,50 +1,61 @@
use std::num::NonZeroUsize;
use std::str::FromStr;
-use comemo::Track;
+use comemo::{Track, Tracked};
+use smallvec::SmallVec;
use typst_syntax::Span;
-use typst_utils::NonZeroExt;
+use typst_utils::{Get, NonZeroExt};
-use crate::diag::{bail, At, SourceResult};
+use crate::diag::{bail, error, At, HintedStrResult, SourceResult, StrResult};
use crate::engine::Engine;
use crate::foundations::{
- cast, elem, scope, select_where, Content, Context, Func, LocatableSelector,
- NativeElement, Packed, Show, ShowSet, Smart, StyleChain, Styles,
+ cast, elem, func, scope, select_where, Args, Construct, Content, Context, Func,
+ LocatableSelector, NativeElement, Packed, Resolve, Show, ShowSet, Smart, StyleChain,
+ Styles,
};
-use crate::introspection::{Counter, CounterKey, Locatable};
-use crate::layout::{
- BoxElem, Dir, Em, Fr, HElem, HideElem, Length, Rel, RepeatElem, Spacing,
+use crate::introspection::{
+ Counter, CounterKey, Introspector, Locatable, Location, Locator, LocatorLink,
};
-use crate::model::{
- Destination, HeadingElem, NumberingPattern, ParElem, ParbreakElem, Refable,
+use crate::layout::{
+ Abs, Axes, BlockBody, BlockElem, BoxElem, Dir, Em, Fr, HElem, Length, Region, Rel,
+ RepeatElem, Sides,
};
-use crate::text::{LinebreakElem, LocalName, SpaceElem, TextElem};
+use crate::math::EquationElem;
+use crate::model::{Destination, HeadingElem, NumberingPattern, ParElem, Refable};
+use crate::text::{LocalName, SpaceElem, TextElem};
/// A table of contents, figures, or other elements.
///
/// This function generates a list of all occurrences of an element in the
-/// document, up to a given depth. The element's numbering and page number will
-/// be displayed in the outline alongside its title or caption. By default this
-/// generates a table of contents.
+/// document, up to a given [`depth`]($outline.depth). The element's numbering
+/// and page number will be displayed in the outline alongside its title or
+/// caption.
///
/// # Example
/// ```example
+/// #set heading(numbering: "1.")
/// #outline()
///
/// = Introduction
/// #lorem(5)
///
-/// = Prior work
+/// = Methods
+/// == Setup
/// #lorem(10)
/// ```
///
/// # Alternative outlines
+/// In its default configuration, this function generates a table of contents.
/// By setting the `target` parameter, the outline can be used to generate a
-/// list of other kinds of elements than headings. In the example below, we list
-/// all figures containing images by setting `target` to `{figure.where(kind:
-/// image)}`. We could have also set it to just `figure`, but then the list
-/// would also include figures containing tables or other material. For more
-/// details on the `where` selector, [see here]($function.where).
+/// list of other kinds of elements than headings.
+///
+/// In the example below, we list all figures containing images by setting
+/// `target` to `{figure.where(kind: image)}`. Just the same, we could have set
+/// it to `{figure.where(kind: table)}` to generate a list of tables.
+///
+/// We could also set it to just `figure`, without using a [`where`]($function.where)
+/// selector, but then the list would contain _all_ figures, be it ones
+/// containing images, tables, or other material.
///
/// ```example
/// #outline(
@@ -59,16 +70,89 @@ use crate::text::{LinebreakElem, LocalName, SpaceElem, TextElem};
/// ```
///
/// # Styling the outline
-/// The outline element has several options for customization, such as its
-/// `title` and `indent` parameters. If desired, however, it is possible to have
-/// more control over the outline's look and style through the
-/// [`outline.entry`]($outline.entry) element.
-#[elem(scope, keywords = ["Table of Contents"], Show, ShowSet, LocalName)]
+/// At the most basic level, you can style the outline by setting properties on
+/// it and its entries. This way, you can customize the outline's
+/// [title]($outline.title), how outline entries are
+/// [indented]($outline.indent), and how the space between an entry's text and
+/// its page number should be [filled]($outline.entry.fill).
+///
+/// Richer customization is possible through configuration of the outline's
+/// [entries]($outline.entry). The outline generates one entry for each outlined
+/// element.
+///
+/// ## Spacing the entries { #entry-spacing }
+/// Outline entries are [blocks]($block), so you can adjust the spacing between
+/// them with normal block-spacing rules:
+///
+/// ```example
+/// #show outline.entry.where(
+/// level: 1
+/// ): set block(above: 1.2em)
+///
+/// #outline()
+///
+/// = About ACME Corp.
+/// == History
+/// === Origins
+/// = Products
+/// == ACME Tools
+/// ```
+///
+/// ## Building an outline entry from its parts { #building-an-entry }
+/// For full control, you can also write a transformational show rule on
+/// `outline.entry`. However, the logic for properly formatting and indenting
+/// outline entries is quite complex and the outline entry itself only contains
+/// two fields: The level and the outlined element.
+///
+/// For this reason, various helper functions are provided. You can mix and
+/// match these to compose an entry from just the parts you like.
+///
+/// The default show rule for an outline entry looks like this[^1]:
+/// ```typ
+/// #show outline.entry: it => link(
+/// it.element.location(),
+/// it.indented(it.prefix(), it.inner()),
+/// )
+/// ```
+///
+/// - The [`indented`]($outline.entry.indented) function takes an optional
+/// prefix and inner content and automatically applies the proper indentation
+/// to it, such that different entries align nicely and long headings wrap
+/// properly.
+///
+/// - The [`prefix`]($outline.entry.prefix) function formats the element's
+/// numbering (if any). It also appends a supplement for certain elements.
+///
+/// - The [`inner`]($outline.entry.inner) function combines the element's
+/// [`body`]($outline.entry.body), the filler, and the
+/// [`page` number]($outline.entry.page).
+///
+/// You can use these individual functions to format the outline entry in
+/// different ways. Let's say, you'd like to fully remove the filler and page
+/// numbers. To achieve this, you could write a show rule like this:
+///
+/// ```example
+/// #show outline.entry: it => link(
+/// it.element.location(),
+/// // Keep just the body, dropping
+/// // the fill and the page.
+/// it.indented(it.prefix(), it.body()),
+/// )
+///
+/// #outline()
+///
+/// = About ACME Corp.
+/// == History
+/// ```
+///
+/// [^1]: The outline of equations is the exception to this rule as it does not
+/// have a body and thus does not use indented layout.
+#[elem(scope, keywords = ["Table of Contents", "toc"], Show, ShowSet, LocalName, Locatable)]
pub struct OutlineElem {
/// The title of the outline.
///
/// - When set to `{auto}`, an appropriate title for the
- /// [text language]($text.lang) will be used. This is the default.
+ /// [text language]($text.lang) will be used.
/// - When set to `{none}`, the outline will not have a title.
/// - A custom title can be set by passing content.
///
@@ -79,8 +163,10 @@ pub struct OutlineElem {
/// The type of element to include in the outline.
///
- /// To list figures containing a specific kind of element, like a table, you
- /// can write `{figure.where(kind: table)}`.
+ /// To list figures containing a specific kind of element, like an image or
+ /// a table, you can specify the desired kind in a [`where`]($function.where)
+ /// selector. See the section on [alternative outlines]($outline/#alternative-outlines)
+ /// for more details.
///
/// ```example
/// #outline(
@@ -97,7 +183,7 @@ pub struct OutlineElem {
/// caption: [Experiment results],
/// )
/// ```
- #[default(LocatableSelector(select_where!(HeadingElem, Outlined => true)))]
+ #[default(LocatableSelector(HeadingElem::elem().select()))]
#[borrowed]
pub target: LocatableSelector,
@@ -121,21 +207,22 @@ pub struct OutlineElem {
/// How to indent the outline's entries.
///
- /// - `{none}`: No indent
- /// - `{auto}`: Indents the numbering of the nested entry with the title of
- /// its parent entry. This only has an effect if the entries are numbered
- /// (e.g., via [heading numbering]($heading.numbering)).
- /// - [Relative length]($relative): Indents the item by this length
- /// multiplied by its nesting level. Specifying `{2em}`, for instance,
- /// would indent top-level headings (not nested) by `{0em}`, second level
+ /// - `{auto}`: Indents the numbering/prefix of a nested entry with the
+ /// title of its parent entry. If the entries are not numbered (e.g., via
+ /// [heading numbering]($heading.numbering)), this instead simply inserts
+ /// a fixed amount of `{1.2em}` indent per level.
+ ///
+ /// - [Relative length]($relative): Indents the entry by the specified
+ /// length per nesting level. Specifying `{2em}`, for instance, would
+ /// indent top-level headings by `{0em}` (not nested), second level
/// headings by `{2em}` (nested once), third-level headings by `{4em}`
/// (nested twice) and so on.
- /// - [Function]($function): You can completely customize this setting with
- /// a function. That function receives the nesting level as a parameter
- /// (starting at 0 for top-level headings/elements) and can return a
- /// relative length or content making up the indent. For example,
- /// `{n => n * 2em}` would be equivalent to just specifying `{2em}`, while
- /// `{n => [→ ] * n}` would indent with one arrow per nesting level.
+ ///
+ /// - [Function]($function): You can further customize this setting with a
+ /// function. That function receives the nesting level as a parameter
+ /// (starting at 0 for top-level headings/elements) and should return a
+ /// (relative) length. For example, `{n => n * 2em}` would be equivalent
+ /// to just specifying `{2em}`.
///
/// ```example
/// #set heading(numbering: "1.a.")
@@ -150,11 +237,6 @@ pub struct OutlineElem {
/// indent: 2em,
/// )
///
- /// #outline(
- /// title: [Contents (Function)],
- /// indent: n => [→ ] * n,
- /// )
- ///
/// = About ACME Corp.
/// == History
/// === Origins
@@ -163,20 +245,7 @@ pub struct OutlineElem {
/// == Products
/// #lorem(10)
/// ```
- #[default(None)]
- #[borrowed]
- pub indent: Option<Smart<OutlineIndent>>,
-
- /// Content to fill the space between the title and the page number. Can be
- /// set to `{none}` to disable filling.
- ///
- /// ```example
- /// #outline(fill: line(length: 100%))
- ///
- /// = A New Beginning
- /// ```
- #[default(Some(RepeatElem::new(TextElem::packed(".")).pack()))]
- pub fill: Option<Content>,
+ pub indent: Smart<OutlineIndent>,
}
#[scope]
@@ -188,79 +257,52 @@ impl OutlineElem {
impl Show for Packed<OutlineElem> {
#[typst_macros::time(name = "outline", span = self.span())]
fn show(&self, engine: &mut Engine, styles: StyleChain) -> SourceResult<Content> {
- let mut seq = vec![ParbreakElem::shared().clone()];
+ let span = self.span();
+
// Build the outline title.
+ let mut seq = vec![];
if let Some(title) = self.title(styles).unwrap_or_else(|| {
- Some(TextElem::packed(Self::local_name_in(styles)).spanned(self.span()))
+ Some(TextElem::packed(Self::local_name_in(styles)).spanned(span))
}) {
seq.push(
HeadingElem::new(title)
.with_depth(NonZeroUsize::ONE)
.pack()
- .spanned(self.span()),
+ .spanned(span),
);
}
- let indent = self.indent(styles);
- let depth = self.depth(styles).unwrap_or(NonZeroUsize::new(usize::MAX).unwrap());
-
- let mut ancestors: Vec<&Content> = vec![];
let elems = engine.introspector.query(&self.target(styles).0);
+ let depth = self.depth(styles).unwrap_or(NonZeroUsize::MAX);
- for elem in &elems {
- let Some(entry) = OutlineEntry::from_outlinable(
- engine,
- self.span(),
- elem.clone(),
- self.fill(styles),
- styles,
- )?
- else {
- continue;
+ // Build the outline entries.
+ for elem in elems {
+ let Some(outlinable) = elem.with::<dyn Outlinable>() else {
+ bail!(span, "cannot outline {}", elem.func().name());
};
- if depth < entry.level {
- continue;
+ let level = outlinable.level();
+ if outlinable.outlined() && level <= depth {
+ let entry = OutlineEntry::new(level, elem);
+ seq.push(entry.pack().spanned(span));
}
-
- // Deals with the ancestors of the current element.
- // This is only applicable for elements with a hierarchy/level.
- while ancestors
- .last()
- .and_then(|ancestor| ancestor.with::<dyn Outlinable>())
- .is_some_and(|last| last.level() >= entry.level)
- {
- ancestors.pop();
- }
-
- OutlineIndent::apply(
- indent,
- engine,
- &ancestors,
- &mut seq,
- styles,
- self.span(),
- )?;
-
- // Add the overridable outline entry, followed by a line break.
- seq.push(entry.pack().spanned(self.span()));
- seq.push(LinebreakElem::shared().clone());
-
- ancestors.push(elem);
}
- seq.push(ParbreakElem::shared().clone());
-
Ok(Content::sequence(seq))
}
}
impl ShowSet for Packed<OutlineElem> {
- fn show_set(&self, _: StyleChain) -> Styles {
+ fn show_set(&self, styles: StyleChain) -> Styles {
let mut out = Styles::new();
out.set(HeadingElem::set_outlined(false));
out.set(HeadingElem::set_numbering(None));
out.set(ParElem::set_first_line_indent(Em::new(0.0).into()));
+ out.set(ParElem::set_justify(false));
+ out.set(BlockElem::set_above(Smart::Custom(ParElem::leading_in(styles).into())));
+ // Makes the outline itself available to its entries. Should be
+ // superseded by a proper ancestry mechanism in the future.
+ out.set(OutlineEntry::set_parent(Some(self.clone())));
out
}
}
@@ -269,93 +311,29 @@ impl LocalName for Packed<OutlineElem> {
const KEY: &'static str = "outline";
}
-/// Marks an element as being able to be outlined. This is used to implement the
-/// `#outline()` element.
-pub trait Outlinable: Refable {
- /// Produce an outline item for this element.
- fn outline(
- &self,
- engine: &mut Engine,
-
- styles: StyleChain,
- ) -> SourceResult<Option<Content>>;
-
- /// Returns the nesting level of this element.
- fn level(&self) -> NonZeroUsize {
- NonZeroUsize::ONE
- }
-}
-
/// Defines how an outline is indented.
#[derive(Debug, Clone, PartialEq, Hash)]
pub enum OutlineIndent {
- Rel(Rel<Length>),
+ /// Indents by the specified length per level.
+ Rel(Rel),
+ /// Resolve the indent for a specific level through the given function.
Func(Func),
}
impl OutlineIndent {
- fn apply(
- indent: &Option<Smart<Self>>,
+ /// Resolve the indent for an entry with the given level.
+ fn resolve(
+ &self,
engine: &mut Engine,
- ancestors: &Vec<&Content>,
- seq: &mut Vec<Content>,
- styles: StyleChain,
+ context: Tracked<Context>,
+ level: NonZeroUsize,
span: Span,
- ) -> SourceResult<()> {
- match indent {
- // 'none' | 'false' => no indenting
- None => {}
-
- // 'auto' | 'true' => use numbering alignment for indenting
- Some(Smart::Auto) => {
- // Add hidden ancestors numberings to realize the indent.
- let mut hidden = Content::empty();
- for ancestor in ancestors {
- let ancestor_outlinable = ancestor.with::<dyn Outlinable>().unwrap();
-
- if let Some(numbering) = ancestor_outlinable.numbering() {
- let numbers = ancestor_outlinable.counter().display_at_loc(
- engine,
- ancestor.location().unwrap(),
- styles,
- numbering,
- )?;
-
- hidden += numbers + SpaceElem::shared().clone();
- };
- }
-
- if !ancestors.is_empty() {
- seq.push(HideElem::new(hidden).pack().spanned(span));
- seq.push(SpaceElem::shared().clone().spanned(span));
- }
- }
-
- // Length => indent with some fixed spacing per level
- Some(Smart::Custom(OutlineIndent::Rel(length))) => {
- seq.push(
- HElem::new(Spacing::Rel(*length))
- .pack()
- .spanned(span)
- .repeat(ancestors.len()),
- );
- }
-
- // Function => call function with the current depth and take
- // the returned content
- Some(Smart::Custom(OutlineIndent::Func(func))) => {
- let depth = ancestors.len();
- let LengthOrContent(content) = func
- .call(engine, Context::new(None, Some(styles)).track(), [depth])?
- .cast()
- .at(span)?;
- if !content.is_empty() {
- seq.push(content);
- }
- }
- };
-
- Ok(())
+ ) -> SourceResult<Rel> {
+ let depth = level.get() - 1;
+ match self {
+ Self::Rel(length) => Ok(*length * depth as f64),
+ Self::Func(func) => func.call(engine, context, [depth])?.cast().at(span),
+ }
}
}
@@ -365,46 +343,33 @@ cast! {
Self::Rel(v) => v.into_value(),
Self::Func(v) => v.into_value()
},
- v: Rel<Length> => OutlineIndent::Rel(v),
- v: Func => OutlineIndent::Func(v),
+ v: Rel<Length> => Self::Rel(v),
+ v: Func => Self::Func(v),
}
-struct LengthOrContent(Content);
+/// Marks an element as being able to be outlined.
+pub trait Outlinable: Refable {
+ /// Whether this element should be included in the outline.
+ fn outlined(&self) -> bool;
-cast! {
- LengthOrContent,
- v: Rel<Length> => Self(HElem::new(Spacing::Rel(v)).pack()),
- v: Content => Self(v),
+ /// The nesting level of this element.
+ fn level(&self) -> NonZeroUsize {
+ NonZeroUsize::ONE
+ }
+
+ /// Constructs the default prefix given the formatted numbering.
+ fn prefix(&self, numbers: Content) -> Content;
+
+ /// The body of the entry.
+ fn body(&self) -> Content;
}
-/// Represents each entry line in an outline, including the reference to the
-/// outlined element, its page number, and the filler content between both.
-///
-/// This element is intended for use with show rules to control the appearance
-/// of outlines. To customize an entry's line, you can build it from scratch by
-/// accessing the `level`, `element`, `body`, `fill` and `page` fields on the
-/// entry.
+/// Represents an entry line in an outline.
///
-/// ```example
-/// #set heading(numbering: "1.")
-///
-/// #show outline.entry.where(
-/// level: 1
-/// ): it => {
-/// v(12pt, weak: true)
-/// strong(it)
-/// }
-///
-/// #outline(indent: auto)
-///
-/// = Introduction
-/// = Background
-/// == History
-/// == State of the Art
-/// = Analysis
-/// == Setup
-/// ```
-#[elem(name = "entry", title = "Outline Entry", Show)]
+/// With show-set and show rules on outline entries, you can richly customize
+/// the outline's appearance. See the
+/// [section on styling the outline]($outline/#styling-the-outline) for details.
+#[elem(scope, name = "entry", title = "Outline Entry", Show)]
pub struct OutlineEntry {
/// The nesting level of this outline entry. Starts at `{1}` for top-level
/// entries.
@@ -412,89 +377,205 @@ pub struct OutlineEntry {
pub level: NonZeroUsize,
/// The element this entry refers to. Its location will be available
- /// through the [`location`]($content.location) method on content
+ /// through the [`location`]($content.location) method on the content
/// and can be [linked]($link) to.
#[required]
pub element: Content,
- /// The content which is displayed in place of the referred element at its
- /// entry in the outline. For a heading, this would be its number followed
- /// by the heading's title, for example.
- #[required]
- pub body: Content,
-
- /// The content used to fill the space between the element's outline and
- /// its page number, as defined by the outline element this entry is
- /// located in. When `{none}`, empty space is inserted in that gap instead.
+ /// Content to fill the space between the title and the page number. Can be
+ /// set to `{none}` to disable filling.
///
- /// Note that, when using show rules to override outline entries, it is
- /// recommended to wrap the filling content in a [`box`] with fractional
- /// width. For example, `{box(width: 1fr, repeat[-])}` would show precisely
- /// as many `-` characters as necessary to fill a particular gap.
- #[required]
+ /// The `fill` will be placed into a fractionally sized box that spans the
+ /// space between the entry's body and the page number. When using show
+ /// rules to override outline entries, it is thus recommended to wrap the
+ /// fill in a [`box`] with fractional width, i.e.
+ /// `{box(width: 1fr, it.fill}`.
+ ///
+ /// When using [`repeat`], the [`gap`]($repeat.gap) property can be useful
+ /// to tweak the visual weight of the fill.
+ ///
+ /// ```example
+ /// #set outline.entry(fill: line(length: 100%))
+ /// #outline()
+ ///
+ /// = A New Beginning
+ /// ```
+ #[borrowed]
+ #[default(Some(
+ RepeatElem::new(TextElem::packed("."))
+ .with_gap(Em::new(0.15).into())
+ .pack()
+ ))]
pub fill: Option<Content>,
- /// The page number of the element this entry links to, formatted with the
- /// numbering set for the referenced page.
- #[required]
- pub page: Content,
+ /// Lets outline entries access the outline they are part of. This is a bit
+ /// of a hack and should be superseded by a proper ancestry mechanism.
+ #[ghost]
+ #[internal]
+ pub parent: Option<Packed<OutlineElem>>,
}
+impl Show for Packed<OutlineEntry> {
+ #[typst_macros::time(name = "outline.entry", span = self.span())]
+ fn show(&self, engine: &mut Engine, styles: StyleChain) -> SourceResult<Content> {
+ let span = self.span();
+ let context = Context::new(None, Some(styles));
+ let context = context.track();
+
+ let prefix = self.prefix(engine, context, span)?;
+ let inner = self.inner(engine, context, span)?;
+ let block = if self.element.is::<EquationElem>() {
+ let body = prefix.unwrap_or_default() + inner;
+ BlockElem::new()
+ .with_body(Some(BlockBody::Content(body)))
+ .pack()
+ .spanned(span)
+ } else {
+ self.indented(engine, context, span, prefix, inner, Em::new(0.5).into())?
+ };
+
+ let loc = self.element_location().at(span)?;
+ Ok(block.linked(Destination::Location(loc)))
+ }
+}
+
+#[scope]
impl OutlineEntry {
- /// Generates an OutlineEntry from the given element, if possible (errors if
- /// the element does not implement `Outlinable`). If the element should not
- /// be outlined (e.g. heading with 'outlined: false'), does not generate an
- /// entry instance (returns `Ok(None)`).
- fn from_outlinable(
+ /// A helper function for producing an indented entry layout: Lays out a
+ /// prefix and the rest of the entry in an indent-aware way.
+ ///
+ /// If the parent outline's [`indent`]($outline.indent) is `{auto}`, the
+ /// inner content of all entries at level `N` is aligned with the prefix of
+ /// all entries at with level `N + 1`, leaving at least `gap` space between
+ /// the prefix and inner parts. Furthermore, the `inner` contents of all
+ /// entries at the same level are aligned.
+ ///
+ /// If the outline's indent is a fixed value or a function, the prefixes are
+ /// indented, but the inner contents are simply inset from the prefix by the
+ /// specified `gap`, rather than aligning outline-wide.
+ #[func(contextual)]
+ pub fn indented(
+ &self,
engine: &mut Engine,
+ context: Tracked<Context>,
span: Span,
- elem: Content,
- fill: Option<Content>,
- styles: StyleChain,
- ) -> SourceResult<Option<Self>> {
- let Some(outlinable) = elem.with::<dyn Outlinable>() else {
- bail!(span, "cannot outline {}", elem.func().name());
+ /// The `prefix` is aligned with the `inner` content of entries that
+ /// have level one less.
+ ///
+ /// In the default show rule, this is just to `it.prefix()`, but it can
+ /// be freely customized.
+ prefix: Option<Content>,
+ /// The formatted inner content of the entry.
+ ///
+ /// In the default show rule, this is just to `it.inner()`, but it can
+ /// be freely customized.
+ inner: Content,
+ /// The gap between the prefix and the inner content.
+ #[named]
+ #[default(Em::new(0.5).into())]
+ gap: Length,
+ ) -> SourceResult<Content> {
+ let styles = context.styles().at(span)?;
+ let outline = Self::parent_in(styles)
+ .ok_or("must be called within the context of an outline")
+ .at(span)?;
+ let outline_loc = outline.location().unwrap();
+
+ let prefix_width = prefix
+ .as_ref()
+ .map(|prefix| measure_prefix(engine, prefix, outline_loc, styles))
+ .transpose()?;
+ let prefix_inset = prefix_width.map(|w| w + gap.resolve(styles));
+
+ let indent = outline.indent(styles);
+ let (base_indent, hanging_indent) = match &indent {
+ Smart::Auto => compute_auto_indents(
+ engine.introspector,
+ outline_loc,
+ styles,
+ self.level,
+ prefix_inset,
+ ),
+ Smart::Custom(amount) => {
+ let base = amount.resolve(engine, context, self.level, span)?;
+ (base, prefix_inset)
+ }
};
- let Some(body) = outlinable.outline(engine, styles)? else {
- return Ok(None);
+ let body = if let (
+ Some(prefix),
+ Some(prefix_width),
+ Some(prefix_inset),
+ Some(hanging_indent),
+ ) = (prefix, prefix_width, prefix_inset, hanging_indent)
+ {
+ // Save information about our prefix that other outline entries
+ // can query for (within `compute_auto_indent`) to align
+ // themselves).
+ let mut seq = Vec::with_capacity(5);
+ if indent.is_auto() {
+ seq.push(PrefixInfo::new(outline_loc, self.level, prefix_inset).pack());
+ }
+
+ // Dedent the prefix by the amount of hanging indent and then skip
+ // ahead so that the inner contents are aligned.
+ seq.extend([
+ HElem::new((-hanging_indent).into()).pack(),
+ prefix,
+ HElem::new((hanging_indent - prefix_width).into()).pack(),
+ inner,
+ ]);
+ Content::sequence(seq)
+ } else {
+ inner
};
- let location = elem.location().unwrap();
- let page_numbering = engine
- .introspector
- .page_numbering(location)
- .cloned()
- .unwrap_or_else(|| NumberingPattern::from_str("1").unwrap().into());
+ let inset = Sides::default().with(
+ TextElem::dir_in(styles).start(),
+ Some(base_indent + Rel::from(hanging_indent.unwrap_or_default())),
+ );
- let page = Counter::new(CounterKey::Page).display_at_loc(
- engine,
- location,
- styles,
- &page_numbering,
- )?;
+ Ok(BlockElem::new()
+ .with_inset(inset)
+ .with_body(Some(BlockBody::Content(body)))
+ .pack()
+ .spanned(span))
+ }
- Ok(Some(Self::new(outlinable.level(), elem, body, fill, page)))
+ /// Formats the element's numbering (if any).
+ ///
+ /// This also appends the element's supplement in case of figures or
+ /// equations. For instance, it would output `1.1` for a heading, but
+ /// `Figure 1` for a figure, as is usual for outlines.
+ #[func(contextual)]
+ pub fn prefix(
+ &self,
+ engine: &mut Engine,
+ context: Tracked<Context>,
+ span: Span,
+ ) -> SourceResult<Option<Content>> {
+ let outlinable = self.outlinable().at(span)?;
+ let Some(numbering) = outlinable.numbering() else { return Ok(None) };
+ let loc = self.element_location().at(span)?;
+ let styles = context.styles().at(span)?;
+ let numbers =
+ outlinable.counter().display_at_loc(engine, loc, styles, numbering)?;
+ Ok(Some(outlinable.prefix(numbers)))
}
-}
-impl Show for Packed<OutlineEntry> {
- #[typst_macros::time(name = "outline.entry", span = self.span())]
- fn show(&self, _: &mut Engine, styles: StyleChain) -> SourceResult<Content> {
- let mut seq = vec![];
- let elem = &self.element;
+ /// Creates the default inner content of the entry.
+ ///
+ /// This includes the body, the fill, and page number.
+ #[func(contextual)]
+ pub fn inner(
+ &self,
+ engine: &mut Engine,
+ context: Tracked<Context>,
+ span: Span,
+ ) -> SourceResult<Content> {
+ let styles = context.styles().at(span)?;
- // In case a user constructs an outline entry with an arbitrary element.
- let Some(location) = elem.location() else {
- if elem.can::<dyn Locatable>() && elem.can::<dyn Outlinable>() {
- bail!(
- self.span(), "{} must have a location", elem.func().name();
- hint: "try using a query or a show rule to customize the outline.entry instead",
- )
- } else {
- bail!(self.span(), "cannot outline {}", elem.func().name())
- }
- };
+ let mut seq = vec![];
// Isolate the entry body in RTL because the page number is typically
// LTR. I'm not sure whether LTR should conceptually also be isolated,
@@ -511,32 +592,174 @@ impl Show for Packed<OutlineEntry> {
seq.push(TextElem::packed("\u{202B}"));
}
- seq.push(self.body.clone().linked(Destination::Location(location)));
+ seq.push(self.body().at(span)?);
if rtl {
// "Pop Directional Formatting"
seq.push(TextElem::packed("\u{202C}"));
}
- // Add filler symbols between the section name and page number.
- if let Some(filler) = &self.fill {
+ // Add the filler between the section name and page number.
+ if let Some(filler) = self.fill(styles) {
seq.push(SpaceElem::shared().clone());
seq.push(
BoxElem::new()
.with_body(Some(filler.clone()))
.with_width(Fr::one().into())
.pack()
- .spanned(self.span()),
+ .spanned(span),
);
seq.push(SpaceElem::shared().clone());
} else {
- seq.push(HElem::new(Fr::one().into()).pack().spanned(self.span()));
+ seq.push(HElem::new(Fr::one().into()).pack().spanned(span));
}
- // Add the page number.
- let page = self.page.clone().linked(Destination::Location(location));
- seq.push(page);
+ // Add the page number. The word joiner in front ensures that the page
+ // number doesn't stand alone in its line.
+ seq.push(TextElem::packed("\u{2060}"));
+ seq.push(self.page(engine, context, span)?);
Ok(Content::sequence(seq))
}
+
+ /// The content which is displayed in place of the referred element at its
+ /// entry in the outline. For a heading, this is its
+ /// [`body`]($heading.body), for a figure a caption, and for equations it is
+ /// empty.
+ #[func]
+ pub fn body(&self) -> StrResult<Content> {
+ Ok(self.outlinable()?.body())
+ }
+
+ /// The page number of this entry's element, formatted with the numbering
+ /// set for the referenced page.
+ #[func(contextual)]
+ pub fn page(
+ &self,
+ engine: &mut Engine,
+ context: Tracked<Context>,
+ span: Span,
+ ) -> SourceResult<Content> {
+ let loc = self.element_location().at(span)?;
+ let styles = context.styles().at(span)?;
+ let numbering = engine
+ .introspector
+ .page_numbering(loc)
+ .cloned()
+ .unwrap_or_else(|| NumberingPattern::from_str("1").unwrap().into());
+ Counter::new(CounterKey::Page).display_at_loc(engine, loc, styles, &numbering)
+ }
+}
+
+impl OutlineEntry {
+ fn outlinable(&self) -> StrResult<&dyn Outlinable> {
+ self.element
+ .with::<dyn Outlinable>()
+ .ok_or_else(|| error!("cannot outline {}", self.element.func().name()))
+ }
+
+ fn element_location(&self) -> HintedStrResult<Location> {
+ let elem = &self.element;
+ elem.location().ok_or_else(|| {
+ if elem.can::<dyn Locatable>() && elem.can::<dyn Outlinable>() {
+ error!(
+ "{} must have a location", elem.func().name();
+ hint: "try using a show rule to customize the outline.entry instead",
+ )
+ } else {
+ error!("cannot outline {}", elem.func().name())
+ }
+ })
+ }
+}
+
+cast! {
+ OutlineEntry,
+ v: Content => v.unpack::<Self>().map_err(|_| "expected outline entry")?
+}
+
+/// Measures the width of a prefix.
+fn measure_prefix(
+ engine: &mut Engine,
+ prefix: &Content,
+ loc: Location,
+ styles: StyleChain,
+) -> SourceResult<Abs> {
+ let pod = Region::new(Axes::splat(Abs::inf()), Axes::splat(false));
+ let link = LocatorLink::measure(loc);
+ Ok((engine.routines.layout_frame)(engine, prefix, Locator::link(&link), styles, pod)?
+ .width())
+}
+
+/// Compute the base indent and hanging indent for an auto-indented outline
+/// entry of the given level, with the given prefix inset.
+fn compute_auto_indents(
+ introspector: Tracked<Introspector>,
+ outline_loc: Location,
+ styles: StyleChain,
+ level: NonZeroUsize,
+ prefix_inset: Option<Abs>,
+) -> (Rel, Option<Abs>) {
+ let indents = query_prefix_widths(introspector, outline_loc);
+
+ let fallback = Em::new(1.2).resolve(styles);
+ let get = |i: usize| indents.get(i).copied().flatten().unwrap_or(fallback);
+
+ let last = level.get() - 1;
+ let base: Abs = (0..last).map(get).sum();
+ let hang = prefix_inset.map(|p| p.max(get(last)));
+
+ (base.into(), hang)
+}
+
+/// Determines the maximum prefix inset (prefix width + gap) at each outline
+/// level, for the outline with the given `loc`. Levels for which there is no
+/// information available yield `None`.
+#[comemo::memoize]
+fn query_prefix_widths(
+ introspector: Tracked<Introspector>,
+ outline_loc: Location,
+) -> SmallVec<[Option<Abs>; 4]> {
+ let mut widths = SmallVec::<[Option<Abs>; 4]>::new();
+ let elems = introspector.query(&select_where!(PrefixInfo, Key => outline_loc));
+ for elem in &elems {
+ let info = elem.to_packed::<PrefixInfo>().unwrap();
+ let level = info.level.get();
+ if widths.len() < level {
+ widths.resize(level, None);
+ }
+ widths[level - 1].get_or_insert(info.inset).set_max(info.inset);
+ }
+ widths
+}
+
+/// Helper type for introspection-based prefix alignment.
+#[elem(Construct, Locatable, Show)]
+struct PrefixInfo {
+ /// The location of the outline this prefix is part of. This is used to
+ /// scope prefix computations to a specific outline.
+ #[required]
+ key: Location,
+
+ /// The level of this prefix's entry.
+ #[required]
+ #[internal]
+ level: NonZeroUsize,
+
+ /// The width of the prefix, including the gap.
+ #[required]
+ #[internal]
+ inset: Abs,
+}
+
+impl Construct for PrefixInfo {
+ fn construct(_: &mut Engine, args: &mut Args) -> SourceResult<Content> {
+ bail!(args.span, "cannot be constructed manually");
+ }
+}
+
+impl Show for Packed<PrefixInfo> {
+ fn show(&self, _: &mut Engine, _: StyleChain) -> SourceResult<Content> {
+ Ok(Content::empty())
+ }
}
diff --git a/crates/typst-library/src/model/terms.rs b/crates/typst-library/src/model/terms.rs
index 1261ea4f..c91eeb17 100644
--- a/crates/typst-library/src/model/terms.rs
+++ b/crates/typst-library/src/model/terms.rs
@@ -1,4 +1,4 @@
-use typst_utils::Numeric;
+use typst_utils::{Get, Numeric};
use crate::diag::{bail, SourceResult};
use crate::engine::Engine;
@@ -7,7 +7,7 @@ use crate::foundations::{
Styles, TargetElem,
};
use crate::html::{tag, HtmlElem};
-use crate::layout::{Dir, Em, HElem, Length, Sides, StackChild, StackElem, VElem};
+use crate::layout::{Em, HElem, Length, Sides, StackChild, StackElem, VElem};
use crate::model::{ListItemLike, ListLike, ParElem};
use crate::text::TextElem;
@@ -160,12 +160,7 @@ impl Show for Packed<TermsElem> {
children.push(StackChild::Block(Content::sequence(seq)));
}
- let mut padding = Sides::default();
- if TextElem::dir_in(styles) == Dir::LTR {
- padding.left = pad.into();
- } else {
- padding.right = pad.into();
- }
+ let padding = Sides::default().with(TextElem::dir_in(styles).start(), pad.into());
let mut realized = StackElem::new(children)
.with_spacing(Some(gutter.into()))
diff --git a/crates/typst-utils/src/lib.rs b/crates/typst-utils/src/lib.rs
index d392e409..f3fe79d2 100644
--- a/crates/typst-utils/src/lib.rs
+++ b/crates/typst-utils/src/lib.rs
@@ -276,6 +276,15 @@ pub trait Get<Index> {
fn set(&mut self, index: Index, component: Self::Component) {
*self.get_mut(index) = component;
}
+
+ /// Builder-style method for setting a component.
+ fn with(mut self, index: Index, component: Self::Component) -> Self
+ where
+ Self: Sized,
+ {
+ self.set(index, component);
+ self
+ }
}
/// A numeric type.