Skip to main content

typst_bake/
stats.rs

1//! Compression statistics for embedded files.
2//!
3//! All embedded resources (templates, fonts, packages) are compressed with zstd
4//! and decompressed lazily at runtime.
5
6/// Compression statistics for all embedded content.
7///
8/// Resources are compressed with zstd at compile time and decompressed lazily at runtime.
9#[derive(Debug, Clone)]
10pub struct EmbedStats {
11    /// Template files statistics.
12    pub templates: CategoryStats,
13    /// Package files statistics.
14    pub packages: PackageStats,
15    /// Font files statistics.
16    pub fonts: CategoryStats,
17    /// Deduplication statistics.
18    pub dedup: DedupStats,
19    /// Zstd compression level used.
20    pub compression_level: i32,
21}
22
23/// Statistics for content deduplication across all categories.
24#[derive(Debug, Clone, Copy)]
25pub struct DedupStats {
26    /// Total number of files (before dedup).
27    pub total_files: usize,
28    /// Number of unique blobs after dedup.
29    pub unique_blobs: usize,
30    /// Number of duplicate files removed.
31    pub duplicate_count: usize,
32    /// Bytes saved by deduplication.
33    pub saved_bytes: usize,
34}
35
36/// Statistics for a category of files (templates, fonts).
37#[derive(Debug, Clone, Copy)]
38pub struct CategoryStats {
39    /// Original uncompressed size in bytes.
40    pub original_size: usize,
41    /// Compressed size in bytes.
42    pub compressed_size: usize,
43    /// Number of files.
44    pub file_count: usize,
45}
46
47/// Statistics for all packages.
48#[derive(Debug, Clone)]
49pub struct PackageStats {
50    /// Per-package statistics.
51    pub packages: Vec<PackageInfo>,
52    /// Original uncompressed size in bytes.
53    pub original_size: usize,
54    /// Compressed size in bytes.
55    pub compressed_size: usize,
56}
57
58/// Statistics for a single package.
59#[derive(Debug, Clone)]
60pub struct PackageInfo {
61    /// Package name with version (e.g., "gentle-clues:1.2.0").
62    pub name: String,
63    /// Original uncompressed size in bytes.
64    pub original_size: usize,
65    /// Compressed size in bytes.
66    pub compressed_size: usize,
67    /// Number of files in this package.
68    pub file_count: usize,
69}
70
71impl EmbedStats {
72    /// Calculate total original size across all categories.
73    pub fn total_original(&self) -> usize {
74        self.templates.original_size + self.packages.original_size + self.fonts.original_size
75    }
76
77    /// Calculate total compressed size across all categories.
78    pub fn total_compressed(&self) -> usize {
79        self.templates.compressed_size + self.packages.compressed_size + self.fonts.compressed_size
80    }
81
82    /// Calculate compression ratio (0.0 to 1.0, where 0.0 means no compression).
83    pub fn compression_ratio(&self) -> f64 {
84        compression_ratio(self.total_original(), self.total_compressed())
85    }
86
87    /// Total size after deduplication (actual binary footprint).
88    pub fn total_deduplicated(&self) -> usize {
89        self.total_compressed() - self.dedup.saved_bytes
90    }
91
92    /// Overall reduction ratio from original to deduplicated.
93    pub fn overall_ratio(&self) -> f64 {
94        compression_ratio(self.total_original(), self.total_deduplicated())
95    }
96
97    /// Total number of files across all categories.
98    fn total_file_count(&self) -> usize {
99        self.templates.file_count
100            + self.fonts.file_count
101            + self
102                .packages
103                .packages
104                .iter()
105                .map(|p| p.file_count)
106                .sum::<usize>()
107    }
108
109    /// Display compression statistics in a human-readable format.
110    pub fn display(&self) {
111        print!("{self}");
112    }
113}
114
115impl std::fmt::Display for EmbedStats {
116    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
117        writeln!(f, "Embed Summary")?;
118        writeln!(f, "========================")?;
119
120        // Templates
121        if self.templates.file_count > 0 {
122            writeln!(
123                f,
124                "Templates:  {:>9} -> {:>9} ({:>5.1}% reduced, {} files)",
125                format_size(self.templates.original_size),
126                format_size(self.templates.compressed_size),
127                self.templates.compression_ratio() * 100.0,
128                self.templates.file_count
129            )?;
130        }
131
132        // Fonts
133        if self.fonts.file_count > 0 {
134            writeln!(
135                f,
136                "Fonts:      {:>9} -> {:>9} ({:>5.1}% reduced, {} files)",
137                format_size(self.fonts.original_size),
138                format_size(self.fonts.compressed_size),
139                self.fonts.compression_ratio() * 100.0,
140                self.fonts.file_count
141            )?;
142        }
143
144        // Packages
145        if !self.packages.packages.is_empty() {
146            writeln!(f, "Packages:")?;
147
148            // Calculate column widths for package alignment
149            let (name_width, orig_width, comp_width) =
150                self.packages
151                    .packages
152                    .iter()
153                    .fold((0, 0, 0), |(nw, ow, cw), p| {
154                        (
155                            nw.max(p.name.len()),
156                            ow.max(format_size(p.original_size).len()),
157                            cw.max(format_size(p.compressed_size).len()),
158                        )
159                    });
160
161            for pkg in &self.packages.packages {
162                writeln!(
163                    f,
164                    "  {:<name_w$}  {:>orig_w$} -> {:>comp_w$}  ({:>5.1}%)",
165                    pkg.name,
166                    format_size(pkg.original_size),
167                    format_size(pkg.compressed_size),
168                    pkg.compression_ratio() * 100.0,
169                    name_w = name_width,
170                    orig_w = orig_width,
171                    comp_w = comp_width,
172                )?;
173            }
174        }
175
176        // Compressed total
177        writeln!(f, "------------------------")?;
178        writeln!(
179            f,
180            "Compressed: {} -> {} (level {}, {:.1}% reduced, {} files)",
181            format_size(self.total_original()),
182            format_size(self.total_compressed()),
183            self.compression_level,
184            self.compression_ratio() * 100.0,
185            self.total_file_count()
186        )?;
187
188        // Deduplicated (only shown when there are duplicates)
189        if self.dedup.duplicate_count > 0 {
190            writeln!(
191                f,
192                "Deduplicated: {} unique blobs, {} duplicates removed (-{})",
193                self.dedup.unique_blobs,
194                self.dedup.duplicate_count,
195                format_size(self.dedup.saved_bytes)
196            )?;
197        }
198
199        // Total (actual binary footprint)
200        writeln!(
201            f,
202            "Total: {} -> {} ({:.1}% reduced)",
203            format_size(self.total_original()),
204            format_size(self.total_deduplicated()),
205            self.overall_ratio() * 100.0
206        )
207    }
208}
209
210/// Trait for types that have original/compressed sizes and can compute a compression ratio.
211pub trait HasCompressionRatio {
212    fn original_size(&self) -> usize;
213    fn compressed_size(&self) -> usize;
214
215    fn compression_ratio(&self) -> f64 {
216        compression_ratio(self.original_size(), self.compressed_size())
217    }
218}
219
220macro_rules! impl_has_compression_ratio {
221    ($($ty:ty),*) => {
222        $(impl HasCompressionRatio for $ty {
223            fn original_size(&self) -> usize { self.original_size }
224            fn compressed_size(&self) -> usize { self.compressed_size }
225        })*
226    };
227}
228
229impl_has_compression_ratio!(CategoryStats, PackageInfo, PackageStats);
230
231/// Calculate compression ratio from original and compressed sizes.
232/// Returns 0.0 when original is 0, otherwise 1.0 - (compressed / original).
233fn compression_ratio(original: usize, compressed: usize) -> f64 {
234    if original == 0 {
235        return 0.0;
236    }
237    1.0 - (compressed as f64 / original as f64)
238}
239
240/// Format bytes into a human-readable size string.
241fn format_size(bytes: usize) -> String {
242    const KB: usize = 1024;
243    const MB: usize = KB * 1024;
244
245    if bytes >= MB {
246        format!("{:.2} MB", bytes as f64 / MB as f64)
247    } else if bytes >= KB {
248        format!("{:.1} KB", bytes as f64 / KB as f64)
249    } else {
250        format!("{bytes} B")
251    }
252}
253
254#[cfg(test)]
255mod tests {
256    use super::*;
257
258    #[test]
259    fn test_format_size_bytes() {
260        assert_eq!(format_size(0), "0 B");
261        assert_eq!(format_size(512), "512 B");
262        assert_eq!(format_size(1023), "1023 B");
263    }
264
265    #[test]
266    fn test_format_size_kilobytes() {
267        assert_eq!(format_size(1024), "1.0 KB");
268        assert_eq!(format_size(1536), "1.5 KB");
269        assert_eq!(format_size(10240), "10.0 KB");
270    }
271
272    #[test]
273    fn test_format_size_megabytes() {
274        assert_eq!(format_size(1048576), "1.00 MB");
275        assert_eq!(format_size(1572864), "1.50 MB");
276    }
277
278    #[test]
279    fn test_compression_ratio_zero_original() {
280        let stats = CategoryStats {
281            original_size: 0,
282            compressed_size: 0,
283            file_count: 0,
284        };
285        assert_eq!(stats.compression_ratio(), 0.0);
286    }
287
288    #[test]
289    fn test_compression_ratio_75_percent() {
290        // Asymmetric values to distinguish from incorrect calculation (original/compressed)
291        // Correct: 1 - (250/1000) = 0.75
292        // Wrong:   1 - (1000/250) = -3.0
293        let stats = CategoryStats {
294            original_size: 1000,
295            compressed_size: 250,
296            file_count: 1,
297        };
298        assert!((stats.compression_ratio() - 0.75).abs() < 0.001);
299    }
300
301    #[test]
302    fn test_embed_stats_totals() {
303        let stats = EmbedStats {
304            templates: CategoryStats {
305                original_size: 1000,
306                compressed_size: 200, // 80% compression
307                file_count: 1,
308            },
309            fonts: CategoryStats {
310                original_size: 2000,
311                compressed_size: 600, // 70% compression
312                file_count: 2,
313            },
314            packages: PackageStats {
315                packages: vec![],
316                original_size: 1000,
317                compressed_size: 200, // 80% compression
318            },
319            dedup: DedupStats {
320                total_files: 4,
321                unique_blobs: 3,
322                duplicate_count: 1,
323                saved_bytes: 100,
324            },
325            compression_level: 19,
326        };
327        // Total: 4000 -> 1000 (75% compression)
328        assert_eq!(stats.total_original(), 4000);
329        assert_eq!(stats.total_compressed(), 1000);
330        assert!((stats.compression_ratio() - 0.75).abs() < 0.001);
331        // Deduplicated: 1000 - 100 = 900
332        assert_eq!(stats.total_deduplicated(), 900);
333    }
334}