1use crate::archive::EpubArchive;
2use crate::book::Book;
3use serde::{Deserialize, Serialize};
4
5#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
7pub enum ValidationSeverity {
8 Error,
9 Warning,
10 Info,
11}
12
13#[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#[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
32pub struct EpubValidator;
34
35impl EpubValidator {
36 pub fn validate(book: &Book) -> ValidationReport {
38 let mut errors = Vec::new();
39 let meta = book.metadata();
40
41 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 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 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 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
166pub 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 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 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 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 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 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 struct ZipEntry {
276 path: String,
277 bytes: Vec<u8>,
278 }
279
280 let mut entries = Vec::new();
281
282 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 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 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}