1use std::collections::HashMap;
2
3use base64::{Engine, engine::general_purpose::STANDARD as BASE64};
4use serde::Serialize;
5use tracing::instrument;
6
7use crate::{
8 error::VecslideError,
9 manifest::{Animation, PointerTrail, Presentation, Slide, Transcript, TransitionKind},
10 player_template::TEMPLATE,
11};
12
13const DEFAULT_THEME_CSS: &str = "\
15--vs-primary: #1c4f82;\n \
16--vs-primary-content: #d6e4f0;\n \
17--vs-secondary: #7c3aed;\n \
18--vs-accent: #e68a00;\n \
19--vs-neutral: #23282f;\n \
20--vs-neutral-content: #a6adba;\n \
21--vs-base-100: #1f2937;\n \
22--vs-base-200: #111827;\n \
23--vs-base-300: #0f1623;\n \
24--vs-base-content: #d5d9de;\n \
25--vs-error: #f87272;";
26
27pub struct UnpackedPresentation {
29 pub manifest: Presentation,
30 pub svgs: HashMap<String, Vec<u8>>,
32 pub extra_files: HashMap<String, Vec<u8>>,
34 pub audio: Vec<u8>,
36 pub theme_css: Option<String>,
38}
39
40impl UnpackedPresentation {
41 pub fn read_file_str(&self, path: &str) -> Result<String, VecslideError> {
43 let bytes = self
44 .svgs
45 .get(path)
46 .or_else(|| self.extra_files.get(path))
47 .ok_or_else(|| VecslideError::MissingSvgFile { path: path.to_string() })?;
48 std::str::from_utf8(bytes)
49 .map(|s| s.to_string())
50 .map_err(|e| VecslideError::Other(format!("file '{path}' is not valid UTF-8: {e}")))
51 }
52}
53
54#[derive(Serialize)]
57struct ViewerManifest<'a> {
58 title: &'a str,
59 #[serde(skip_serializing_if = "Option::is_none")]
60 author: Option<&'a str>,
61 #[serde(skip_serializing_if = "Option::is_none")]
63 audio_track: Option<&'a str>,
64 total_duration_ms: u64,
65 slides: Vec<ViewerSlide<'a>>,
66 #[serde(skip_serializing_if = "Option::is_none")]
67 transcript: Option<&'a Transcript>,
68}
69
70#[derive(Serialize)]
71struct ViewerSlide<'a> {
72 id: &'a str,
73 time_start: u64,
74 #[serde(skip_serializing_if = "Vec::is_empty")]
75 animations: &'a Vec<Animation>,
76 #[serde(skip_serializing_if = "Option::is_none")]
77 pointer_trail: Option<&'a PointerTrail>,
78 #[serde(skip_serializing_if = "Option::is_none")]
79 transition: Option<&'a TransitionKind>,
80}
81
82#[instrument(skip(unpacked), fields(title = %unpacked.manifest.title, slides = unpacked.manifest.slides.len()))]
87pub fn compile_html(unpacked: &UnpackedPresentation) -> Result<String, VecslideError> {
88 let pres = &unpacked.manifest;
89
90 let total_duration_ms = pres
92 .transcript
93 .as_ref()
94 .and_then(|t| t.segments.last())
95 .map(|seg| seg.end_ms)
96 .or_else(|| pres.slides.last().map(|s| s.time_start + 5000))
97 .unwrap_or(0);
98
99 let viewer_manifest = ViewerManifest {
100 title: &pres.title,
101 author: pres.author.as_deref(),
102 audio_track: pres.audio_track.as_deref(),
103 total_duration_ms,
104 transcript: pres.transcript.as_ref(),
105 slides: pres
106 .slides
107 .iter()
108 .map(|s: &Slide| ViewerSlide {
109 id: &s.id,
110 time_start: s.time_start,
111 animations: &s.animations,
112 pointer_trail: s.pointer_trail.as_ref(),
113 transition: s.transition.as_ref(),
114 })
115 .collect(),
116 };
117
118 let manifest_json = serde_json::to_string(&viewer_manifest)?;
119
120 let audio_b64 = BASE64.encode(&unpacked.audio);
122
123 #[cfg(feature = "native")]
125 let typst_source_svgs: Option<Vec<String>> = if let Some(ref path) = pres.typst_source {
126 use crate::{typst_fonts, typst_render, typst_split};
127 let source = unpacked.read_file_str(path)?;
128 let (_, slide_sources) = typst_split::split_typst_slides(&source);
129 if slide_sources.len() != pres.slides.len() {
130 return Err(VecslideError::SlideCountMismatch {
131 typst: slide_sources.len(),
132 manifest: pres.slides.len(),
133 });
134 }
135 let fonts = typst_fonts::bundled_fonts();
136 let svgs: Result<Vec<_>, _> = slide_sources
137 .iter()
138 .map(|src| typst_render::compile_typst_to_svg(src, &fonts))
139 .collect();
140 Some(svgs?)
141 } else {
142 None
143 };
144
145 #[cfg(not(feature = "native"))]
146 let typst_source_svgs: Option<Vec<String>> = None;
147
148 let slide_scripts = pres
150 .slides
151 .iter()
152 .enumerate()
153 .map(|(i, slide)| {
154 let svg_string: String = if let Some(ref svgs) = typst_source_svgs {
156 svgs[i].clone()
158 } else if let Some(ref svg_path) = slide.svg_file {
159 let svg_bytes = unpacked.svgs.get(svg_path.as_str()).ok_or_else(|| {
161 VecslideError::MissingSvgFile { path: svg_path.clone() }
162 })?;
163 std::str::from_utf8(svg_bytes)
164 .map(|s| s.to_string())
165 .map_err(|e| VecslideError::Other(format!("SVG is not valid UTF-8: {e}")))?
166 } else if let Some(ref src) = slide.typst_inline {
167 #[cfg(feature = "native")]
169 {
170 use crate::{typst_fonts, typst_render};
171 let fonts = typst_fonts::bundled_fonts();
172 typst_render::compile_typst_to_svg(src, &fonts)?
173 }
174 #[cfg(not(feature = "native"))]
175 {
176 let _ = src;
177 return Err(VecslideError::Other(
178 "typst_inline requires the 'native' feature".into(),
179 ));
180 }
181 } else if let Some(ref path) = slide.typst_file {
182 #[cfg(feature = "native")]
184 {
185 use crate::{typst_fonts, typst_render};
186 let src = unpacked.read_file_str(path)?;
187 let fonts = typst_fonts::bundled_fonts();
188 typst_render::compile_typst_to_svg(&src, &fonts)?
189 }
190 #[cfg(not(feature = "native"))]
191 {
192 let _ = path;
193 return Err(VecslideError::Other(
194 "typst_file requires the 'native' feature".into(),
195 ));
196 }
197 } else {
198 return Err(VecslideError::NoSlideSource { id: slide.id.clone() });
199 };
200
201 let escaped = svg_string.replace("</script>", "<\\/script>");
203 Ok(format!(
204 r#"<script type="text/xml" id="slide-{i}">{escaped}</script>"#
205 ))
206 })
207 .collect::<Result<Vec<_>, VecslideError>>()?
208 .join("\n");
209
210 let theme_css = unpacked
211 .theme_css
212 .as_deref()
213 .unwrap_or(DEFAULT_THEME_CSS);
214
215 let html = TEMPLATE
216 .replace("{{MANIFEST_JSON}}", &manifest_json)
217 .replace("{{AUDIO_BASE64}}", &audio_b64)
218 .replace("{{SLIDE_SCRIPTS}}", &slide_scripts)
219 .replace("{{THEME_CSS}}", theme_css);
220
221 Ok(html)
222}
223
224#[cfg(test)]
225mod tests {
226 use super::*;
227 use crate::manifest::{Presentation, Slide};
228
229 fn minimal_unpacked(title: &str, svg: &str) -> UnpackedPresentation {
230 let slide = Slide {
231 id: "slide_01".to_string(),
232 svg_file: Some("assets/01.svg".to_string()),
233 typst_file: None,
234 typst_inline: None,
235 time_start: 0,
236 animations: vec![],
237 pointer_trail: None,
238 transition: None,
239 };
240 let manifest = Presentation {
241 format_version: "1.0".to_string(),
242 title: title.to_string(),
243 author: None,
244 description: None,
245 date: None,
246 language: None,
247 audio_track: Some("audio/test.opus".to_string()),
248 typst_source: None,
249 slides: vec![slide],
250 annotations: vec![],
251 transcript: None,
252 };
253 let mut svgs = HashMap::new();
254 svgs.insert("assets/01.svg".to_string(), svg.as_bytes().to_vec());
255 UnpackedPresentation {
256 manifest,
257 svgs,
258 extra_files: HashMap::new(),
259 audio: b"fake-opus-bytes".to_vec(),
260 theme_css: None,
261 }
262 }
263
264 #[test]
265 fn output_contains_manifest_json() {
266 let unpacked = minimal_unpacked("Test Title", "<svg></svg>");
267 let html = compile_html(&unpacked).unwrap();
268 assert!(html.contains("\"title\":\"Test Title\""), "manifest JSON missing from output");
269 }
270
271 #[test]
272 fn output_contains_slide_script_block() {
273 let unpacked = minimal_unpacked("T", "<svg><circle/></svg>");
274 let html = compile_html(&unpacked).unwrap();
275 assert!(html.contains(r#"id="slide-0""#), "slide script block missing");
276 assert!(html.contains("<svg><circle/></svg>"), "SVG content missing");
277 }
278
279 #[test]
280 fn output_contains_audio_base64() {
281 let unpacked = minimal_unpacked("T", "<svg/>");
282 let html = compile_html(&unpacked).unwrap();
283 let expected_b64 = BASE64.encode(b"fake-opus-bytes");
285 assert!(html.contains(&expected_b64), "audio base64 missing from output");
286 }
287
288 #[test]
289 fn annotations_are_excluded_from_output() {
290 use crate::manifest::{Annotation, AnnotationType, Point};
291 let mut unpacked = minimal_unpacked("T", "<svg/>");
292 unpacked.manifest.annotations.push(Annotation {
293 slide_id: "slide_01".to_string(),
294 kind: AnnotationType::Comment {
295 position: Point { x: 10.0, y: 20.0 },
296 text: "SECRET_NOTE".to_string(),
297 },
298 });
299 let html = compile_html(&unpacked).unwrap();
300 assert!(!html.contains("SECRET_NOTE"), "annotation data leaked into viewer HTML");
301 assert!(!html.contains("annotations"), "annotations key leaked into viewer HTML");
302 }
303
304 #[test]
305 fn missing_svg_file_returns_error() {
306 let unpacked = UnpackedPresentation {
307 manifest: Presentation {
308 format_version: "1.0".to_string(),
309 title: "T".to_string(),
310 author: None,
311 description: None,
312 date: None,
313 language: None,
314 audio_track: Some("audio/test.opus".to_string()),
315 typst_source: None,
316 slides: vec![Slide {
317 id: "s1".to_string(),
318 svg_file: Some("missing.svg".to_string()),
319 typst_file: None,
320 typst_inline: None,
321 time_start: 0,
322 animations: vec![],
323 pointer_trail: None,
324 transition: None,
325 }],
326 annotations: vec![],
327 transcript: None,
328 },
329 svgs: HashMap::new(), extra_files: HashMap::new(),
331 audio: vec![],
332 theme_css: None,
333 };
334 assert!(matches!(
335 compile_html(&unpacked),
336 Err(VecslideError::MissingSvgFile { .. })
337 ));
338 }
339
340 #[test]
341 fn script_tag_in_svg_is_escaped() {
342 let svg_with_script = "<svg><script>alert(1)</script></svg>";
343 let unpacked = minimal_unpacked("T", svg_with_script);
344 let html = compile_html(&unpacked).unwrap();
345 assert!(!html.contains("</script>alert"), "unescaped </script> in SVG content");
347 }
348}