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