Skip to main content

ebook_rs/
optimizer.rs

1use crate::archive::EpubArchive;
2use crate::book::Book;
3use crate::section::Section;
4use ahash::{AHashMap, AHashSet};
5use serde::{Deserialize, Serialize};
6
7/// Configuration options for the EPUB 3 optimizer and minifier engine.
8#[derive(Debug, Clone, Serialize, Deserialize)]
9pub struct EpubOptimizerOptions {
10    /// Minify HTML/XHTML structures (strip non-essential whitespace, HTML comments).
11    pub minify_html: bool,
12    /// Minify CSS style sheets (collapse whitespace, strip CSS comments, simplify rules).
13    pub minify_css: bool,
14    /// Purge unreferenced CSS rules across all section documents.
15    pub purge_unused_css: bool,
16    /// Deduplicate identical fonts and binary images using SHA-256 fingerprinting.
17    pub deduplicate_assets: bool,
18}
19
20impl Default for EpubOptimizerOptions {
21    fn default() -> Self {
22        Self {
23            minify_html: true,
24            minify_css: true,
25            purge_unused_css: true,
26            deduplicate_assets: true,
27        }
28    }
29}
30
31/// Statistics and report returned after running EPUB optimization.
32#[derive(Debug, Clone, Default, Serialize, Deserialize)]
33pub struct OptimizationReport {
34    pub original_size_bytes: usize,
35    pub optimized_size_bytes: usize,
36    pub saved_bytes: usize,
37    pub deduplicated_assets_count: usize,
38    pub purged_css_rules_count: usize,
39}
40
41/// EPUB 3 Lossless Optimizer and Minifier engine.
42pub struct EpubOptimizer;
43
44impl EpubOptimizer {
45    /// Optimize a loaded `Book` instance in-place with the specified options.
46    pub fn optimize(book: &mut Book, options: &EpubOptimizerOptions) -> OptimizationReport {
47        let mut report = OptimizationReport::default();
48
49        // 1. Gather all referenced classes, IDs, and element tag names for CSS Purging
50        let mut used_classes = AHashSet::new();
51        let mut used_ids = AHashSet::new();
52        let mut used_tags = AHashSet::new();
53
54        for section in &book.sections {
55            extract_dom_identifiers(
56                &section.raw_html,
57                &mut used_classes,
58                &mut used_ids,
59                &mut used_tags,
60            );
61            extract_dom_identifiers(
62                &section.processed_html,
63                &mut used_classes,
64                &mut used_ids,
65                &mut used_tags,
66            );
67        }
68
69        // 2. HTML Minification
70        if options.minify_html {
71            for section in &mut book.sections {
72                section.raw_html = Self::minify_html(&section.raw_html);
73                section.processed_html = Self::minify_html(&section.processed_html);
74            }
75        }
76
77        // 3. Asset & Font Deduplication
78        if options.deduplicate_assets {
79            let dedup_count = Self::deduplicate_assets(&mut book.archive, &mut book.sections);
80            report.deduplicated_assets_count = dedup_count;
81        }
82
83        // 4. CSS Minification & Purging
84        if options.minify_css || options.purge_unused_css {
85            let css_files: Vec<String> = book
86                .archive
87                .list_files()
88                .into_iter()
89                .filter(|p| p.to_lowercase().ends_with(".css"))
90                .collect();
91
92            for css_path in css_files {
93                if let Ok(css_content) = book.archive.read_string(&css_path) {
94                    let mut optimized_css = css_content;
95                    if options.purge_unused_css {
96                        let (purged, count) =
97                            Self::purge_css(&optimized_css, &used_classes, &used_ids, &used_tags);
98                        optimized_css = purged;
99                        report.purged_css_rules_count += count;
100                    }
101                    if options.minify_css {
102                        optimized_css = Self::minify_css(&optimized_css);
103                    }
104                    book.archive.insert(&css_path, optimized_css.into_bytes());
105                }
106            }
107        }
108
109        book.invalidate_render_cache();
110        report
111    }
112
113    /// Minify HTML content by removing HTML comments and collapsing redundant whitespace.
114    pub fn minify_html(html: &str) -> String {
115        let mut out = String::with_capacity(html.len());
116        let mut in_tag = false;
117        let mut in_quote: Option<char> = None;
118        let mut in_pre = false;
119        let bytes = html.as_bytes();
120        let mut i = 0;
121
122        while i < bytes.len() {
123            if !in_tag && html[i..].starts_with("<!--") {
124                if let Some(end_idx) = html[i + 4..].find("-->") {
125                    i += 4 + end_idx + 3;
126                } else {
127                    break;
128                }
129                continue;
130            }
131
132            if !in_tag && bytes[i] == b'<' {
133                in_tag = true;
134                in_quote = None;
135                let rest = &html[i..];
136                if rest.starts_with("<pre")
137                    || rest.starts_with("<code")
138                    || rest.starts_with("<script")
139                    || rest.starts_with("<style")
140                    || rest.starts_with("<textarea")
141                {
142                    in_pre = true;
143                } else if rest.starts_with("</pre>")
144                    || rest.starts_with("</code>")
145                    || rest.starts_with("</script>")
146                    || rest.starts_with("</style>")
147                    || rest.starts_with("</textarea>")
148                {
149                    in_pre = false;
150                }
151            } else if in_tag {
152                if let Some(q) = in_quote {
153                    if html.as_bytes()[i] == q as u8 {
154                        in_quote = None;
155                    }
156                } else if bytes[i] == b'"' || bytes[i] == b'\'' {
157                    in_quote = Some(bytes[i] as char);
158                } else if bytes[i] == b'>' {
159                    in_tag = false;
160                    in_quote = None;
161                }
162            }
163
164            let ch = html[i..].chars().next().unwrap_or(' ');
165            let ch_len = ch.len_utf8();
166
167            if !in_pre && in_quote.is_none() && ch.is_whitespace() {
168                if !out.ends_with(' ')
169                    && !out.ends_with('>')
170                    && !out.ends_with('<')
171                    && !out.is_empty()
172                {
173                    out.push(' ');
174                }
175                i += ch_len;
176                continue;
177            }
178
179            if in_tag && in_quote.is_none() && (ch == '>' || ch == '/') && out.ends_with(' ') {
180                out.pop();
181            }
182
183            out.push(ch);
184            i += ch_len;
185        }
186
187        out.trim().to_string()
188    }
189
190    /// Minify CSS style sheets by removing CSS comments and collapsing whitespace.
191    pub fn minify_css(css: &str) -> String {
192        let mut out = String::with_capacity(css.len());
193        let bytes = css.as_bytes();
194        let mut i = 0;
195
196        while i < bytes.len() {
197            if css[i..].starts_with("/*") {
198                if let Some(end_idx) = css[i + 2..].find("*/") {
199                    i += 2 + end_idx + 2;
200                } else {
201                    break;
202                }
203                continue;
204            }
205
206            let ch = css[i..].chars().next().unwrap_or(' ');
207            let ch_len = ch.len_utf8();
208
209            if ch.is_whitespace() {
210                if !out.ends_with(' ')
211                    && !out.ends_with('{')
212                    && !out.ends_with('}')
213                    && !out.ends_with(':')
214                    && !out.ends_with(';')
215                    && !out.ends_with(',')
216                    && !out.is_empty()
217                {
218                    out.push(' ');
219                }
220                i += ch_len;
221                continue;
222            }
223
224            if (ch == '{' || ch == '}' || ch == ':' || ch == ';' || ch == ',') && out.ends_with(' ')
225            {
226                out.pop();
227            }
228
229            out.push(ch);
230            i += ch_len;
231        }
232
233        out.trim().to_string()
234    }
235
236    /// Purge unreferenced CSS rules based on observed classes, IDs, and HTML tags.
237    pub fn purge_css(
238        css: &str,
239        used_classes: &AHashSet<String>,
240        used_ids: &AHashSet<String>,
241        used_tags: &AHashSet<String>,
242    ) -> (String, usize) {
243        let mut purged = String::with_capacity(css.len());
244        let mut purged_count = 0;
245
246        let mut i = 0;
247        let bytes = css.as_bytes();
248        let len = bytes.len();
249
250        while i < len {
251            // Skip leading whitespace
252            while i < len && bytes[i].is_ascii_whitespace() {
253                i += 1;
254            }
255            if i >= len {
256                break;
257            }
258
259            // Find matching statement/block with proper brace nesting
260            let start = i;
261            let mut brace_depth = 0;
262            let mut found_open = false;
263
264            while i < len {
265                if bytes[i] == b'{' {
266                    brace_depth += 1;
267                    found_open = true;
268                } else if bytes[i] == b'}' {
269                    if brace_depth > 0 {
270                        brace_depth -= 1;
271                    }
272                    if found_open && brace_depth == 0 {
273                        i += 1;
274                        break;
275                    }
276                } else if bytes[i] == b';' && !found_open {
277                    i += 1;
278                    break;
279                }
280                i += 1;
281            }
282
283            let block = &css[start..i];
284            let trimmed = block.trim();
285            if trimmed.is_empty() {
286                continue;
287            }
288
289            if let Some(open_brace) = trimmed.find('{') {
290                let selector_part = trimmed[..open_brace].trim();
291
292                // At-rules (@media, @supports, @font-face, @keyframes, @import, @charset) are preserved intact
293                if selector_part.starts_with('@') {
294                    purged.push_str(trimmed);
295                    purged.push('\n');
296                    continue;
297                }
298
299                let body_part = if trimmed.ends_with('}') {
300                    &trimmed[open_brace + 1..trimmed.len() - 1]
301                } else {
302                    &trimmed[open_brace + 1..]
303                };
304
305                // Check selector components
306                let mut is_used = false;
307                for selector in selector_part.split(',') {
308                    let clean_sel = selector.trim();
309                    if clean_sel == "*"
310                        || clean_sel == ":root"
311                        || clean_sel == "html"
312                        || clean_sel == "body"
313                    {
314                        is_used = true;
315                        break;
316                    }
317
318                    // Check if class selector (.my-class)
319                    if let Some(dot_idx) = clean_sel.find('.') {
320                        let class_name: String = clean_sel[dot_idx + 1..]
321                            .chars()
322                            .take_while(|c| c.is_ascii_alphanumeric() || *c == '-' || *c == '_')
323                            .collect();
324                        if used_classes.contains(&class_name) {
325                            is_used = true;
326                            break;
327                        }
328                    }
329
330                    // Check if ID selector (#my-id)
331                    if let Some(hash_idx) = clean_sel.find('#') {
332                        let id_name: String = clean_sel[hash_idx + 1..]
333                            .chars()
334                            .take_while(|c| c.is_ascii_alphanumeric() || *c == '-' || *c == '_')
335                            .collect();
336                        if used_ids.contains(&id_name) {
337                            is_used = true;
338                            break;
339                        }
340                    }
341
342                    // Check tag selector
343                    let tag_name: String = clean_sel
344                        .chars()
345                        .take_while(|c| c.is_ascii_alphabetic())
346                        .collect();
347                    if !tag_name.is_empty() && used_tags.contains(&tag_name) {
348                        is_used = true;
349                        break;
350                    }
351                }
352
353                if is_used {
354                    purged.push_str(selector_part);
355                    purged.push('{');
356                    purged.push_str(body_part.trim());
357                    purged.push_str("}\n");
358                } else {
359                    purged_count += 1;
360                }
361            } else {
362                purged.push_str(trimmed);
363                purged.push('\n');
364            }
365        }
366
367        (purged, purged_count)
368    }
369
370    /// Deduplicate identical images and fonts across the EPUB archive.
371    pub fn deduplicate_assets(archive: &mut EpubArchive, sections: &mut [Section]) -> usize {
372        let mut hash_to_canonical_path: AHashMap<String, String> = AHashMap::new();
373        let mut path_redirects: AHashMap<String, String> = AHashMap::new();
374        let mut dedup_count = 0;
375
376        let all_files = archive.list_files();
377        for path in all_files {
378            let lower = path.to_lowercase();
379            if lower.ends_with(".png")
380                || lower.ends_with(".jpg")
381                || lower.ends_with(".jpeg")
382                || lower.ends_with(".webp")
383                || lower.ends_with(".gif")
384                || lower.ends_with(".svg")
385                || lower.ends_with(".ttf")
386                || lower.ends_with(".otf")
387                || lower.ends_with(".woff")
388                || lower.ends_with(".woff2")
389            {
390                if let Ok(bytes) = archive.read_bytes(&path) {
391                    let hash = sha1_smol::Sha1::from(&bytes).digest().to_string();
392                    if let Some(canonical) = hash_to_canonical_path.get(&hash) {
393                        path_redirects.insert(path.clone(), canonical.clone());
394                        dedup_count += 1;
395                    } else {
396                        hash_to_canonical_path.insert(hash, path.clone());
397                    }
398                }
399            }
400        }
401
402        // Remap section HTML src / href references and remove duplicate files from archive
403        if !path_redirects.is_empty() {
404            for section in sections {
405                for (old_path, new_path) in &path_redirects {
406                    let old_filename = old_path.split('/').next_back().unwrap_or(old_path);
407                    let new_filename = new_path.split('/').next_back().unwrap_or(new_path);
408                    section.raw_html = section.raw_html.replace(old_filename, new_filename);
409                    section.processed_html =
410                        section.processed_html.replace(old_filename, new_filename);
411                }
412            }
413            for old_path in path_redirects.keys() {
414                archive.remove(old_path);
415            }
416        }
417
418        dedup_count
419    }
420}
421
422/// Helper to extract class names, ID attributes, and tag names from HTML.
423fn extract_dom_identifiers(
424    html: &str,
425    classes: &mut AHashSet<String>,
426    ids: &mut AHashSet<String>,
427    tags: &mut AHashSet<String>,
428) {
429    let mut i = 0;
430    let bytes = html.as_bytes();
431
432    while i < bytes.len() {
433        if let Some(open_tag) = memchr::memchr(b'<', &bytes[i..]) {
434            let abs_open = i + open_tag;
435            let rest = &html[abs_open + 1..];
436
437            // Extract tag name
438            let tag: String = rest
439                .chars()
440                .take_while(|c| c.is_ascii_alphanumeric() || *c == '-')
441                .collect();
442            if !tag.is_empty() && !tag.starts_with('/') && !tag.starts_with('!') {
443                tags.insert(tag.to_lowercase());
444            }
445
446            if let Some(close_tag) = rest.find('>') {
447                let tag_body = &rest[..close_tag];
448
449                // Extract class="..."
450                if let Some(class_pos) = tag_body.find("class=") {
451                    let after = &tag_body[class_pos + 6..].trim_start();
452                    let quote = after.chars().next().unwrap_or('"');
453                    if (quote == '"' || quote == '\'') && after.len() > 1 {
454                        if let Some(end_q) = after[1..].find(quote) {
455                            let class_str = &after[1..=end_q];
456                            for cls in class_str.split_whitespace() {
457                                classes.insert(cls.to_string());
458                            }
459                        }
460                    }
461                }
462
463                // Extract id="..."
464                if let Some(id_pos) = tag_body.find("id=") {
465                    let after = &tag_body[id_pos + 3..].trim_start();
466                    let quote = after.chars().next().unwrap_or('"');
467                    if (quote == '"' || quote == '\'') && after.len() > 1 {
468                        if let Some(end_q) = after[1..].find(quote) {
469                            let id_str = &after[1..=end_q];
470                            ids.insert(id_str.trim().to_string());
471                        }
472                    }
473                }
474
475                i = abs_open + 1 + close_tag + 1;
476            } else {
477                break;
478            }
479        } else {
480            break;
481        }
482    }
483}