use serde::{Deserialize, Serialize};
use std::path::Path;
use tokio::io::AsyncWriteExt;
#[derive(Debug, Deserialize, Serialize)]
pub(crate) struct SlideSpec {
pub title: String,
#[serde(default)]
pub bullets: Vec<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub notes: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub layout: Option<usize>,
}
#[derive(Debug, Default, Deserialize)]
pub(crate) struct PptxStyle {
pub template_path: Option<String>,
pub accent_color: Option<String>,
}
fn valid_hex(hex: &str) -> Option<String> {
let h = hex.trim().trim_start_matches('#');
if h.len() == 6 && h.chars().all(|c| c.is_ascii_hexdigit()) {
Some(format!("#{h}"))
} else {
tracing::warn!("generate_document: ignoring invalid pptx accent color {hex:?}");
None
}
}
const PPTX_SCRIPT: &str = r##"
import json, sys
from pptx import Presentation
from pptx.dml.color import RGBColor
spec = json.load(sys.stdin)
tpl = spec.get("template")
prs = Presentation(tpl) if tpl else Presentation()
accent = spec.get("accent")
def hex_rgb(h):
h = h.lstrip("#")
return tuple(int(h[i:i + 2], 16) for i in (0, 2, 4))
for s in spec["slides"]:
idx = s.get("layout")
if idx is None or idx >= len(prs.slide_layouts):
idx = 1 if len(prs.slide_layouts) > 1 else 0
slide = prs.slides.add_slide(prs.slide_layouts[idx])
if slide.shapes.title is not None:
slide.shapes.title.text = s.get("title", "")
if accent:
r, g, b = hex_rgb(accent)
for p in slide.shapes.title.text_frame.paragraphs:
for run in p.runs:
run.font.color.rgb = RGBColor(r, g, b)
bullets = s.get("bullets", [])
if bullets:
body = None
for ph in slide.placeholders:
if ph.placeholder_format.idx != 0 and ph.has_text_frame:
body = ph
break
if body is not None:
tf = body.text_frame
tf.text = bullets[0]
for b in bullets[1:]:
p = tf.add_paragraph()
p.text = b
notes = s.get("notes")
if notes:
slide.notes_slide.notes_text_frame.text = notes
prs.save(sys.argv[1])
print(f"{len(spec['slides'])} slide(s)")
"##;
const INSTALL_HINT: &str = "PPTX generation needs python3 with the python-pptx package on this host \
(install with: pip3 install python-pptx). XLSX, DOCX, and PDF work without it.";
pub(crate) async fn write_deck(
path: &Path,
slides: &[SlideSpec],
style: &PptxStyle,
) -> Result<String, String> {
let template = match style.template_path.as_deref() {
Some(t) => {
let expanded = crate::brain::tools::error::expand_tilde(t);
if !expanded.exists() {
return Err(format!(
"Template not found at {t}. Fix the template_path or omit it \
to generate on the default blank master."
));
}
Some(expanded.to_string_lossy().into_owned())
}
None => None,
};
let accent = style.accent_color.as_deref().and_then(valid_hex);
let payload = serde_json::json!({
"slides": slides,
"template": template,
"accent": accent,
})
.to_string();
let mut child = tokio::process::Command::new("python3")
.arg("-c")
.arg(PPTX_SCRIPT)
.arg(path)
.stdin(std::process::Stdio::piped())
.stdout(std::process::Stdio::piped())
.stderr(std::process::Stdio::piped())
.spawn()
.map_err(|e| format!("python3 could not be started ({e}). {INSTALL_HINT}"))?;
if let Some(mut stdin) = child.stdin.take() {
stdin
.write_all(payload.as_bytes())
.await
.map_err(|e| format!("failed to send slide data to python3: {e}"))?;
}
let out = child
.wait_with_output()
.await
.map_err(|e| format!("python3 did not finish: {e}"))?;
if !out.status.success() {
let stderr = String::from_utf8_lossy(&out.stderr);
if stderr.contains("No module named") {
return Err(format!("python-pptx is not installed. {INSTALL_HINT}"));
}
return Err(format!(
"python-pptx generation failed: {}",
crate::utils::truncate_str(stderr.trim(), 500)
));
}
let summary = String::from_utf8_lossy(&out.stdout).trim().to_string();
if summary.is_empty() {
Ok(format!("{} slide(s)", slides.len()))
} else {
Ok(summary)
}
}