botserver/src/slides/utils.rs

704 lines
24 KiB
Rust

use crate::slides::types::{
ElementContent, ElementStyle, Presentation, PresentationTheme, Slide, SlideBackground,
SlideElement, ThemeColors, ThemeFonts,
};
use uuid::Uuid;
pub fn create_default_theme() -> PresentationTheme {
PresentationTheme {
name: "Default".to_string(),
colors: ThemeColors {
primary: "#1a73e8".to_string(),
secondary: "#34a853".to_string(),
accent: "#ea4335".to_string(),
background: "#ffffff".to_string(),
text: "#202124".to_string(),
text_light: "#5f6368".to_string(),
},
fonts: ThemeFonts {
heading: "Arial".to_string(),
body: "Arial".to_string(),
},
}
}
pub fn create_title_slide(theme: &PresentationTheme) -> Slide {
Slide {
id: Uuid::new_v4().to_string(),
layout: "title".to_string(),
elements: vec![
SlideElement {
id: Uuid::new_v4().to_string(),
element_type: "text".to_string(),
x: 100.0,
y: 200.0,
width: 760.0,
height: 100.0,
rotation: 0.0,
content: ElementContent {
text: Some("Presentation Title".to_string()),
html: Some("<h1>Presentation Title</h1>".to_string()),
src: None,
shape_type: None,
chart_data: None,
table_data: None,
},
style: ElementStyle {
fill: None,
stroke: None,
stroke_width: None,
opacity: None,
shadow: None,
font_family: Some(theme.fonts.heading.clone()),
font_size: Some(44.0),
font_weight: Some("bold".to_string()),
font_style: None,
text_align: Some("center".to_string()),
vertical_align: Some("middle".to_string()),
color: Some(theme.colors.text.clone()),
line_height: None,
border_radius: None,
},
animations: vec![],
z_index: 1,
locked: false,
},
SlideElement {
id: Uuid::new_v4().to_string(),
element_type: "text".to_string(),
x: 100.0,
y: 320.0,
width: 760.0,
height: 60.0,
rotation: 0.0,
content: ElementContent {
text: Some("Subtitle".to_string()),
html: Some("<p>Subtitle</p>".to_string()),
src: None,
shape_type: None,
chart_data: None,
table_data: None,
},
style: ElementStyle {
fill: None,
stroke: None,
stroke_width: None,
opacity: None,
shadow: None,
font_family: Some(theme.fonts.body.clone()),
font_size: Some(24.0),
font_weight: None,
font_style: None,
text_align: Some("center".to_string()),
vertical_align: Some("middle".to_string()),
color: Some(theme.colors.text_light.clone()),
line_height: None,
border_radius: None,
},
animations: vec![],
z_index: 2,
locked: false,
},
],
background: SlideBackground {
bg_type: "solid".to_string(),
color: Some(theme.colors.background.clone()),
gradient: None,
image_url: None,
image_fit: None,
},
notes: None,
transition: None,
transition_config: None,
media: None,
}
}
pub fn create_content_slide(theme: &PresentationTheme) -> Slide {
Slide {
id: Uuid::new_v4().to_string(),
layout: "content".to_string(),
elements: vec![
SlideElement {
id: Uuid::new_v4().to_string(),
element_type: "text".to_string(),
x: 50.0,
y: 40.0,
width: 860.0,
height: 60.0,
rotation: 0.0,
content: ElementContent {
text: Some("Slide Title".to_string()),
html: Some("<h2>Slide Title</h2>".to_string()),
src: None,
shape_type: None,
chart_data: None,
table_data: None,
},
style: ElementStyle {
fill: None,
stroke: None,
stroke_width: None,
opacity: None,
shadow: None,
font_family: Some(theme.fonts.heading.clone()),
font_size: Some(32.0),
font_weight: Some("bold".to_string()),
font_style: None,
text_align: Some("left".to_string()),
vertical_align: Some("middle".to_string()),
color: Some(theme.colors.text.clone()),
line_height: None,
border_radius: None,
},
animations: vec![],
z_index: 1,
locked: false,
},
SlideElement {
id: Uuid::new_v4().to_string(),
element_type: "text".to_string(),
x: 50.0,
y: 120.0,
width: 860.0,
height: 400.0,
rotation: 0.0,
content: ElementContent {
text: Some("Content goes here...".to_string()),
html: Some("<p>Content goes here...</p>".to_string()),
src: None,
shape_type: None,
chart_data: None,
table_data: None,
},
style: ElementStyle {
fill: None,
stroke: None,
stroke_width: None,
opacity: None,
shadow: None,
font_family: Some(theme.fonts.body.clone()),
font_size: Some(18.0),
font_weight: None,
font_style: None,
text_align: Some("left".to_string()),
vertical_align: Some("top".to_string()),
color: Some(theme.colors.text.clone()),
line_height: Some(1.5),
border_radius: None,
},
animations: vec![],
z_index: 2,
locked: false,
},
],
background: SlideBackground {
bg_type: "solid".to_string(),
color: Some(theme.colors.background.clone()),
gradient: None,
image_url: None,
image_fit: None,
},
notes: None,
transition: None,
transition_config: None,
media: None,
}
}
pub fn create_blank_slide(theme: &PresentationTheme) -> Slide {
Slide {
id: Uuid::new_v4().to_string(),
layout: "blank".to_string(),
elements: vec![],
background: SlideBackground {
bg_type: "solid".to_string(),
color: Some(theme.colors.background.clone()),
gradient: None,
image_url: None,
image_fit: None,
},
notes: None,
transition: None,
transition_config: None,
media: None,
}
}
pub fn get_user_presentations_path(user_id: &str) -> String {
format!("users/{}/presentations", user_id)
}
pub fn generate_presentation_id() -> String {
Uuid::new_v4().to_string()
}
pub fn export_to_html(presentation: &crate::slides::types::Presentation) -> String {
let mut html = String::from(
r#"<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>"#,
);
html.push_str(&presentation.name);
html.push_str(
r#"</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body { font-family: Arial, sans-serif; background: #000; }
.slide {
width: 960px;
height: 540px;
margin: 20px auto;
position: relative;
overflow: hidden;
box-shadow: 0 4px 20px rgba(0,0,0,0.3);
}
.element { position: absolute; }
.element-text { white-space: pre-wrap; }
</style>
</head>
<body>
"#,
);
for slide in &presentation.slides {
let bg_color = slide
.background
.color
.as_deref()
.unwrap_or("#ffffff");
html.push_str(&format!(
r#" <div class="slide" style="background-color: {};">
"#,
bg_color
));
for element in &slide.elements {
let style = format!(
"left: {}px; top: {}px; width: {}px; height: {}px;",
element.x, element.y, element.width, element.height
);
let content = element
.content
.html
.as_deref()
.or(element.content.text.as_deref())
.unwrap_or("");
html.push_str(&format!(
r#" <div class="element element-{}" style="{}">{}</div>
"#,
element.element_type, style, content
));
}
html.push_str(" </div>\n");
}
html.push_str("</body>\n</html>");
html
}
pub fn sanitize_filename(name: &str) -> String {
name.chars()
.map(|c| {
if c.is_alphanumeric() || c == '-' || c == '_' || c == '.' {
c
} else if c == ' ' {
'_'
} else {
'_'
}
})
.collect::<String>()
.trim_matches('_')
.to_string()
}
pub fn export_to_svg(slide: &Slide, width: u32, height: u32) -> String {
let mut svg = format!(
r#"<?xml version="1.0" encoding="UTF-8"?>
<svg xmlns="http://www.w3.org/2000/svg" width="{}" height="{}" viewBox="0 0 {} {}">
"#,
width, height, width, height
);
let bg_color = slide.background.color.as_deref().unwrap_or("#ffffff");
svg.push_str(&format!(
r#" <rect width="100%" height="100%" fill="{}"/>
"#,
bg_color
));
for element in &slide.elements {
match element.element_type.as_str() {
"text" => {
let text = element.content.text.as_deref().unwrap_or("");
let font_size = element.style.font_size.unwrap_or(18.0);
let color = element.style.color.as_deref().unwrap_or("#000000");
let font_family = element.style.font_family.as_deref().unwrap_or("Arial");
let font_weight = element.style.font_weight.as_deref().unwrap_or("normal");
svg.push_str(&format!(
r#" <text x="{}" y="{}" font-family="{}" font-size="{}" font-weight="{}" fill="{}">{}</text>
"#,
element.x,
element.y + font_size,
font_family,
font_size,
font_weight,
color,
xml_escape(text)
));
}
"shape" => {
let shape_type = element.content.shape_type.as_deref().unwrap_or("rectangle");
let fill = element.style.fill.as_deref().unwrap_or("#cccccc");
let stroke = element.style.stroke.as_deref().unwrap_or("none");
let stroke_width = element.style.stroke_width.unwrap_or(1.0);
match shape_type {
"rectangle" | "rect" => {
let rx = element.style.border_radius.unwrap_or(0.0);
svg.push_str(&format!(
r#" <rect x="{}" y="{}" width="{}" height="{}" rx="{}" fill="{}" stroke="{}" stroke-width="{}"/>
"#,
element.x, element.y, element.width, element.height, rx, fill, stroke, stroke_width
));
}
"circle" | "ellipse" => {
let cx = element.x + element.width / 2.0;
let cy = element.y + element.height / 2.0;
let rx = element.width / 2.0;
let ry = element.height / 2.0;
svg.push_str(&format!(
r#" <ellipse cx="{}" cy="{}" rx="{}" ry="{}" fill="{}" stroke="{}" stroke-width="{}"/>
"#,
cx, cy, rx, ry, fill, stroke, stroke_width
));
}
"line" => {
svg.push_str(&format!(
r#" <line x1="{}" y1="{}" x2="{}" y2="{}" stroke="{}" stroke-width="{}"/>
"#,
element.x,
element.y,
element.x + element.width,
element.y + element.height,
stroke,
stroke_width
));
}
"triangle" => {
let x1 = element.x + element.width / 2.0;
let y1 = element.y;
let x2 = element.x;
let y2 = element.y + element.height;
let x3 = element.x + element.width;
let y3 = element.y + element.height;
svg.push_str(&format!(
r#" <polygon points="{},{} {},{} {},{}" fill="{}" stroke="{}" stroke-width="{}"/>
"#,
x1, y1, x2, y2, x3, y3, fill, stroke, stroke_width
));
}
_ => {}
}
}
"image" => {
if let Some(ref src) = element.content.src {
svg.push_str(&format!(
r#" <image x="{}" y="{}" width="{}" height="{}" href="{}"/>
"#,
element.x, element.y, element.width, element.height, src
));
}
}
_ => {}
}
}
svg.push_str("</svg>");
svg
}
fn xml_escape(s: &str) -> String {
s.replace('&', "&amp;")
.replace('<', "&lt;")
.replace('>', "&gt;")
.replace('"', "&quot;")
.replace('\'', "&apos;")
}
pub fn export_slide_to_png_placeholder(slide: &Slide, width: u32, height: u32) -> Vec<u8> {
let svg = export_to_svg(slide, width, height);
svg.into_bytes()
}
pub fn export_to_odp_content(presentation: &Presentation) -> String {
let mut xml = String::from(r#"<?xml version="1.0" encoding="UTF-8"?>
<office:document-content xmlns:office="urn:oasis:names:tc:opendocument:xmlns:office:1.0"
xmlns:draw="urn:oasis:names:tc:opendocument:xmlns:drawing:1.0"
xmlns:presentation="urn:oasis:names:tc:opendocument:xmlns:presentation:1.0"
xmlns:text="urn:oasis:names:tc:opendocument:xmlns:text:1.0"
xmlns:svg="urn:oasis:names:tc:opendocument:xmlns:svg-compatible:1.0"
office:version="1.2">
<office:body>
<office:presentation>
"#);
for (idx, slide) in presentation.slides.iter().enumerate() {
xml.push_str(&format!(
"<draw:page draw:name=\"Slide{}\" draw:style-name=\"dp1\">\n",
idx + 1
));
for element in &slide.elements {
match element.element_type.as_str() {
"text" => {
let text = element.content.text.as_deref().unwrap_or("");
xml.push_str(&format!(
r#" <draw:frame svg:x="{}pt" svg:y="{}pt" svg:width="{}pt" svg:height="{}pt">
<draw:text-box>
<text:p>{}</text:p>
</draw:text-box>
</draw:frame>
"#,
element.x, element.y, element.width, element.height, xml_escape(text)
));
}
"shape" => {
let shape_type = element.content.shape_type.as_deref().unwrap_or("rectangle");
let fill = element.style.fill.as_deref().unwrap_or("#cccccc");
match shape_type {
"rectangle" | "rect" => {
xml.push_str(&format!(
r#" <draw:rect svg:x="{}pt" svg:y="{}pt" svg:width="{}pt" svg:height="{}pt" draw:fill-color="{}"/>
"#,
element.x, element.y, element.width, element.height, fill
));
}
"circle" | "ellipse" => {
xml.push_str(&format!(
r#" <draw:ellipse svg:x="{}pt" svg:y="{}pt" svg:width="{}pt" svg:height="{}pt" draw:fill-color="{}"/>
"#,
element.x, element.y, element.width, element.height, fill
));
}
_ => {}
}
}
"image" => {
if let Some(ref src) = element.content.src {
xml.push_str(&format!(
r#" <draw:frame svg:x="{}pt" svg:y="{}pt" svg:width="{}pt" svg:height="{}pt">
<draw:image xlink:href="{}"/>
</draw:frame>
"#,
element.x, element.y, element.width, element.height, src
));
}
}
_ => {}
}
}
xml.push_str("</draw:page>\n");
}
xml.push_str("</office:presentation>\n</office:body>\n</office:document-content>");
xml
}
pub fn export_to_json(presentation: &Presentation) -> String {
serde_json::to_string_pretty(presentation).unwrap_or_default()
}
pub fn export_to_markdown(presentation: &Presentation) -> String {
let mut md = format!("# {}\n\n", presentation.name);
for (idx, slide) in presentation.slides.iter().enumerate() {
md.push_str(&format!("---\n\n## Slide {}\n\n", idx + 1));
for element in &slide.elements {
if element.element_type == "text" {
if let Some(ref text) = element.content.text {
let font_size = element.style.font_size.unwrap_or(18.0);
if font_size >= 32.0 {
md.push_str(&format!("### {}\n\n", text));
} else {
md.push_str(&format!("{}\n\n", text));
}
}
} else if element.element_type == "image" {
if let Some(ref src) = element.content.src {
md.push_str(&format!("![Image]({})\n\n", src));
}
}
}
if let Some(ref notes) = slide.notes {
md.push_str(&format!("**Speaker Notes:**\n{}\n\n", notes));
}
}
md
}
pub fn slides_from_markdown(md: &str) -> Vec<Slide> {
let theme = create_default_theme();
let mut slides = Vec::new();
let sections: Vec<&str> = md.split("\n---\n").collect();
for section in sections {
let lines: Vec<&str> = section.lines().filter(|l| !l.trim().is_empty()).collect();
if lines.is_empty() {
continue;
}
let mut slide = create_blank_slide(&theme);
let mut y_offset = 50.0;
for line in lines {
let trimmed = line.trim();
if trimmed.starts_with("# ") {
slide.elements.push(create_text_element(
&trimmed[2..],
50.0,
y_offset,
860.0,
60.0,
44.0,
true,
&theme,
));
y_offset += 80.0;
} else if trimmed.starts_with("## ") {
slide.elements.push(create_text_element(
&trimmed[3..],
50.0,
y_offset,
860.0,
50.0,
32.0,
true,
&theme,
));
y_offset += 60.0;
} else if trimmed.starts_with("### ") {
slide.elements.push(create_text_element(
&trimmed[4..],
50.0,
y_offset,
860.0,
40.0,
24.0,
true,
&theme,
));
y_offset += 50.0;
} else if trimmed.starts_with("![") {
if let Some(start) = trimmed.find('(') {
if let Some(end) = trimmed.find(')') {
let src = &trimmed[start + 1..end];
slide.elements.push(SlideElement {
id: Uuid::new_v4().to_string(),
element_type: "image".to_string(),
x: 50.0,
y: y_offset,
width: 400.0,
height: 300.0,
rotation: 0.0,
content: ElementContent {
text: None,
html: None,
src: Some(src.to_string()),
shape_type: None,
chart_data: None,
table_data: None,
},
style: ElementStyle::default(),
animations: vec![],
z_index: slide.elements.len() as i32,
locked: false,
});
y_offset += 320.0;
}
}
} else if !trimmed.is_empty() {
slide.elements.push(create_text_element(
trimmed,
50.0,
y_offset,
860.0,
30.0,
18.0,
false,
&theme,
));
y_offset += 40.0;
}
}
slides.push(slide);
}
if slides.is_empty() {
slides.push(create_title_slide(&theme));
}
slides
}
fn create_text_element(
text: &str,
x: f64,
y: f64,
width: f64,
height: f64,
font_size: f64,
bold: bool,
theme: &PresentationTheme,
) -> SlideElement {
SlideElement {
id: Uuid::new_v4().to_string(),
element_type: "text".to_string(),
x,
y,
width,
height,
rotation: 0.0,
content: ElementContent {
text: Some(text.to_string()),
html: Some(format!("<p>{}</p>", text)),
src: None,
shape_type: None,
chart_data: None,
table_data: None,
},
style: ElementStyle {
fill: None,
stroke: None,
stroke_width: None,
opacity: None,
shadow: None,
font_family: Some(theme.fonts.body.clone()),
font_size: Some(font_size),
font_weight: if bold { Some("bold".to_string()) } else { None },
font_style: None,
text_align: Some("left".to_string()),
vertical_align: Some("top".to_string()),
color: Some(theme.colors.text.clone()),
line_height: None,
border_radius: None,
},
animations: vec![],
z_index: 0,
locked: false,
}
}