use crate::{
core::{
extensions::{
Extension, ExtensionMode, ExtensionTool, ForkProjectInfo, ToolAnnotations, ToolExecCtx,
ToolExposure,
},
tools::{ToolError, ToolResult},
},
extensions::{EXTENSION_FACTORIES, ExtensionFactory, PRIORITY_PDF, tool_search},
utils::{
paths::resolve_path,
truncate::{format_size, truncate_head},
},
};
use futures_util::future::BoxFuture;
use serde_json::{Value, json};
use std::{panic::AssertUnwindSafe, path::Path, sync::Arc};
const EXT: &str = "pdf";
const TOOL_READ_PDF: &str = "read_pdf";
const MAX_FILE_BYTES: u64 = 32 * 1024 * 1024;
#[linkme::distributed_slice(EXTENSION_FACTORIES)]
static PDF_FACTORY: ExtensionFactory = ExtensionFactory {
priority: PRIORITY_PDF,
make: || -> Arc<dyn Extension> { Arc::new(Pdf) },
};
pub struct Pdf;
impl Extension for Pdf {
fn name(&self) -> &str {
EXT
}
fn description(&self) -> &str {
"Read the text layer of PDF files (read_pdf)."
}
fn fork_project(&self) -> Option<ForkProjectInfo> {
Some(ForkProjectInfo {
plugin_name: "jcode".to_string(),
plugin_version: "0.94.0".to_string(),
url: "https://github.com/1jehuang/jcode".to_string(),
})
}
fn prerequisite_tools(&self) -> Vec<String> {
vec![tool_search::TOOL_NAME.to_string()]
}
fn default_enabled(&self) -> bool {
false
}
fn modes(&self) -> Vec<ExtensionMode> {
vec![ExtensionMode::Dev, ExtensionMode::Creator]
}
fn tools(&self) -> Vec<ExtensionTool> {
vec![read_pdf_tool()]
}
fn execute_tool_async(
&self,
name: String,
args: Value,
ctx: ToolExecCtx,
) -> BoxFuture<'static, Result<ToolResult, ToolError>> {
Box::pin(async move {
match name.as_str() {
TOOL_READ_PDF => run_read_pdf(&args, &ctx).await,
other => Err(ToolError(format!("extension {EXT}: unknown tool: {other}"))),
}
})
}
}
fn read_pdf_tool() -> ExtensionTool {
ExtensionTool {
exposure: ToolExposure::Deferred,
namespace: None,
annotations: ToolAnnotations {
read_only: true,
destructive: false,
idempotent: true,
},
output_schema: None,
name: TOOL_READ_PDF.to_string(),
description: "Extract the text layer of a PDF file. Returns plain text, not a rendered layout; scanned (image-only) PDFs have no text layer and report that instead of content. Long documents are truncated like `read`.".to_string(),
label: Some("Read PDF".to_string()),
parameters: json!({
"type": "object",
"properties": {
"path": {
"type": "string",
"description": "PDF file path; relative paths resolve against the working directory."
}
},
"required": ["path"]
}),
snippet: "Use to read text out of a PDF; scanned PDFs without a text layer return nothing useful.".to_string(),
prompt_guidelines: vec![
"`read_pdf` only extracts the PDF text layer; for scanned documents ask the user for a text version instead of retrying.".to_string(),
],
constrained_sampling: false,
render_shell: None,
execution_mode: None,
prepare_arguments: None,
grammar_sampling: None,
}
}
async fn run_read_pdf(args: &Value, ctx: &ToolExecCtx) -> Result<ToolResult, ToolError> {
let raw = args
.get("path")
.and_then(Value::as_str)
.ok_or_else(|| ToolError("missing required parameter: path".to_string()))?;
let raw = raw.trim();
if raw.is_empty() {
return Err(ToolError("`path` must not be empty.".to_string()));
}
let path = resolve_path(raw, &ctx.cwd);
let text = extract_pdf(&path).map_err(ToolError)?;
if text.trim().is_empty() {
return Ok(ToolResult::text(format!(
"Read PDF {} [text layer is empty: this looks like a scanned document with no extractable text]",
path.display()
)));
}
let truncation = truncate_head(&text, (None, None));
let mut output = format!(
"Read PDF {} ({} characters)",
path.display(),
text.chars().count()
);
output.push('\n');
output.push_str(&truncation.content);
if truncation.truncated {
output.push_str(&format!(
"\n\n[Showing {} of {} lines ({} limit). Convert the PDF to text to read the rest.]",
truncation.output_lines,
truncation.total_lines,
format_size(truncation.total_bytes)
));
}
Ok(ToolResult::text(output))
}
fn extract_pdf(path: &Path) -> Result<String, String> {
let meta =
std::fs::metadata(path).map_err(|e| format!("cannot read {}: {e}", path.display()))?;
if !meta.is_file() {
return Err(format!("not a file: {}", path.display()));
}
if meta.len() > MAX_FILE_BYTES {
return Err(format!(
"PDF is too large ({}; limit {})",
format_size(meta.len() as usize),
format_size(MAX_FILE_BYTES as usize)
));
}
let bytes = std::fs::read(path).map_err(|e| format!("cannot read {}: {e}", path.display()))?;
match std::panic::catch_unwind(AssertUnwindSafe(|| {
pdf_extract::extract_text_from_mem(&bytes)
})) {
Ok(Ok(text)) => Ok(text),
Ok(Err(e)) => Err(format!("failed to extract PDF text: {e}")),
Err(_) => Err("PDF parsing panicked; the file may be corrupt".to_string()),
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::core::extensions::ToolExecCtx;
fn ctx(cwd: &str) -> ToolExecCtx {
ToolExecCtx {
cwd: cwd.to_string(),
make_sub_agent: Arc::new(|_spec| Err("subagents unavailable in tests".to_string())),
parent_abort: Arc::new(std::sync::atomic::AtomicBool::new(false)),
agent_id: None,
depth: 0,
parent_model: None,
execute_tool: crate::core::extensions::unavailable_tool_exec(),
parent_tool_call_id: None,
nested_calls: Default::default(),
script_tools: Default::default(),
session_branch_entries: Default::default(),
script_call: false,
}
}
fn temp_dir(tag: &str) -> std::path::PathBuf {
let dir = std::env::temp_dir().join(format!("prux-pdf-{tag}-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
dir
}
#[test]
fn fork_project_reports_jcode_source() {
let info = Pdf.fork_project().expect("pdf 应声明 fork 来源");
assert_eq!(info.plugin_name, "jcode");
assert_eq!(info.plugin_version, "0.94.0");
assert_eq!(info.url, "https://github.com/1jehuang/jcode");
}
#[test]
fn tool_declares_required_path_parameter() {
let tool = read_pdf_tool();
assert_eq!(tool.name, TOOL_READ_PDF);
assert_eq!(tool.parameters["required"], json!(["path"]));
assert!(tool.annotations.read_only);
}
#[test]
fn missing_file_reports_read_error() {
let err = extract_pdf(Path::new("/definitely/not/here.pdf")).unwrap_err();
assert!(err.contains("cannot read"), "{err}");
}
#[test]
fn directory_is_rejected() {
let dir = temp_dir("dir");
let err = extract_pdf(&dir).unwrap_err();
assert!(err.contains("not a file"), "{err}");
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn oversized_file_is_rejected_before_parsing() {
let dir = temp_dir("big");
let file = dir.join("big.pdf");
let f = std::fs::File::create(&file).unwrap();
f.set_len(MAX_FILE_BYTES + 1).unwrap();
drop(f);
let err = extract_pdf(&file).unwrap_err();
assert!(err.contains("too large"), "{err}");
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn garbage_bytes_report_error_instead_of_panicking() {
let dir = temp_dir("garbage");
let file = dir.join("garbage.pdf");
std::fs::write(&file, b"not a pdf at all").unwrap();
let err = extract_pdf(&file).unwrap_err();
assert!(!err.is_empty());
std::fs::remove_dir_all(&dir).ok();
}
fn minimal_pdf() -> Vec<u8> {
let content = b"BT /F1 24 Tf 20 100 Td (Hello PDF) Tj ET";
let objs: Vec<Vec<u8>> = vec![
b"<< /Type /Catalog /Pages 2 0 R >>".to_vec(),
b"<< /Type /Pages /Kids [3 0 R] /Count 1 >>".to_vec(),
b"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 200 200] /Resources << /Font << /F1 5 0 R >> >> /Contents 4 0 R >>".to_vec(),
[
b"<< /Length ".to_vec(),
content.len().to_string().into_bytes(),
b" >>\nstream\n".to_vec(),
content.to_vec(),
b"\nendstream".to_vec(),
]
.concat(),
b"<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>".to_vec(),
];
let mut out = b"%PDF-1.4\n".to_vec();
let mut offsets = Vec::new();
for (i, obj) in objs.iter().enumerate() {
offsets.push(out.len());
out.extend_from_slice(format!("{} 0 obj\n", i + 1).as_bytes());
out.extend_from_slice(obj);
out.extend_from_slice(b"\nendobj\n");
}
let xref_pos = out.len();
out.extend_from_slice(format!("xref\n0 {}\n", objs.len() + 1).as_bytes());
out.extend_from_slice(b"0000000000 65535 f \n");
for off in &offsets {
out.extend_from_slice(format!("{off:010} 00000 n \n").as_bytes());
}
out.extend_from_slice(
format!(
"trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{xref_pos}\n%%EOF\n",
objs.len() + 1
)
.as_bytes(),
);
out
}
#[test]
fn extracts_text_from_minimal_pdf() {
let dir = temp_dir("ok");
let file = dir.join("hello.pdf");
std::fs::write(&file, minimal_pdf()).unwrap();
let text = extract_pdf(&file).unwrap();
assert!(text.contains("Hello PDF"), "{text:?}");
std::fs::remove_dir_all(&dir).ok();
}
#[tokio::test]
async fn run_read_pdf_reports_text_and_path() {
let dir = temp_dir("run");
std::fs::write(dir.join("hello.pdf"), minimal_pdf()).unwrap();
let res = run_read_pdf(
&json!({ "path": "hello.pdf" }),
&ctx(&dir.to_string_lossy()),
)
.await
.unwrap();
assert!(res.text.contains("Read PDF"), "{}", res.text);
assert!(res.text.contains("Hello PDF"), "{}", res.text);
std::fs::remove_dir_all(&dir).ok();
}
#[tokio::test]
async fn missing_parameter_is_an_error() {
let err = run_read_pdf(&json!({}), &ctx("/tmp")).await.unwrap_err();
assert!(err.0.contains("missing required parameter"), "{}", err.0);
}
#[tokio::test]
async fn empty_path_is_an_error() {
let err = run_read_pdf(&json!({ "path": " " }), &ctx("/tmp"))
.await
.unwrap_err();
assert!(err.0.contains("must not be empty"), "{}", err.0);
}
#[tokio::test]
async fn unknown_tool_name_is_rejected() {
let err = Pdf
.execute_tool_async("nope".to_string(), json!({}), ctx("/tmp"))
.await
.unwrap_err();
assert!(err.0.contains("unknown tool: nope"), "{}", err.0);
}
}