Skip to main content

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