typst_library/model/
figure.rs

1use std::borrow::Cow;
2use std::num::NonZeroUsize;
3use std::str::FromStr;
4
5use ecow::EcoString;
6use typst_utils::NonZeroExt;
7
8use crate::diag::{bail, SourceResult};
9use crate::engine::Engine;
10use crate::foundations::{
11    cast, elem, scope, select_where, Content, Element, NativeElement, Packed, Selector,
12    Show, ShowSet, Smart, StyleChain, Styles, Synthesize, TargetElem,
13};
14use crate::html::{tag, HtmlElem};
15use crate::introspection::{
16    Count, Counter, CounterKey, CounterUpdate, Locatable, Location,
17};
18use crate::layout::{
19    AlignElem, Alignment, BlockBody, BlockElem, Em, HAlignment, Length, OuterVAlignment,
20    PlaceElem, PlacementScope, VAlignment, VElem,
21};
22use crate::model::{
23    Numbering, NumberingPattern, Outlinable, ParbreakElem, Refable, Supplement,
24};
25use crate::text::{Lang, Region, TextElem};
26use crate::visualize::ImageElem;
27
28/// A figure with an optional caption.
29///
30/// Automatically detects its kind to select the correct counting track. For
31/// example, figures containing images will be numbered separately from figures
32/// containing tables.
33///
34/// # Examples
35/// The example below shows a basic figure with an image:
36/// ```example
37/// @glacier shows a glacier. Glaciers
38/// are complex systems.
39///
40/// #figure(
41///   image("glacier.jpg", width: 80%),
42///   caption: [A curious figure.],
43/// ) <glacier>
44/// ```
45///
46/// You can also insert [tables]($table) into figures to give them a caption.
47/// The figure will detect this and automatically use a separate counter.
48///
49/// ```example
50/// #figure(
51///   table(
52///     columns: 4,
53///     [t], [1], [2], [3],
54///     [y], [0.3s], [0.4s], [0.8s],
55///   ),
56///   caption: [Timing results],
57/// )
58/// ```
59///
60/// This behaviour can be overridden by explicitly specifying the figure's
61/// `kind`. All figures of the same kind share a common counter.
62///
63/// # Figure behaviour
64/// By default, figures are placed within the flow of content. To make them
65/// float to the top or bottom of the page, you can use the
66/// [`placement`]($figure.placement) argument.
67///
68/// If your figure is too large and its contents are breakable across pages
69/// (e.g. if it contains a large table), then you can make the figure itself
70/// breakable across pages as well with this show rule:
71/// ```typ
72/// #show figure: set block(breakable: true)
73/// ```
74///
75/// See the [block]($block.breakable) documentation for more information about
76/// breakable and non-breakable blocks.
77///
78/// # Caption customization
79/// You can modify the appearance of the figure's caption with its associated
80/// [`caption`]($figure.caption) function. In the example below, we emphasize
81/// all captions:
82///
83/// ```example
84/// #show figure.caption: emph
85///
86/// #figure(
87///   rect[Hello],
88///   caption: [I am emphasized!],
89/// )
90/// ```
91///
92/// By using a [`where`]($function.where) selector, we can scope such rules to
93/// specific kinds of figures. For example, to position the caption above
94/// tables, but keep it below for all other kinds of figures, we could write the
95/// following show-set rule:
96///
97/// ```example
98/// #show figure.where(
99///   kind: table
100/// ): set figure.caption(position: top)
101///
102/// #figure(
103///   table(columns: 2)[A][B][C][D],
104///   caption: [I'm up here],
105/// )
106/// ```
107#[elem(scope, Locatable, Synthesize, Count, Show, ShowSet, Refable, Outlinable)]
108pub struct FigureElem {
109    /// The content of the figure. Often, an [image].
110    #[required]
111    pub body: Content,
112
113    /// The figure's placement on the page.
114    ///
115    /// - `{none}`: The figure stays in-flow exactly where it was specified
116    ///   like other content.
117    /// - `{auto}`: The figure picks `{top}` or `{bottom}` depending on which
118    ///   is closer.
119    /// - `{top}`: The figure floats to the top of the page.
120    /// - `{bottom}`: The figure floats to the bottom of the page.
121    ///
122    /// The gap between the main flow content and the floating figure is
123    /// controlled by the [`clearance`]($place.clearance) argument on the
124    /// `place` function.
125    ///
126    /// ```example
127    /// #set page(height: 200pt)
128    ///
129    /// = Introduction
130    /// #figure(
131    ///   placement: bottom,
132    ///   caption: [A glacier],
133    ///   image("glacier.jpg", width: 60%),
134    /// )
135    /// #lorem(60)
136    /// ```
137    pub placement: Option<Smart<VAlignment>>,
138
139    /// Relative to which containing scope the figure is placed.
140    ///
141    /// Set this to `{"parent"}` to create a full-width figure in a two-column
142    /// document.
143    ///
144    /// Has no effect if `placement` is `{none}`.
145    ///
146    /// ```example
147    /// #set page(height: 250pt, columns: 2)
148    ///
149    /// = Introduction
150    /// #figure(
151    ///   placement: bottom,
152    ///   scope: "parent",
153    ///   caption: [A glacier],
154    ///   image("glacier.jpg", width: 60%),
155    /// )
156    /// #lorem(60)
157    /// ```
158    pub scope: PlacementScope,
159
160    /// The figure's caption.
161    #[borrowed]
162    pub caption: Option<Packed<FigureCaption>>,
163
164    /// The kind of figure this is.
165    ///
166    /// All figures of the same kind share a common counter.
167    ///
168    /// If set to `{auto}`, the figure will try to automatically determine its
169    /// kind based on the type of its body. Automatically detected kinds are
170    /// [tables]($table) and [code]($raw). In other cases, the inferred kind is
171    /// that of an [image].
172    ///
173    /// Setting this to something other than `{auto}` will override the
174    /// automatic detection. This can be useful if
175    /// - you wish to create a custom figure type that is not an
176    ///   [image], a [table] or [code]($raw),
177    /// - you want to force the figure to use a specific counter regardless of
178    ///   its content.
179    ///
180    /// You can set the kind to be an element function or a string. If you set
181    /// it to an element function other than [`{table}`]($table), [`{raw}`](raw)
182    /// or [`{image}`](image), you will need to manually specify the figure's
183    /// supplement.
184    ///
185    /// ```example
186    /// #figure(
187    ///   circle(radius: 10pt),
188    ///   caption: [A curious atom.],
189    ///   kind: "atom",
190    ///   supplement: [Atom],
191    /// )
192    /// ```
193    pub kind: Smart<FigureKind>,
194
195    /// The figure's supplement.
196    ///
197    /// If set to `{auto}`, the figure will try to automatically determine the
198    /// correct supplement based on the `kind` and the active
199    /// [text language]($text.lang). If you are using a custom figure type, you
200    /// will need to manually specify the supplement.
201    ///
202    /// If a function is specified, it is passed the first descendant of the
203    /// specified `kind` (typically, the figure's body) and should return
204    /// content.
205    ///
206    /// ```example
207    /// #figure(
208    ///   [The contents of my figure!],
209    ///   caption: [My custom figure],
210    ///   supplement: [Bar],
211    ///   kind: "foo",
212    /// )
213    /// ```
214    #[borrowed]
215    pub supplement: Smart<Option<Supplement>>,
216
217    /// How to number the figure. Accepts a
218    /// [numbering pattern or function]($numbering).
219    #[default(Some(NumberingPattern::from_str("1").unwrap().into()))]
220    #[borrowed]
221    pub numbering: Option<Numbering>,
222
223    /// The vertical gap between the body and caption.
224    #[default(Em::new(0.65).into())]
225    pub gap: Length,
226
227    /// Whether the figure should appear in an [`outline`] of figures.
228    #[default(true)]
229    pub outlined: bool,
230
231    /// Convenience field to get access to the counter for this figure.
232    ///
233    /// The counter only depends on the `kind`:
234    /// - For (tables)[@table]: `{counter(figure.where(kind: table))}`
235    /// - For (images)[@image]: `{counter(figure.where(kind: image))}`
236    /// - For a custom kind: `{counter(figure.where(kind: kind))}`
237    ///
238    /// These are the counters you'll need to modify if you want to skip a
239    /// number or reset the counter.
240    #[synthesized]
241    pub counter: Option<Counter>,
242}
243
244#[scope]
245impl FigureElem {
246    #[elem]
247    type FigureCaption;
248}
249
250impl Synthesize for Packed<FigureElem> {
251    fn synthesize(
252        &mut self,
253        engine: &mut Engine,
254        styles: StyleChain,
255    ) -> SourceResult<()> {
256        let span = self.span();
257        let location = self.location();
258        let elem = self.as_mut();
259        let numbering = elem.numbering(styles);
260
261        // Determine the figure's kind.
262        let kind = elem.kind(styles).unwrap_or_else(|| {
263            elem.body
264                .query_first(&Selector::can::<dyn Figurable>())
265                .map(|elem| FigureKind::Elem(elem.func()))
266                .unwrap_or_else(|| FigureKind::Elem(ImageElem::elem()))
267        });
268
269        // Resolve the supplement.
270        let supplement = match elem.supplement(styles).as_ref() {
271            Smart::Auto => {
272                // Default to the local name for the kind, if available.
273                let name = match &kind {
274                    FigureKind::Elem(func) => func
275                        .local_name(
276                            TextElem::lang_in(styles),
277                            TextElem::region_in(styles),
278                        )
279                        .map(TextElem::packed),
280                    FigureKind::Name(_) => None,
281                };
282
283                if numbering.is_some() && name.is_none() {
284                    bail!(span, "please specify the figure's supplement")
285                }
286
287                Some(name.unwrap_or_default())
288            }
289            Smart::Custom(None) => None,
290            Smart::Custom(Some(supplement)) => {
291                // Resolve the supplement with the first descendant of the kind or
292                // just the body, if none was found.
293                let descendant = match kind {
294                    FigureKind::Elem(func) => {
295                        elem.body.query_first(&Selector::Elem(func, None)).map(Cow::Owned)
296                    }
297                    FigureKind::Name(_) => None,
298                };
299
300                let target = descendant.unwrap_or_else(|| Cow::Borrowed(&elem.body));
301                Some(supplement.resolve(engine, styles, [target])?)
302            }
303        };
304
305        // Construct the figure's counter.
306        let counter = Counter::new(CounterKey::Selector(
307            select_where!(FigureElem, Kind => kind.clone()),
308        ));
309
310        // Fill the figure's caption.
311        let mut caption = elem.caption(styles).clone();
312        if let Some(caption) = &mut caption {
313            caption.synthesize(engine, styles)?;
314            caption.push_kind(kind.clone());
315            caption.push_supplement(supplement.clone());
316            caption.push_numbering(numbering.clone());
317            caption.push_counter(Some(counter.clone()));
318            caption.push_figure_location(location);
319        }
320
321        elem.push_kind(Smart::Custom(kind));
322        elem.push_supplement(Smart::Custom(supplement.map(Supplement::Content)));
323        elem.push_counter(Some(counter));
324        elem.push_caption(caption);
325
326        Ok(())
327    }
328}
329
330impl Show for Packed<FigureElem> {
331    #[typst_macros::time(name = "figure", span = self.span())]
332    fn show(&self, _: &mut Engine, styles: StyleChain) -> SourceResult<Content> {
333        let span = self.span();
334        let target = TargetElem::target_in(styles);
335        let mut realized = self.body.clone();
336
337        // Build the caption, if any.
338        if let Some(caption) = self.caption(styles).clone() {
339            let (first, second) = match caption.position(styles) {
340                OuterVAlignment::Top => (caption.pack(), realized),
341                OuterVAlignment::Bottom => (realized, caption.pack()),
342            };
343            let mut seq = Vec::with_capacity(3);
344            seq.push(first);
345            if !target.is_html() {
346                let v = VElem::new(self.gap(styles).into()).with_weak(true);
347                seq.push(v.pack().spanned(span))
348            }
349            seq.push(second);
350            realized = Content::sequence(seq)
351        }
352
353        // Ensure that the body is considered a paragraph.
354        realized += ParbreakElem::shared().clone().spanned(span);
355
356        if target.is_html() {
357            return Ok(HtmlElem::new(tag::figure)
358                .with_body(Some(realized))
359                .pack()
360                .spanned(span));
361        }
362
363        // Wrap the contents in a block.
364        realized = BlockElem::new()
365            .with_body(Some(BlockBody::Content(realized)))
366            .pack()
367            .spanned(span);
368
369        // Wrap in a float.
370        if let Some(align) = self.placement(styles) {
371            realized = PlaceElem::new(realized)
372                .with_alignment(align.map(|align| HAlignment::Center + align))
373                .with_scope(self.scope(styles))
374                .with_float(true)
375                .pack()
376                .spanned(span);
377        } else if self.scope(styles) == PlacementScope::Parent {
378            bail!(
379                span,
380                "parent-scoped placement is only available for floating figures";
381                hint: "you can enable floating placement with `figure(placement: auto, ..)`"
382            );
383        }
384
385        Ok(realized)
386    }
387}
388
389impl ShowSet for Packed<FigureElem> {
390    fn show_set(&self, _: StyleChain) -> Styles {
391        // Still allows breakable figures with
392        // `show figure: set block(breakable: true)`.
393        let mut map = Styles::new();
394        map.set(BlockElem::set_breakable(false));
395        map.set(AlignElem::set_alignment(Alignment::CENTER));
396        map
397    }
398}
399
400impl Count for Packed<FigureElem> {
401    fn update(&self) -> Option<CounterUpdate> {
402        // If the figure is numbered, step the counter by one.
403        // This steps the `counter(figure)` which is global to all numbered figures.
404        self.numbering()
405            .is_some()
406            .then(|| CounterUpdate::Step(NonZeroUsize::ONE))
407    }
408}
409
410impl Refable for Packed<FigureElem> {
411    fn supplement(&self) -> Content {
412        // After synthesis, this should always be custom content.
413        match (**self).supplement(StyleChain::default()).as_ref() {
414            Smart::Custom(Some(Supplement::Content(content))) => content.clone(),
415            _ => Content::empty(),
416        }
417    }
418
419    fn counter(&self) -> Counter {
420        (**self)
421            .counter()
422            .cloned()
423            .flatten()
424            .unwrap_or_else(|| Counter::of(FigureElem::elem()))
425    }
426
427    fn numbering(&self) -> Option<&Numbering> {
428        (**self).numbering(StyleChain::default()).as_ref()
429    }
430}
431
432impl Outlinable for Packed<FigureElem> {
433    fn outlined(&self) -> bool {
434        (**self).outlined(StyleChain::default())
435            && (self.caption(StyleChain::default()).is_some()
436                || self.numbering().is_some())
437    }
438
439    fn prefix(&self, numbers: Content) -> Content {
440        let supplement = self.supplement();
441        if !supplement.is_empty() {
442            supplement + TextElem::packed('\u{a0}') + numbers
443        } else {
444            numbers
445        }
446    }
447
448    fn body(&self) -> Content {
449        self.caption(StyleChain::default())
450            .as_ref()
451            .map(|caption| caption.body.clone())
452            .unwrap_or_default()
453    }
454}
455
456/// The caption of a figure. This element can be used in set and show rules to
457/// customize the appearance of captions for all figures or figures of a
458/// specific kind.
459///
460/// In addition to its `pos` and `body`, the `caption` also provides the
461/// figure's `kind`, `supplement`, `counter`, and `numbering` as fields. These
462/// parts can be used in [`where`]($function.where) selectors and show rules to
463/// build a completely custom caption.
464///
465/// ```example
466/// #show figure.caption: emph
467///
468/// #figure(
469///   rect[Hello],
470///   caption: [A rectangle],
471/// )
472/// ```
473#[elem(name = "caption", Synthesize, Show)]
474pub struct FigureCaption {
475    /// The caption's position in the figure. Either `{top}` or `{bottom}`.
476    ///
477    /// ```example
478    /// #show figure.where(
479    ///   kind: table
480    /// ): set figure.caption(position: top)
481    ///
482    /// #figure(
483    ///   table(columns: 2)[A][B],
484    ///   caption: [I'm up here],
485    /// )
486    ///
487    /// #figure(
488    ///   rect[Hi],
489    ///   caption: [I'm down here],
490    /// )
491    ///
492    /// #figure(
493    ///   table(columns: 2)[A][B],
494    ///   caption: figure.caption(
495    ///     position: bottom,
496    ///     [I'm down here too!]
497    ///   )
498    /// )
499    /// ```
500    #[default(OuterVAlignment::Bottom)]
501    pub position: OuterVAlignment,
502
503    /// The separator which will appear between the number and body.
504    ///
505    /// If set to `{auto}`, the separator will be adapted to the current
506    /// [language]($text.lang) and [region]($text.region).
507    ///
508    /// ```example
509    /// #set figure.caption(separator: [ --- ])
510    ///
511    /// #figure(
512    ///   rect[Hello],
513    ///   caption: [A rectangle],
514    /// )
515    /// ```
516    pub separator: Smart<Content>,
517
518    /// The caption's body.
519    ///
520    /// Can be used alongside `kind`, `supplement`, `counter`, `numbering`, and
521    /// `location` to completely customize the caption.
522    ///
523    /// ```example
524    /// #show figure.caption: it => [
525    ///   #underline(it.body) |
526    ///   #it.supplement
527    ///   #context it.counter.display(it.numbering)
528    /// ]
529    ///
530    /// #figure(
531    ///   rect[Hello],
532    ///   caption: [A rectangle],
533    /// )
534    /// ```
535    #[required]
536    pub body: Content,
537
538    /// The figure's supplement.
539    #[synthesized]
540    pub kind: FigureKind,
541
542    /// The figure's supplement.
543    #[synthesized]
544    pub supplement: Option<Content>,
545
546    /// How to number the figure.
547    #[synthesized]
548    pub numbering: Option<Numbering>,
549
550    /// The counter for the figure.
551    #[synthesized]
552    pub counter: Option<Counter>,
553
554    /// The figure's location.
555    #[internal]
556    #[synthesized]
557    pub figure_location: Option<Location>,
558}
559
560impl FigureCaption {
561    /// Gets the default separator in the given language and (optionally)
562    /// region.
563    fn local_separator(lang: Lang, _: Option<Region>) -> &'static str {
564        match lang {
565            Lang::CHINESE => "\u{2003}",
566            Lang::FRENCH => ".\u{a0}– ",
567            Lang::RUSSIAN => ". ",
568            Lang::ENGLISH | _ => ": ",
569        }
570    }
571
572    fn get_separator(&self, styles: StyleChain) -> Content {
573        self.separator(styles).unwrap_or_else(|| {
574            TextElem::packed(Self::local_separator(
575                TextElem::lang_in(styles),
576                TextElem::region_in(styles),
577            ))
578        })
579    }
580}
581
582impl Synthesize for Packed<FigureCaption> {
583    fn synthesize(&mut self, _: &mut Engine, styles: StyleChain) -> SourceResult<()> {
584        let elem = self.as_mut();
585        elem.push_separator(Smart::Custom(elem.get_separator(styles)));
586        Ok(())
587    }
588}
589
590impl Show for Packed<FigureCaption> {
591    #[typst_macros::time(name = "figure.caption", span = self.span())]
592    fn show(&self, engine: &mut Engine, styles: StyleChain) -> SourceResult<Content> {
593        let mut realized = self.body.clone();
594
595        if let (
596            Some(Some(mut supplement)),
597            Some(Some(numbering)),
598            Some(Some(counter)),
599            Some(Some(location)),
600        ) = (
601            self.supplement().cloned(),
602            self.numbering(),
603            self.counter(),
604            self.figure_location(),
605        ) {
606            let numbers = counter.display_at_loc(engine, *location, styles, numbering)?;
607            if !supplement.is_empty() {
608                supplement += TextElem::packed('\u{a0}');
609            }
610            realized = supplement + numbers + self.get_separator(styles) + realized;
611        }
612
613        Ok(if TargetElem::target_in(styles).is_html() {
614            HtmlElem::new(tag::figcaption)
615                .with_body(Some(realized))
616                .pack()
617                .spanned(self.span())
618        } else {
619            BlockElem::new()
620                .with_body(Some(BlockBody::Content(realized)))
621                .pack()
622                .spanned(self.span())
623        })
624    }
625}
626
627cast! {
628    FigureCaption,
629    v: Content => v.unpack::<Self>().unwrap_or_else(Self::new),
630}
631
632/// The `kind` parameter of a [`FigureElem`].
633#[derive(Debug, Clone, PartialEq, Hash)]
634pub enum FigureKind {
635    /// The kind is an element function.
636    Elem(Element),
637    /// The kind is a name.
638    Name(EcoString),
639}
640
641cast! {
642    FigureKind,
643    self => match self {
644        Self::Elem(v) => v.into_value(),
645        Self::Name(v) => v.into_value(),
646    },
647    v: Element => Self::Elem(v),
648    v: EcoString => Self::Name(v),
649}
650
651/// An element that can be auto-detected in a figure.
652///
653/// This trait is used to determine the type of a figure.
654pub trait Figurable {}