pub mod pdf;
use crate::config::Route;
use anyhow::{Context, Result, bail};
use regex::Regex;
use serde::Serialize;
use std::path::Path;
use std::process::Command;
use std::sync::LazyLock;
#[derive(Debug, Serialize)]
pub struct Extraction {
#[serde(skip)]
pub text: String,
pub method: String,
pub pages_needing_ocr: Vec<u32>,
pub pages_with_tables: Vec<u32>,
pub pages_with_columns: Vec<u32>,
pub page_count: Option<u32>,
}
impl Extraction {
fn plain(text: String, method: &str) -> Self {
Extraction {
text,
method: method.to_string(),
pages_needing_ocr: Vec::new(),
pages_with_tables: Vec::new(),
pages_with_columns: Vec::new(),
page_count: None,
}
}
}
pub fn extract(path: &Path) -> Result<Extraction> {
let ext = path
.extension()
.and_then(|e| e.to_str())
.unwrap_or_default()
.to_ascii_lowercase();
let route = Route::for_extension(&ext).with_context(|| {
format!(
"unsupported format '.{ext}' — supported: {}",
crate::config::supported_extensions().join(", ")
)
})?;
match route {
Route::Pdf => pdf::extract(path),
Route::Text => {
let text = read_text(path)?;
Ok(Extraction::plain(text, "plain-text"))
}
Route::Html => {
let raw = read_text(path)?;
Ok(Extraction::plain(strip_html(&raw), "html-strip"))
}
Route::Anydoc => {
let text = anydoc::to_markdown(path)
.map_err(|e| anyhow::anyhow!("{e:?}"))
.with_context(|| format!("anydoc could not convert {}", path.display()))?;
Ok(Extraction::plain(text, "anydoc"))
}
Route::Calibre => calibre(path),
}
}
fn read_text(path: &Path) -> Result<String> {
let bytes = std::fs::read(path).with_context(|| format!("reading {}", path.display()))?;
Ok(String::from_utf8_lossy(&bytes).into_owned())
}
static SCRIPT_STYLE: LazyLock<Regex> = LazyLock::new(|| {
Regex::new(r"(?is)<(script|style)\b[^>]*>.*?</\s*(script|style)\s*>").unwrap()
});
static BLOCK_END: LazyLock<Regex> = LazyLock::new(|| {
Regex::new(r"(?i)</\s*(p|div|section|article|li|tr|h[1-6]|blockquote|pre)\s*>|<\s*br\s*/?>")
.unwrap()
});
static TAG: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"(?s)<[^>]+>").unwrap());
static BLANK_RUN: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"\n{3,}").unwrap());
fn strip_html(raw: &str) -> String {
let no_scripts = SCRIPT_STYLE.replace_all(raw, " ");
let with_breaks = BLOCK_END.replace_all(&no_scripts, "\n");
let no_tags = TAG.replace_all(&with_breaks, "");
let decoded = no_tags
.replace(" ", " ")
.replace("&", "&")
.replace("<", "<")
.replace(">", ">")
.replace(""", "\"")
.replace("'", "'")
.replace("'", "'")
.replace("—", "—")
.replace("–", "–")
.replace("…", "…");
let lines: Vec<&str> = decoded.lines().map(str::trim).collect();
BLANK_RUN
.replace_all(&lines.join("\n"), "\n\n")
.trim()
.to_string()
}
fn calibre(path: &Path) -> Result<Extraction> {
if which("ebook-convert").is_none() {
bail!(
"{} needs Calibre's `ebook-convert`, which is not on PATH.\n\
Install Calibre (https://calibre-ebook.com/download), or convert the\n\
file to EPUB yourself and pass that instead.",
path.display()
);
}
let out_dir = std::env::temp_dir().join("anything-to-skill-calibre");
std::fs::create_dir_all(&out_dir).context("creating Calibre scratch directory")?;
let out_file = out_dir.join("converted.txt");
let status = Command::new("ebook-convert")
.arg(path)
.arg(&out_file)
.output()
.context("running ebook-convert")?;
if !status.status.success() {
bail!(
"ebook-convert failed on {}: {}",
path.display(),
String::from_utf8_lossy(&status.stderr).trim()
);
}
let text = read_text(&out_file)?;
let _ = std::fs::remove_file(&out_file);
Ok(Extraction::plain(text, "calibre"))
}
pub fn which(program: &str) -> Option<std::path::PathBuf> {
std::env::var_os("PATH").and_then(|paths| {
std::env::split_paths(&paths).find_map(|dir| {
let candidate = dir.join(program);
candidate.is_file().then_some(candidate)
})
})
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn html_keeps_prose_and_drops_markup() {
let html = "<html><head><style>p{color:red}</style></head><body>\
<h1>Title</h1><p>First & second.</p><p>Third</p>\
<script>alert('x')</script></body></html>";
let out = strip_html(html);
assert!(out.contains("Title"));
assert!(out.contains("First & second."));
assert!(out.contains("Third"));
assert!(!out.contains("color:red"));
assert!(!out.contains("alert"));
}
#[test]
fn html_separates_block_elements() {
let out = strip_html("<p>one</p><p>two</p>");
assert!(out.contains('\n'), "blocks ran together: {out:?}");
}
#[test]
fn unsupported_extension_names_the_alternatives() {
let err = extract(Path::new("/tmp/whatever.xyz"))
.unwrap_err()
.to_string();
assert!(err.contains("unsupported format"), "{err}");
assert!(err.contains("epub"), "{err}");
}
}