Skip to main content

ebook_rs/
validator.rs

1use crate::archive::EpubArchive;
2use crate::book::Book;
3use serde::{Deserialize, Serialize};
4
5/// Severity level for EPUB validation diagnostic items.
6#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
7pub enum ValidationSeverity {
8    Error,
9    Warning,
10    Info,
11}
12
13/// Diagnostic item representing a validation rule check result.
14#[derive(Debug, Clone, Serialize, Deserialize)]
15pub struct ValidationError {
16    pub severity: ValidationSeverity,
17    pub code: String,
18    pub message: String,
19    pub location: Option<String>,
20}
21
22/// Complete report returned by the `EpubValidator`.
23#[derive(Debug, Clone, Serialize, Deserialize)]
24pub struct ValidationReport {
25    pub is_valid: bool,
26    pub errors: Vec<ValidationError>,
27    pub errors_count: usize,
28    pub warnings_count: usize,
29    pub info_count: usize,
30}
31
32/// Comprehensive EPUB and eBook structural validator.
33pub struct EpubValidator;
34
35impl EpubValidator {
36    /// Validate a `Book` instance against EPUB 2 / EPUB 3 structural standards.
37    pub fn validate(book: &Book) -> ValidationReport {
38        let mut errors = Vec::new();
39        let meta = book.metadata();
40
41        // 1. Mandatory OPF Metadata Checks
42        if meta.title.trim().is_empty() {
43            errors.push(ValidationError {
44                severity: ValidationSeverity::Error,
45                code: "PKG-001".to_string(),
46                message: "dc:title is missing or empty in OPF metadata".to_string(),
47                location: Some("metadata.title".to_string()),
48            });
49        }
50
51        if meta.identifier.as_deref().unwrap_or("").trim().is_empty() {
52            errors.push(ValidationError {
53                severity: ValidationSeverity::Warning,
54                code: "PKG-002".to_string(),
55                message: "dc:identifier is missing or empty in OPF metadata".to_string(),
56                location: Some("metadata.identifier".to_string()),
57            });
58        }
59
60        if meta.language().trim().is_empty() {
61            errors.push(ValidationError {
62                severity: ValidationSeverity::Warning,
63                code: "PKG-003".to_string(),
64                message: "dc:language is missing or empty in OPF metadata".to_string(),
65                location: Some("metadata.language".to_string()),
66            });
67        }
68
69        if meta.creator().trim().is_empty() {
70            errors.push(ValidationError {
71                severity: ValidationSeverity::Info,
72                code: "PKG-004".to_string(),
73                message: "dc:creator (author) is not specified".to_string(),
74                location: Some("metadata.creator".to_string()),
75            });
76        }
77
78        // 2. Spine & Section Integrity Checks
79        if book.spine().is_empty() {
80            errors.push(ValidationError {
81                severity: ValidationSeverity::Error,
82                code: "RSC-001".to_string(),
83                message: "Spine contains 0 reading items".to_string(),
84                location: Some("spine".to_string()),
85            });
86        }
87
88        let hydrated_sections = book.get_all_sections_hydrated();
89        if hydrated_sections.is_empty() {
90            errors.push(ValidationError {
91                severity: ValidationSeverity::Error,
92                code: "RSC-002".to_string(),
93                message: "Book contains 0 readable content sections".to_string(),
94                location: Some("sections".to_string()),
95            });
96        }
97
98        for (idx, section) in hydrated_sections.iter().enumerate() {
99            if section.href.trim().is_empty() {
100                errors.push(ValidationError {
101                    severity: ValidationSeverity::Error,
102                    code: "RSC-003".to_string(),
103                    message: format!("Section {} has empty href reference", idx),
104                    location: Some(format!("sections[{}]", idx)),
105                });
106            }
107
108            if section.char_count == 0 && section.raw_html.trim().is_empty() {
109                errors.push(ValidationError {
110                    severity: ValidationSeverity::Warning,
111                    code: "RSC-004".to_string(),
112                    message: format!(
113                        "Section {} ('{}') has no extracted text or HTML content",
114                        idx, section.href
115                    ),
116                    location: Some(format!("sections[{}]", idx)),
117                });
118            }
119        }
120
121        // 3. Navigation / TOC Link Resolution Checks
122        if book.toc().is_empty() {
123            errors.push(ValidationError {
124                severity: ValidationSeverity::Warning,
125                code: "NAV-001".to_string(),
126                message: "Table of Contents (NCX / NAV) is empty or missing".to_string(),
127                location: Some("toc".to_string()),
128            });
129        }
130
131        // 4. EPUB 3 Accessibility Metadata Verification
132        let a11y = &meta.accessibility;
133        if !a11y.is_accessible && a11y.access_modes.is_empty() {
134            errors.push(ValidationError {
135                severity: ValidationSeverity::Info,
136                code: "A11Y-001".to_string(),
137                message: "No EPUB 3 accessibility metadata (schema:accessMode) declared"
138                    .to_string(),
139                location: Some("metadata.accessibility".to_string()),
140            });
141        }
142
143        let errors_count = errors
144            .iter()
145            .filter(|e| e.severity == ValidationSeverity::Error)
146            .count();
147        let warnings_count = errors
148            .iter()
149            .filter(|e| e.severity == ValidationSeverity::Warning)
150            .count();
151        let info_count = errors
152            .iter()
153            .filter(|e| e.severity == ValidationSeverity::Info)
154            .count();
155
156        ValidationReport {
157            is_valid: errors_count == 0,
158            errors,
159            errors_count,
160            warnings_count,
161            info_count,
162        }
163    }
164}
165
166/// Universal EPUB 3 Exporter capable of serializing any `Book` (EPUB, MOBI, PDF, FB2, CBZ, TXT, ODT) to a valid EPUB 3 zip buffer.
167pub struct UniversalEpub3Exporter;
168
169impl UniversalEpub3Exporter {
170    pub fn export(book: &Book) -> Result<Vec<u8>, String> {
171        use std::io::Write;
172        let mut zip_buf = Vec::new();
173        {
174            let mut zip = zip::ZipWriter::new(std::io::Cursor::new(&mut zip_buf));
175
176            // 1. mimetype (uncompressed, first file in ZIP per EPUB spec)
177            let options_stored = zip::write::FileOptions::<()>::default()
178                .compression_method(zip::CompressionMethod::Stored);
179            zip.start_file("mimetype", options_stored)
180                .map_err(|e| e.to_string())?;
181            zip.write_all(b"application/epub+zip")
182                .map_err(|e| e.to_string())?;
183
184            let options_deflate = zip::write::FileOptions::<()>::default()
185                .compression_method(zip::CompressionMethod::Deflated);
186
187            // 2. META-INF/container.xml
188            zip.start_file("META-INF/container.xml", options_deflate)
189                .map_err(|e| e.to_string())?;
190            zip.write_all(b"<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<container version=\"1.0\" xmlns=\"urn:oasis:names:tc:opendocument:xmlns:container\">\n  <rootfiles>\n    <rootfile full-path=\"OEBPS/content.opf\" media-type=\"application/oebps-package+xml\"/>\n  </rootfiles>\n</container>")
191                .map_err(|e| e.to_string())?;
192
193            // 3. OEBPS/nav.xhtml
194            zip.start_file("OEBPS/nav.xhtml", options_deflate)
195                .map_err(|e| e.to_string())?;
196            let mut nav_html = String::from(
197                "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<!DOCTYPE html>\n<html xmlns=\"http://www.w3.org/1999/xhtml\" xmlns:epub=\"http://www.idpf.org/2007/ops\">\n<head><title>TOC</title></head>\n<body>\n<nav epub:type=\"toc\" id=\"toc\"><h1>Table of Contents</h1><ol>",
198            );
199            for (idx, _) in book.spine().iter().enumerate() {
200                nav_html.push_str(&format!(
201                    "<li><a href=\"sec_{}.xhtml\">Section {}</a></li>",
202                    idx,
203                    idx + 1
204                ));
205            }
206            nav_html.push_str("</ol></nav>\n</body>\n</html>");
207            zip.write_all(nav_html.as_bytes())
208                .map_err(|e| e.to_string())?;
209
210            // 4. OEBPS/content.opf
211            zip.start_file("OEBPS/content.opf", options_deflate)
212                .map_err(|e| e.to_string())?;
213            let meta = book.metadata();
214            let lang = if meta.language().is_empty() {
215                "en"
216            } else {
217                meta.language()
218            };
219            let mut opf_xml = format!(
220                "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<package xmlns=\"http://www.idpf.org/2007/opf\" version=\"3.0\" unique-identifier=\"uid\">\n  <metadata xmlns:dc=\"http://purl.org/dc/elements/1.1/\">\n    <dc:title>{}</dc:title>\n    <dc:identifier id=\"uid\">{}</dc:identifier>\n    <dc:language>{}</dc:language>\n",
221                crate::dom::xml_escape(&meta.title),
222                meta.identifier
223                    .as_deref()
224                    .unwrap_or("urn:uuid:ebook-rs-export"),
225                lang
226            );
227            for creator in &meta.creators {
228                opf_xml.push_str(&format!(
229                    "    <dc:creator>{}</dc:creator>\n",
230                    crate::dom::xml_escape(creator)
231                ));
232            }
233            opf_xml.push_str("  </metadata>\n  <manifest>\n    <item id=\"nav\" href=\"nav.xhtml\" media-type=\"application/xhtml+xml\" properties=\"nav\"/>\n");
234
235            for (idx, _) in book.spine().iter().enumerate() {
236                opf_xml.push_str(&format!("    <item id=\"sec_{}\" href=\"sec_{}.xhtml\" media-type=\"application/xhtml+xml\"/>\n", idx, idx));
237            }
238            // Add asset files (images, css, fonts) from book.archive to content.opf manifest
239            let mut asset_idx = 0;
240            for path in book.archive.files().keys() {
241                let path_low = path.to_lowercase();
242                if path_low.ends_with(".opf")
243                    || path_low.ends_with(".ncx")
244                    || path_low == "mimetype"
245                    || path_low == "meta-inf/container.xml"
246                    || path_low == "oebps/nav.xhtml"
247                    || (path_low.contains("sec_") && path_low.ends_with(".xhtml"))
248                {
249                    continue;
250                }
251                let rel_href = if path_low.starts_with("oebps/") {
252                    &path[6..]
253                } else {
254                    path.as_str()
255                };
256                let mime = EpubArchive::get_mime_type(path);
257                opf_xml.push_str(&format!(
258                    "    <item id=\"asset_{}\" href=\"{}\" media-type=\"{}\"/>\n",
259                    asset_idx,
260                    crate::dom::sanitize_and_repair_xml(rel_href),
261                    mime
262                ));
263                asset_idx += 1;
264            }
265
266            opf_xml.push_str("  </manifest>\n  <spine>\n");
267            for (idx, _) in book.spine().iter().enumerate() {
268                opf_xml.push_str(&format!("    <itemref idref=\"sec_{}\"/>\n", idx));
269            }
270            opf_xml.push_str("  </spine>\n</package>");
271            zip.write_all(opf_xml.as_bytes())
272                .map_err(|e| e.to_string())?;
273
274            // 5 & 6: Prepare section documents & asset files for ZIP entry creation
275            struct ZipEntry {
276                path: String,
277                bytes: Vec<u8>,
278            }
279
280            let mut entries = Vec::new();
281
282            // Collect section HTML documents
283            let hydrated_sections = book.get_all_sections_hydrated();
284            for (idx, sec) in hydrated_sections.iter().enumerate() {
285                let html_body = if !sec.raw_html.is_empty() {
286                    &sec.raw_html
287                } else {
288                    &sec.processed_html
289                };
290                let trimmed = html_body.trim();
291                let doc_xhtml = if trimmed.contains("<html") || trimmed.contains("<body") {
292                    if trimmed.starts_with("<?xml") {
293                        trimmed.to_string()
294                    } else {
295                        format!(
296                            "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<!DOCTYPE html>\n{}",
297                            trimmed
298                        )
299                    }
300                } else {
301                    format!(
302                        "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<!DOCTYPE html>\n<html xmlns=\"http://www.w3.org/1999/xhtml\">\n<head><title>Section {}</title></head>\n<body>{}</body>\n</html>",
303                        idx + 1,
304                        trimmed
305                    )
306                };
307                entries.push(ZipEntry {
308                    path: format!("OEBPS/sec_{}.xhtml", idx),
309                    bytes: doc_xhtml.into_bytes(),
310                });
311            }
312
313            // Collect asset files (images, CSS, fonts) from book.archive
314            for (path, bytes) in book.archive.files() {
315                let path_low = path.to_lowercase();
316                if path_low.ends_with(".opf")
317                    || path_low.ends_with(".ncx")
318                    || path_low == "mimetype"
319                    || path_low == "meta-inf/container.xml"
320                    || path_low == "oebps/nav.xhtml"
321                    || (path_low.contains("sec_") && path_low.ends_with(".xhtml"))
322                {
323                    continue;
324                }
325                let zip_path = if path_low.starts_with("oebps/") {
326                    path.clone()
327                } else {
328                    format!("OEBPS/{}", path)
329                };
330                entries.push(ZipEntry {
331                    path: zip_path,
332                    bytes: bytes.clone(),
333                });
334            }
335
336            // Write all section and asset entries into ZIP archive
337
338            for entry in entries {
339                zip.start_file(&entry.path, options_deflate)
340                    .map_err(|e| e.to_string())?;
341                zip.write_all(&entry.bytes).map_err(|e| e.to_string())?;
342            }
343
344            zip.finish().map_err(|e| e.to_string())?;
345        }
346
347        Ok(zip_buf)
348    }
349}