#[cfg(feature = "office")]
use crate::office::types::{Slide, SlideDeckSpec, WorkbookProperties};
#[cfg(feature = "office")]
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
#[cfg_attr(feature = "schema_gen", derive(schemars::JsonSchema))]
pub struct PitchDeckInput {
pub deal_name: String,
pub subtitle: String,
pub date: String,
pub agenda: Vec<String>,
pub executive_summary: Vec<String>,
pub market_overview: Vec<String>,
pub business_overview: Vec<String>,
pub financial_highlights: Vec<Vec<String>>,
pub returns_summary: Vec<(String, String)>,
pub process_timeline: Vec<(String, String)>,
pub conclusion: Vec<String>,
}
#[cfg(feature = "office")]
pub fn pitch_deck_to_deck(input: &PitchDeckInput) -> SlideDeckSpec {
let mut slides: Vec<Slide> = Vec::with_capacity(13);
slides.push(Slide::Title {
title: input.deal_name.clone(),
subtitle: Some(format!("{}\n{}", input.subtitle, input.date)),
});
slides.push(Slide::Content {
title: "Agenda".into(),
bullets: input.agenda.clone(),
});
if !input.executive_summary.is_empty() {
slides.push(Slide::Section {
heading: "Executive Summary".into(),
});
slides.push(Slide::Content {
title: "Executive Summary".into(),
bullets: input.executive_summary.clone(),
});
}
let has_market = !input.market_overview.is_empty();
let has_business = !input.business_overview.is_empty();
if has_market || has_business {
slides.push(Slide::Section {
heading: "Market & Business".into(),
});
if has_market {
slides.push(Slide::Content {
title: "Market Overview".into(),
bullets: input.market_overview.clone(),
});
}
if has_business {
slides.push(Slide::Content {
title: "Business Overview".into(),
bullets: input.business_overview.clone(),
});
}
}
if !input.financial_highlights.is_empty() {
slides.push(Slide::Section {
heading: "Financials".into(),
});
slides.push(build_financial_highlights_slide(input));
}
slides.push(build_returns_summary_slide(input));
if !input.process_timeline.is_empty() {
slides.push(Slide::Section {
heading: "Process".into(),
});
slides.push(build_process_timeline_slide(input));
}
if !input.conclusion.is_empty() {
slides.push(Slide::Content {
title: "Conclusion".into(),
bullets: input.conclusion.clone(),
});
}
SlideDeckSpec {
slides,
properties: WorkbookProperties {
title: Some(format!("Pitch Deck — {}", input.deal_name)),
author: Some("corp-finance-core".into()),
company: None,
subject: Some("Sell-side IB Pitch Deck".into()),
},
}
}
#[cfg(feature = "office")]
fn build_financial_highlights_slide(input: &PitchDeckInput) -> Slide {
if input.financial_highlights.len() >= 2 {
Slide::Table {
title: "Financial Highlights".into(),
headers: input.financial_highlights[0].clone(),
rows: input.financial_highlights[1..].to_vec(),
}
} else {
let headers = if input.financial_highlights.is_empty() {
vec!["Metric".into(), "Value".into()]
} else {
input.financial_highlights[0].clone()
};
Slide::Table {
title: "Financial Highlights".into(),
headers,
rows: vec![],
}
}
}
#[cfg(feature = "office")]
fn build_returns_summary_slide(input: &PitchDeckInput) -> Slide {
let rows: Vec<Vec<String>> = input
.returns_summary
.iter()
.map(|(metric, value)| vec![metric.clone(), value.clone()])
.collect();
Slide::Table {
title: "Returns Summary".into(),
headers: vec!["Metric".into(), "Value".into()],
rows,
}
}
#[cfg(feature = "office")]
fn build_process_timeline_slide(input: &PitchDeckInput) -> Slide {
let rows: Vec<Vec<String>> = input
.process_timeline
.iter()
.map(|(milestone, date)| vec![milestone.clone(), date.clone()])
.collect();
Slide::Table {
title: "Process Timeline".into(),
headers: vec!["Milestone".into(), "Date".into()],
rows,
}
}
#[cfg(all(test, feature = "office"))]
mod tests {
use super::*;
fn minimal_input() -> PitchDeckInput {
PitchDeckInput {
deal_name: "Project Apex".into(),
subtitle: "Project Apex — Confidential".into(),
date: "May 2026".into(),
agenda: vec![
"Executive Summary".into(),
"Market Overview".into(),
"Financial Highlights".into(),
],
executive_summary: vec![
"Leading market position with 35% share".into(),
"EBITDA margins expanding to 28%".into(),
"Multiple strategic acquirers identified".into(),
],
market_overview: vec!["$12B TAM growing at 8% CAGR".into()],
business_overview: vec!["Founded 2015; 400 employees across 3 geographies".into()],
financial_highlights: vec![
vec!["Metric".into(), "FY24A".into(), "FY25E".into()],
vec!["Revenue ($M)".into(), "320".into(), "358".into()],
vec!["EBITDA ($M)".into(), "80".into(), "100".into()],
],
returns_summary: vec![("IRR".into(), "22%".into()), ("MOIC".into(), "2.8x".into())],
process_timeline: vec![
("Launch".into(), "Jun 2026".into()),
("LOIs".into(), "Aug 2026".into()),
("Close".into(), "Oct 2026".into()),
],
conclusion: vec![
"Rare asset with defensible moat".into(),
"Clear path to value creation".into(),
"Process launching immediately".into(),
],
}
}
#[test]
fn pitch_deck_to_deck_basic() {
let input = minimal_input();
let deck = pitch_deck_to_deck(&input);
assert_eq!(
deck.slides.len(),
13,
"expected 13 slides for fully-populated input, got {}",
deck.slides.len()
);
match &deck.slides[0] {
Slide::Title { title, .. } => {
assert_eq!(title, "Project Apex");
}
other => panic!("expected Title slide, got {:?}", other),
}
assert_eq!(
deck.properties.title.as_deref(),
Some("Pitch Deck — Project Apex")
);
}
#[test]
fn pitch_deck_to_deck_round_trips_through_writer() {
use crate::office::pptx::write_slide_deck;
use tempfile::tempdir;
let input = minimal_input();
let deck = pitch_deck_to_deck(&input);
let dir = tempdir().expect("tempdir creation failed");
let path = dir.path().join("pitch_deck.pptx");
let result = write_slide_deck(&deck, &path).expect("write_slide_deck failed");
assert!(path.exists(), "output file does not exist");
assert!(
result.bytes_written > 0,
"expected nonzero bytes, got {}",
result.bytes_written
);
let bytes = std::fs::read(&path).expect("failed to read output file");
assert!(
bytes.starts_with(b"PK\x03\x04"),
"output does not start with ZIP magic bytes"
);
}
}