1use crate::api::Presentation;
2use crate::generator::{SlideContent, Image};
3use crate::exc::Result;
4
5#[derive(Clone, Debug)]
7pub struct HtmlExportOptions {
8 pub include_notes: bool,
10 pub enable_navigation: bool,
12 pub syntax_highlight: bool,
14 pub export_images_as_files: bool,
16 pub image_output_dir: Option<String>,
18}
19
20impl Default for HtmlExportOptions {
21 fn default() -> Self {
22 Self {
23 include_notes: true,
24 enable_navigation: true,
25 syntax_highlight: true,
26 export_images_as_files: false,
27 image_output_dir: None,
28 }
29 }
30}
31
32impl HtmlExportOptions {
33 pub fn new() -> Self {
34 Self::default()
35 }
36
37 pub fn with_notes(mut self, include: bool) -> Self {
38 self.include_notes = include;
39 self
40 }
41
42 pub fn with_navigation(mut self, enable: bool) -> Self {
43 self.enable_navigation = enable;
44 self
45 }
46
47 pub fn with_syntax_highlight(mut self, enable: bool) -> Self {
48 self.syntax_highlight = enable;
49 self
50 }
51
52 pub fn with_image_files(mut self, enable: bool, dir: Option<&str>) -> Self {
53 self.export_images_as_files = enable;
54 self.image_output_dir = dir.map(|d| d.to_string());
55 self
56 }
57}
58
59pub fn export_to_html(presentation: &Presentation) -> Result<String> {
61 export_to_html_with_options(presentation, &HtmlExportOptions::default())
62}
63
64pub fn export_to_html_with_options(presentation: &Presentation, options: &HtmlExportOptions) -> Result<String> {
66 let mut html = String::new();
67
68 html.push_str("<!DOCTYPE html>\n<html>\n<head>\n");
70 html.push_str("<meta charset=\"UTF-8\">\n");
71 html.push_str("<meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\">\n");
72 html.push_str(&format!("<title>{}</title>\n", presentation.get_title()));
73
74 html.push_str("<style>\n");
76 html.push_str(include_str!("html_style.css"));
77
78 if options.enable_navigation {
80 html.push_str(include_str!("html_navigation.css"));
81 }
82
83 if options.include_notes {
84 html.push_str(include_str!("html_notes.css"));
85 }
86
87 html.push_str("</style>\n");
88
89 if options.enable_navigation {
91 html.push_str("<script>\n");
92 html.push_str(include_str!("html_navigation.js"));
93 html.push_str("</script>\n");
94 }
95
96 html.push_str("</head>\n<body>\n");
97
98 html.push_str("<div class=\"slide title-slide\" data-slide=\"0\">\n");
100 html.push_str(&format!("<h1>{}</h1>\n", presentation.get_title()));
101 html.push_str("</div>\n");
102
103 for (i, slide) in presentation.slides().iter().enumerate() {
105 html.push_str(&render_slide_with_options(slide, i + 1, options));
106 }
107
108 if options.enable_navigation {
110 html.push_str("<div class=\"navigation-controls\">\n");
111 html.push_str("<button onclick=\"previousSlide()\" id=\"prevBtn\">Previous</button>\n");
112 html.push_str("<button onclick=\"nextSlide()\" id=\"nextBtn\">Next</button>\n");
113 html.push_str("<span id=\"slideCounter\"></span>\n");
114 html.push_str("</div>\n");
115 }
116
117 html.push_str("</body>\n</html>");
118
119 Ok(html)
120}
121
122fn render_slide_with_options(slide: &SlideContent, index: usize, options: &HtmlExportOptions) -> String {
123 let mut html = String::new();
124
125 html.push_str(&format!("<div class=\"slide\" id=\"slide-{}\" data-slide=\"{}\">\n", index, index));
126
127 html.push_str(&format!("<div class=\"slide-number\">{}</div>\n", index));
129
130 html.push_str(&format!("<h2>{}</h2>\n", slide.title));
132
133 html.push_str("<div class=\"content\">\n");
135
136 if !slide.content.is_empty() {
138 html.push_str("<ul>\n");
139 for item in &slide.content {
140 html.push_str(&format!("<li>{}</li>\n", item));
141 }
142 html.push_str("</ul>\n");
143 }
144
145 if let Some(ref table) = slide.table {
147 html.push_str(&render_table_html(table));
148 }
149
150 for image in &slide.images {
152 if let Some(img_html) = render_image_with_options(image, options) {
153 html.push_str(&img_html);
154 }
155 }
156
157 for code in &slide.code_blocks {
159 if options.syntax_highlight {
160 html.push_str(&format!("<pre><code class=\"language-{}\">", code.language));
161 } else {
162 html.push_str("<pre><code>");
163 }
164 html.push_str(&escape_html(&code.code));
165 html.push_str("</code></pre>\n");
166 }
167
168 if options.include_notes {
170 if let Some(ref notes) = slide.notes {
171 html.push_str(&format!("<div class=\"speaker-notes\"><strong>Notes:</strong> {}</div>\n", escape_html(notes)));
172 }
173 }
174
175 html.push_str("</div>\n"); html.push_str("</div>\n"); html
179}
180
181fn render_table_html(_table: &crate::generator::Table) -> String {
183 let mut html = String::new();
184 html.push_str("<table class=\"ppt-table\">\n");
185
186 html.push_str("<tbody>\n");
189 html.push_str("<tr><td>Table content</td></tr>\n"); html.push_str("</tbody>\n");
191
192 html.push_str("</table>\n");
193 html
194}
195
196fn escape_html(text: &str) -> String {
198 text.replace('&', "&")
199 .replace('<', "<")
200 .replace('>', ">")
201 .replace('"', """)
202 .replace('\'', "'")
203}
204
205fn render_image_with_options(image: &Image, options: &HtmlExportOptions) -> Option<String> {
206 let bytes = image.get_bytes()?;
207 let mime = match image.format.to_lowercase().as_str() {
208 "jpg" | "jpeg" => "image/jpeg",
209 "png" => "image/png",
210 "gif" => "image/gif",
211 "svg" => "image/svg+xml",
212 _ => "application/octet-stream",
213 };
214
215 if options.export_images_as_files {
216 let filename = format!("slide_img_{}.{}", image.filename, image.format);
218 let filepath = if let Some(ref dir) = options.image_output_dir {
219 format!("{}/{}", dir, filename)
220 } else {
221 filename.clone()
222 };
223
224 Some(format!(
225 "<div class=\"image-container\"><img src=\"{}\" alt=\"{}\" /></div>\n",
226 filepath, image.filename
227 ))
228 } else {
229 let b64 = base64_encode(&bytes);
231 Some(format!(
232 "<div class=\"image-container\"><img src=\"data:{};base64,{}\" alt=\"{}\" /></div>\n",
233 mime, b64, image.filename
234 ))
235 }
236}
237
238fn base64_encode(data: &[u8]) -> String {
240 const ALPHABET: &[u8] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
241 let mut output = String::with_capacity(data.len() * 4 / 3 + 4);
242
243 let mut i = 0;
244 while i < data.len() {
245 let mut buf = [0u8; 3];
246 let mut len = 0;
247
248 for j in 0..3 {
249 if i + j < data.len() {
250 buf[j] = data[i + j];
251 len += 1;
252 }
253 }
254
255 let b0 = (buf[0] >> 2) & 0x3F;
256 let b1 = ((buf[0] & 0x03) << 4) | ((buf[1] >> 4) & 0x0F);
257 let b2 = ((buf[1] & 0x0F) << 2) | ((buf[2] >> 6) & 0x03);
258 let b3 = buf[2] & 0x3F;
259
260 output.push(ALPHABET[b0 as usize] as char);
261 output.push(ALPHABET[b1 as usize] as char);
262
263 if len > 1 {
264 output.push(ALPHABET[b2 as usize] as char);
265 } else {
266 output.push('=');
267 }
268
269 if len > 2 {
270 output.push(ALPHABET[b3 as usize] as char);
271 } else {
272 output.push('=');
273 }
274
275 i += 3;
276 }
277
278 output
279}