Skip to main content

fastpaper/
read.rs

1use std::path::Path;
2
3/// Extract full text from a local PDF file.
4pub fn extract_text(path: &Path) -> Result<String, String> {
5    let bytes = std::fs::read(path).map_err(|e| format!("Failed to read file: {}", e))?;
6    extract_text_from_bytes(&bytes)
7}
8
9/// Extract full text from PDF bytes in memory.
10pub fn extract_text_from_bytes(bytes: &[u8]) -> Result<String, String> {
11    let doc = pdf_oxide::PdfDocument::from_bytes(bytes.to_vec())
12        .map_err(|e| format!("Failed to open PDF: {}", e))?;
13    let text = doc
14        .extract_all_text()
15        .map_err(|e| format!("Failed to extract text: {}", e))?;
16    Ok(text)
17}
18
19/// Section headings recognised when slicing a paper, in the order they
20/// normally appear. Extraction is heuristic: PDF text has no structure, so the
21/// best we can do is cut from one known heading to whichever known heading
22/// comes next.
23const SECTION_HEADINGS: &[&str] = &[
24    "abstract",
25    "introduction",
26    "background",
27    "related work",
28    "methods",
29    "methodology",
30    "materials and methods",
31    "results",
32    "discussion",
33    "conclusion",
34    "references",
35];
36
37/// Extract one section from PDF text by heading name (case-insensitive).
38///
39/// Returns `None` when the heading is not found or the slice is empty.
40pub fn extract_section(full_text: &str, heading: &str) -> Option<String> {
41    let lower = full_text.to_lowercase();
42    let heading = heading.to_lowercase();
43    let start = lower.find(&heading)?;
44    let body_start = start + heading.len();
45
46    let end = SECTION_HEADINGS
47        .iter()
48        .filter(|h| **h != heading)
49        .filter_map(|h| lower[body_start..].find(h).map(|i| body_start + i))
50        .min()
51        .unwrap_or(full_text.len());
52
53    let section = full_text[body_start..end].trim();
54    if section.is_empty() {
55        None
56    } else {
57        Some(section.to_string())
58    }
59}
60
61/// Extract only the abstract section from PDF text.
62pub fn extract_section_abstract(full_text: &str) -> Option<String> {
63    extract_section(full_text, "abstract")
64}
65
66#[cfg(test)]
67mod tests {
68    use super::*;
69    use std::path::PathBuf;
70
71    fn fixture_path() -> PathBuf {
72        PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/test.pdf")
73    }
74
75    // Behavior 1: read local test.pdf → output contains text
76    #[test]
77    fn extract_text_returns_content() {
78        let text = extract_text(&fixture_path()).unwrap();
79        assert!(!text.is_empty(), "extracted text should not be empty");
80    }
81
82    #[test]
83    fn extract_text_contains_expected_words() {
84        let text = extract_text(&fixture_path()).unwrap();
85        assert!(
86            text.contains("Transformer")
87                || text.contains("attention")
88                || text.contains("Attention"),
89            "text should contain expected words, got: {}",
90            &text[..text.len().min(200)]
91        );
92    }
93
94    // Behavior 2: --section abstract → only abstract part
95    #[test]
96    fn extract_section_abstract_returns_content() {
97        let text = extract_text(&fixture_path()).unwrap();
98        let abstract_text = extract_section_abstract(&text);
99        assert!(abstract_text.is_some(), "should find abstract section");
100        let abs = abstract_text.unwrap();
101        assert!(!abs.is_empty());
102    }
103
104    #[test]
105    fn extract_section_abstract_does_not_contain_introduction() {
106        let text = extract_text(&fixture_path()).unwrap();
107        let abs = extract_section_abstract(&text).unwrap();
108        let lower = abs.to_lowercase();
109        assert!(
110            !lower.contains("introduction"),
111            "abstract should not contain introduction heading, got: {}",
112            abs
113        );
114    }
115
116    #[test]
117    fn extract_text_from_bytes_works() {
118        let bytes = std::fs::read(fixture_path()).unwrap();
119        let text = extract_text_from_bytes(&bytes).unwrap();
120        assert!(!text.is_empty());
121    }
122
123    // --section previously accepted seven values but only ever honoured
124    // `abstract`; every other value silently returned the whole document.
125    #[test]
126    fn extract_section_introduction_returns_content() {
127        let text = extract_text(&fixture_path()).unwrap();
128        let intro = extract_section(&text, "introduction");
129        assert!(intro.is_some(), "should find the introduction");
130        assert!(!intro.unwrap().trim().is_empty());
131    }
132
133    #[test]
134    fn extract_section_stops_at_the_next_heading() {
135        let text = extract_text(&fixture_path()).unwrap();
136        let intro = extract_section(&text, "introduction").unwrap();
137        assert!(
138            !intro.to_lowercase().contains("\nreferences"),
139            "introduction should not run into the references section"
140        );
141    }
142
143    // extract_section is a pure string function, so exercise the generic
144    // behaviour on synthetic text. The fixture PDF is a 311-character stub with
145    // only an abstract and an introduction.
146    const PAPER: &str = "\
147Title Here
148Abstract
149We present a thing.
150Introduction
151Prior work was slow.
152Methods
153We used a hammer.
154Results
155It worked.
156Conclusion
157Hammers are good.
158References
159[1] Someone, 2020.";
160
161    #[test]
162    fn extract_section_conclusion_returns_content() {
163        let s = extract_section(PAPER, "conclusion").unwrap();
164        assert!(s.contains("Hammers are good"), "got: {:?}", s);
165    }
166
167    #[test]
168    fn extract_section_conclusion_stops_before_references() {
169        let s = extract_section(PAPER, "conclusion").unwrap();
170        assert!(!s.contains("Someone, 2020"), "ran into references: {:?}", s);
171    }
172
173    #[test]
174    fn extract_section_methods_returns_only_methods() {
175        let s = extract_section(PAPER, "methods").unwrap();
176        assert!(s.contains("hammer"), "got: {:?}", s);
177        assert!(!s.contains("It worked"), "ran into results: {:?}", s);
178    }
179
180    #[test]
181    fn extract_section_is_case_insensitive() {
182        assert_eq!(
183            extract_section(PAPER, "METHODS"),
184            extract_section(PAPER, "methods")
185        );
186    }
187
188    #[test]
189    fn extract_section_unknown_heading_returns_none() {
190        assert!(extract_section(PAPER, "acknowledgements of nonexistence").is_none());
191    }
192
193    #[test]
194    fn extract_section_abstract_matches_the_generic_extractor() {
195        let text = extract_text(&fixture_path()).unwrap();
196        assert_eq!(
197            extract_section_abstract(&text),
198            extract_section(&text, "abstract")
199        );
200    }
201
202    #[test]
203    fn extract_text_nonexistent_file_returns_err() {
204        let result = extract_text(Path::new("/nonexistent/fake.pdf"));
205        assert!(result.is_err());
206    }
207}