Skip to main content

vecslide_core/
compile_html.rs

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
13/// Default theme CSS for the viewer when no explicit theme is provided (dark theme).
14const 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
27/// Input to the HTML compiler. Contains the already-decoded binary content of a .vecslide.
28pub struct UnpackedPresentation {
29    pub manifest: Presentation,
30    /// Maps svg_file path (as in manifest) to raw SVG UTF-8 bytes.
31    pub svgs: HashMap<String, Vec<u8>>,
32    /// All other files in the archive, keyed by path (used for typst_file lookups).
33    pub extra_files: HashMap<String, Vec<u8>>,
34    /// Raw audio bytes (Opus/OGG).
35    pub audio: Vec<u8>,
36    /// CSS custom properties for viewer theming. If `None`, uses dark-theme defaults.
37    pub theme_css: Option<String>,
38}
39
40impl UnpackedPresentation {
41    /// Reads a file from the archive by path (checks SVGs first, then extra_files).
42    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/// Viewer-only manifest: mirrors Presentation but excludes annotation data.
55/// This struct is what gets serialized into the compiled HTML — provably annotation-free.
56#[derive(Serialize)]
57struct ViewerManifest<'a> {
58    title: &'a str,
59    #[serde(skip_serializing_if = "Option::is_none")]
60    author: Option<&'a str>,
61    /// `None` signals Light mode: no audio, viewer uses TTS instead.
62    #[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/// Compiles an unpacked presentation into a single self-contained HTML file.
83/// The result can be saved as `.html` and opened in any modern browser without a server.
84///
85/// Annotations are intentionally excluded from the output.
86#[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    // Build the viewer manifest (no annotations).
91    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    // Base64-encode the audio.
121    let audio_b64 = BASE64.encode(&unpacked.audio);
122
123    // Pre-compile all slides from typst_source if present (F3 single-file mode).
124    #[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    // Build <script type="text/xml"> blocks for each slide (in order).
149    let slide_scripts = pres
150        .slides
151        .iter()
152        .enumerate()
153        .map(|(i, slide)| {
154            // Resolve the SVG string from whichever source is configured.
155            let svg_string: String = if let Some(ref svgs) = typst_source_svgs {
156                // Single typst_source mode: SVGs pre-compiled above.
157                svgs[i].clone()
158            } else if let Some(ref svg_path) = slide.svg_file {
159                // Pre-existing SVG from the archive.
160                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                // Inline Typst source.
168                #[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                // External .typ file inside the archive.
183                #[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            // Escape </script> occurrences inside SVG content to avoid breaking the HTML parser.
202            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        // base64 of b"fake-opus-bytes"
284        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(), // intentionally empty
330            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        // The literal </script> inside SVG content must be escaped to not break the HTML parser
346        assert!(!html.contains("</script>alert"), "unescaped </script> in SVG content");
347    }
348}