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 {}