1use std::{fs, path::Path};
11
12const OPF_NS: &str = "http://www.idpf.org/2007/opf";
14const DC_NS: &str = "http://purl.org/dc/elements/1.1/";
16
17const CONTAINER_XML: &str = r#"<?xml version="1.0"?>
19<container version="1.0" xmlns="urn:oasis:names:tc:opendocument:xmlns:container">
20 <rootfiles>
21 <rootfile full-path="OPS/content.opf" media-type="application/oebps-package+xml"/>
22 </rootfiles>
23</container>"#;
24
25fn ctx<E>(what: impl Into<String>) -> impl Fn(E) -> String {
31 let what = what.into();
32 move |_| format!("couldn't create {what}")
33}
34
35fn core_media_type(ext: &str) -> &'static str {
37 match ext.to_lowercase().as_str() {
38 "gif" => "image/gif",
39 "jpg" | "jpeg" => "image/jpeg",
40 "png" => "image/png",
41 "svg" => "image/svg+xml",
42 "mp3" => "audio/mpeg",
43 "mp4" | "mpg4" => "audio/mp4",
44 "css" => "text/css",
45 "ttf" => "font/ttf",
46 "otf" => "font/otf",
47 "woff" => "font/woff",
48 "woff2" => "font/woff2",
49 "xhtml" => "application/xhtml+xml",
50 "js" => "text/javascript",
51 "ncx" => "application/x-dtbncx+xml",
52 "smi" | "smil" => "application/smil+xml",
53 "pls" => "application/pls+xml",
54 _ => "application/octet-stream",
55 }
56}
57
58pub struct EpubManifest {
62 site_directory: String,
63 unique_identifier: Option<String>,
64}
65
66impl EpubManifest {
67 pub fn new(site_directory: &str) -> Self {
68 EpubManifest {
69 site_directory: site_directory.to_string(),
70 unique_identifier: None,
71 }
72 }
73
74 pub fn initialize(
78 &mut self,
79 _title: &str,
80 _authors: &[String],
81 _language: &str,
82 ) -> Result<(), String> {
83 let dir = &self.site_directory;
84
85 let mime_path = format!("{}/mimetype", dir);
87 fs::write(&mime_path, "application/epub+zip")
88 .map_err(|e| format!("Couldn't write mimetype: {}", e))?;
89
90 let meta_inf = format!("{}/META-INF", dir);
92 fs::create_dir_all(&meta_inf).map_err(|e| format!("Couldn't create META-INF: {}", e))?;
93 fs::write(format!("{}/container.xml", meta_inf), CONTAINER_XML)
94 .map_err(|e| format!("Couldn't write container.xml: {}", e))?;
95
96 let ops_dir = format!("{}/OPS", dir);
98 fs::create_dir_all(&ops_dir).map_err(|e| format!("Couldn't create OPS: {}", e))?;
99
100 self.unique_identifier = Some(format!("urn:uuid:{}", generate_uuid()));
102
103 Ok(())
104 }
105
106 pub fn add_document(
110 &self,
111 destination: &str,
112 has_math: bool,
113 has_svg: bool,
114 has_nav: bool,
115 ) -> SpineEntry {
116 let path = Path::new(destination);
117 let name = path.file_stem().and_then(|s| s.to_str()).unwrap_or("doc");
118 let ext = path.extension().and_then(|e| e.to_str()).unwrap_or("xhtml");
119 let item_id = url_to_id(&format!("{}.{}", name, ext));
120
121 let mut properties = Vec::new();
122 if has_math {
123 properties.push("mathml");
124 }
125 if has_svg {
126 properties.push("svg");
127 }
128 if has_nav {
129 properties.push("nav");
130 }
131
132 SpineEntry {
133 id: item_id,
134 href: format!("{}.{}", name, ext),
135 media_type: "application/xhtml+xml".to_string(),
136 properties: if properties.is_empty() {
137 None
138 } else {
139 Some(properties.join(" "))
140 },
141 }
142 }
143
144 pub fn generate_opf(
157 &self,
158 title: &str,
159 authors: &[String],
160 language: &str,
161 spine: &[SpineEntry],
162 resources: &[ResourceEntry],
163 ) -> String {
164 let uid = self
165 .unique_identifier
166 .as_deref()
167 .unwrap_or("urn:uuid:00000000-0000-0000-0000-000000000000");
168
169 match self.build_opf_dom(title, authors, language, uid, spine, resources) {
170 Ok(xml) => xml,
171 Err(e) => {
174 crate::Error!(
175 "epub",
176 "opf",
177 "Couldn't build the EPUB package document: {}",
178 e
179 );
180 String::new()
181 },
182 }
183 }
184
185 fn build_opf_dom(
186 &self,
187 title: &str,
188 authors: &[String],
189 language: &str,
190 uid: &str,
191 spine: &[SpineEntry],
192 resources: &[ResourceEntry],
193 ) -> Result<String, String> {
194 use libxml::tree::{Document as XmlDoc, Namespace, Node};
195
196 let mut doc = XmlDoc::new().map_err(|_| "couldn't create the OPF document".to_string())?;
197
198 let mut package = Node::new("package", None, &doc).map_err(ctx("<package>"))?;
199 let opf_ns = Namespace::new("", OPF_NS, &mut package).map_err(ctx("the OPF namespace"))?;
200 package
201 .set_namespace(&opf_ns)
202 .map_err(ctx("the OPF namespace"))?;
203 package
204 .set_attribute("unique-identifier", "pub-id")
205 .map_err(ctx("@unique-identifier"))?;
206 package
207 .set_attribute("version", "3.0")
208 .map_err(ctx("@version"))?;
209 doc.set_root_element(&package);
210
211 let mut metadata = Node::new("metadata", None, &doc).map_err(ctx("<metadata>"))?;
213 let dc_ns = Namespace::new("dc", DC_NS, &mut metadata).map_err(ctx("the dc namespace"))?;
216 package
217 .add_child(&mut metadata)
218 .map_err(ctx("<metadata>"))?;
219
220 fn dc_child(
223 doc: &XmlDoc,
224 dc_ns: &Namespace,
225 metadata: &mut Node,
226 name: &str,
227 text: &str,
228 ) -> Result<Node, String> {
229 let mut n = Node::new(name, Some(dc_ns.clone()), doc).map_err(ctx(name))?;
230 n.set_content(text).map_err(ctx(name))?;
231 metadata.add_child(&mut n).map_err(ctx(name))?;
232 Ok(n)
233 }
234 dc_child(&doc, &dc_ns, &mut metadata, "title", title)?;
235 for author in authors {
236 dc_child(&doc, &dc_ns, &mut metadata, "creator", author)?;
237 }
238 dc_child(&doc, &dc_ns, &mut metadata, "language", language)?;
239
240 let mut modified = Node::new("meta", None, &doc).map_err(ctx("<meta>"))?;
241 modified
242 .set_attribute("property", "dcterms:modified")
243 .map_err(ctx("@property"))?;
244 modified
245 .set_content(&chrono_like_now())
246 .map_err(ctx("<meta>"))?;
247 metadata.add_child(&mut modified).map_err(ctx("<meta>"))?;
248
249 let mut identifier = dc_child(&doc, &dc_ns, &mut metadata, "identifier", uid)?;
250 identifier
251 .set_attribute("id", "pub-id")
252 .map_err(ctx("@id"))?;
253
254 let mut manifest = Node::new("manifest", None, &doc).map_err(ctx("<manifest>"))?;
256 package
257 .add_child(&mut manifest)
258 .map_err(ctx("<manifest>"))?;
259 let mut item =
260 |id: &str, href: &str, media_type: &str, properties: Option<&str>| -> Result<(), String> {
261 let mut n = Node::new("item", None, &doc).map_err(ctx("<item>"))?;
262 n.set_attribute("id", id).map_err(ctx("@id"))?;
263 n.set_attribute("href", href).map_err(ctx("@href"))?;
264 n.set_attribute("media-type", media_type)
265 .map_err(ctx("@media-type"))?;
266 if let Some(props) = properties {
267 n.set_attribute("properties", props)
268 .map_err(ctx("@properties"))?;
269 }
270 manifest.add_child(&mut n).map_err(ctx("<item>"))
271 };
272 for entry in spine {
273 item(
274 &entry.id,
275 &entry.href,
276 &entry.media_type,
277 entry.properties.as_deref(),
278 )?;
279 }
280 for res in resources {
281 item(&res.id, &res.href, &res.media_type, None)?;
282 }
283
284 let mut spine_el = Node::new("spine", None, &doc).map_err(ctx("<spine>"))?;
286 package.add_child(&mut spine_el).map_err(ctx("<spine>"))?;
287 for entry in spine {
288 let mut itemref = Node::new("itemref", None, &doc).map_err(ctx("<itemref>"))?;
289 itemref
290 .set_attribute("idref", &entry.id)
291 .map_err(ctx("@idref"))?;
292 spine_el.add_child(&mut itemref).map_err(ctx("<itemref>"))?;
293 }
294
295 Ok(doc.to_string())
296 }
297}
298
299#[derive(Debug)]
301pub struct SpineEntry {
302 pub id: String,
303 pub href: String,
304 pub media_type: String,
305 pub properties: Option<String>,
306}
307
308#[derive(Debug)]
310pub struct ResourceEntry {
311 pub id: String,
312 pub href: String,
313 pub media_type: String,
314}
315
316fn url_to_id(name: &str) -> String {
320 let mut result = String::from("_");
321 for ch in name.chars() {
322 if ch.is_ascii_alphanumeric() || ch == '_' || ch == '-' || ch == '.' {
323 result.push(ch);
324 } else {
325 result.push_str(&format!("_x{:X}_", ch as u32));
326 }
327 }
328 result
329}
330
331fn generate_uuid() -> String {
333 use std::time::{SystemTime, UNIX_EPOCH};
335 let seed = SystemTime::now()
336 .duration_since(UNIX_EPOCH)
337 .unwrap_or_default()
338 .as_nanos();
339 let mut state = seed as u64;
341 let mut bytes = [0u8; 16];
342 for b in &mut bytes {
343 state = state
344 .wrapping_mul(6364136223846793005)
345 .wrapping_add(1442695040888963407);
346 *b = (state >> 33) as u8;
347 }
348 bytes[6] = (bytes[6] & 0x0F) | 0x40; bytes[8] = (bytes[8] & 0x3F) | 0x80; format!(
351 "{:02x}{:02x}{:02x}{:02x}-{:02x}{:02x}-{:02x}{:02x}-{:02x}{:02x}-{:02x}{:02x}{:02x}{:02x}{:02x}{:02x}",
352 bytes[0],
353 bytes[1],
354 bytes[2],
355 bytes[3],
356 bytes[4],
357 bytes[5],
358 bytes[6],
359 bytes[7],
360 bytes[8],
361 bytes[9],
362 bytes[10],
363 bytes[11],
364 bytes[12],
365 bytes[13],
366 bytes[14],
367 bytes[15]
368 )
369}
370
371fn chrono_like_now() -> String {
373 use std::time::{SystemTime, UNIX_EPOCH};
374 let secs = SystemTime::now()
375 .duration_since(UNIX_EPOCH)
376 .unwrap_or_default()
377 .as_secs();
378 let days = secs / 86400;
380 let time_of_day = secs % 86400;
381 let hours = time_of_day / 3600;
382 let minutes = (time_of_day % 3600) / 60;
383 let seconds = time_of_day % 60;
384 let (year, month, day) = days_to_ymd(days as i64);
386 format!(
387 "{:04}-{:02}-{:02}T{:02}:{:02}:{:02}Z",
388 year, month, day, hours, minutes, seconds
389 )
390}
391
392fn days_to_ymd(days: i64) -> (i64, u32, u32) {
394 let z = days + 719468;
396 let era = if z >= 0 { z } else { z - 146096 } / 146097;
397 let doe = (z - era * 146097) as u32;
398 let yoe = (doe - doe / 1460 + doe / 36524 - doe / 146096) / 365;
399 let y = yoe as i64 + era * 400;
400 let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
401 let mp = (5 * doy + 2) / 153;
402 let d = doy - (153 * mp + 2) / 5 + 1;
403 let m = if mp < 10 { mp + 3 } else { mp - 9 };
404 (y + if m <= 2 { 1 } else { 0 }, m, d)
405}
406
407#[cfg(test)]
408mod tests {
409 use super::*;
410
411 fn spine_entry(id: &str, href: &str, properties: Option<&str>) -> SpineEntry {
412 SpineEntry {
413 id: id.to_string(),
414 href: href.to_string(),
415 media_type: "application/xhtml+xml".to_string(),
416 properties: properties.map(str::to_string),
417 }
418 }
419
420 #[test]
433 fn opf_stays_well_formed_when_author_input_carries_xml_specials() {
434 let mut manifest = EpubManifest::new("/tmp/does-not-need-to-exist");
435 manifest.unique_identifier = Some("urn:uuid:test".to_string());
436
437 let spine = vec![
438 spine_entry(
439 "_Fisher_x26_Yates.xhtml",
440 "Fisher&Yates.xhtml",
441 Some("mathml"),
442 ),
443 spine_entry("_quote_x22_.xhtml", "a\"quote\".xhtml", None),
444 spine_entry("_lt_x3C_.xhtml", "a<less>.xhtml", None),
445 ];
446 let resources = vec![ResourceEntry {
447 id: "_css".to_string(),
448 href: "LaTeXML&core.css".to_string(),
449 media_type: "text/css".to_string(),
450 }];
451
452 let opf = manifest.generate_opf(
453 "Tom & Jerry: a <study> of \"conflict\"",
454 &["Ampersand & Co.".to_string()],
455 "en",
456 &spine,
457 &resources,
458 );
459
460 let parser = libxml::parser::Parser::default();
461 let doc = parser.parse_string(&opf).unwrap_or_else(|e| {
462 panic!("the OPF is not well-formed XML ({e:?}) — an unescaped special reached it:\n{opf}")
463 });
464 let root = doc.get_root_element().expect("a root element");
465 assert_eq!(root.get_name(), "package");
466
467 let hrefs: Vec<String> = crate::document::element_children(
471 &crate::document::element_children(&root)
472 .into_iter()
473 .find(|n| n.get_name() == "manifest")
474 .expect("a <manifest>"),
475 )
476 .iter()
477 .filter_map(|n| n.get_attribute("href"))
478 .collect();
479 assert!(
480 hrefs.contains(&"Fisher&Yates.xhtml".to_string()),
481 "the ampersand href did not survive the round-trip: {hrefs:?}"
482 );
483 assert!(
484 hrefs.contains(&"a\"quote\".xhtml".to_string())
485 && hrefs.contains(&"a<less>.xhtml".to_string()),
486 "a quote/less-than href did not survive the round-trip: {hrefs:?}"
487 );
488 assert!(
489 hrefs.contains(&"LaTeXML&core.css".to_string()),
490 "a RESOURCE href is interpolated by the same code path and must be escaped too: {hrefs:?}"
491 );
492 }
493
494 #[test]
497 fn opf_carries_the_structure_epub_readers_require() {
498 let mut manifest = EpubManifest::new("/tmp/does-not-need-to-exist");
499 manifest.unique_identifier = Some("urn:uuid:abc".to_string());
500 let spine = vec![spine_entry("_a.xhtml", "a.xhtml", Some("nav"))];
501
502 let opf = manifest.generate_opf("T", &["A".to_string()], "en", &spine, &[]);
503 let parser = libxml::parser::Parser::default();
504 let doc = parser.parse_string(&opf).expect("well-formed");
505 let root = doc.get_root_element().expect("root");
506
507 assert_eq!(root.get_attribute("version").as_deref(), Some("3.0"));
508 assert_eq!(
509 root.get_attribute("unique-identifier").as_deref(),
510 Some("pub-id")
511 );
512
513 let kids = crate::document::element_children(&root);
514 let names: Vec<String> = kids.iter().map(|n| n.get_name()).collect();
515 assert_eq!(
516 names,
517 vec!["metadata", "manifest", "spine"],
518 "OPF child order"
519 );
520
521 let meta = kids.iter().find(|n| n.get_name() == "metadata").unwrap();
523 let ident = crate::document::element_children(meta)
524 .into_iter()
525 .find(|n| n.get_attribute("id").as_deref() == Some("pub-id"))
526 .expect("a <dc:identifier id='pub-id'>");
527 assert_eq!(ident.get_content(), "urn:uuid:abc");
528
529 let item =
531 crate::document::element_children(kids.iter().find(|n| n.get_name() == "manifest").unwrap())
532 .into_iter()
533 .next()
534 .expect("an <item>");
535 assert_eq!(item.get_attribute("properties").as_deref(), Some("nav"));
536
537 let itemref =
538 crate::document::element_children(kids.iter().find(|n| n.get_name() == "spine").unwrap())
539 .into_iter()
540 .next()
541 .expect("an <itemref>");
542 assert_eq!(itemref.get_attribute("idref").as_deref(), Some("_a.xhtml"));
543 }
544}