Skip to main content

vecslide_core/
pack.rs

1use std::io::Write;
2
3use zip::{CompressionMethod, ZipWriter, write::SimpleFileOptions};
4
5use crate::{compile_html::UnpackedPresentation, error::VecslideError};
6
7/// Packages an `UnpackedPresentation` into a `.vecslide` archive written to `writer`.
8///
9/// WASM-safe: operates entirely in memory — no `std::fs` access.
10/// Pass a `std::io::Cursor<Vec<u8>>` to collect the bytes.
11///
12/// Compression strategy:
13/// - `manifest.yaml`, `.svg`, and `extra_files`: `Deflated`
14/// - Audio: `Stored` (Opus is already compressed)
15pub fn pack_to_writer<W: Write + std::io::Seek>(
16    unpacked: &UnpackedPresentation,
17    writer: W,
18) -> Result<(), VecslideError> {
19    let mut zip = ZipWriter::new(writer);
20
21    let deflated = SimpleFileOptions::default().compression_method(CompressionMethod::Deflated);
22    let stored   = SimpleFileOptions::default().compression_method(CompressionMethod::Stored);
23
24    // ── manifest.yaml ────────────────────────────────────────────────────────
25    let yaml = serde_norway::to_string(&unpacked.manifest)?;
26    zip.start_file("manifest.yaml", deflated)?;
27    zip.write_all(yaml.as_bytes())?;
28
29    // ── SVG files ────────────────────────────────────────────────────────────
30    for (path, bytes) in &unpacked.svgs {
31        zip.start_file(path.as_str(), deflated)?;
32        zip.write_all(bytes)?;
33    }
34
35    // ── extra_files (source.typ and any other editing artefacts) ─────────────
36    for (path, bytes) in &unpacked.extra_files {
37        zip.start_file(path.as_str(), deflated)?;
38        zip.write_all(bytes)?;
39    }
40
41    // ── audio (stored — Opus is already compressed) ───────────────────────────
42    if !unpacked.audio.is_empty()
43        && let Some(ref track) = unpacked.manifest.audio_track
44    {
45        zip.start_file(track.as_str(), stored)?;
46        zip.write_all(&unpacked.audio)?;
47    }
48
49    zip.finish()?;
50    Ok(())
51}
52
53/// Packages a source directory into a `.vecslide` archive (ZIP with renamed extension).
54///
55/// Only available with the `native` feature (requires `std::fs`).
56/// For in-memory packing (e.g. WASM), use `pack_to_writer` instead.
57///
58/// Compression strategy:
59/// - `manifest.yaml` and all `.svg` files: `Deflated`
60/// - Audio file: `Stored` (Opus is already compressed; re-compressing wastes CPU)
61#[cfg(feature = "native")]
62pub fn pack(
63    source_dir: &std::path::Path,
64    manifest: &crate::manifest::Presentation,
65    output: &std::path::Path,
66) -> Result<(), VecslideError> {
67    use std::{
68        fs::File,
69        io::{BufWriter, Read},
70    };
71
72    let file = File::create(output)?;
73    let mut zip = ZipWriter::new(BufWriter::new(file));
74
75    let deflated = SimpleFileOptions::default().compression_method(CompressionMethod::Deflated);
76    let stored   = SimpleFileOptions::default().compression_method(CompressionMethod::Stored);
77
78    // Write manifest.yaml
79    let yaml = serde_norway::to_string(manifest)?;
80    zip.start_file("manifest.yaml", deflated)?;
81    zip.write_all(yaml.as_bytes())?;
82
83    // Track all paths already written to avoid duplicates.
84    let mut written: std::collections::HashSet<String> = std::collections::HashSet::new();
85    written.insert("manifest.yaml".to_string());
86
87    // Write SVG files (only slides with a direct svg_file reference).
88    for slide in &manifest.slides {
89        if let Some(ref svg_file) = slide.svg_file {
90            if written.contains(svg_file.as_str()) {
91                continue;
92            }
93            let svg_path = source_dir.join(svg_file);
94            let mut buf = Vec::new();
95            File::open(&svg_path)?.read_to_end(&mut buf)?;
96
97            zip.start_file(svg_file.as_str(), deflated)?;
98            zip.write_all(&buf)?;
99            written.insert(svg_file.clone());
100        }
101    }
102
103    // Write typst_source (single-file mode, e.g. "lezione.typ").
104    if let Some(ref typst_source) = manifest.typst_source
105        && !written.contains(typst_source.as_str())
106    {
107        let path = source_dir.join(typst_source);
108        let mut buf = Vec::new();
109        File::open(&path)?.read_to_end(&mut buf)?;
110
111        zip.start_file(typst_source.as_str(), deflated)?;
112        zip.write_all(&buf)?;
113        written.insert(typst_source.clone());
114    }
115
116    // Write per-slide typst_file references.
117    for slide in &manifest.slides {
118        if let Some(ref typst_file) = slide.typst_file {
119            if written.contains(typst_file.as_str()) {
120                continue;
121            }
122            let path = source_dir.join(typst_file);
123            let mut buf = Vec::new();
124            File::open(&path)?.read_to_end(&mut buf)?;
125
126            zip.start_file(typst_file.as_str(), deflated)?;
127            zip.write_all(&buf)?;
128            written.insert(typst_file.clone());
129        }
130    }
131
132    // Write audio without compression (Opus is already compressed).
133    // Skipped in Light mode (audio_track = None).
134    if let Some(ref track) = manifest.audio_track {
135        let audio_path = source_dir.join(track);
136        let mut audio_buf = Vec::new();
137        File::open(&audio_path)?.read_to_end(&mut audio_buf)?;
138        zip.start_file(track.as_str(), stored)?;
139        zip.write_all(&audio_buf)?;
140        written.insert(track.clone());
141    }
142
143    zip.finish()?;
144    Ok(())
145}