1use std::{
15 path::Path,
16 sync::{
17 atomic::{AtomicU64, Ordering},
18 LazyLock,
19 },
20 time::Duration,
21};
22
23use tokio::sync::Semaphore;
24
25pub const MAX_PDF_BYTES: u64 = 32 * 1024 * 1024;
27
28pub const MAX_PAGES: usize = 100;
30
31pub const MAX_CHARS: usize = 200_000;
33
34pub const TIME_LIMIT: Duration = Duration::from_secs(60);
36
37const PARALLEL: usize = 2;
39
40static SLOTS: LazyLock<Semaphore> = LazyLock::new(|| Semaphore::new(PARALLEL));
41
42static MEMORY_BYTES: AtomicU64 = AtomicU64::new(1024 * 1024 * 1024);
44
45pub fn configure(memory_bytes: u64) {
47 MEMORY_BYTES.store(memory_bytes, Ordering::Relaxed);
48}
49
50pub fn is_pdf(filename: &str) -> bool {
52 filename
53 .rsplit_once('.')
54 .is_some_and(|(stem, ext)| !stem.is_empty() && ext.eq_ignore_ascii_case("pdf"))
55}
56
57pub fn extract(bytes: &[u8]) -> anyhow::Result<String> {
61 let bytes = bytes.to_vec();
62 let result = std::panic::catch_unwind(move || -> anyhow::Result<String> {
63 let doc = pdf_oxide::PdfDocument::from_bytes(bytes)
64 .map_err(|e| anyhow::anyhow!("not a readable PDF: {e}"))?;
65 let pages = doc
66 .page_count()
67 .map_err(|e| anyhow::anyhow!("not a readable PDF: {e}"))?;
68 let mut out = String::new();
69 for page in 0..pages.min(MAX_PAGES) {
70 if let Ok(text) = doc.extract_text(page) {
71 out.push_str(&text);
72 out.push('\n');
73 }
74 if out.chars().count() > MAX_CHARS {
75 break;
76 }
77 }
78 Ok(tidy(&out))
79 });
80 match result {
81 Ok(r) => r,
82 Err(_) => Err(anyhow::anyhow!("the PDF could not be read")),
83 }
84}
85
86pub(crate) fn tidy(text: &str) -> String {
89 let mut out = String::with_capacity(text.len().min(MAX_CHARS + 16));
90 let mut blank_lines = 0;
91 for line in text.lines() {
92 let line: String = line
93 .chars()
94 .map(|c| if c.is_control() { ' ' } else { c })
95 .collect();
96 let line = line.trim_end();
97 if line.trim().is_empty() {
98 blank_lines += 1;
99 if blank_lines > 1 {
100 continue;
101 }
102 } else {
103 blank_lines = 0;
104 }
105 out.push_str(line);
106 out.push('\n');
107 if out.chars().count() > MAX_CHARS {
108 break;
109 }
110 }
111 out.trim().chars().take(MAX_CHARS).collect()
112}
113
114pub fn run_job(
118 input: impl std::io::Read,
119 mut output: impl std::io::Write,
120 max_input_bytes: u64,
121) -> anyhow::Result<usize> {
122 use std::io::Read as _;
123 let mut bytes = Vec::new();
124 input
125 .take(max_input_bytes.saturating_add(1))
126 .read_to_end(&mut bytes)?;
127 if bytes.len() as u64 > max_input_bytes {
128 anyhow::bail!(
129 "This PDF is too large to read (more than {} MB).",
130 max_input_bytes / (1024 * 1024)
131 );
132 }
133 let text = extract(&bytes)?;
134 output.write_all(text.as_bytes())?;
135 output.flush()?;
136 Ok(text.chars().count())
137}
138
139#[derive(Clone, Copy, Debug, PartialEq, Eq)]
141pub enum TextFormat {
142 Pdf,
143 Html,
145}
146
147impl TextFormat {
148 pub fn as_str(self) -> &'static str {
150 match self {
151 TextFormat::Pdf => "pdf",
152 TextFormat::Html => "html",
153 }
154 }
155
156 pub fn parse(s: &str) -> Option<Self> {
157 match s {
158 "pdf" => Some(TextFormat::Pdf),
159 "html" => Some(TextFormat::Html),
160 _ => None,
161 }
162 }
163
164 pub fn max_input_bytes(self) -> u64 {
166 match self {
167 TextFormat::Pdf => MAX_PDF_BYTES,
168 TextFormat::Html => crate::htmltext::MAX_HTML_BYTES,
169 }
170 }
171
172 fn name(self) -> &'static str {
173 match self {
174 TextFormat::Pdf => "PDF",
175 TextFormat::Html => "HTML file",
176 }
177 }
178}
179
180pub fn run_job_as(
183 format: TextFormat,
184 input: impl std::io::Read,
185 output: impl std::io::Write,
186 max_input_bytes: u64,
187) -> anyhow::Result<usize> {
188 match format {
189 TextFormat::Pdf => run_job(input, output, max_input_bytes),
190 TextFormat::Html => crate::htmltext::run_job(input, output, max_input_bytes),
191 }
192}
193
194pub async fn extract_file(file: tokio::fs::File) -> String {
199 match file.metadata().await {
201 Ok(meta) if meta.len() <= MAX_PDF_BYTES => {}
202 _ => return String::new(),
203 }
204 extract_isolated(file, TextFormat::Pdf).await
205}
206
207pub(crate) async fn extract_isolated(file: tokio::fs::File, format: TextFormat) -> String {
211 let Ok(_slot) = SLOTS.acquire().await else {
212 return String::new();
213 };
214 let file = file.into_std().await;
215 let outcome = match crate::preview::worker_exe() {
216 Some(exe) => run_worker(&exe, file, format).await,
217 None => run_in_process(file, format).await,
218 };
219 match outcome {
220 Ok(text) => text.chars().take(MAX_CHARS).collect(),
221 Err(message) => {
222 tracing::debug!(%message, "no text from the {}", format.name());
223 String::new()
224 }
225 }
226}
227
228async fn run_worker(
229 exe: &Path,
230 input: std::fs::File,
231 format: TextFormat,
232) -> Result<String, String> {
233 let child = tokio::process::Command::new(exe)
234 .arg("text-extract")
235 .arg("--format")
236 .arg(format.as_str())
237 .arg("--max-input-bytes")
238 .arg(format.max_input_bytes().to_string())
239 .arg("--max-memory-bytes")
240 .arg(MEMORY_BYTES.load(Ordering::Relaxed).to_string())
241 .env_clear()
242 .env("RUST_LOG", "error")
243 .stdin(std::process::Stdio::from(input))
244 .stdout(std::process::Stdio::piped())
245 .stderr(std::process::Stdio::piped())
246 .kill_on_drop(true)
247 .spawn()
248 .map_err(|e| format!("the text extractor could not be started: {e}"))?;
249 match tokio::time::timeout(TIME_LIMIT, child.wait_with_output()).await {
250 Err(_) => Err(format!("no result within {} s", TIME_LIMIT.as_secs())),
251 Ok(Err(e)) => Err(e.to_string()),
252 Ok(Ok(out)) if out.status.success() => {
253 Ok(String::from_utf8_lossy(&out.stdout).into_owned())
254 }
255 Ok(Ok(out)) => Err(String::from_utf8_lossy(&out.stderr)
256 .lines()
257 .rev()
258 .find(|l| !l.trim().is_empty())
259 .unwrap_or("the extractor stopped unexpectedly")
260 .to_string()),
261 }
262}
263
264async fn run_in_process(input: std::fs::File, format: TextFormat) -> Result<String, String> {
265 let task = tokio::task::spawn_blocking(move || {
266 let mut text = Vec::new();
267 run_job_as(format, input, &mut text, format.max_input_bytes())
268 .map(|_| String::from_utf8_lossy(&text).into_owned())
269 .map_err(|e| e.to_string())
270 });
271 match tokio::time::timeout(TIME_LIMIT, task).await {
272 Err(_) => Err("timed out".into()),
273 Ok(Err(e)) => Err(e.to_string()),
274 Ok(Ok(r)) => r,
275 }
276}
277
278#[cfg(test)]
279mod tests {
280 use super::*;
281
282 pub(crate) fn sample_pdf(lines: &[&str]) -> Vec<u8> {
284 let mut content = String::from("BT /F1 12 Tf 14 TL 50 740 Td\n");
285 for line in lines {
286 let escaped = line
287 .replace('\\', "\\\\")
288 .replace('(', "\\(")
289 .replace(')', "\\)");
290 content.push_str(&format!("({escaped}) Tj T*\n"));
291 }
292 content.push_str("ET");
293 let objects = [
294 "<< /Type /Catalog /Pages 2 0 R >>".to_string(),
295 "<< /Type /Pages /Kids [3 0 R] /Count 1 >>".to_string(),
296 "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] /Contents 4 0 R \
297 /Resources << /Font << /F1 5 0 R >> >> >>"
298 .to_string(),
299 format!(
300 "<< /Length {} >>\nstream\n{content}\nendstream",
301 content.len()
302 ),
303 "<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>".to_string(),
304 ];
305 let mut pdf = String::from("%PDF-1.4\n");
306 let mut offsets = Vec::new();
307 for (i, body) in objects.iter().enumerate() {
308 offsets.push(pdf.len());
309 pdf.push_str(&format!("{} 0 obj\n{body}\nendobj\n", i + 1));
310 }
311 let xref = pdf.len();
312 pdf.push_str(&format!(
313 "xref\n0 {}\n0000000000 65535 f \n",
314 objects.len() + 1
315 ));
316 for offset in offsets {
317 pdf.push_str(&format!("{offset:010} 00000 n \n"));
318 }
319 pdf.push_str(&format!(
320 "trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n",
321 objects.len() + 1
322 ));
323 pdf.into_bytes()
324 }
325
326 #[test]
327 fn the_text_of_a_pdf_is_found() {
328 let pdf = sample_pdf(&[
329 "Pressure drop: 388.25 Pa",
330 "Max. von Mises stress 182.5 MPa",
331 ]);
332 let text = extract(&pdf).unwrap();
333 assert!(text.contains("388.25"), "{text:?}");
334 assert!(text.contains("von Mises"), "{text:?}");
335 }
336
337 #[test]
338 fn damaged_and_hostile_files_fail_cleanly() {
339 let pdf = sample_pdf(&["Pressure drop: 388.25 Pa"]);
340 for cut in [0, 5, 40, pdf.len() / 2, pdf.len() - 10] {
341 let _ = extract(&pdf[..cut]);
343 }
344 assert!(extract(b"not a pdf at all").is_err());
345 assert!(extract(&[]).is_err());
346 let mut flipped = pdf.clone();
347 for i in (0..flipped.len()).step_by(7) {
348 flipped[i] ^= 0xFF;
349 }
350 let _ = extract(&flipped);
351 }
352
353 #[test]
354 fn tidy_keeps_rows_and_drops_noise() {
355 assert_eq!(tidy("a\u{0}b \n\n\n\nc\t d\n"), "a b\n\nc d");
356 let long = "x".repeat(MAX_CHARS * 2);
357 assert_eq!(tidy(&long).chars().count(), MAX_CHARS);
358 }
359
360 #[test]
361 fn names_and_size_cap() {
362 assert!(is_pdf("report.PDF") && is_pdf("a.b.pdf") && !is_pdf("pdf") && !is_pdf(".pdf"));
363 let dir = std::env::temp_dir().join(format!("tc-pdf-{}", uuid::Uuid::new_v4()));
364 std::fs::create_dir_all(&dir).unwrap();
365 let big = dir.join("big.pdf");
366 std::fs::File::create(&big)
367 .unwrap()
368 .set_len(5 * 1024 * 1024)
369 .unwrap();
370 let mut sink = Vec::new();
371 let e = run_job(std::fs::File::open(&big).unwrap(), &mut sink, 1024 * 1024).unwrap_err();
372 assert!(e.to_string().contains("too large to read"), "{e}");
373 assert!(sink.is_empty());
374 std::fs::remove_dir_all(&dir).ok();
375 }
376
377 #[tokio::test]
378 async fn stored_pdfs_are_read_in_process_during_tests() {
379 let dir = std::env::temp_dir().join(format!("tc-pdf-{}", uuid::Uuid::new_v4()));
380 std::fs::create_dir_all(&dir).unwrap();
381 let path = dir.join("report.pdf");
382 std::fs::write(&path, sample_pdf(&["Mass flow 2.25 kg/s"])).unwrap();
383 assert!(extract_file(tokio::fs::File::open(&path).await.unwrap())
384 .await
385 .contains("2.25"));
386 std::fs::write(&path, b"junk").unwrap();
387 assert_eq!(
388 extract_file(tokio::fs::File::open(&path).await.unwrap()).await,
389 ""
390 );
391 std::fs::remove_dir_all(&dir).ok();
392 }
393}