use std::path::{Path, PathBuf};
use crate::domain::paper::Paper;
use crate::error::{ResearchError, Result};
pub struct PdfSource;
const MAX_BODY_CHARS: usize = 500_000;
pub const PAGE_MARKER_PREFIX: &str = "<!-- page ";
fn page_marker(n: usize) -> String {
format!("{PAGE_MARKER_PREFIX}{n} -->")
}
fn join_pages(pages: &[String]) -> String {
let mut out = String::new();
for (i, page) in pages.iter().enumerate() {
out.push_str(&page_marker(i + 1));
out.push('\n');
out.push_str(page);
if !page.ends_with('\n') {
out.push('\n');
}
}
out
}
fn section_heading_re() -> &'static regex::Regex {
use std::sync::OnceLock;
static RE: OnceLock<regex::Regex> = OnceLock::new();
RE.get_or_init(|| {
regex::Regex::new(r"(?mi)^\s*(abstract|introduction|background|related work|methods|method|materials and methods|results|results and discussion|discussion|conclusions?|references|acknowledg(?:e)?ments?)\s*:?\s*$")
.unwrap()
})
}
fn prepare_body(text: &str) -> Option<String> {
let collapsed: String = text
.lines()
.map(|l| l.trim_end())
.collect::<Vec<_>>()
.join("\n");
let marked = section_heading_re().replace_all(&collapsed, "\n## $1\n");
if marked.trim().is_empty() {
return None;
}
Some(marked.chars().take(MAX_BODY_CHARS).collect())
}
fn warn_truncated(bytes: &[u8], pages: &[String], label: &str) {
if let Some(expected) = load_page_count(bytes)
&& expected > pages.len()
{
eprintln!(
"Warning: extracted {} of {expected} page(s) from {label}; the stored body is truncated.",
pages.len()
);
}
}
fn extract_pages(bytes: &[u8], label: &str) -> Result<Vec<String>> {
match std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
pdf_extract::extract_text_from_mem_by_pages(bytes)
})) {
Ok(Ok(pages)) => Ok(pages),
Ok(Err(e)) => Err(ResearchError::Source(format!(
"PDF text extraction failed for {label}: {e}"
))),
Err(p) => {
let detail = p
.downcast_ref::<&str>()
.map(|s| (*s).to_string())
.or_else(|| p.downcast_ref::<String>().cloned())
.unwrap_or_else(|| "unknown panic".into());
Err(ResearchError::Source(format!(
"PDF text extraction panicked for {label}: {detail}"
)))
}
}
}
fn load_page_count(bytes: &[u8]) -> Option<usize> {
std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
pdf_extract::Document::load_mem(bytes)
.ok()
.map(|d| d.get_pages().len())
}))
.unwrap_or(None)
}
impl PdfSource {
pub fn new() -> Self {
Self
}
pub fn collect_paths(path: &Path) -> Result<Vec<PathBuf>> {
if path.is_file() {
return Ok(vec![path.to_path_buf()]);
}
if path.is_dir() {
let mut paths = Vec::new();
for entry in std::fs::read_dir(path)? {
let p = entry?.path();
if p.extension().and_then(|e| e.to_str()) == Some("pdf") {
paths.push(p);
}
}
paths.sort();
return Ok(paths);
}
Err(ResearchError::Source(format!(
"Path does not exist: {}",
path.display()
)))
}
pub fn extract_body(&self, bytes: &[u8], label: &str) -> Result<Option<String>> {
let pages = extract_pages(bytes, label)?;
warn_truncated(bytes, &pages, label);
Ok(prepare_body(&join_pages(&pages)))
}
pub fn ingest_file(&self, path: &Path) -> Result<(Paper, Option<String>)> {
let bytes = std::fs::read(path)?;
let pages = extract_pages(&bytes, &path.display().to_string())?;
warn_truncated(&bytes, &pages, &path.display().to_string());
let text = join_pages(&pages);
let title = path
.file_stem()
.and_then(|s| s.to_str())
.unwrap_or("Untitled PDF")
.to_string();
let mut paper = Paper::new(title);
paper.abstract_text = text
.lines()
.take(30)
.collect::<Vec<_>>()
.join(" ")
.split_whitespace()
.collect::<Vec<_>>()
.join(" ")
.chars()
.take(1000)
.collect();
paper.pdf_path = Some(
path.canonicalize()
.unwrap_or_else(|_| path.to_path_buf())
.display()
.to_string(),
);
let body = prepare_body(&text);
Ok((paper, body))
}
pub fn name(&self) -> &str {
"pdf"
}
}
impl Default for PdfSource {
fn default() -> Self {
Self::new()
}
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::tempdir;
#[test]
fn collect_paths_single_file() {
let dir = tempdir().unwrap();
let pdf = dir.path().join("paper.pdf");
std::fs::write(&pdf, b"%PDF-1.4 fake").unwrap();
let paths = PdfSource::collect_paths(&pdf).unwrap();
assert_eq!(paths, vec![pdf]);
}
#[test]
fn collect_paths_directory() {
let dir = tempdir().unwrap();
std::fs::write(dir.path().join("a.pdf"), b"%PDF-1.4 fake").unwrap();
std::fs::write(dir.path().join("b.pdf"), b"%PDF-1.4 fake").unwrap();
std::fs::write(dir.path().join("readme.txt"), b"text").unwrap();
let paths = PdfSource::collect_paths(dir.path()).unwrap();
assert_eq!(paths.len(), 2);
assert!(paths.iter().all(|p| p.extension().unwrap() == "pdf"));
}
#[test]
fn collect_paths_nonexistent_errors() {
let result = PdfSource::collect_paths(Path::new("/nonexistent/path/to/file.pdf"));
assert!(result.is_err());
}
#[test]
fn source_name() {
assert_eq!(PdfSource::new().name(), "pdf");
}
#[test]
fn joins_pages_with_markers() {
let joined = join_pages(&["first page".into(), "second page".into()]);
assert!(joined.starts_with("<!-- page 1 -->\nfirst page"));
assert!(joined.contains("<!-- page 2 -->\nsecond page"));
assert_eq!(joined.matches(PAGE_MARKER_PREFIX).count(), 2);
}
#[test]
fn page_markers_survive_prepare_body() {
let joined = join_pages(&["Introduction\ntext here".into(), "Results\nmore".into()]);
let body = prepare_body(&joined).unwrap();
assert!(body.contains("<!-- page 1 -->"));
assert!(body.contains("<!-- page 2 -->"));
assert!(body.contains("## Introduction"));
assert!(body.contains("## Results"));
}
#[test]
fn empty_page_list_yields_no_body() {
assert!(prepare_body(&join_pages(&[])).is_none());
}
#[test]
fn garbage_pdf_yields_error_not_panic() {
let bytes = b"%PDF-1.4\ntrailer\n<< /Root 1 0 R >>\nstartxref\n0\n%%EOF";
match PdfSource::new().extract_body(bytes, "garbage") {
Ok(None) | Err(_) => {}
Ok(Some(body)) => assert!(!body.is_empty()),
}
}
}