Skip to main content

latexml_post/manifest/
epub.rs

1//! EPUB manifest creation.
2//!
3//! Port of `LaTeXML::Post::Manifest::Epub` (252 lines of Perl).
4//! Creates the EPUB 3.2 package structure:
5//! - `mimetype` file
6//! - `META-INF/container.xml`
7//! - `OPS/content.opf` (spine + manifest)
8//! - Indexes all content files with correct media types
9
10use std::{fs, path::Path};
11
12/// The OPF package namespace (EPUB 3).
13const OPF_NS: &str = "http://www.idpf.org/2007/opf";
14/// Dublin Core, the metadata vocabulary an OPF's `<metadata>` carries.
15const DC_NS: &str = "http://purl.org/dc/elements/1.1/";
16
17/// EPUB 3.2 container.xml content.
18const 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
25/// Error context for a libxml call.
26///
27/// Generic over the error type on purpose: `rust-libxml` returns
28/// `Box<dyn Error>` from some constructors and `String` from others, so one
29/// fixed-argument closure cannot serve both.
30fn ctx<E>(what: impl Into<String>) -> impl Fn(E) -> String {
31  let what = what.into();
32  move |_| format!("couldn't create {what}")
33}
34
35/// Core Media Types as per EPUB 3.2 spec.
36fn 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
58/// EPUB manifest builder.
59///
60/// Port of `LaTeXML::Post::Manifest::Epub`.
61pub 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  /// Initialize the EPUB directory structure.
75  ///
76  /// Port of `Epub::initialize`.
77  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    // 1. Create mimetype file
86    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    // 2. Create META-INF/container.xml
91    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    // 3. Create OPS directory
97    let ops_dir = format!("{}/OPS", dir);
98    fs::create_dir_all(&ops_dir).map_err(|e| format!("Couldn't create OPS: {}", e))?;
99
100    // Generate a UUID for the publication
101    self.unique_identifier = Some(format!("urn:uuid:{}", generate_uuid()));
102
103    Ok(())
104  }
105
106  /// Add a document to the EPUB spine.
107  ///
108  /// Port of `Epub::process` per-document loop.
109  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  /// Generate the content.opf package document.
145  ///
146  /// Port of `Epub::finalize`. Built through the libxml DOM rather than by
147  /// `push_str`, because an OPF is parsed by strict readers: one unescaped `&`
148  /// in a single `href` invalidates the whole package, not one entry.
149  ///
150  /// That was reachable. `href` is `format!("{}.{}", name, ext)` over a split
151  /// document's file stem, and `--splitnaming=label` takes that stem from the
152  /// author's `\label{...}` — `\label{Fisher&Yates}` really does produce a file
153  /// named `Fisher&Yates.xhtml`. The old builder escaped 2 of 12 interpolated
154  /// values and `href` was not one of them. libxml escapes on set, so the
155  /// question no longer arises for any of them. (Issue 386 item 2.)
156  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      // A DOM allocation failure is not something a caller can act on, and an
172      // empty package is a clearer failure than a half-built one.
173      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    // ---- metadata ----
212    let mut metadata = Node::new("metadata", None, &doc).map_err(ctx("<metadata>"))?;
213    // `dc:` is declared HERE, matching the shape readers expect, and the
214    // Dublin Core children below are created in it.
215    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    // A closure would hold `metadata` borrowed across every call, so the
221    // Dublin Core children are appended inline through one small helper.
222    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    // ---- manifest ----
255    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    // ---- spine ----
285    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/// An entry in the EPUB spine (content document).
300#[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/// A resource entry (CSS, images, fonts).
309#[derive(Debug)]
310pub struct ResourceEntry {
311  pub id:         String,
312  pub href:       String,
313  pub media_type: String,
314}
315
316/// Convert a URL/filename to a valid NCName for use as an XML id.
317///
318/// Port of `url_id`.
319fn 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
331/// Generate a UUID v4 string.
332fn generate_uuid() -> String {
333  // Simple random UUID v4 (no external dependency)
334  use std::time::{SystemTime, UNIX_EPOCH};
335  let seed = SystemTime::now()
336    .duration_since(UNIX_EPOCH)
337    .unwrap_or_default()
338    .as_nanos();
339  // LCG-based pseudo-random for simplicity
340  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; // version 4
349  bytes[8] = (bytes[8] & 0x3F) | 0x80; // variant 1
350  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
371/// Generate an ISO 8601 timestamp (CCYY-MM-DDThh:mm:ssZ).
372fn 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  // Simple UTC timestamp computation (no chrono dependency)
379  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  // Rough date from epoch days (good enough for timestamps)
385  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
392/// Convert days since Unix epoch to (year, month, day).
393fn days_to_ymd(days: i64) -> (i64, u32, u32) {
394  // Algorithm from https://howardhinnant.github.io/date_algorithms.html
395  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  /// The package document must be well-formed for **author-controlled** input.
421  ///
422  /// Issue 386 item 2. An OPF is read by strict parsers, so one unescaped `&`
423  /// invalidates the whole book rather than one entry — and `&` is reachable:
424  /// `href` is built from a split document's file stem, and `--splitnaming=label`
425  /// takes that stem from the author's `\label{...}`, so `\label{Fisher&Yates}`
426  /// yields a file named `Fisher&Yates.xhtml`. The old `push_str` builder escaped
427  /// 2 of 12 interpolated values and `href` was not among them.
428  ///
429  /// The assertion is deliberately "it parses", not "it contains `&amp;`":
430  /// well-formedness is the property that matters, and it cannot be satisfied by
431  /// accident.
432  #[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    // The values must round-trip to their ORIGINAL text, not to an escaped or
468    // truncated form — well-formedness alone would also be satisfied by dropping
469    // the offending characters.
470    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  /// Structure the readers rely on, so the DOM rewrite cannot quietly drop a
495  /// required element or attribute.
496  #[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    // The identifier the package points at must actually carry that id.
522    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    // `properties` is optional and must be emitted only when present.
530    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}