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