返回 CodeWhale
pdf.rs
根目录 / crates / tui / src / tools / pdf.rs
1 //! Shared PDF-to-text adapter.
2 //!
3 //! PDF parsing is intentionally delegated to the optional `pdftotext`
4 //! executable. Keeping the adapter here gives file and web tools one error
5 //! contract without carrying a second parser and font stack in Codewhale.
6
7 use std::ffi::OsStr;
8 use std::fmt;
9 use std::io::Write;
10 use std::path::Path;
11 use std::process::Stdio;
12 use std::time::Duration;
13
14 use serde_json::json;
15 use tokio::io::{AsyncRead, AsyncReadExt};
16 use tokio_util::sync::CancellationToken;
17
18 use super::spec::ToolError;
19
20 const PDF_TEXT_TIMEOUT: Duration = Duration::from_secs(30);
21 const PDF_PIPE_DRAIN_TIMEOUT: Duration = Duration::from_secs(1);
22 const MAX_PDF_STDOUT_BYTES: usize = 16 * 1024 * 1024;
23 const MAX_PDF_STDERR_BYTES: usize = 32 * 1024;
24
25 #[derive(Debug, Clone, PartialEq, Eq)]
26 pub(super) enum PdfTextError {
27 BinaryUnavailable,
28 Cancelled,
29 TimedOut,
30 Execution(String),
31 }
32
33 impl fmt::Display for PdfTextError {
34 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
35 match self {
36 Self::BinaryUnavailable => formatter.write_str(
37 "PDF text extraction requires the optional `pdftotext` executable (Poppler)",
38 ),
39 Self::Cancelled => formatter.write_str("PDF text extraction was cancelled"),
40 Self::TimedOut => write!(
41 formatter,
42 "PDF text extraction timed out after {} seconds",
43 PDF_TEXT_TIMEOUT.as_secs()
44 ),
45 Self::Execution(message) => formatter.write_str(message),
46 }
47 }
48 }
49
50 /// One typed mapping shared by local-file and fetched-PDF consumers.
51 ///
52 /// The missing-binary message is deliberately a small JSON object. The
53 /// `NotAvailable` variant gives the runtime a failed terminal status while
54 /// callers that inspect the variant retain machine-readable recovery data.
55 pub(super) fn into_tool_error(error: PdfTextError) -> ToolError {
56 match error {
57 PdfTextError::BinaryUnavailable => ToolError::not_available(
58 json!({
59 "type": "binary_unavailable",
60 "kind": "pdf",
61 "binary": "pdftotext",
62 "reason": "optional pdftotext executable is not installed",
63 "hint": "install Poppler and ensure pdftotext is on PATH"
64 })
65 .to_string(),
66 ),
67 PdfTextError::Cancelled => ToolError::cancelled("PDF text extraction was cancelled"),
68 PdfTextError::TimedOut => ToolError::Timeout {
69 seconds: PDF_TEXT_TIMEOUT.as_secs(),
70 },
71 PdfTextError::Execution(message) => ToolError::execution_failed(message),
72 }
73 }
74
75 #[derive(Clone, Copy)]
76 pub(crate) struct PdfTextCommand<'a> {
77 binary: &'a OsStr,
78 timeout: Duration,
79 cancel: Option<&'a CancellationToken>,
80 }
81
82 impl<'a> PdfTextCommand<'a> {
83 pub(super) fn system(cancel: Option<&'a CancellationToken>) -> Self {
84 Self {
85 binary: OsStr::new("pdftotext"),
86 timeout: PDF_TEXT_TIMEOUT,
87 cancel,
88 }
89 }
90
91 #[cfg(test)]
92 pub(super) fn test(
93 binary: &'a OsStr,
94 timeout: Duration,
95 cancel: Option<&'a CancellationToken>,
96 ) -> Self {
97 Self {
98 binary,
99 timeout,
100 cancel,
101 }
102 }
103 }
104
105 pub(super) async fn extract_path(
106 path: &Path,
107 page_range: Option<(u32, u32)>,
108 command: PdfTextCommand<'_>,
109 ) -> Result<String, PdfTextError> {
110 extract_path_with_command(path, page_range, command).await
111 }
112
113 pub(super) async fn extract_bytes(
114 bytes: &[u8],
115 command: PdfTextCommand<'_>,
116 ) -> Result<String, PdfTextError> {
117 let mut input = tempfile::NamedTempFile::new().map_err(|error| {
118 PdfTextError::Execution(format!("failed to stage fetched PDF: {error}"))
119 })?;
120 input.write_all(bytes).map_err(|error| {
121 PdfTextError::Execution(format!("failed to stage fetched PDF: {error}"))
122 })?;
123 input.flush().map_err(|error| {
124 PdfTextError::Execution(format!("failed to stage fetched PDF: {error}"))
125 })?;
126 extract_path_with_command(input.path(), None, command).await
127 }
128
129 async fn extract_path_with_command(
130 path: &Path,
131 page_range: Option<(u32, u32)>,
132 request: PdfTextCommand<'_>,
133 ) -> Result<String, PdfTextError> {
134 if request.cancel.is_some_and(CancellationToken::is_cancelled) {
135 return Err(PdfTextError::Cancelled);
136 }
137
138 let mut command = tokio::process::Command::new(request.binary);
139 crate::utils::suppress_tokio_console_window(&mut command);
140 command.arg("-layout");
141 if let Some((start, end)) = page_range {
142 command.arg("-f").arg(start.to_string());
143 command.arg("-l").arg(end.to_string());
144 }
145 command
146 .arg(path)
147 .arg("-")
148 .stdin(Stdio::null())
149 .stdout(Stdio::piped())
150 .stderr(Stdio::piped())
151 .kill_on_drop(true);
152
153 let mut child = command.spawn().map_err(|error| {
154 if error.kind() == std::io::ErrorKind::NotFound {
155 PdfTextError::BinaryUnavailable
156 } else {
157 PdfTextError::Execution(format!("failed to launch pdftotext: {error}"))
158 }
159 })?;
160 let stdout = child
161 .stdout
162 .take()
163 .ok_or_else(|| PdfTextError::Execution("failed to capture pdftotext stdout".to_string()))?;
164 let stderr = child
165 .stderr
166 .take()
167 .ok_or_else(|| PdfTextError::Execution("failed to capture pdftotext stderr".to_string()))?;
168 let stdout_task = tokio::spawn(read_bounded(stdout, MAX_PDF_STDOUT_BYTES));
169 let stderr_task = tokio::spawn(read_bounded(stderr, MAX_PDF_STDERR_BYTES));
170
171 let status = tokio::select! {
172 result = child.wait() => result.map_err(|error| {
173 PdfTextError::Execution(format!("failed to wait for pdftotext: {error}"))
174 })?,
175 () = wait_for_cancellation(request.cancel) => {
176 terminate_child(&mut child).await;
177 finish_capture_tasks(stdout_task, stderr_task).await?;
178 return Err(PdfTextError::Cancelled);
179 }
180 () = tokio::time::sleep(request.timeout) => {
181 terminate_child(&mut child).await;
182 finish_capture_tasks(stdout_task, stderr_task).await?;
183 return Err(PdfTextError::TimedOut);
184 }
185 };
186 let (stdout, stderr) = finish_capture_tasks(stdout_task, stderr_task).await?;
187
188 if stdout.truncated {
189 return Err(PdfTextError::Execution(format!(
190 "pdftotext output exceeded the {} byte safety limit",
191 MAX_PDF_STDOUT_BYTES
192 )));
193 }
194 if !status.success() {
195 let stderr_truncated = stderr.truncated;
196 let stderr = sanitized_text(&stderr.bytes);
197 let suffix = if stderr_truncated { " [truncated]" } else { "" };
198 let stderr = if stderr.is_empty() {
199 "no diagnostic output".to_string()
200 } else {
201 stderr
202 };
203 return Err(PdfTextError::Execution(format!(
204 "pdftotext failed (exit {:?}): {stderr}{suffix}",
205 status.code()
206 )));
207 }
208 Ok(String::from_utf8_lossy(&stdout.bytes).into_owned())
209 }
210
211 async fn wait_for_cancellation(cancel: Option<&CancellationToken>) {
212 match cancel {
213 Some(cancel) => cancel.cancelled().await,
214 None => std::future::pending::<()>().await,
215 }
216 }
217
218 async fn terminate_child(child: &mut tokio::process::Child) {
219 let _ = child.kill().await;
220 let _ = child.wait().await;
221 }
222
223 struct BoundedOutput {
224 bytes: Vec<u8>,
225 truncated: bool,
226 }
227
228 async fn read_bounded(
229 mut reader: impl AsyncRead + Unpin,
230 max_bytes: usize,
231 ) -> std::io::Result<BoundedOutput> {
232 let mut bytes = Vec::with_capacity(max_bytes.min(8 * 1024));
233 let mut buffer = [0u8; 8 * 1024];
234 let mut truncated = false;
235 loop {
236 let read = reader.read(&mut buffer).await?;
237 if read == 0 {
238 break;
239 }
240 let remaining = max_bytes.saturating_sub(bytes.len());
241 let retained = read.min(remaining);
242 bytes.extend_from_slice(&buffer[..retained]);
243 truncated |= retained < read;
244 }
245 Ok(BoundedOutput { bytes, truncated })
246 }
247
248 async fn finish_capture_tasks(
249 mut stdout: tokio::task::JoinHandle<std::io::Result<BoundedOutput>>,
250 mut stderr: tokio::task::JoinHandle<std::io::Result<BoundedOutput>>,
251 ) -> Result<(BoundedOutput, BoundedOutput), PdfTextError> {
252 let joined = tokio::time::timeout(PDF_PIPE_DRAIN_TIMEOUT, async {
253 tokio::join!(&mut stdout, &mut stderr)
254 })
255 .await;
256 let (stdout, stderr) = match joined {
257 Ok(output) => output,
258 Err(_) => {
259 stdout.abort();
260 stderr.abort();
261 let _ = tokio::join!(stdout, stderr);
262 return Err(PdfTextError::Execution(
263 "pdftotext output pipes did not close after process termination".to_string(),
264 ));
265 }
266 };
267 let stdout = stdout
268 .map_err(|error| PdfTextError::Execution(format!("stdout reader failed: {error}")))?
269 .map_err(|error| PdfTextError::Execution(format!("stdout reader failed: {error}")))?;
270 let stderr = stderr
271 .map_err(|error| PdfTextError::Execution(format!("stderr reader failed: {error}")))?
272 .map_err(|error| PdfTextError::Execution(format!("stderr reader failed: {error}")))?;
273 Ok((stdout, stderr))
274 }
275
276 fn sanitized_text(bytes: &[u8]) -> String {
277 String::from_utf8_lossy(bytes)
278 .trim()
279 .chars()
280 .map(|character| match character {
281 '\n' | '\t' => character,
282 character if character.is_control() => '\u{fffd}',
283 character => character,
284 })
285 .collect()
286 }
287
288 #[cfg(test)]
289 mod tests;
290
290 lines RUST