use crate::{
Parser, attributes::Attrlist, blocks::is_built_in_context, document::InterpretedValue,
};
pub(crate) fn caption_attribute_name(context: &str) -> Option<&'static str> {
match context {
"example" => Some("example-caption"),
"figure" => Some("figure-caption"),
"listing" => Some("listing-caption"),
"table" => Some("table-caption"),
_ => None,
}
}
#[derive(Clone, Debug, Default, Eq, PartialEq)]
pub(crate) struct Caption {
pub(crate) prefix: String,
pub(crate) number: Option<usize>,
}
pub(crate) fn assign_block_caption(
parser: &mut Parser,
raw_context: &str,
attrlist: Option<&Attrlist<'_>>,
has_title: bool,
) -> Option<Caption> {
let resolved_context = attrlist
.and_then(|attrlist| attrlist.block_style())
.filter(|style| is_built_in_context(style))
.unwrap_or(raw_context);
let explicit_caption =
if let Some(caption) = attrlist.and_then(|attrlist| attrlist.named_attribute("caption")) {
Some(caption.value().to_string())
} else if resolved_context == "example"
&& attrlist.is_some_and(|attrlist| attrlist.has_option("collapsible"))
{
Some(String::new())
} else {
None
};
assign_caption(
parser,
resolved_context,
has_title,
explicit_caption.as_deref(),
)
}
pub(crate) fn assign_caption(
parser: &mut Parser,
context: &str,
has_title: bool,
explicit_caption: Option<&str>,
) -> Option<Caption> {
if !has_title {
return None;
}
let attr_name = caption_attribute_name(context)?;
if let Some(value) = explicit_caption {
return if value.is_empty() {
None
} else {
Some(Caption {
prefix: value.to_string(),
number: None,
})
};
}
if let InterpretedValue::Value(value) = parser.attribute_value("caption")
&& !value.is_empty()
{
return Some(Caption {
prefix: value,
number: None,
});
}
match parser.attribute_value(attr_name) {
InterpretedValue::Value(label) if !label.is_empty() => {
let value = parser.counter(&format!("{context}-number"), None);
let prefix = format!("{label} {value}. ");
Some(Caption {
prefix,
number: value.parse::<usize>().ok(),
})
}
_ => None,
}
}
#[cfg(test)]
mod tests {
#![allow(clippy::expect_used)]
use crate::{blocks::IsBlock, tests::prelude::*};
fn first_block_caption(input: &str) -> (Option<String>, Option<usize>) {
let doc = Parser::default().parse(input);
let block = doc.nested_blocks().next().expect("expected a block");
(block.caption().map(str::to_string), block.number())
}
#[test]
fn caption_attribute_name_lookup() {
assert_eq!(
super::caption_attribute_name("example"),
Some("example-caption")
);
assert_eq!(
super::caption_attribute_name("figure"),
Some("figure-caption")
);
assert_eq!(
super::caption_attribute_name("listing"),
Some("listing-caption")
);
assert_eq!(
super::caption_attribute_name("table"),
Some("table-caption")
);
assert_eq!(super::caption_attribute_name("sidebar"), None);
assert_eq!(super::caption_attribute_name("paragraph"), None);
}
#[test]
fn untitled_block_is_not_captioned() {
assert_eq!(first_block_caption("====\nbody.\n===="), (None, None));
}
#[test]
fn titled_example_is_numbered_by_default() {
assert_eq!(
first_block_caption(".Title\n====\nbody.\n===="),
(Some("Example 1. ".to_string()), Some(1))
);
}
#[test]
fn counter_is_per_context_and_document_wide() {
let doc =
Parser::default().parse(".One\n====\na\n====\n\n====\nb\n====\n\n.Two\n====\nc\n====");
let numbers: Vec<_> = doc.nested_blocks().map(|b| b.number()).collect();
assert_eq!(numbers, vec![Some(1), None, Some(2)]);
}
#[test]
fn custom_label_from_caption_attribute() {
assert_eq!(
first_block_caption(":example-caption: Exhibit\n\n.Title\n====\nbody.\n===="),
(Some("Exhibit 1. ".to_string()), Some(1))
);
}
#[test]
fn unset_caption_attribute_disables_numbering() {
assert_eq!(
first_block_caption(":!example-caption:\n\n.Title\n====\nbody.\n===="),
(None, None)
);
}
#[test]
fn explicit_caption_attribute_is_verbatim_and_unnumbered() {
assert_eq!(
first_block_caption("[caption=\"Sample: \"]\n.Title\n====\nbody.\n===="),
(Some("Sample: ".to_string()), None)
);
}
#[test]
fn empty_explicit_caption_suppresses_caption() {
assert_eq!(
first_block_caption("[caption=]\n.Title\n====\nbody.\n===="),
(None, None)
);
}
#[test]
fn document_caption_attribute_overrides_label() {
assert_eq!(
first_block_caption(":caption: Sample\n\n.Title\n====\nbody.\n===="),
(Some("Sample".to_string()), None)
);
assert_eq!(
first_block_caption(":caption: Sample\n\n.Title\nplain paragraph."),
(None, None)
);
}
#[test]
fn collapsible_example_is_unnumbered() {
let doc = Parser::default()
.parse("[%collapsible]\n====\nhidden\n====\n\n.Title\n====\nbody.\n====");
let numbers: Vec<_> = doc.nested_blocks().map(|b| b.number()).collect();
assert_eq!(numbers, vec![None, Some(1)]);
}
#[test]
fn listing_caption_is_unset_by_default() {
assert_eq!(
first_block_caption(".Title\n----\ncode\n----"),
(None, None)
);
}
#[test]
fn listing_block_uses_listing_caption_when_set() {
assert_eq!(
first_block_caption(":listing-caption: Listing\n\n.Title\n----\ncode\n----"),
(Some("Listing 1. ".to_string()), Some(1))
);
}
#[test]
fn source_block_uses_listing_caption() {
assert_eq!(
first_block_caption(
":listing-caption: Listing\n\n.Title\n[source,ruby]\n----\nx\n----"
),
(Some("Listing 1. ".to_string()), Some(1))
);
}
#[test]
fn literal_block_is_never_captioned() {
assert_eq!(
first_block_caption(".Title\n....\ntext\n...."),
(None, None)
);
}
#[test]
fn image_uses_figure_caption_and_number() {
assert_eq!(
first_block_caption(".Sunset\nimage::sunset.jpg[]"),
(Some("Figure 1. ".to_string()), Some(1))
);
}
#[test]
fn image_caption_override_on_macro_wins() {
assert_eq!(
first_block_caption(
"[caption=\"Block. \"]\n.Sunset\nimage::sunset.jpg[caption=\"Photo. \"]"
),
(Some("Photo. ".to_string()), None)
);
}
#[test]
fn image_caption_override_on_block_attrlist() {
assert_eq!(
first_block_caption("[caption=\"Photo. \"]\n.Sunset\nimage::sunset.jpg[]"),
(Some("Photo. ".to_string()), None)
);
}
#[test]
fn image_caption_suppressed_when_figure_caption_unset() {
assert_eq!(
first_block_caption(":!figure-caption:\n\n.Sunset\nimage::sunset.jpg[]"),
(None, None)
);
}
#[test]
fn untitled_image_is_not_captioned() {
assert_eq!(first_block_caption("image::sunset.jpg[]"), (None, None));
}
#[test]
fn video_and_audio_are_not_captioned() {
assert_eq!(
first_block_caption(".Clip\nvideo::movie.mp4[]"),
(None, None)
);
assert_eq!(
first_block_caption(".Track\naudio::sound.mp3[]"),
(None, None)
);
}
#[test]
fn image_numbering_is_sequential() {
let doc = Parser::default().parse(".First\nimage::a.jpg[]\n\n.Second\nimage::b.jpg[]");
let numbers: Vec<_> = doc.nested_blocks().map(|b| b.number()).collect();
assert_eq!(numbers, vec![Some(1), Some(2)]);
}
#[test]
fn image_number_is_stored_in_figure_number_attribute() {
assert_eq!(
rendered_paragraphs(
&Parser::default().parse(".Sunset\nimage::sunset.jpg[]\n\n{figure-number}")
),
vec!["1".to_string()]
);
}
#[test]
fn listing_numbering_is_sequential_when_captioned() {
let doc = Parser::default()
.parse(":listing-caption: Listing\n\n.First\n----\na\n----\n\n.Second\n----\nb\n----");
let numbers: Vec<_> = doc.nested_blocks().map(|b| b.number()).collect();
assert_eq!(numbers, vec![Some(1), Some(2)]);
}
#[test]
fn listing_number_is_stored_in_listing_number_attribute() {
assert_eq!(
rendered_paragraphs(
&Parser::default().parse(
":listing-caption: Listing\n\n.Out\n----\ncode\n----\n\n{listing-number}"
)
),
vec!["1".to_string()]
);
}
#[test]
fn dropped_image_does_not_consume_figure_number() {
let doc = Parser::default().parse(
":attribute-missing: drop-line\n\n.Gone\nimage::{undefined}.jpg[]\n\n.Kept\nimage::ok.jpg[]",
);
let captions: Vec<_> = doc
.nested_blocks()
.map(|b| b.caption().map(str::to_string))
.collect();
assert_eq!(captions, vec![Some("Figure 1. ".to_string())]);
}
}