| 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 |