univert 0.4.0

Universal file converter (library and CLI)
Documentation
use std::fs;

use pulldown_cmark::{Event, Options, Parser, Tag, TagEnd, html};

use crate::{Job, Result};

pub fn to_md(job: &Job) -> Result<()> {
  let html = fs::read_to_string(job.input)?;
  let md = html2md::parse_html(&html);
  fs::write(job.output, md)?;
  Ok(())
}

pub fn md_to_html(job: &Job) -> Result<()> {
  let md = fs::read_to_string(job.input)?;
  let mut opts = Options::empty();
  opts.insert(Options::ENABLE_TABLES);
  opts.insert(Options::ENABLE_FOOTNOTES);
  opts.insert(Options::ENABLE_STRIKETHROUGH);
  opts.insert(Options::ENABLE_TASKLISTS);
  opts.insert(Options::ENABLE_SMART_PUNCTUATION);
  let parser = Parser::new_ext(&md, opts);
  let mut out = String::new();
  html::push_html(&mut out, parser);
  fs::write(job.output, out)?;
  Ok(())
}

/// Strip HTML tags and decode the most common entities. Produces plain text
/// suitable for a `.txt` output; formatting is discarded.
pub fn html_to_txt(job: &Job) -> Result<()> {
  let html = fs::read_to_string(job.input)?;
  let text = strip_html(&html);
  fs::write(job.output, text)?;
  Ok(())
}

/// Render Markdown to plain text by walking the event stream and emitting
/// only textual content (paragraphs, list items, headings).
pub fn md_to_txt(job: &Job) -> Result<()> {
  let md = fs::read_to_string(job.input)?;
  let mut opts = Options::empty();
  opts.insert(Options::ENABLE_TABLES);
  opts.insert(Options::ENABLE_STRIKETHROUGH);
  opts.insert(Options::ENABLE_TASKLISTS);
  let parser = Parser::new_ext(&md, opts);
  let mut out = String::new();
  for event in parser {
    match event {
      Event::Text(t) | Event::Code(t) => out.push_str(&t),
      Event::SoftBreak => out.push(' '),
      Event::HardBreak => out.push('\n'),
      Event::End(TagEnd::Paragraph | TagEnd::Heading(_) | TagEnd::Item) => {
        out.push('\n');
      }
      Event::End(TagEnd::CodeBlock) => out.push('\n'),
      Event::Start(Tag::Item) => out.push_str("- "),
      _ => {}
    }
  }
  fs::write(job.output, out)?;
  Ok(())
}

/// Plain text is valid Markdown, so we simply copy the bytes through.
pub fn txt_to_md(job: &Job) -> Result<()> {
  fs::copy(job.input, job.output)?;
  Ok(())
}

fn strip_html(html: &str) -> String {
  let mut out = String::with_capacity(html.len());
  let bytes = html.as_bytes();
  let mut i = 0;
  let mut in_script_or_style: Option<&str> = None;
  while i < bytes.len() {
    if let Some(tag) = in_script_or_style {
      let close = format!("</{tag}");
      if let Some(rel) = html[i..]
        .to_ascii_lowercase()
        .find(&close)
        .and_then(|r| html[i + r..].find('>').map(|gt| r + gt + 1))
      {
        i += rel;
        in_script_or_style = None;
      } else {
        break;
      }
      continue;
    }
    let b = bytes[i];
    if b == b'<' {
      // Look ahead for tag name to detect script/style.
      let tag_start = i + 1;
      let mut j = tag_start;
      if j < bytes.len() && bytes[j] == b'/' {
        j += 1;
      }
      let name_start = j;
      while j < bytes.len()
        && (bytes[j].is_ascii_alphanumeric() || bytes[j] == b'-')
      {
        j += 1;
      }
      let name = html[name_start..j].to_ascii_lowercase();
      let end = html[i..]
        .find('>')
        .map(|rel| i + rel + 1)
        .unwrap_or(bytes.len());
      if name == "script" || name == "style" {
        in_script_or_style =
          Some(if name == "script" { "script" } else { "style" });
      } else if matches!(
        name.as_str(),
        "p"
          | "br"
          | "div"
          | "li"
          | "tr"
          | "h1"
          | "h2"
          | "h3"
          | "h4"
          | "h5"
          | "h6"
      ) {
        out.push('\n');
      }
      i = end;
    } else if b == b'&' {
      if let Some(semi) = html[i..].find(';') {
        let entity = &html[i + 1..i + semi];
        let decoded = match entity {
          "amp" => Some('&'),
          "lt" => Some('<'),
          "gt" => Some('>'),
          "quot" => Some('"'),
          "apos" => Some('\''),
          "nbsp" => Some(' '),
          s if s.starts_with("#x") || s.starts_with("#X") => {
            u32::from_str_radix(&s[2..], 16)
              .ok()
              .and_then(char::from_u32)
          }
          s if s.starts_with('#') => {
            s[1..].parse::<u32>().ok().and_then(char::from_u32)
          }
          _ => None,
        };
        if let Some(c) = decoded {
          out.push(c);
          i += semi + 1;
          continue;
        }
      }
      out.push(b as char);
      i += 1;
    } else {
      out.push(b as char);
      i += 1;
    }
  }
  // Collapse runs of blank lines.
  let mut collapsed = String::with_capacity(out.len());
  let mut blank = 0;
  for line in out.lines() {
    let trimmed = line.trim_end();
    if trimmed.is_empty() {
      blank += 1;
      if blank <= 1 {
        collapsed.push('\n');
      }
    } else {
      blank = 0;
      collapsed.push_str(trimmed);
      collapsed.push('\n');
    }
  }
  collapsed
}