mirror of
https://github.com/typst/typst
synced 2025-05-20 03:55:29 +08:00
Add figure.caption
element (#1704)
Co-authored-by: Laurenz <laurmaedje@gmail.com>
This commit is contained in:
parent
e39be71a54
commit
976abdfe7d
@ -11,9 +11,9 @@ use crate::visualize::ImageElem;
|
|||||||
|
|
||||||
/// A figure with an optional caption.
|
/// A figure with an optional caption.
|
||||||
///
|
///
|
||||||
/// Automatically detects its contents to select the correct counting track.
|
/// Automatically detects its contents to select the correct counting track. For
|
||||||
/// For example, figures containing images will be numbered separately from
|
/// example, figures containing images will be numbered separately from figures
|
||||||
/// figures containing tables.
|
/// containing tables.
|
||||||
///
|
///
|
||||||
/// # Examples
|
/// # Examples
|
||||||
/// The example below shows a basic figure with an image:
|
/// The example below shows a basic figure with an image:
|
||||||
@ -44,36 +44,51 @@ use crate::visualize::ImageElem;
|
|||||||
/// This behaviour can be overridden by explicitly specifying the figure's
|
/// This behaviour can be overridden by explicitly specifying the figure's
|
||||||
/// `kind`. All figures of the same kind share a common counter.
|
/// `kind`. All figures of the same kind share a common counter.
|
||||||
///
|
///
|
||||||
/// # Modifying the appearance { #modifying-appearance }
|
/// # Figure behaviour
|
||||||
/// You can completely customize the look of your figures with a [show
|
/// By default, figures are placed within the flow of content. To make them
|
||||||
/// rule]($styling/#show-rules). In the example below, we show the figure's
|
/// float to the top or bottom of the page, you can use the
|
||||||
/// caption above its body and display its supplement and counter after the
|
/// [`placement`]($figure.placement) argument.
|
||||||
/// caption.
|
///
|
||||||
|
/// If your figure is too large and its contents are breakable across pages
|
||||||
|
/// (e.g. if it contains a large table), then you can make the figure itself
|
||||||
|
/// breakable across pages as well with this show rule:
|
||||||
|
/// ```typ
|
||||||
|
/// #show figure: set block(breakable: true)
|
||||||
|
/// ```
|
||||||
|
///
|
||||||
|
/// See the [block]($block.breakable) documentation for more information about
|
||||||
|
/// breakable and non-breakable blocks.
|
||||||
|
///
|
||||||
|
/// # Caption customization
|
||||||
|
/// You can modify the apperance of the figure's caption with its associated
|
||||||
|
/// [`caption`]($figure.caption) function. In the example below, we emphasize
|
||||||
|
/// all captions:
|
||||||
///
|
///
|
||||||
/// ```example
|
/// ```example
|
||||||
/// #show figure: it => align(center)[
|
/// #show figure.caption: emph
|
||||||
/// #it.caption |
|
|
||||||
/// #emph[
|
|
||||||
/// #it.supplement
|
|
||||||
/// #it.counter.display(it.numbering)
|
|
||||||
/// ]
|
|
||||||
/// #v(10pt, weak: true)
|
|
||||||
/// #it.body
|
|
||||||
/// ]
|
|
||||||
///
|
///
|
||||||
/// #figure(
|
/// #figure(
|
||||||
/// image("molecular.jpg", width: 80%),
|
/// rect[Hello],
|
||||||
/// caption: [
|
/// caption: [I am emphasized!],
|
||||||
/// The molecular testing pipeline.
|
|
||||||
/// ],
|
|
||||||
/// )
|
/// )
|
||||||
/// ```
|
/// ```
|
||||||
///
|
///
|
||||||
/// If your figure is too large and its contents are breakable across pages
|
/// By using a [`where`]($function.where) selector, we can scope such rules to
|
||||||
/// (e.g. if it contains a large table), then you can make the figure breakable
|
/// specific kinds of figures. For example, to position the caption above
|
||||||
/// across pages as well by using `[#show figure: set block(breakable: true)]`
|
/// tables, but keep it below for all other kinds of figures, we could write the
|
||||||
/// (see the [block]($block) documentation for more information).
|
/// following show-set rule:
|
||||||
#[elem(Locatable, Synthesize, Count, Show, Finalize, Refable, Outlinable)]
|
///
|
||||||
|
/// ```example
|
||||||
|
/// #show figure.where(
|
||||||
|
/// kind: table
|
||||||
|
/// ): set figure.caption(position: top)
|
||||||
|
///
|
||||||
|
/// #figure(
|
||||||
|
/// table(columns: 2)[A][B][C][D],
|
||||||
|
/// caption: [I'm up here],
|
||||||
|
/// )
|
||||||
|
/// ```
|
||||||
|
#[elem(scope, Locatable, Synthesize, Count, Show, Finalize, Refable, Outlinable)]
|
||||||
pub struct FigureElem {
|
pub struct FigureElem {
|
||||||
/// The content of the figure. Often, an [image]($image).
|
/// The content of the figure. Often, an [image]($image).
|
||||||
#[required]
|
#[required]
|
||||||
@ -88,6 +103,10 @@ pub struct FigureElem {
|
|||||||
/// - `{top}`: The figure floats to the top of the page.
|
/// - `{top}`: The figure floats to the top of the page.
|
||||||
/// - `{bottom}`: The figure floats to the bottom of the page.
|
/// - `{bottom}`: The figure floats to the bottom of the page.
|
||||||
///
|
///
|
||||||
|
/// The gap between the main flow content and the floating figure is
|
||||||
|
/// controlled by the [`clearance`]($place.clearance) argument on the
|
||||||
|
/// `place` function.
|
||||||
|
///
|
||||||
/// ```example
|
/// ```example
|
||||||
/// #set page(height: 200pt)
|
/// #set page(height: 200pt)
|
||||||
///
|
///
|
||||||
@ -102,33 +121,7 @@ pub struct FigureElem {
|
|||||||
pub placement: Option<Smart<VAlign>>,
|
pub placement: Option<Smart<VAlign>>,
|
||||||
|
|
||||||
/// The figure's caption.
|
/// The figure's caption.
|
||||||
pub caption: Option<Content>,
|
pub caption: Option<FigureCaption>,
|
||||||
|
|
||||||
/// The caption's position. Either `{top}` or `{bottom}`.
|
|
||||||
///
|
|
||||||
/// ```example
|
|
||||||
/// #figure(
|
|
||||||
/// table(columns: 2)[A][B],
|
|
||||||
/// caption: [I'm up here],
|
|
||||||
/// caption-pos: top,
|
|
||||||
/// )
|
|
||||||
///
|
|
||||||
/// #figure(
|
|
||||||
/// table(columns: 2)[A][B],
|
|
||||||
/// caption: [I'm down here],
|
|
||||||
/// )
|
|
||||||
/// ```
|
|
||||||
#[default(VAlign::Bottom)]
|
|
||||||
#[parse({
|
|
||||||
let option: Option<Spanned<VAlign>> = args.named("caption-pos")?;
|
|
||||||
if let Some(Spanned { v: align, span }) = option {
|
|
||||||
if align == VAlign::Horizon {
|
|
||||||
bail!(span, "expected `top` or `bottom`");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
option.map(|spanned| spanned.v)
|
|
||||||
})]
|
|
||||||
pub caption_pos: VAlign,
|
|
||||||
|
|
||||||
/// The kind of figure this is.
|
/// The kind of figure this is.
|
||||||
///
|
///
|
||||||
@ -204,6 +197,12 @@ pub struct FigureElem {
|
|||||||
pub counter: Option<Counter>,
|
pub counter: Option<Counter>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[scope]
|
||||||
|
impl FigureElem {
|
||||||
|
#[elem]
|
||||||
|
type FigureCaption;
|
||||||
|
}
|
||||||
|
|
||||||
impl Synthesize for FigureElem {
|
impl Synthesize for FigureElem {
|
||||||
fn synthesize(&mut self, vt: &mut Vt, styles: StyleChain) -> SourceResult<()> {
|
fn synthesize(&mut self, vt: &mut Vt, styles: StyleChain) -> SourceResult<()> {
|
||||||
let numbering = self.numbering(styles);
|
let numbering = self.numbering(styles);
|
||||||
@ -238,9 +237,9 @@ impl Synthesize for FigureElem {
|
|||||||
bail!(self.span(), "please specify the figure's supplement")
|
bail!(self.span(), "please specify the figure's supplement")
|
||||||
}
|
}
|
||||||
|
|
||||||
name.unwrap_or_default()
|
Some(name.unwrap_or_default())
|
||||||
}
|
}
|
||||||
Smart::Custom(None) => Content::empty(),
|
Smart::Custom(None) => None,
|
||||||
Smart::Custom(Some(supplement)) => {
|
Smart::Custom(Some(supplement)) => {
|
||||||
// Resolve the supplement with the first descendant of the kind or
|
// Resolve the supplement with the first descendant of the kind or
|
||||||
// just the body, if none was found.
|
// just the body, if none was found.
|
||||||
@ -252,7 +251,7 @@ impl Synthesize for FigureElem {
|
|||||||
};
|
};
|
||||||
|
|
||||||
let target = descendant.unwrap_or_else(|| self.body());
|
let target = descendant.unwrap_or_else(|| self.body());
|
||||||
supplement.resolve(vt, [target])?
|
Some(supplement.resolve(vt, [target])?)
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -264,11 +263,20 @@ impl Synthesize for FigureElem {
|
|||||||
}),
|
}),
|
||||||
)));
|
)));
|
||||||
|
|
||||||
|
// Fill the figure's caption.
|
||||||
|
let mut caption = self.caption(styles);
|
||||||
|
if let Some(caption) = &mut caption {
|
||||||
|
caption.push_kind(kind.clone());
|
||||||
|
caption.push_supplement(supplement.clone());
|
||||||
|
caption.push_numbering(numbering.clone());
|
||||||
|
caption.push_counter(Some(counter.clone()));
|
||||||
|
caption.push_location(self.0.location());
|
||||||
|
}
|
||||||
|
|
||||||
self.push_placement(self.placement(styles));
|
self.push_placement(self.placement(styles));
|
||||||
self.push_caption_pos(self.caption_pos(styles));
|
self.push_caption(caption);
|
||||||
self.push_caption(self.caption(styles));
|
|
||||||
self.push_kind(Smart::Custom(kind));
|
self.push_kind(Smart::Custom(kind));
|
||||||
self.push_supplement(Smart::Custom(Some(Supplement::Content(supplement))));
|
self.push_supplement(Smart::Custom(supplement.map(Supplement::Content)));
|
||||||
self.push_numbering(numbering);
|
self.push_numbering(numbering);
|
||||||
self.push_outlined(self.outlined(styles));
|
self.push_outlined(self.outlined(styles));
|
||||||
self.push_counter(Some(counter));
|
self.push_counter(Some(counter));
|
||||||
@ -279,18 +287,18 @@ impl Synthesize for FigureElem {
|
|||||||
|
|
||||||
impl Show for FigureElem {
|
impl Show for FigureElem {
|
||||||
#[tracing::instrument(name = "FigureElem::show", skip_all)]
|
#[tracing::instrument(name = "FigureElem::show", skip_all)]
|
||||||
fn show(&self, vt: &mut Vt, styles: StyleChain) -> SourceResult<Content> {
|
fn show(&self, _: &mut Vt, styles: StyleChain) -> SourceResult<Content> {
|
||||||
let mut realized = self.body();
|
let mut realized = self.body();
|
||||||
|
|
||||||
// Build the caption, if any.
|
// Build the caption, if any.
|
||||||
if let Some(caption) = self.full_caption(vt)? {
|
if let Some(caption) = self.caption(styles) {
|
||||||
let v = VElem::weak(self.gap(styles).into()).pack();
|
let v = VElem::weak(self.gap(styles).into()).pack();
|
||||||
realized = if self.caption_pos(styles) == VAlign::Bottom {
|
realized = if caption.position(styles) == VAlign::Bottom {
|
||||||
realized + v + caption
|
realized + v + caption.pack()
|
||||||
} else {
|
} else {
|
||||||
caption + v + realized
|
caption.pack() + v + realized
|
||||||
}
|
};
|
||||||
};
|
}
|
||||||
|
|
||||||
// Wrap the contents in a block.
|
// Wrap the contents in a block.
|
||||||
realized = BlockElem::new()
|
realized = BlockElem::new()
|
||||||
@ -351,14 +359,9 @@ impl Outlinable for FigureElem {
|
|||||||
return Ok(None);
|
return Ok(None);
|
||||||
}
|
}
|
||||||
|
|
||||||
self.full_caption(vt)
|
let Some(mut caption) =
|
||||||
}
|
self.caption(StyleChain::default()).map(|caption| caption.body())
|
||||||
}
|
else {
|
||||||
|
|
||||||
impl FigureElem {
|
|
||||||
/// Builds the full caption for the figure (with supplement and numbering).
|
|
||||||
pub fn full_caption(&self, vt: &mut Vt) -> SourceResult<Option<Content>> {
|
|
||||||
let Some(mut caption) = self.caption(StyleChain::default()) else {
|
|
||||||
return Ok(None);
|
return Ok(None);
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -375,7 +378,7 @@ impl FigureElem {
|
|||||||
let numbers = counter.at(vt, location)?.display(vt, &numbering)?;
|
let numbers = counter.at(vt, location)?.display(vt, &numbering)?;
|
||||||
|
|
||||||
if !supplement.is_empty() {
|
if !supplement.is_empty() {
|
||||||
supplement += TextElem::packed("\u{a0}");
|
supplement += TextElem::packed('\u{a0}');
|
||||||
}
|
}
|
||||||
|
|
||||||
caption = supplement + numbers + TextElem::packed(": ") + caption;
|
caption = supplement + numbers + TextElem::packed(": ") + caption;
|
||||||
@ -385,6 +388,133 @@ impl FigureElem {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The caption of a figure. This element can be used in set and show rules to
|
||||||
|
/// customize the appearance of captions for all figures or figures of a
|
||||||
|
/// specific kind.
|
||||||
|
///
|
||||||
|
/// In addition to its `pos` and `body`, the `caption` also provides the
|
||||||
|
/// figure's `kind`, `supplement`, `counter`, `numbering`, and `location` as
|
||||||
|
/// fields. These parts can be used in [`where`]($function.where) selectors and
|
||||||
|
/// show rules to build a completely custom caption.
|
||||||
|
///
|
||||||
|
/// ```example
|
||||||
|
/// #show figure.caption: emph
|
||||||
|
///
|
||||||
|
/// #figure(
|
||||||
|
/// rect[Hello],
|
||||||
|
/// caption: [A rectangle],
|
||||||
|
/// )
|
||||||
|
/// ```
|
||||||
|
#[elem(name = "caption", Synthesize, Show)]
|
||||||
|
pub struct FigureCaption {
|
||||||
|
/// The caption's position in the figure. Either `{top}` or `{bottom}`.
|
||||||
|
///
|
||||||
|
/// ```example
|
||||||
|
/// #show figure.where(
|
||||||
|
/// kind: table
|
||||||
|
/// ): set figure.caption(position: top)
|
||||||
|
///
|
||||||
|
/// #figure(
|
||||||
|
/// table(columns: 2)[A][B],
|
||||||
|
/// caption: [I'm up here],
|
||||||
|
/// )
|
||||||
|
///
|
||||||
|
/// #figure(
|
||||||
|
/// rect[Hi],
|
||||||
|
/// caption: [I'm down here],
|
||||||
|
/// )
|
||||||
|
///
|
||||||
|
/// #figure(
|
||||||
|
/// table(columns: 2)[A][B],
|
||||||
|
/// caption: figure.caption(
|
||||||
|
/// position: bottom,
|
||||||
|
/// [I'm down here too!]
|
||||||
|
/// )
|
||||||
|
/// )
|
||||||
|
/// ```
|
||||||
|
#[default(VAlign::Bottom)]
|
||||||
|
#[parse({
|
||||||
|
let option: Option<Spanned<VAlign>> = args.named("position")?;
|
||||||
|
if let Some(Spanned { v: align, span }) = option {
|
||||||
|
if align == VAlign::Horizon {
|
||||||
|
bail!(span, "expected `top` or `bottom`");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
option.map(|spanned| spanned.v)
|
||||||
|
})]
|
||||||
|
pub position: VAlign,
|
||||||
|
|
||||||
|
/// The caption's body.
|
||||||
|
///
|
||||||
|
/// Can be used alongside `kind`, `supplement`, `counter`, `numbering`, and
|
||||||
|
/// `location` to completely customize the caption.
|
||||||
|
///
|
||||||
|
/// ```example
|
||||||
|
/// #show figure.caption: it => [
|
||||||
|
/// #underline(it.body) |
|
||||||
|
/// #it.supplement #it.counter.display(it.numbering)
|
||||||
|
/// ]
|
||||||
|
///
|
||||||
|
/// #figure(
|
||||||
|
/// rect[Hello],
|
||||||
|
/// caption: [A rectangle],
|
||||||
|
/// )
|
||||||
|
/// ```
|
||||||
|
#[required]
|
||||||
|
pub body: Content,
|
||||||
|
|
||||||
|
/// The figure's supplement.
|
||||||
|
#[synthesized]
|
||||||
|
pub kind: FigureKind,
|
||||||
|
|
||||||
|
/// The figure's supplement.
|
||||||
|
#[synthesized]
|
||||||
|
pub supplement: Option<Content>,
|
||||||
|
|
||||||
|
/// How to number the figure.
|
||||||
|
#[synthesized]
|
||||||
|
pub numbering: Option<Numbering>,
|
||||||
|
|
||||||
|
/// The counter for the figure.
|
||||||
|
#[synthesized]
|
||||||
|
pub counter: Option<Counter>,
|
||||||
|
|
||||||
|
/// The figure's location.
|
||||||
|
#[synthesized]
|
||||||
|
pub location: Option<Location>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Synthesize for FigureCaption {
|
||||||
|
fn synthesize(&mut self, _: &mut Vt, styles: StyleChain) -> SourceResult<()> {
|
||||||
|
self.push_position(self.position(styles));
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Show for FigureCaption {
|
||||||
|
#[tracing::instrument(name = "FigureCaption::show", skip_all)]
|
||||||
|
fn show(&self, vt: &mut Vt, _: StyleChain) -> SourceResult<Content> {
|
||||||
|
let mut realized = self.body();
|
||||||
|
|
||||||
|
if let (Some(mut supplement), Some(numbering), Some(counter), Some(location)) =
|
||||||
|
(self.supplement(), self.numbering(), self.counter(), self.location())
|
||||||
|
{
|
||||||
|
let numbers = counter.at(vt, location)?.display(vt, &numbering)?;
|
||||||
|
if !supplement.is_empty() {
|
||||||
|
supplement += TextElem::packed('\u{a0}');
|
||||||
|
}
|
||||||
|
realized = supplement + numbers + TextElem::packed(": ") + realized;
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(realized)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
cast! {
|
||||||
|
FigureCaption,
|
||||||
|
v: Content => v.to::<Self>().cloned().unwrap_or_else(|| Self::new(v.clone())),
|
||||||
|
}
|
||||||
|
|
||||||
/// The `kind` parameter of a [`FigureElem`].
|
/// The `kind` parameter of a [`FigureElem`].
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
pub enum FigureKind {
|
pub enum FigureKind {
|
||||||
|
@ -124,8 +124,8 @@ description: |
|
|||||||
- Miscellaneous Improvements
|
- Miscellaneous Improvements
|
||||||
- Added [`bookmarked`]($heading.bookmarked) argument to heading to control
|
- Added [`bookmarked`]($heading.bookmarked) argument to heading to control
|
||||||
whether a heading becomes part of the PDF outline
|
whether a heading becomes part of the PDF outline
|
||||||
- Added [`caption-pos`]($figure.caption-pos) argument to control the position
|
- Added [`caption-pos`]($figure.caption.position) argument to control the
|
||||||
of a figure's caption
|
position of a figure's caption
|
||||||
- Added [`metadata`]($metadata) function for exposing an arbitrary value to
|
- Added [`metadata`]($metadata) function for exposing an arbitrary value to
|
||||||
the introspection system
|
the introspection system
|
||||||
- Fixed that a [`state`]($state) was identified by the pair `(key, init)`
|
- Fixed that a [`state`]($state) was identified by the pair `(key, init)`
|
||||||
|
BIN
tests/ref/meta/figure-caption.png
Normal file
BIN
tests/ref/meta/figure-caption.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 9.0 KiB |
56
tests/typ/meta/figure-caption.typ
Normal file
56
tests/typ/meta/figure-caption.typ
Normal file
@ -0,0 +1,56 @@
|
|||||||
|
// Test figure captions.
|
||||||
|
|
||||||
|
---
|
||||||
|
// Test figure.caption element
|
||||||
|
#show figure.caption: emph
|
||||||
|
|
||||||
|
#figure(
|
||||||
|
[Not italicized],
|
||||||
|
caption: [Italicized],
|
||||||
|
)
|
||||||
|
|
||||||
|
---
|
||||||
|
// Test figure.caption element for specific figure kinds
|
||||||
|
#show figure.caption.where(kind: table): underline
|
||||||
|
|
||||||
|
#figure(
|
||||||
|
[Not a table],
|
||||||
|
caption: [Not underlined],
|
||||||
|
)
|
||||||
|
|
||||||
|
#figure(
|
||||||
|
table[A table],
|
||||||
|
caption: [Underlined],
|
||||||
|
)
|
||||||
|
|
||||||
|
---
|
||||||
|
// Test creating custom figure and custom caption
|
||||||
|
|
||||||
|
#let gap = 0.7em
|
||||||
|
#show figure.where(kind: "custom"): it => rect(inset: gap, {
|
||||||
|
align(center, it.body)
|
||||||
|
v(gap, weak: true)
|
||||||
|
line(length: 100%)
|
||||||
|
v(gap, weak: true)
|
||||||
|
align(center, it.caption)
|
||||||
|
})
|
||||||
|
|
||||||
|
#figure(
|
||||||
|
[A figure],
|
||||||
|
kind: "custom",
|
||||||
|
caption: [Hi],
|
||||||
|
supplement: [A],
|
||||||
|
)
|
||||||
|
|
||||||
|
#show figure.caption: it => emph[
|
||||||
|
#it.body
|
||||||
|
(#it.supplement
|
||||||
|
#it.counter.display(it.numbering))
|
||||||
|
]
|
||||||
|
|
||||||
|
#figure(
|
||||||
|
[Another figure],
|
||||||
|
kind: "custom",
|
||||||
|
caption: [Hi],
|
||||||
|
supplement: [B],
|
||||||
|
)
|
@ -41,7 +41,7 @@ We can clearly see that @fig-cylinder and
|
|||||||
#show figure.where(kind: "theorem"): it => {
|
#show figure.where(kind: "theorem"): it => {
|
||||||
let name = none
|
let name = none
|
||||||
if not it.caption == none {
|
if not it.caption == none {
|
||||||
name = [ #emph(it.caption)]
|
name = [ #emph(it.caption.body)]
|
||||||
} else {
|
} else {
|
||||||
name = []
|
name = []
|
||||||
}
|
}
|
||||||
|
@ -17,7 +17,7 @@
|
|||||||
Figure
|
Figure
|
||||||
#numbering(it.numbering,
|
#numbering(it.numbering,
|
||||||
..counter(figure).at(it.location())):
|
..counter(figure).at(it.location())):
|
||||||
#it.caption
|
#it.caption.body
|
||||||
#box(width: 1fr, repeat[.])
|
#box(width: 1fr, repeat[.])
|
||||||
#counter(page).at(it.location()).first() \
|
#counter(page).at(it.location()).first() \
|
||||||
]
|
]
|
||||||
|
Loading…
x
Reference in New Issue
Block a user