use std::collections::HashMap;
use uzor::fonts::FontFamily;
use uzor_text::{FontSpec, StyledRun};
use crate::scene::{resolve_block_ids, Block, BlockId, BlockNode, CaptionKind};
use crate::toc::TocArena;
#[derive(Debug, Clone, Copy)]
pub struct CaptionStyle {
pub font: FontSpec,
pub label: fn(CaptionKind, u32, &str) -> String,
pub ref_label: fn(CaptionKind, u32) -> String,
}
pub fn default_caption_label(kind: CaptionKind, number: u32, text: &str) -> String {
let noun = match kind {
CaptionKind::Figure => "Figure",
CaptionKind::Table => "Table",
};
format!("{noun} {number} — {text}")
}
pub fn default_caption_ref_label(kind: CaptionKind, number: u32) -> String {
let noun = match kind {
CaptionKind::Figure => "Figure",
CaptionKind::Table => "Table",
};
format!("{noun} {number}")
}
impl Default for CaptionStyle {
fn default() -> Self {
Self { font: FontSpec::new(FontFamily::Roboto, 11.0), label: default_caption_label, ref_label: default_caption_ref_label }
}
}
pub fn resolve_caption_numbers(flow: &[BlockNode<'_>]) -> HashMap<BlockId, (CaptionKind, u32)> {
let ids = resolve_block_ids(flow);
let mut counters: HashMap<CaptionKind, u32> = HashMap::new();
let mut out = HashMap::new();
for (node, id) in flow.iter().zip(ids.iter()) {
if let Some(caption) = &node.caption {
let counter = counters.entry(caption.kind).or_insert(0);
*counter += 1;
out.insert(*id, (caption.kind, *counter));
}
}
out
}
pub fn attach_captions<'a>(flow: &'a [BlockNode<'a>], style: &CaptionStyle, arena: &'a mut TocArena) -> Vec<BlockNode<'a>> {
let numbers = resolve_caption_numbers(flow);
let ids = resolve_block_ids(flow);
let mut text_index_for: Vec<Option<usize>> = Vec::with_capacity(flow.len());
for (node, id) in flow.iter().zip(ids.iter()) {
let idx = node.caption.as_ref().map(|caption| {
let (kind, number) = numbers.get(id).copied().unwrap_or((caption.kind, 0));
arena.push((style.label)(kind, number, &caption.text))
});
text_index_for.push(idx);
}
let mut out = Vec::with_capacity(flow.len());
for ((node, _id), text_index) in flow.iter().zip(ids.iter()).zip(text_index_for) {
let Some(caption_text_index) = text_index else {
out.push(reborrow(node));
continue;
};
let mut carried = reborrow(node);
if carried.break_control == crate::compose::BreakControl::Auto {
carried.break_control = crate::compose::BreakControl::AvoidAfter;
}
out.push(carried);
let label_text: &'a str = arena.text(caption_text_index);
let run: &'a [StyledRun<'a>] = &*vec![StyledRun::new(label_text, style.font)].leak();
let paragraph = uzor_text::Paragraph::new(run, f64::MAX);
out.push(BlockNode::new(Block::Paragraph(paragraph)));
}
out
}
fn reborrow<'a>(node: &BlockNode<'a>) -> BlockNode<'a> {
BlockNode {
id: node.id,
kind: reborrow_block(&node.kind),
break_control: node.break_control,
outline: node.outline.clone(),
link_target: node.link_target,
caption: node.caption.clone(),
footnotes: node.footnotes,
header_placeholder: node.header_placeholder,
}
}
fn reborrow_block<'a>(kind: &Block<'a>) -> Block<'a> {
match kind {
Block::Paragraph(p) => Block::Paragraph(*p),
Block::Figure(f) => Block::Figure(crate::scene::FigureBlock::new(f.figure, f.sizing)),
Block::Image(i) => Block::Image(crate::scene::ImageBlock::new(i.rgba, i.intrinsic_width, i.intrinsic_height, i.sizing, i.fit)),
Block::Island(isl) => {
Block::Island(crate::scene::AnchoredIsland::new(
crate::scene::ImageBlock::new(isl.image.rgba, isl.image.intrinsic_width, isl.image.intrinsic_height, isl.image.sizing, isl.image.fit),
isl.anchor,
isl.width,
isl.margin,
))
}
Block::Table(t) => Block::Table(crate::scene::TableBlock { columns: t.columns, rows: t.rows, cell_padding: t.cell_padding, header_repeat: t.header_repeat }),
Block::List(l) => Block::List(crate::scene::ListBlock::new(l.items, l.marker.clone(), l.indent_px)),
Block::Spacer(g) => Block::Spacer(*g),
}
}
pub enum RefSegment<'a> {
Text(&'a str),
Ref(BlockId),
}
pub fn resolve_refs(segments: &[RefSegment<'_>], numbers: &HashMap<BlockId, (CaptionKind, u32)>, style: &CaptionStyle) -> String {
let mut out = String::new();
for seg in segments {
match seg {
RefSegment::Text(t) => out.push_str(t),
RefSegment::Ref(id) => match numbers.get(id) {
Some((kind, number)) => out.push_str(&(style.ref_label)(*kind, *number)),
None => out.push_str("[missing ref]"),
},
}
}
out
}
#[cfg(test)]
mod tests {
use super::*;
use uzor::render::RenderContext;
use uzor::types::Rect;
use uzor_figures::FigureTheme;
use uzor_text::Paragraph;
use crate::scene::{Block, TypesetFigure};
struct StubFig;
impl TypesetFigure for StubFig {
fn render(&self, _ctx: &mut dyn RenderContext, _rect: Rect, _theme: &FigureTheme) {}
}
fn style() -> CaptionStyle {
CaptionStyle::default()
}
fn stub_figure_node(fig: &StubFig) -> BlockNode<'_> {
BlockNode::new(Block::Figure(crate::scene::FigureBlock::new(fig, crate::scene::BlockSizing::FixedHeight(100.0))))
}
#[test]
fn numbers_increment_per_kind_independently_in_document_order() {
let a = StubFig;
let b = StubFig;
let c = StubFig;
let flow = [
stub_figure_node(&a).with_caption(CaptionKind::Figure, "first figure"),
BlockNode::new(Block::Spacer(4.0)).with_caption(CaptionKind::Table, "a table pretending to be a spacer for this fixture"),
stub_figure_node(&b).with_caption(CaptionKind::Figure, "second figure"),
stub_figure_node(&c), ];
let numbers = resolve_caption_numbers(&flow);
let ids = resolve_block_ids(&flow);
assert_eq!(numbers.get(&ids[0]), Some(&(CaptionKind::Figure, 1)));
assert_eq!(numbers.get(&ids[1]), Some(&(CaptionKind::Table, 1)), "Table counter is independent of the Figure counter");
assert_eq!(numbers.get(&ids[2]), Some(&(CaptionKind::Figure, 2)));
assert_eq!(numbers.get(&ids[3]), None, "an untagged node must never appear in the resolved map");
}
#[test]
fn numbering_is_stable_across_repeated_calls_over_the_same_flow() {
let a = StubFig;
let flow = [stub_figure_node(&a).with_caption(CaptionKind::Figure, "x")];
let first = resolve_caption_numbers(&flow);
let second = resolve_caption_numbers(&flow);
assert_eq!(first, second);
}
#[test]
fn attach_captions_splices_a_resolved_label_paragraph_immediately_after_each_captioned_node() {
let a = StubFig;
let flow = [stub_figure_node(&a).with_caption(CaptionKind::Figure, "a seeded bar chart")];
let mut arena = TocArena::new();
let attached = attach_captions(&flow, &style(), &mut arena);
assert_eq!(attached.len(), 2, "one figure + one synthesized caption paragraph");
assert!(matches!(attached[0].kind, Block::Figure(_)));
let Block::Paragraph(p) = &attached[1].kind else { panic!("caption must be a real Block::Paragraph") };
assert_eq!(p.runs.len(), 1);
assert_eq!(p.runs[0].text, "Figure 1 — a seeded bar chart");
}
#[test]
fn attach_captions_upgrades_auto_break_control_to_avoid_after_but_never_overrides_an_explicit_choice() {
let a = StubFig;
let b = StubFig;
let flow = [
stub_figure_node(&a).with_caption(CaptionKind::Figure, "auto"),
stub_figure_node(&b).with_caption(CaptionKind::Figure, "explicit").with_break_control(crate::compose::BreakControl::ForceAfter),
];
let mut arena = TocArena::new();
let attached = attach_captions(&flow, &style(), &mut arena);
assert_eq!(attached[0].break_control, crate::compose::BreakControl::AvoidAfter, "an Auto captioned node must be upgraded");
assert_eq!(attached[2].break_control, crate::compose::BreakControl::ForceAfter, "an explicit break control must survive untouched");
}
#[test]
fn attach_captions_leaves_untagged_nodes_completely_unchanged_in_place() {
let runs = [StyledRun::new("plain paragraph", style().font)];
let flow = [BlockNode::new(Block::Paragraph(Paragraph::new(&runs, 200.0)))];
let mut arena = TocArena::new();
let attached = attach_captions(&flow, &style(), &mut arena);
assert_eq!(attached.len(), 1, "an untagged node never gains a synthesized sibling");
}
#[test]
fn resolve_refs_substitutes_a_typed_ref_with_its_resolved_caption_label() {
let a = StubFig;
let flow = [stub_figure_node(&a).with_caption(CaptionKind::Figure, "seeded")];
let numbers = resolve_caption_numbers(&flow);
let ids = resolve_block_ids(&flow);
let segments = [RefSegment::Text("As shown in "), RefSegment::Ref(ids[0]), RefSegment::Text(", sales grew.")];
let text = resolve_refs(&segments, &numbers, &style());
assert_eq!(text, "As shown in Figure 1, sales grew.");
}
#[test]
fn resolve_refs_resolves_a_dangling_reference_to_a_visible_placeholder_never_a_panic() {
let numbers: HashMap<BlockId, (CaptionKind, u32)> = HashMap::new();
let segments = [RefSegment::Text("See "), RefSegment::Ref(BlockId(999)), RefSegment::Text(".")];
let text = resolve_refs(&segments, &numbers, &style());
assert_eq!(text, "See [missing ref].");
}
}