univert 0.4.0

Universal file converter (library and CLI)
Documentation
//! Document converters: epub, rtf, pdf.

use std::fs;

use crate::{Error, Job, Result};

pub fn epub_to_html(job: &Job) -> Result<()> {
  let mut doc = epub::doc::EpubDoc::new(job.input)
    .map_err(|e| Error::other(format!("epub open: {e}")))?;

  let mut out = String::from("<!doctype html><html><head>");
  if let Some(title) = doc.mdata("title") {
    out.push_str("<title>");
    push_html_escaped(&mut out, &title);
    out.push_str("</title>");
  }
  out.push_str("</head><body>");

  let spine = doc.spine.clone();
  for item in &spine {
    if let Some((content, _mime)) = doc.get_resource_str(&item.idref) {
      out.push_str(&extract_body(&content));
    }
  }

  out.push_str("</body></html>");
  fs::write(job.output, out)?;
  Ok(())
}

pub fn epub_to_md(job: &Job) -> Result<()> {
  let mut doc = epub::doc::EpubDoc::new(job.input)
    .map_err(|e| Error::other(format!("epub open: {e}")))?;

  let mut md = String::new();
  if let Some(title) = doc.mdata("title") {
    md.push_str("# ");
    md.push_str(&title);
    md.push_str("\n\n");
  }

  let spine = doc.spine.clone();
  for item in &spine {
    if let Some((content, _)) = doc.get_resource_str(&item.idref) {
      let body = extract_body(&content);
      md.push_str(&html2md::parse_html(&body));
      md.push_str("\n\n");
    }
  }

  fs::write(job.output, md)?;
  Ok(())
}

pub fn rtf_to_txt(job: &Job) -> Result<()> {
  let raw = fs::read_to_string(job.input)?;
  let text = strip_rtf(&raw);
  fs::write(job.output, text)?;
  Ok(())
}

pub fn rtf_to_md(job: &Job) -> Result<()> {
  // Minimal conversion: strip RTF markup, write as plain Markdown.
  let raw = fs::read_to_string(job.input)?;
  let text = strip_rtf(&raw);
  fs::write(job.output, text)?;
  Ok(())
}

pub fn pdf_to_txt(job: &Job) -> Result<()> {
  let bytes = fs::read(job.input)?;
  let text = pdf_extract::extract_text_from_mem(&bytes)
    .map_err(|e| Error::other(format!("pdf extract: {e}")))?;
  fs::write(job.output, text)?;
  Ok(())
}

/// Pulls the content of `<body>` out of an XHTML document. Falls back to the
/// entire string when no body tag is found.
fn extract_body(html: &str) -> String {
  let lower = html.to_ascii_lowercase();
  let start = match lower.find("<body") {
    Some(i) => i,
    None => return html.to_string(),
  };
  let body_start = match html[start..].find('>') {
    Some(rel) => start + rel + 1,
    None => return html.to_string(),
  };
  let end = lower[body_start..]
    .find("</body>")
    .map(|rel| body_start + rel)
    .unwrap_or(html.len());
  html[body_start..end].to_string()
}

/// Very small RTF stripper: drops control words, groups, and keeps only
/// literal text. Handles common escapes (`\\`, `\{`, `\}`, `\par`, `\line`,
/// `\tab`, `\'xx` hex bytes, `\ud...`).
fn strip_rtf(input: &str) -> String {
  let mut out = String::new();
  let bytes = input.as_bytes();
  let mut i = 0;

  while i < bytes.len() {
    let b = bytes[i];
    match b {
      b'{' | b'}' => {
        i += 1;
      }
      b'\\' => {
        if i + 1 >= bytes.len() {
          break;
        }
        let next = bytes[i + 1];
        if next == b'\\' || next == b'{' || next == b'}' {
          out.push(next as char);
          i += 2;
        } else if next == b'\'' && i + 3 < bytes.len() {
          // \'hh — hex byte. We do not know the code page; best-effort.
          let hex = std::str::from_utf8(&bytes[i + 2..i + 4]).unwrap_or("00");
          if let Ok(byte) = u8::from_str_radix(hex, 16) {
            out.push(byte as char);
          }
          i += 4;
        } else if next.is_ascii_alphabetic() {
          // Control word: \word(-?digits)? followed by optional space.
          i += 1;
          let start = i;
          while i < bytes.len() && bytes[i].is_ascii_alphabetic() {
            i += 1;
          }
          let word = &input[start..i];
          if i < bytes.len() && bytes[i] == b'-' {
            i += 1;
          }
          while i < bytes.len() && bytes[i].is_ascii_digit() {
            i += 1;
          }
          if i < bytes.len() && bytes[i] == b' ' {
            i += 1;
          }
          match word {
            "par" | "line" | "sect" => out.push('\n'),
            "tab" => out.push('\t'),
            _ => {}
          }
        } else {
          i += 2;
        }
      }
      _ => {
        out.push(b as char);
        i += 1;
      }
    }
  }

  out
}

fn push_html_escaped(out: &mut String, s: &str) {
  for c in s.chars() {
    match c {
      '&' => out.push_str("&amp;"),
      '<' => out.push_str("&lt;"),
      '>' => out.push_str("&gt;"),
      '"' => out.push_str("&quot;"),
      '\'' => out.push_str("&#39;"),
      c => out.push(c),
    }
  }
}