use uzor::types::Rect;
use uzor_text::LineShaper;
use crate::compose::{compose, BreakControl, ComposeStyle};
use crate::master::{SlideInstance, SlideLayout};
use crate::region::{CardRegionSequence, FixedRegionSequence, Frame, PlacedBlock, Region};
use crate::scene::{resolve_block_ids, BlockId, BlockNode};
const OVERFLOW_EPSILON: f64 = 1e-6;
pub struct Card<'a> {
pub index: u32,
pub width: f64,
pub natural_height: f64,
pub frame: Frame<'a>,
}
pub struct Slide<'a> {
pub index: u32,
pub width: f64,
pub height: f64,
pub frame: Frame<'a>,
pub shrink_scale: Option<f64>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum SlideOverflow {
Report,
Shrink,
}
impl Default for SlideOverflow {
fn default() -> Self {
SlideOverflow::Report
}
}
#[derive(Debug, Clone, PartialEq)]
pub struct SliceError {
pub slide_index: u32,
pub overflowing: Vec<BlockId>,
}
fn split_groups<'a>(flow: &'a [BlockNode<'a>]) -> Vec<&'a [BlockNode<'a>]> {
if flow.is_empty() {
return Vec::new();
}
let mut groups = Vec::new();
let mut start = 0usize;
for i in 0..flow.len() {
if i > start && flow[i].break_control == BreakControl::ForceBefore {
groups.push(&flow[start..i]);
start = i;
}
if flow[i].break_control == BreakControl::ForceAfter {
groups.push(&flow[start..=i]);
start = i + 1;
}
}
if start < flow.len() {
groups.push(&flow[start..]);
}
groups
}
fn natural_height(frame: &Frame<'_>) -> f64 {
frame.blocks.iter().map(|b| b.rect.y + b.rect.height).fold(0.0_f64, f64::max)
}
fn compose_unbounded<'a>(content: &'a [BlockNode<'a>], origin: (f64, f64), width: f64, style: &ComposeStyle, shaper: &dyn LineShaper) -> (Frame<'a>, f64) {
let mut regions = CardRegionSequence::new(width);
let mut frame = compose(content, &mut regions, style, shaper)
.into_iter()
.next()
.unwrap_or_else(|| Frame { region: Region { rect: Rect::new(0.0, 0.0, width, 0.0) }, blocks: Vec::new(), overflow: None });
let height = natural_height(&frame);
if origin != (0.0, 0.0) {
for placed in &mut frame.blocks {
placed.translate(origin.0, origin.1);
}
}
(frame, height)
}
pub fn slice_cards<'a>(flow: &'a [BlockNode<'a>], width: f64, style: &ComposeStyle, shaper: &dyn LineShaper) -> Vec<Card<'a>> {
split_groups(flow)
.into_iter()
.enumerate()
.map(|(i, group)| {
let (frame, natural_height) = compose_unbounded(group, (0.0, 0.0), width, style, shaper);
Card { index: i as u32, width, natural_height, frame }
})
.collect()
}
pub fn slice_slides<'a>(
flow: &'a [BlockNode<'a>],
width: f64,
height: f64,
overflow_policy: SlideOverflow,
style: &ComposeStyle,
shaper: &dyn LineShaper,
) -> Result<Vec<Slide<'a>>, SliceError> {
let mut slides = Vec::new();
for (i, group) in split_groups(flow).into_iter().enumerate() {
let mut fixed_regions = FixedRegionSequence::new(Rect::new(0.0, 0.0, width, height));
let fixed_frame = compose(group, &mut fixed_regions, style, shaper)
.into_iter()
.next()
.unwrap_or_else(|| Frame { region: Region { rect: Rect::new(0.0, 0.0, width, height) }, blocks: Vec::new(), overflow: None });
let fits = fixed_frame.overflow.is_none() && fixed_frame.blocks.iter().all(|b| b.rect.y + b.rect.height <= height + OVERFLOW_EPSILON);
if fits {
slides.push(Slide { index: i as u32, width, height, frame: fixed_frame, shrink_scale: None });
continue;
}
let (natural_frame, content_height) = compose_unbounded(group, (0.0, 0.0), width, style, shaper);
match overflow_policy {
SlideOverflow::Report => {
let overflowing: Vec<BlockId> = natural_frame
.blocks
.iter()
.filter(|b| b.rect.y + b.rect.height > height + OVERFLOW_EPSILON)
.map(|b| b.id)
.collect();
return Err(SliceError { slide_index: i as u32, overflowing });
}
SlideOverflow::Shrink => {
let scale = (height / content_height).clamp(0.0, 1.0);
slides.push(Slide { index: i as u32, width, height, frame: natural_frame, shrink_scale: Some(scale) });
}
}
}
Ok(slides)
}
pub fn cards_to_slides<'a>(
flow: &'a [BlockNode<'a>],
width: f64,
height: f64,
overflow_policy: SlideOverflow,
style: &ComposeStyle,
shaper: &dyn LineShaper,
) -> Result<Vec<Slide<'a>>, SliceError> {
slice_slides(flow, width, height, overflow_policy, style, shaper)
}
pub fn slides_to_cards<'a>(flow: &'a [BlockNode<'a>], width: f64, style: &ComposeStyle, shaper: &dyn LineShaper) -> Vec<Card<'a>> {
slice_cards(flow, width, style, shaper)
}
pub fn slice_slide_instance<'a>(
instance: &'a SlideInstance<'a>,
layout: &SlideLayout,
slide_width: f64,
slide_height: f64,
overflow_policy: SlideOverflow,
style: &ComposeStyle,
shaper: &dyn LineShaper,
) -> Result<Slide<'a>, SliceError> {
let mut blocks: Vec<PlacedBlock<'a>> = Vec::new();
let mut overflowing: Vec<BlockId> = Vec::new();
let mut worst_scale = 1.0_f64;
for (slot, rect) in &layout.arrangement {
let Some(fill) = instance.fills.iter().find(|f| f.slot == *slot) else { continue };
let content: &'a BlockNode<'a> = &fill.content;
let one = std::slice::from_ref(content);
let mut fixed_regions = FixedRegionSequence::new(*rect);
let fixed_frame = compose(one, &mut fixed_regions, style, shaper)
.into_iter()
.next()
.unwrap_or_else(|| Frame { region: Region { rect: *rect }, blocks: Vec::new(), overflow: None });
let bound_bottom = rect.y + rect.height;
let fits = fixed_frame.overflow.is_none() && fixed_frame.blocks.iter().all(|b| b.rect.y + b.rect.height <= bound_bottom + OVERFLOW_EPSILON);
if fits {
blocks.extend(fixed_frame.blocks);
continue;
}
let (natural_frame, content_height) = compose_unbounded(one, (rect.x, rect.y), rect.width, style, shaper);
match overflow_policy {
SlideOverflow::Report => {
overflowing.push(resolve_block_ids(one)[0]);
}
SlideOverflow::Shrink => {
worst_scale = worst_scale.min((rect.height / content_height).clamp(0.0, 1.0));
blocks.extend(natural_frame.blocks);
}
}
}
if !overflowing.is_empty() {
return Err(SliceError { slide_index: 0, overflowing });
}
let shrink_scale = if worst_scale < 1.0 { Some(worst_scale) } else { None };
let region = Region { rect: Rect::new(0.0, 0.0, slide_width, slide_height) };
Ok(Slide { index: 0, width: slide_width, height: slide_height, frame: Frame { region, blocks, overflow: None }, shrink_scale })
}
#[cfg(test)]
mod tests {
use super::*;
use uzor::fonts::FontFamily;
use uzor_text::{CosmicShaper, FontSpec, Paragraph, StyledRun};
use crate::master::{PlaceholderFill, PlaceholderKind, PlaceholderSlot, SlideInstance};
use crate::scene::Block;
fn font() -> FontSpec {
FontSpec::new(FontFamily::Roboto, 16.0)
}
fn style() -> ComposeStyle {
ComposeStyle::new(6.0, font())
}
#[test]
fn card_mode_splits_at_force_before_markers_and_each_card_is_its_own_natural_height() {
let shaper = CosmicShaper::headless();
let run_a = [StyledRun::new("Card A — short.", font())];
let run_b = [StyledRun::new(
"Card B has a longer paragraph than card A, tall enough that its own natural height must exceed card A's.",
font(),
)];
let run_c = [StyledRun::new("Card C — short again.", font())];
let flow = [
BlockNode::new(Block::Paragraph(Paragraph::new(&run_a, 300.0))),
BlockNode::new(Block::Paragraph(Paragraph::new(&run_b, 300.0))).with_break_control(BreakControl::ForceBefore),
BlockNode::new(Block::Paragraph(Paragraph::new(&run_c, 300.0))).with_break_control(BreakControl::ForceBefore),
];
let cards = slice_cards(&flow, 300.0, &style(), &shaper);
assert_eq!(cards.len(), 3, "two ForceBefore markers must split the flow into exactly 3 cards");
for (i, card) in cards.iter().enumerate() {
assert_eq!(card.index, i as u32);
assert_eq!(card.width, 300.0);
assert!(card.natural_height > 0.0, "every card must have real, non-zero natural content height");
}
assert!(cards[1].natural_height > cards[0].natural_height, "card B's own longer paragraph must yield a taller natural height");
}
#[test]
fn card_mode_with_no_break_markers_produces_exactly_one_card_regardless_of_length() {
let shaper = CosmicShaper::headless();
let run = [StyledRun::new(
"A single long repeated filler paragraph with no break markers at all, composed purely to prove that card mode never \
splits content on its own — only an explicit author-marked break ever starts a new card, matching the design doc's own \
P3 gate: card mode always produces exactly one Slide at natural height regardless of content length.",
font(),
)];
let flow: Vec<BlockNode<'_>> = (0..12).map(|_| BlockNode::new(Block::Paragraph(Paragraph::new(&run, 300.0)))).collect();
let cards = slice_cards(&flow, 300.0, &style(), &shaper);
assert_eq!(cards.len(), 1, "no ForceBefore/ForceAfter markers must yield exactly one card no matter how long the content is");
assert_eq!(cards[0].frame.blocks.len(), 12);
}
#[test]
fn fixed_mode_content_that_fits_matches_card_mode_placement_exactly_conversion_round_trip() {
let shaper = CosmicShaper::headless();
let run_a = [StyledRun::new("Slide A title.", font())];
let run_b = [StyledRun::new("Slide B body copy, still short.", font())];
let flow = [
BlockNode::new(Block::Paragraph(Paragraph::new(&run_a, 300.0))),
BlockNode::new(Block::Paragraph(Paragraph::new(&run_b, 300.0))).with_break_control(BreakControl::ForceBefore),
];
let cards = slides_to_cards(&flow, 300.0, &style(), &shaper);
let slides = cards_to_slides(&flow, 300.0, 500.0, SlideOverflow::Report, &style(), &shaper).expect("content fits, must not overflow");
assert_eq!(cards.len(), slides.len());
for (card, slide) in cards.iter().zip(slides.iter()) {
assert_eq!(card.index, slide.index);
assert!(slide.shrink_scale.is_none(), "content that fits must never engage a shrink transform");
assert_eq!(card.frame.blocks.len(), slide.frame.blocks.len());
for (cb, sb) in card.frame.blocks.iter().zip(slide.frame.blocks.iter()) {
assert_eq!(cb.id, sb.id, "block ids must be preserved across the card<->slide conversion");
assert_eq!(cb.rect, sb.rect, "block placement must be identical when content fits either mode");
}
}
let cards_again = slides_to_cards(&flow, 300.0, &style(), &shaper);
assert_eq!(cards_again.len(), cards.len());
for (a, b) in cards_again.iter().zip(cards.iter()) {
let ids_a: Vec<_> = a.frame.blocks.iter().map(|p| p.id).collect();
let ids_b: Vec<_> = b.frame.blocks.iter().map(|p| p.id).collect();
assert_eq!(ids_a, ids_b, "re-slicing as cards again must preserve block ids and order");
}
}
#[test]
fn fixed_mode_report_overflow_names_the_overflowing_block_never_silently_clips() {
let shaper = CosmicShaper::headless();
let run = [StyledRun::new(
"A deliberately long paragraph, repeated wrapped across many lines at this narrow width, tall enough on its own that it \
cannot possibly fit inside a very short fixed slide viewport, forcing the Report overflow policy to name it explicitly \
rather than silently truncating or dropping the remainder of its own content.",
font(),
)];
let flow = [BlockNode::new(Block::Paragraph(Paragraph::new(&run, 300.0))).with_id(crate::scene::BlockId(99))];
let result = slice_slides(&flow, 300.0, 40.0, SlideOverflow::Report, &style(), &shaper);
let err = match result {
Err(e) => e,
Ok(_) => panic!("content taller than the fixed viewport must report overflow, not silently succeed"),
};
assert_eq!(err.slide_index, 0);
assert_eq!(err.overflowing, vec![crate::scene::BlockId(99)], "the overflowing paragraph's own author id must be named");
}
#[test]
fn fixed_mode_shrink_computes_a_uniform_scale_and_preserves_every_block() {
let shaper = CosmicShaper::headless();
let run_a = [StyledRun::new("Heading that stays on the shrunk slide.", font())];
let run_b = [StyledRun::new(
"A long body paragraph, wrapped across several lines, that on its own already exceeds a very short fixed slide height, \
forcing the Shrink policy to scale the whole slide down instead of reporting an error or dropping any of this text.",
font(),
)];
let flow = [
BlockNode::new(Block::Paragraph(Paragraph::new(&run_a, 300.0))),
BlockNode::new(Block::Paragraph(Paragraph::new(&run_b, 300.0))),
];
let (_, natural) = compose_unbounded(&flow, (0.0, 0.0), 300.0, &style(), &shaper);
let target_height = natural / 2.0;
let slides = slice_slides(&flow, 300.0, target_height, SlideOverflow::Shrink, &style(), &shaper).expect("Shrink must never error");
assert_eq!(slides.len(), 1);
let slide = &slides[0];
let scale = slide.shrink_scale.expect("overflowing content under Shrink must engage a scale factor");
assert!(scale > 0.0 && scale < 1.0, "scale must be a real shrink factor, got {scale}");
assert!((scale - target_height / natural).abs() < 1e-9, "scale must be exactly target/natural, one uniform factor");
assert_eq!(slide.frame.blocks.len(), 2, "both blocks must survive a shrink, never truncated");
}
#[test]
fn slide_instance_placeholder_fills_compose_into_their_own_arranged_rects() {
use crate::master::{LayoutId, MasterId};
let shaper = CosmicShaper::headless();
let title_run = [StyledRun::new("Deck Title", font())];
let body_run = [StyledRun::new("Deck body copy, short enough to fit comfortably.", font())];
let title_slot = PlaceholderSlot::new(PlaceholderKind::Title, 0);
let body_slot = PlaceholderSlot::new(PlaceholderKind::Body, 0);
let fills = vec![
PlaceholderFill::new(title_slot, BlockNode::new(Block::Paragraph(Paragraph::new(&title_run, 400.0)))),
PlaceholderFill::new(body_slot, BlockNode::new(Block::Paragraph(Paragraph::new(&body_run, 400.0)))),
];
let instance = SlideInstance::new(LayoutId(1), fills);
let layout = SlideLayout::new(
LayoutId(1),
MasterId(1),
vec![(title_slot, Rect::new(0.0, 0.0, 400.0, 60.0)), (body_slot, Rect::new(0.0, 60.0, 400.0, 200.0))],
);
let slide = slice_slide_instance(&instance, &layout, 400.0, 260.0, SlideOverflow::Report, &style(), &shaper)
.expect("comfortably-fitting placeholder content must not overflow");
assert_eq!(slide.frame.blocks.len(), 2, "both filled placeholders must contribute their own placed content");
assert!(slide.shrink_scale.is_none());
for placed in &slide.frame.blocks {
assert!(placed.rect.y >= 0.0 && placed.rect.y < 260.0);
}
let title_placed = slide.frame.blocks.iter().find(|b| b.rect.y < 60.0).expect("title content must land in the title band");
assert_eq!(title_placed.rect.x, 0.0);
}
#[test]
fn slide_instance_report_overflow_names_the_overflowing_placeholder() {
use crate::master::{LayoutId, MasterId};
let shaper = CosmicShaper::headless();
let body_run = [StyledRun::new(
"A deliberately long body paragraph, wrapped across many lines at this placeholder's own width, tall enough that it \
cannot possibly fit inside a very short placeholder rect, forcing Report to name it rather than silently truncating.",
font(),
)];
let body_slot = PlaceholderSlot::new(PlaceholderKind::Body, 0);
let fills = vec![PlaceholderFill::new(
body_slot,
BlockNode::new(Block::Paragraph(Paragraph::new(&body_run, 300.0))).with_id(crate::scene::BlockId(7)),
)];
let instance = SlideInstance::new(LayoutId(1), fills);
let layout = SlideLayout::new(LayoutId(1), MasterId(1), vec![(body_slot, Rect::new(0.0, 0.0, 300.0, 20.0))]);
let result = slice_slide_instance(&instance, &layout, 300.0, 20.0, SlideOverflow::Report, &style(), &shaper);
let err = match result {
Err(e) => e,
Ok(_) => panic!("a placeholder too short for its own content must report overflow"),
};
assert_eq!(err.overflowing, vec![crate::scene::BlockId(7)]);
}
#[test]
fn slide_instance_skips_an_unfilled_layout_slot() {
use crate::master::{LayoutId, MasterId};
let shaper = CosmicShaper::headless();
let title_run = [StyledRun::new("Only the title is filled", font())];
let title_slot = PlaceholderSlot::new(PlaceholderKind::Title, 0);
let media_slot = PlaceholderSlot::new(PlaceholderKind::Media, 0);
let fills = vec![PlaceholderFill::new(title_slot, BlockNode::new(Block::Paragraph(Paragraph::new(&title_run, 400.0))))];
let instance = SlideInstance::new(LayoutId(1), fills);
let layout = SlideLayout::new(
LayoutId(1),
MasterId(1),
vec![(title_slot, Rect::new(0.0, 0.0, 400.0, 60.0)), (media_slot, Rect::new(0.0, 60.0, 400.0, 200.0))],
);
let slide = slice_slide_instance(&instance, &layout, 400.0, 260.0, SlideOverflow::Report, &style(), &shaper).expect("must not overflow");
assert_eq!(slide.frame.blocks.len(), 1, "the unfilled Media slot must contribute nothing, never a placeholder stub");
}
#[test]
fn slide_overflow_default_is_report() {
assert_eq!(SlideOverflow::default(), SlideOverflow::Report);
}
}