summaryrefslogtreecommitdiff
path: root/crates/typst-library/src/model/figure.rs
diff options
context:
space:
mode:
authorLaurenz <laurmaedje@gmail.com>2024-10-27 19:04:55 +0100
committerGitHub <noreply@github.com>2024-10-27 18:04:55 +0000
commitbe7cfc85d08c545abfac08098b7b33b4bd71f37e (patch)
treef4137fa2aaa57babae1f7603a9b2ed7e688f43d8 /crates/typst-library/src/model/figure.rs
parentb8034a343831e8609aec2ec81eb7eeda57aa5d81 (diff)
Split out four new crates (#5302)
Diffstat (limited to 'crates/typst-library/src/model/figure.rs')
-rw-r--r--crates/typst-library/src/model/figure.rs641
1 files changed, 641 insertions, 0 deletions
diff --git a/crates/typst-library/src/model/figure.rs b/crates/typst-library/src/model/figure.rs
new file mode 100644
index 00000000..abdf2a4e
--- /dev/null
+++ b/crates/typst-library/src/model/figure.rs
@@ -0,0 +1,641 @@
+use std::borrow::Cow;
+use std::num::NonZeroUsize;
+use std::str::FromStr;
+
+use ecow::EcoString;
+use typst_utils::NonZeroExt;
+
+use crate::diag::{bail, SourceResult};
+use crate::engine::Engine;
+use crate::foundations::{
+ cast, elem, scope, select_where, Content, Element, NativeElement, Packed, Selector,
+ Show, ShowSet, Smart, StyleChain, Styles, Synthesize,
+};
+use crate::introspection::{
+ Count, Counter, CounterKey, CounterUpdate, Locatable, Location,
+};
+use crate::layout::{
+ AlignElem, Alignment, BlockBody, BlockElem, Em, HAlignment, Length, OuterVAlignment,
+ PlaceElem, PlacementScope, VAlignment, VElem,
+};
+use crate::model::{Numbering, NumberingPattern, Outlinable, Refable, Supplement};
+use crate::text::{Lang, Region, TextElem};
+use crate::visualize::ImageElem;
+
+/// A figure with an optional caption.
+///
+/// Automatically detects its kind to select the correct counting track. For
+/// example, figures containing images will be numbered separately from figures
+/// containing tables.
+///
+/// # Examples
+/// The example below shows a basic figure with an image:
+/// ```example
+/// @glacier shows a glacier. Glaciers
+/// are complex systems.
+///
+/// #figure(
+/// image("glacier.jpg", width: 80%),
+/// caption: [A curious figure.],
+/// ) <glacier>
+/// ```
+///
+/// You can also insert [tables]($table) into figures to give them a caption.
+/// The figure will detect this and automatically use a separate counter.
+///
+/// ```example
+/// #figure(
+/// table(
+/// columns: 4,
+/// [t], [1], [2], [3],
+/// [y], [0.3s], [0.4s], [0.8s],
+/// ),
+/// caption: [Timing results],
+/// )
+/// ```
+///
+/// This behaviour can be overridden by explicitly specifying the figure's
+/// `kind`. All figures of the same kind share a common counter.
+///
+/// # Figure behaviour
+/// By default, figures are placed within the flow of content. To make them
+/// float to the top or bottom of the page, you can use the
+/// [`placement`]($figure.placement) argument.
+///
+/// If your figure is too large and its contents are breakable across pages
+/// (e.g. if it contains a large table), then you can make the figure itself
+/// breakable across pages as well with this show rule:
+/// ```typ
+/// #show figure: set block(breakable: true)
+/// ```
+///
+/// See the [block]($block.breakable) documentation for more information about
+/// breakable and non-breakable blocks.
+///
+/// # Caption customization
+/// You can modify the appearance of the figure's caption with its associated
+/// [`caption`]($figure.caption) function. In the example below, we emphasize
+/// all captions:
+///
+/// ```example
+/// #show figure.caption: emph
+///
+/// #figure(
+/// rect[Hello],
+/// caption: [I am emphasized!],
+/// )
+/// ```
+///
+/// By using a [`where`]($function.where) selector, we can scope such rules to
+/// specific kinds of figures. For example, to position the caption above
+/// tables, but keep it below for all other kinds of figures, we could write the
+/// following show-set rule:
+///
+/// ```example
+/// #show figure.where(
+/// kind: table
+/// ): set figure.caption(position: top)
+///
+/// #figure(
+/// table(columns: 2)[A][B][C][D],
+/// caption: [I'm up here],
+/// )
+/// ```
+#[elem(scope, Locatable, Synthesize, Count, Show, ShowSet, Refable, Outlinable)]
+pub struct FigureElem {
+ /// The content of the figure. Often, an [image].
+ #[required]
+ pub body: Content,
+
+ /// The figure's placement on the page.
+ ///
+ /// - `{none}`: The figure stays in-flow exactly where it was specified
+ /// like other content.
+ /// - `{auto}`: The figure picks `{top}` or `{bottom}` depending on which
+ /// is closer.
+ /// - `{top}`: The figure floats to the top of the page.
+ /// - `{bottom}`: The figure floats to the bottom of the page.
+ ///
+ /// The gap between the main flow content and the floating figure is
+ /// controlled by the [`clearance`]($place.clearance) argument on the
+ /// `place` function.
+ ///
+ /// ```example
+ /// #set page(height: 200pt)
+ ///
+ /// = Introduction
+ /// #figure(
+ /// placement: bottom,
+ /// caption: [A glacier],
+ /// image("glacier.jpg", width: 60%),
+ /// )
+ /// #lorem(60)
+ /// ```
+ pub placement: Option<Smart<VAlignment>>,
+
+ /// Relative to which containing scope the figure is placed.
+ ///
+ /// Set this to `{"parent"}` to create a full-width figure in a two-column
+ /// document.
+ ///
+ /// Has no effect if `placement` is `{none}`.
+ ///
+ /// ```example
+ /// #set page(height: 250pt, columns: 2)
+ ///
+ /// = Introduction
+ /// #figure(
+ /// placement: bottom,
+ /// scope: "parent",
+ /// caption: [A glacier],
+ /// image("glacier.jpg", width: 60%),
+ /// )
+ /// #lorem(60)
+ /// ```
+ pub scope: PlacementScope,
+
+ /// The figure's caption.
+ pub caption: Option<Packed<FigureCaption>>,
+
+ /// The kind of figure this is.
+ ///
+ /// All figures of the same kind share a common counter.
+ ///
+ /// If set to `{auto}`, the figure will try to automatically determine its
+ /// kind based on the type of its body. Automatically detected kinds are
+ /// [tables]($table) and [code]($raw). In other cases, the inferred kind is
+ /// that of an [image].
+ ///
+ /// Setting this to something other than `{auto}` will override the
+ /// automatic detection. This can be useful if
+ /// - you wish to create a custom figure type that is not an
+ /// [image], a [table] or [code]($raw),
+ /// - you want to force the figure to use a specific counter regardless of
+ /// its content.
+ ///
+ /// You can set the kind to be an element function or a string. If you set
+ /// it to an element function other than [`{table}`]($table), [`{raw}`](raw)
+ /// or [`{image}`](image), you will need to manually specify the figure's
+ /// supplement.
+ ///
+ /// ```example
+ /// #figure(
+ /// circle(radius: 10pt),
+ /// caption: [A curious atom.],
+ /// kind: "atom",
+ /// supplement: [Atom],
+ /// )
+ /// ```
+ pub kind: Smart<FigureKind>,
+
+ /// The figure's supplement.
+ ///
+ /// If set to `{auto}`, the figure will try to automatically determine the
+ /// correct supplement based on the `kind` and the active
+ /// [text language]($text.lang). If you are using a custom figure type, you
+ /// will need to manually specify the supplement.
+ ///
+ /// If a function is specified, it is passed the first descendant of the
+ /// specified `kind` (typically, the figure's body) and should return
+ /// content.
+ ///
+ /// ```example
+ /// #figure(
+ /// [The contents of my figure!],
+ /// caption: [My custom figure],
+ /// supplement: [Bar],
+ /// kind: "foo",
+ /// )
+ /// ```
+ #[borrowed]
+ pub supplement: Smart<Option<Supplement>>,
+
+ /// How to number the figure. Accepts a
+ /// [numbering pattern or function]($numbering).
+ #[default(Some(NumberingPattern::from_str("1").unwrap().into()))]
+ #[borrowed]
+ pub numbering: Option<Numbering>,
+
+ /// The vertical gap between the body and caption.
+ #[default(Em::new(0.65).into())]
+ pub gap: Length,
+
+ /// Whether the figure should appear in an [`outline`] of figures.
+ #[default(true)]
+ pub outlined: bool,
+
+ /// Convenience field to get access to the counter for this figure.
+ ///
+ /// The counter only depends on the `kind`:
+ /// - For (tables)[@table]: `{counter(figure.where(kind: table))}`
+ /// - For (images)[@image]: `{counter(figure.where(kind: image))}`
+ /// - For a custom kind: `{counter(figure.where(kind: kind))}`
+ ///
+ /// These are the counters you'll need to modify if you want to skip a
+ /// number or reset the counter.
+ #[synthesized]
+ pub counter: Option<Counter>,
+}
+
+#[scope]
+impl FigureElem {
+ #[elem]
+ type FigureCaption;
+}
+
+impl Synthesize for Packed<FigureElem> {
+ fn synthesize(
+ &mut self,
+ engine: &mut Engine,
+ styles: StyleChain,
+ ) -> SourceResult<()> {
+ let span = self.span();
+ let location = self.location();
+ let elem = self.as_mut();
+ let numbering = elem.numbering(styles);
+
+ // Determine the figure's kind.
+ let kind = elem.kind(styles).unwrap_or_else(|| {
+ elem.body()
+ .query_first(Selector::can::<dyn Figurable>())
+ .map(|elem| FigureKind::Elem(elem.func()))
+ .unwrap_or_else(|| FigureKind::Elem(ImageElem::elem()))
+ });
+
+ // Resolve the supplement.
+ let supplement = match elem.supplement(styles).as_ref() {
+ Smart::Auto => {
+ // Default to the local name for the kind, if available.
+ let name = match &kind {
+ FigureKind::Elem(func) => func
+ .local_name(
+ TextElem::lang_in(styles),
+ TextElem::region_in(styles),
+ )
+ .map(TextElem::packed),
+ FigureKind::Name(_) => None,
+ };
+
+ if numbering.is_some() && name.is_none() {
+ bail!(span, "please specify the figure's supplement")
+ }
+
+ Some(name.unwrap_or_default())
+ }
+ Smart::Custom(None) => None,
+ Smart::Custom(Some(supplement)) => {
+ // Resolve the supplement with the first descendant of the kind or
+ // just the body, if none was found.
+ let descendant = match kind {
+ FigureKind::Elem(func) => elem
+ .body()
+ .query_first(Selector::Elem(func, None))
+ .map(Cow::Owned),
+ FigureKind::Name(_) => None,
+ };
+
+ let target = descendant.unwrap_or_else(|| Cow::Borrowed(elem.body()));
+ Some(supplement.resolve(engine, styles, [target])?)
+ }
+ };
+
+ // Construct the figure's counter.
+ let counter = Counter::new(CounterKey::Selector(
+ select_where!(FigureElem, Kind => kind.clone()),
+ ));
+
+ // Fill the figure's caption.
+ let mut caption = elem.caption(styles);
+ if let Some(caption) = &mut caption {
+ caption.push_kind(kind.clone());
+ caption.push_supplement(supplement.clone());
+ caption.push_numbering(numbering.clone());
+ caption.push_counter(Some(counter.clone()));
+ caption.push_figure_location(location);
+ }
+
+ elem.push_kind(Smart::Custom(kind));
+ elem.push_supplement(Smart::Custom(supplement.map(Supplement::Content)));
+ elem.push_counter(Some(counter));
+ elem.push_caption(caption);
+
+ Ok(())
+ }
+}
+
+impl Show for Packed<FigureElem> {
+ #[typst_macros::time(name = "figure", span = self.span())]
+ fn show(&self, _: &mut Engine, styles: StyleChain) -> SourceResult<Content> {
+ let mut realized = self.body().clone();
+
+ // Build the caption, if any.
+ if let Some(caption) = self.caption(styles) {
+ let v = VElem::new(self.gap(styles).into()).with_weak(true).pack();
+ realized = match caption.position(styles) {
+ OuterVAlignment::Top => caption.pack() + v + realized,
+ OuterVAlignment::Bottom => realized + v + caption.pack(),
+ };
+ }
+
+ // Wrap the contents in a block.
+ realized = BlockElem::new()
+ .with_body(Some(BlockBody::Content(realized)))
+ .pack()
+ .spanned(self.span());
+
+ // Wrap in a float.
+ if let Some(align) = self.placement(styles) {
+ realized = PlaceElem::new(realized)
+ .with_alignment(align.map(|align| HAlignment::Center + align))
+ .with_scope(self.scope(styles))
+ .with_float(true)
+ .pack()
+ .spanned(self.span());
+ } else if self.scope(styles) == PlacementScope::Parent {
+ bail!(
+ self.span(),
+ "parent-scoped placement is only available for floating figures";
+ hint: "you can enable floating placement with `figure(placement: auto, ..)`"
+ );
+ }
+
+ Ok(realized)
+ }
+}
+
+impl ShowSet for Packed<FigureElem> {
+ fn show_set(&self, _: StyleChain) -> Styles {
+ // Still allows breakable figures with
+ // `show figure: set block(breakable: true)`.
+ let mut map = Styles::new();
+ map.set(BlockElem::set_breakable(false));
+ map.set(AlignElem::set_alignment(Alignment::CENTER));
+ map
+ }
+}
+
+impl Count for Packed<FigureElem> {
+ fn update(&self) -> Option<CounterUpdate> {
+ // If the figure is numbered, step the counter by one.
+ // This steps the `counter(figure)` which is global to all numbered figures.
+ self.numbering()
+ .is_some()
+ .then(|| CounterUpdate::Step(NonZeroUsize::ONE))
+ }
+}
+
+impl Refable for Packed<FigureElem> {
+ fn supplement(&self) -> Content {
+ // After synthesis, this should always be custom content.
+ match (**self).supplement(StyleChain::default()).as_ref() {
+ Smart::Custom(Some(Supplement::Content(content))) => content.clone(),
+ _ => Content::empty(),
+ }
+ }
+
+ fn counter(&self) -> Counter {
+ (**self)
+ .counter()
+ .cloned()
+ .flatten()
+ .unwrap_or_else(|| Counter::of(FigureElem::elem()))
+ }
+
+ fn numbering(&self) -> Option<&Numbering> {
+ (**self).numbering(StyleChain::default()).as_ref()
+ }
+}
+
+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());
+
+ realized = supplement + numbers + separator + caption.body();
+ }
+
+ Ok(Some(realized))
+ }
+}
+
+/// The caption of a figure. This element can be used in set and show rules to
+/// customize the appearance of captions for all figures or figures of a
+/// specific kind.
+///
+/// In addition to its `pos` and `body`, the `caption` also provides the
+/// figure's `kind`, `supplement`, `counter`, and `numbering` as fields. These
+/// parts can be used in [`where`]($function.where) selectors and show rules to
+/// build a completely custom caption.
+///
+/// ```example
+/// #show figure.caption: emph
+///
+/// #figure(
+/// rect[Hello],
+/// caption: [A rectangle],
+/// )
+/// ```
+#[elem(name = "caption", Synthesize, Show)]
+pub struct FigureCaption {
+ /// The caption's position in the figure. Either `{top}` or `{bottom}`.
+ ///
+ /// ```example
+ /// #show figure.where(
+ /// kind: table
+ /// ): set figure.caption(position: top)
+ ///
+ /// #figure(
+ /// table(columns: 2)[A][B],
+ /// caption: [I'm up here],
+ /// )
+ ///
+ /// #figure(
+ /// rect[Hi],
+ /// caption: [I'm down here],
+ /// )
+ ///
+ /// #figure(
+ /// table(columns: 2)[A][B],
+ /// caption: figure.caption(
+ /// position: bottom,
+ /// [I'm down here too!]
+ /// )
+ /// )
+ /// ```
+ #[default(OuterVAlignment::Bottom)]
+ pub position: OuterVAlignment,
+
+ /// The separator which will appear between the number and body.
+ ///
+ /// If set to `{auto}`, the separator will be adapted to the current
+ /// [language]($text.lang) and [region]($text.region).
+ ///
+ /// ```example
+ /// #set figure.caption(separator: [ --- ])
+ ///
+ /// #figure(
+ /// rect[Hello],
+ /// caption: [A rectangle],
+ /// )
+ /// ```
+ pub separator: Smart<Content>,
+
+ /// The caption's body.
+ ///
+ /// Can be used alongside `kind`, `supplement`, `counter`, `numbering`, and
+ /// `location` to completely customize the caption.
+ ///
+ /// ```example
+ /// #show figure.caption: it => [
+ /// #underline(it.body) |
+ /// #it.supplement
+ /// #context it.counter.display(it.numbering)
+ /// ]
+ ///
+ /// #figure(
+ /// rect[Hello],
+ /// caption: [A rectangle],
+ /// )
+ /// ```
+ #[required]
+ pub body: Content,
+
+ /// The figure's supplement.
+ #[synthesized]
+ pub kind: FigureKind,
+
+ /// The figure's supplement.
+ #[synthesized]
+ pub supplement: Option<Content>,
+
+ /// How to number the figure.
+ #[synthesized]
+ pub numbering: Option<Numbering>,
+
+ /// The counter for the figure.
+ #[synthesized]
+ pub counter: Option<Counter>,
+
+ /// The figure's location.
+ #[internal]
+ #[synthesized]
+ pub figure_location: Option<Location>,
+}
+
+impl FigureCaption {
+ /// Gets the default separator in the given language and (optionally)
+ /// region.
+ fn local_separator(lang: Lang, _: Option<Region>) -> &'static str {
+ match lang {
+ Lang::CHINESE => "\u{2003}",
+ Lang::FRENCH => ".\u{a0}– ",
+ Lang::RUSSIAN => ". ",
+ Lang::ENGLISH | _ => ": ",
+ }
+ }
+
+ fn get_separator(&self, styles: StyleChain) -> Content {
+ self.separator(styles).unwrap_or_else(|| {
+ TextElem::packed(Self::local_separator(
+ TextElem::lang_in(styles),
+ TextElem::region_in(styles),
+ ))
+ })
+ }
+}
+
+impl Synthesize for Packed<FigureCaption> {
+ fn synthesize(&mut self, _: &mut Engine, styles: StyleChain) -> SourceResult<()> {
+ let elem = self.as_mut();
+ elem.push_separator(Smart::Custom(elem.get_separator(styles)));
+ Ok(())
+ }
+}
+
+impl Show for Packed<FigureCaption> {
+ #[typst_macros::time(name = "figure.caption", span = self.span())]
+ fn show(&self, engine: &mut Engine, styles: StyleChain) -> SourceResult<Content> {
+ let mut realized = self.body().clone();
+
+ if let (
+ Some(Some(mut supplement)),
+ Some(Some(numbering)),
+ Some(Some(counter)),
+ Some(Some(location)),
+ ) = (
+ self.supplement().cloned(),
+ self.numbering(),
+ self.counter(),
+ self.figure_location(),
+ ) {
+ let numbers = counter.display_at_loc(engine, *location, styles, numbering)?;
+ if !supplement.is_empty() {
+ supplement += TextElem::packed('\u{a0}');
+ }
+ realized = supplement + numbers + self.get_separator(styles) + realized;
+ }
+
+ Ok(realized)
+ }
+}
+
+cast! {
+ FigureCaption,
+ v: Content => v.unpack::<Self>().unwrap_or_else(Self::new),
+}
+
+/// The `kind` parameter of a [`FigureElem`].
+#[derive(Debug, Clone, PartialEq, Hash)]
+pub enum FigureKind {
+ /// The kind is an element function.
+ Elem(Element),
+ /// The kind is a name.
+ Name(EcoString),
+}
+
+cast! {
+ FigureKind,
+ self => match self {
+ Self::Elem(v) => v.into_value(),
+ Self::Name(v) => v.into_value(),
+ },
+ v: Element => Self::Elem(v),
+ v: EcoString => Self::Name(v),
+}
+
+/// An element that can be auto-detected in a figure.
+///
+/// This trait is used to determine the type of a figure.
+pub trait Figurable {}