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