1use crate::archive::EpubArchive;
2use crate::book::Book;
3use crate::section::Section;
4use ahash::{AHashMap, AHashSet};
5use serde::{Deserialize, Serialize};
6
7#[derive(Debug, Clone, Serialize, Deserialize)]
9pub struct EpubOptimizerOptions {
10 pub minify_html: bool,
12 pub minify_css: bool,
14 pub purge_unused_css: bool,
16 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#[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
41pub struct EpubOptimizer;
43
44impl EpubOptimizer {
45 pub fn optimize(book: &mut Book, options: &EpubOptimizerOptions) -> OptimizationReport {
47 let mut report = OptimizationReport::default();
48
49 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 §ion.raw_html,
57 &mut used_classes,
58 &mut used_ids,
59 &mut used_tags,
60 );
61 extract_dom_identifiers(
62 §ion.processed_html,
63 &mut used_classes,
64 &mut used_ids,
65 &mut used_tags,
66 );
67 }
68
69 if options.minify_html {
71 for section in &mut book.sections {
72 section.raw_html = Self::minify_html(§ion.raw_html);
73 section.processed_html = Self::minify_html(§ion.processed_html);
74 }
75 }
76
77 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 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 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 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 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 while i < len && bytes[i].is_ascii_whitespace() {
253 i += 1;
254 }
255 if i >= len {
256 break;
257 }
258
259 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 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 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 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 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 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 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 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
422fn 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 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 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 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}