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