File size: 8,872 Bytes
eca5751
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
"""
PDF Generator Tool - Sinh PDF từ text/HTML/markdown.
===========================================
Tool sinh PDF từ nội dung text thuần, HTML hoặc Markdown.
Backend: `reportlab` (text/markdown trực tiếp) hoặc `weasyprint` (HTML→PDF).

Author: Hieu Louis (2026)
"""
from __future__ import annotations

import os
import subprocess
from typing import Any, Dict, Optional

from .base import Tool, ToolResult, ToolContext, ToolCategory, ToolSafety


PAGE_SIZES = {"A4", "A3", "LETTER", "LEGAL"}


class PDFGeneratorTool(Tool):
    """Sinh PDF từ text/HTML/markdown content."""

    category = ToolCategory.CONVERT
    safety = ToolSafety.MODERATE
    requires_confirmation = True

    @property
    def name(self) -> str:
        return "pdf_generator"

    @property
    def description(self) -> str:
        return "Sinh PDF từ text/HTML/markdown. Backend: reportlab (text/md) hoặc weasyprint (HTML)."

    @property
    def parameters(self) -> Dict[str, Any]:
        return {
            "type": "object",
            "properties": {
                "content": {"type": "string", "description": "Nội dung cần convert"},
                "output_path": {"type": "string", "description": "Đường dẫn file PDF output"},
                "format": {
                    "type": "string",
                    "enum": ["text", "html", "markdown"],
                    "default": "text",
                },
                "page_size": {
                    "type": "string",
                    "enum": sorted(PAGE_SIZES),
                    "default": "A4",
                },
                "title": {"type": "string", "description": "Metadata title của PDF"},
                "author": {"type": "string", "description": "Metadata author của PDF"},
            },
            "required": ["content", "output_path"],
        }

    def validate_args(self, args: Dict[str, Any]) -> Optional[str]:
        if not args.get("content"):
            return "Missing required arg: content"
        if not args.get("output_path"):
            return "Missing required arg: output_path"
        page = args.get("page_size", "A4")
        if page not in PAGE_SIZES:
            return f"Invalid page_size='{page}'. Supported: {sorted(PAGE_SIZES)}"
        return None

    # ---- Backends -------------------------------------------------------

    def _gen_reportlab(
        self,
        content: str,
        output_path: str,
        fmt: str,
        page_size: str,
        title: Optional[str],
        author: Optional[str],
    ) -> ToolResult:
        """Sinh PDF bằng reportlab (plaintext hoặc markdown đơn giản)."""
        try:
            from reportlab.lib.pagesizes import A4, A3, letter, legal  # type: ignore
            from reportlab.platypus import SimpleDocTemplate, Paragraph, Spacer  # type: ignore
            from reportlab.lib.styles import getSampleStyleSheet  # type: ignore
            from reportlab.lib.units import inch  # type: ignore  # noqa: F401
            from reportlab.lib import colors  # type: ignore  # noqa: F401
        except ImportError:
            return ToolResult(
                success=False,
                error="reportlab chưa cài. Cài đặt: pip install reportlab",
                return_code=127,
            )

        size_map = {"A4": A4, "A3": A3, "LETTER": letter, "LEGAL": legal}
        try:
            doc = SimpleDocTemplate(
                output_path,
                pagesize=size_map[page_size],
                title=title or "Nexus PDF",
                author=author or "Nexus Coder",
            )
            styles = getSampleStyleSheet()
            story = []
            if fmt == "markdown":
                # Parse markdown rất cơ bản: ## → Heading2, # → Heading1, else Paragraph
                for line in content.split("\n"):
                    stripped = line.strip()
                    if not stripped:
                        story.append(Spacer(1, 6))
                    elif stripped.startswith("# "):
                        story.append(Paragraph(stripped[2:], styles["Heading1"]))
                    elif stripped.startswith("## "):
                        story.append(Paragraph(stripped[3:], styles["Heading2"]))
                    elif stripped.startswith("### "):
                        story.append(Paragraph(stripped[4:], styles["Heading3"]))
                    else:
                        # Escape XML chars / escape XML special chars
                        safe = stripped.replace("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;")
                        story.append(Paragraph(safe, styles["Normal"]))
            else:
                for line in content.split("\n"):
                    safe = line.replace("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;") or "&nbsp;"
                    story.append(Paragraph(safe, styles["Normal"]))
            doc.build(story)
            return ToolResult(
                success=True,
                output=f"PDF generated → {output_path}",
                artifacts=[output_path],
                metadata={"backend": "reportlab", "page_size": page_size, "format": fmt},
            )
        except Exception as e:
            return ToolResult(success=False, error=f"reportlab build failed: {e}", return_code=1)

    def _gen_weasyprint(self, html_content: str, output_path: str, page_size: str, title: Optional[str], author: Optional[str]) -> ToolResult:
        """Sinh PDF từ HTML bằng weasyprint."""
        try:
            from weasyprint import HTML  # type: ignore
        except ImportError:
            return ToolResult(
                success=False,
                error="weasyprint chưa cài. Cài đặt: pip install weasyprint",
                return_code=127,
            )
        try:
            html_obj = HTML(string=html_content)
            html_obj.write_pdf(output_path)
            return ToolResult(
                success=True,
                output=f"PDF generated → {output_path}",
                artifacts=[output_path],
                metadata={"backend": "weasyprint", "page_size": page_size, "format": "html"},
            )
        except Exception as e:
            return ToolResult(success=False, error=f"weasyprint build failed: {e}", return_code=1)

    # ---- Execute --------------------------------------------------------

    def execute(self, args: Dict[str, Any], context: ToolContext) -> ToolResult:
        content = args["content"]
        output_path = args["output_path"]
        fmt = args.get("format", "text")
        page_size = args.get("page_size", "A4")
        title = args.get("title")
        author = args.get("author")

        if context.dry_run:
            return ToolResult(
                success=True,
                output=f"[dry-run] Sẽ sinh PDF ({fmt}, {page_size}) → {output_path} ({len(content)} chars)",
                metadata={"output_path": output_path, "format": fmt, "page_size": page_size, "dry_run": True},
            )

        # Đảm bảo thư mục cha tồn tại / ensure parent dir exists
        parent = os.path.dirname(os.path.abspath(output_path))
        os.makedirs(parent, exist_ok=True)

        if fmt == "html":
            return self._gen_weasyprint(content, output_path, page_size, title, author)

        if fmt == "markdown":
            # Thử pandoc trước (chất lượng cao) / try pandoc first
            import shutil
            if shutil.which("pandoc"):
                tmp_md = os.path.join(parent, f".nexus_pdf_{os.getpid()}.md")
                try:
                    with open(tmp_md, "w", encoding="utf-8") as f:
                        f.write(content)
                    cmd = ["pandoc", tmp_md, "-f", "markdown", "-t", "pdf", "-o", output_path, "-V", f"geometry:{page_size}paper"]
                    proc = subprocess.run(cmd, capture_output=True, text=True, timeout=context.timeout, check=False)
                    if proc.returncode == 0:
                        return ToolResult(
                            success=True,
                            output=f"PDF generated (pandoc) → {output_path}",
                            artifacts=[output_path],
                            metadata={"backend": "pandoc", "format": "markdown"},
                        )
                    # pandoc fail → fallback reportlab
                except subprocess.TimeoutExpired:
                    return ToolResult(success=False, error="pandoc timeout", return_code=124)
                finally:
                    if os.path.exists(tmp_md):
                        os.remove(tmp_md)
            return self._gen_reportlab(content, output_path, fmt, page_size, title, author)

        # format == "text"
        return self._gen_reportlab(content, output_path, fmt, page_size, title, author)