1use std::path::Path;
2
3pub 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
9pub 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
19const 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
37pub 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
61pub 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 #[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 #[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 #[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 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}