Skip to main content

rustant_tools/
pdf_generate.rs

1//! PDF generation tool — create PDF documents from text/markdown content.
2
3use async_trait::async_trait;
4use genpdf::elements::{Break, Paragraph};
5use genpdf::{Document, SimplePageDecorator};
6use rustant_core::error::ToolError;
7use rustant_core::types::{RiskLevel, ToolOutput};
8use serde_json::{Value, json};
9use std::path::PathBuf;
10
11use crate::registry::Tool;
12
13pub struct PdfGenerateTool {
14    workspace: PathBuf,
15}
16
17impl PdfGenerateTool {
18    pub fn new(workspace: PathBuf) -> Self {
19        Self { workspace }
20    }
21}
22
23#[async_trait]
24impl Tool for PdfGenerateTool {
25    fn name(&self) -> &str {
26        "pdf_generate"
27    }
28    fn description(&self) -> &str {
29        "Generate PDF documents from text content. Provide title, content lines, and output path."
30    }
31    fn parameters_schema(&self) -> Value {
32        json!({
33            "type": "object",
34            "properties": {
35                "action": {
36                    "type": "string",
37                    "enum": ["generate"],
38                    "description": "Action to perform"
39                },
40                "title": { "type": "string", "description": "Document title" },
41                "content": { "type": "string", "description": "Document content (plain text, paragraphs separated by blank lines)" },
42                "output": { "type": "string", "description": "Output file path (e.g., 'report.pdf')" }
43            },
44            "required": ["action", "output"]
45        })
46    }
47    fn risk_level(&self) -> RiskLevel {
48        RiskLevel::Write
49    }
50
51    async fn execute(&self, args: Value) -> Result<ToolOutput, ToolError> {
52        let action = args
53            .get("action")
54            .and_then(|v| v.as_str())
55            .unwrap_or("generate");
56        if action != "generate" {
57            return Ok(ToolOutput::text(format!(
58                "Unknown action: {}. Use: generate",
59                action
60            )));
61        }
62
63        let title = args
64            .get("title")
65            .and_then(|v| v.as_str())
66            .unwrap_or("Document");
67        let content = args.get("content").and_then(|v| v.as_str()).unwrap_or("");
68        let output_str = args
69            .get("output")
70            .and_then(|v| v.as_str())
71            .unwrap_or("output.pdf");
72        let output_path = self.workspace.join(output_str);
73
74        // Use the built-in font — try several system locations
75        let font_family =
76            genpdf::fonts::from_files("", "LiberationSans", None).unwrap_or_else(|_| {
77                genpdf::fonts::from_files(
78                    "/usr/share/fonts/truetype/liberation",
79                    "LiberationSans",
80                    None,
81                )
82                .unwrap_or_else(|_| {
83                    genpdf::fonts::from_files("/System/Library/Fonts", "Helvetica", None)
84                        .unwrap_or_else(|_| {
85                            genpdf::fonts::from_files("/Library/Fonts", "Arial", None)
86                                .expect("No suitable font found on this system")
87                        })
88                })
89            });
90
91        let mut doc = Document::new(font_family);
92        doc.set_title(title);
93
94        let mut decorator = SimplePageDecorator::new();
95        decorator.set_margins(30);
96        doc.set_page_decorator(decorator);
97
98        // Add title as a styled paragraph
99        let title_style = genpdf::style::Style::new().bold().with_font_size(18);
100        doc.push(Paragraph::new(genpdf::style::StyledString::new(
101            title.to_string(),
102            title_style,
103        )));
104        doc.push(Break::new(1));
105
106        // Add content paragraphs
107        for paragraph in content.split("\n\n") {
108            let trimmed = paragraph.trim();
109            if !trimmed.is_empty() {
110                doc.push(Paragraph::new(trimmed));
111                doc.push(Break::new(0.5));
112            }
113        }
114
115        if let Some(parent) = output_path.parent() {
116            std::fs::create_dir_all(parent).ok();
117        }
118
119        doc.render_to_file(&output_path)
120            .map_err(|e| ToolError::ExecutionFailed {
121                name: "pdf_generate".into(),
122                message: format!("Failed to render PDF: {}", e),
123            })?;
124
125        let size = std::fs::metadata(&output_path)
126            .map(|m| m.len())
127            .unwrap_or(0);
128        Ok(ToolOutput::text(format!(
129            "Generated PDF: {} ({} bytes)",
130            output_str, size
131        )))
132    }
133}
134
135#[cfg(test)]
136mod tests {
137    use super::*;
138
139    #[tokio::test]
140    async fn test_pdf_generate_schema() {
141        let dir = tempfile::TempDir::new().unwrap();
142        let tool = PdfGenerateTool::new(dir.path().to_path_buf());
143        assert_eq!(tool.name(), "pdf_generate");
144        assert_eq!(tool.risk_level(), RiskLevel::Write);
145        let schema = tool.parameters_schema();
146        assert!(schema.get("properties").is_some());
147    }
148
149    // Note: PDF generation test requires fonts available on the system.
150    // The actual render test is platform-dependent.
151}