"""Advanced PDF Generator Tool.
Creates complex PDFs from JSON specifications with text, images, tables,
headers, links, and various styling options across multiple pages.
"""
from __future__ import annotations
import asyncio
import io
import jsonutil as json
import logging
import os
import re
import tempfile
import uuid
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from tool_context import ToolContext
try:
from reportlab.lib import colors
from reportlab.lib.pagesizes import letter, A4
from reportlab.lib.styles import getSampleStyleSheet, ParagraphStyle
from reportlab.lib.units import inch
from reportlab.pdfgen import canvas
from reportlab.platypus import (
SimpleDocTemplate,
Paragraph,
Spacer,
Table,
TableStyle,
Image,
PageBreak,
)
from reportlab.platypus.flowables import KeepTogether, Flowable
import httpx
from PIL import Image as PILImage
except ImportError as e:
logging.error(f"Required dependencies not installed: {e}")
colors = None
canvas = None
SimpleDocTemplate = None
Flowable = object
logger = logging.getLogger(__name__)
[docs]
class LinkFlowable(Flowable):
"""A ReportLab flowable that renders text as a clickable hyperlink.
Wraps a single ``Paragraph`` and, when drawn, overlays a clickable region
via the canvas so the rendered text becomes a working link in the output
PDF. It exists because plain ``Paragraph`` flowables do not carry an
associated URL, so link and markdown-link elements need this thin subclass
of ``reportlab.platypus.flowables.Flowable``.
Instances are constructed by :meth:`PDFGenerator._parse_markdown_links` and
:meth:`PDFGenerator._create_link_element` and then laid out by ReportLab's
document build, which calls :meth:`wrap` and :meth:`draw`. It is internal to
this module; no external callers were found.
Attributes:
text: The visible link text rendered into the paragraph.
url: The destination URL the drawn region links to.
style: The ``ParagraphStyle`` used to render the text.
width: Computed paragraph width, set during :meth:`wrap`.
height: Computed paragraph height, set during :meth:`wrap`.
paragraph: The underlying ``Paragraph`` flowable.
"""
[docs]
def __init__(self, text, url, style=None):
"""Initialize the instance.
Args:
text: Text content.
url: URL string.
style: The style value.
"""
Flowable.__init__(self)
self.text = text
self.url = url
self.style = style or getSampleStyleSheet()["Normal"]
self.width = 0
self.height = 0
self.paragraph = Paragraph(self.text, self.style)
[docs]
def wrap(self, availWidth, availHeight):
"""Measure the flowable by delegating to the wrapped paragraph.
Part of the ReportLab flowable protocol: the document builder calls this
during layout to learn how much space the link occupies. It forwards to
the underlying ``Paragraph.wrap`` and caches the result in
:attr:`width` and :attr:`height` so :meth:`draw` can size the clickable
region. Called by ReportLab's layout engine, not by repo code.
Args:
availWidth: Available width offered by the layout engine.
availHeight: Available height offered by the layout engine.
Returns:
tuple: The ``(width, height)`` consumed by the wrapped paragraph.
"""
self.width, self.height = self.paragraph.wrap(availWidth, availHeight)
return self.width, self.height
[docs]
def draw(self):
"""Render the paragraph and register the clickable link region.
Part of the ReportLab flowable protocol: the document builder calls this
to paint the flowable onto the page canvas. It draws the wrapped
paragraph at the origin and then calls ``canv.linkURL`` over the area
sized by :meth:`wrap`, turning the rendered text into a working
hyperlink in the output PDF. Called by ReportLab's layout engine, not by
repo code.
"""
self.paragraph.drawOn(self.canv, 0, 0)
self.canv.linkURL(self.url, (0, 0, self.width, self.height), relative=0)
[docs]
class PDFGenerator:
"""Builds a PDF document from a structured JSON-style specification.
Owns the ReportLab style sheet and page geometry and translates a parsed
spec -- a list of pages, each holding typed elements (title, header, text,
image, table, link, spacer, page break) -- into a flowable story that is
rendered to PDF bytes. The per-element ``_create_*`` helpers turn each
element dict into a ReportLab flowable, and :meth:`generate_pdf` assembles
and builds the document.
A single instance is created per request by the nested ``_build_pdf`` helper
inside the module-level :func:`run` tool handler, which then calls
:meth:`generate_pdf`; instances are not reused across requests. It is
internal to this module; no external callers were found.
Attributes:
page_size: The ReportLab page size tuple (A4 or letter).
width: Page width derived from :attr:`page_size`.
height: Page height derived from :attr:`page_size`.
styles: The ReportLab style sheet, extended with custom paragraph
styles used by the element builders.
"""
[docs]
def __init__(self, page_size: str = "A4"):
"""Initialize the instance.
Args:
page_size (str): The page size value.
"""
if not all([colors, canvas, SimpleDocTemplate]):
raise ImportError("Required PDF generation dependencies not available")
self.page_size = A4 if page_size.upper() == "A4" else letter
self.width, self.height = self.page_size
self.styles = getSampleStyleSheet()
self.styles.add(
ParagraphStyle(
name="CustomHeader",
parent=self.styles["Heading1"],
fontSize=24,
spaceAfter=30,
alignment=1,
)
)
self.styles.add(
ParagraphStyle(
name="CustomSubHeader",
parent=self.styles["Heading2"],
fontSize=18,
spaceAfter=20,
)
)
self.styles.add(
ParagraphStyle(
name="CustomBody",
parent=self.styles["Normal"],
fontSize=12,
spaceAfter=12,
)
)
def _parse_color(self, color_spec):
"""Coerce a color specification into a ReportLab color object.
Accepts either a named color string (mapped through a small built-in
table) or an ``[r, g, b]`` / ``[r, g, b, a]`` list of 0-255 channel
values, returning a ``reportlab.lib.colors`` color and falling back to
black for anything unrecognized. This is a pure helper with no I/O. It
is called within this module by :meth:`_create_text_element` to apply a
text color; the module-level ``_parse_color`` in ``tools/discord_embed.py``
is an unrelated function, not this method.
Args:
color_spec: A color name string, or a 3-or-4-element list of
0-255 channel values.
Returns:
A ReportLab color object, defaulting to black on unrecognized input.
"""
if isinstance(color_spec, str):
color_map = {
"red": colors.red,
"blue": colors.blue,
"green": colors.green,
"black": colors.black,
"white": colors.white,
"gray": colors.gray,
}
return color_map.get(color_spec.lower(), colors.black)
elif isinstance(color_spec, list) and len(color_spec) >= 3:
r, g, b = color_spec[:3]
a = color_spec[3] if len(color_spec) == 4 else 255
return colors.Color(r / 255.0, g / 255.0, b / 255.0, a / 255.0)
return colors.black
def _download_image(self, url):
"""Fetch image bytes from a URL through the SSRF-guarded HTTP client.
Performs a synchronous GET (with redirect following capped) using the
``tools._safe_http`` sync client so that LLM-supplied URLs cannot be
used to reach internal hosts; blocked or failed requests are logged and
yield ``None`` rather than raising. It touches the network only -- no
Redis or filesystem -- and is called within this module by
:meth:`_create_image_element`. The similarly named module-level
``_download_image`` functions in ``tools/compose_gameboard.py``,
``tools/lyria_music.py``, ``tools/comfyui_generate_image.py``, and
``tools/edit_image.py`` are separate, unrelated helpers, not this method.
Args:
url: The image URL to download (coerced to a stripped string).
Returns:
bytes | None: The response body on success, or ``None`` if the URL
was blocked by the guard or the request failed.
"""
from tools._safe_http import safe_http_request_sync, safe_httpx_client_sync
try:
with safe_httpx_client_sync(timeout=10) as client:
response = safe_http_request_sync(
client, "GET", str(url).strip(), max_redirects=5
)
response.raise_for_status()
return response.content
except ValueError as exc:
logger.error("Blocked PDF image URL: %s", exc)
return None
except Exception as e:
logger.error(f"Failed to download image from {url}: {e}")
return None
def _get_font_name(self, base_font, is_bold, is_italic):
"""Resolve a base font and bold/italic flags to a ReportLab font name.
Maps the three standard PDF font families (Helvetica, Times, Courier)
and the four bold/italic combinations to their canonical PostScript font
names; for any other base font it appends a best-effort style suffix.
This is a pure string helper with no I/O, called within this module by
:meth:`_create_text_element` when assigning a paragraph's font. No
external callers were found.
Args:
base_font: The requested base font name (case-insensitive).
is_bold: Whether the bold variant is wanted.
is_italic: Whether the italic/oblique variant is wanted.
Returns:
str: The resolved ReportLab font name for the requested style.
"""
font_styles = {
"helvetica": {
(True, True): "Helvetica-BoldOblique",
(True, False): "Helvetica-Bold",
(False, True): "Helvetica-Oblique",
(False, False): "Helvetica",
},
"times": {
(True, True): "Times-BoldItalic",
(True, False): "Times-Bold",
(False, True): "Times-Italic",
(False, False): "Times-Roman",
},
"courier": {
(True, True): "Courier-BoldOblique",
(True, False): "Courier-Bold",
(False, True): "Courier-Oblique",
(False, False): "Courier",
},
}
base = base_font.lower()
if base in font_styles:
return font_styles[base][(is_bold, is_italic)]
suffix = (
"-BoldItalic"
if is_bold and is_italic
else "-Bold" if is_bold else "-Italic" if is_italic else ""
)
return base_font + suffix
def _create_text_element(self, content, style_spec):
"""Build a text flowable (or list of them) from content and style.
Turns a text/paragraph element into a ReportLab ``Paragraph``, applying
font size, color (via :meth:`_parse_color`), bold/italic font name (via
:meth:`_get_font_name`), and alignment from the style spec. When the
spec opts into ``parse_markdown_links`` it instead delegates to
:meth:`_parse_markdown_links`, which may yield several flowables. This is
pure in-memory layout work with no I/O. Called within this module by
:meth:`generate_pdf` while building the story; no external callers were
found.
Args:
content: The text/HTML markup to render in the paragraph.
style_spec: The element dict carrying style hints (size, color,
font, alignment, and the ``parse_markdown_links`` flag).
Returns:
A single ``Paragraph`` flowable, or a list of flowables when
markdown links were parsed.
"""
if style_spec.get("parse_markdown_links", False):
return self._parse_markdown_links(content, style_spec)
base_style_name = style_spec.get("base_style", "Normal")
if base_style_name not in self.styles:
base_style_name = "Normal"
style = self.styles[base_style_name]
if "size" in style_spec or "font_size" in style_spec:
style.fontSize = style_spec.get(
"size", style_spec.get("font_size", style.fontSize)
)
if "color" in style_spec:
style.textColor = self._parse_color(style_spec["color"])
font_style = style_spec.get("font_style", "")
is_bold = style_spec.get("bold", False) or "B" in font_style.upper()
is_italic = style_spec.get("italic", False) or "I" in font_style.upper()
base_font = style_spec.get("font", "Helvetica")
style.fontName = self._get_font_name(base_font, is_bold, is_italic)
if "align" in style_spec:
align_map = {
"left": 0,
"center": 1,
"right": 2,
"justify": 4,
"c": 1,
"l": 0,
"r": 2,
"j": 4,
}
style.alignment = align_map.get(style_spec["align"].lower(), 0)
return Paragraph(content, style)
def _parse_markdown_links(self, text, style_spec):
"""Split text on markdown links into paragraphs and link flowables.
Scans the text for ``[label](url)`` markdown links and emits an
interleaved sequence of plain ``Paragraph`` flowables (for the text
between links) and :class:`LinkFlowable` instances (for each link),
carrying through the requested font size. When no links are present it
returns a single paragraph so the caller always gets a non-empty list.
This is pure in-memory layout work with no I/O. Called within this
module by :meth:`_create_text_element`; no external callers were found.
Args:
text: The raw text possibly containing markdown links.
style_spec: The element dict; its font size is applied to links.
Returns:
list: An ordered list of ``Paragraph`` and :class:`LinkFlowable`
flowables representing the text.
"""
link_pattern = r"\[([^\]]+)\]\(([^)]+)\)"
elements = []
last_end = 0
for match in re.finditer(link_pattern, text):
if match.start() > last_end:
pre_text = text[last_end : match.start()]
if pre_text.strip():
elements.append(Paragraph(pre_text, self.styles["Normal"]))
link_style = ParagraphStyle(
name="MarkdownLink",
parent=self.styles["Normal"],
textColor=colors.blue,
underline=True,
)
if "font_size" in style_spec or "size" in style_spec:
link_style.fontSize = style_spec.get(
"size", style_spec.get("font_size", link_style.fontSize)
)
elements.append(LinkFlowable(match.group(1), match.group(2), link_style))
last_end = match.end()
if last_end < len(text):
post_text = text[last_end:]
if post_text.strip():
elements.append(Paragraph(post_text, self.styles["Normal"]))
return elements or [Paragraph(text, self.styles["Normal"])]
def _create_title_element(self, title_spec):
"""Build a large centered title flowable from a title element.
Renders the title text as a bold ``Paragraph`` using a derived
``ParagraphStyle`` whose font size and alignment come from the spec
(defaulting to a large centered heading), or returns ``None`` when no
text is given so the caller can skip it. Pure in-memory layout work with
no I/O. Called within this module by :meth:`generate_pdf`; no external
callers were found.
Args:
title_spec: The element dict carrying ``text``, optional
``font_size``, and optional ``align``.
Returns:
A ``Paragraph`` flowable, or ``None`` when the title text is empty.
"""
text = title_spec.get("text", "")
if not text:
return None
title_style = ParagraphStyle(
name="Title",
parent=self.styles["Heading1"],
fontSize=title_spec.get("font_size", 28),
spaceAfter=40,
alignment=1,
fontName="Helvetica-Bold",
)
if "align" in title_spec:
align_map = {"left": 0, "center": 1, "right": 2, "c": 1, "l": 0, "r": 2}
title_style.alignment = align_map.get(title_spec["align"].lower(), 1)
return Paragraph(text, title_style)
def _create_header_element(self, header_spec):
"""Build a section-header flowable for a given heading level.
Renders the header text as a ``Paragraph`` whose base style is chosen
from the requested level (clamped to 1-6, mapping to ReportLab
``Heading1``-``Heading5`` and ``Normal``), with optional font-size and
alignment overrides. Returns ``None`` when no text is given. Pure
in-memory layout work with no I/O. Called within this module by
:meth:`generate_pdf`; no external callers were found.
Args:
header_spec: The element dict carrying ``text``, optional ``level``,
``font_size``, and ``align``.
Returns:
A ``Paragraph`` flowable, or ``None`` when the header text is empty.
"""
text = header_spec.get("text", "")
if not text:
return None
level = max(1, min(6, header_spec.get("level", 1)))
style_map = {
1: "Heading1",
2: "Heading2",
3: "Heading3",
4: "Heading4",
5: "Heading5",
6: "Normal",
}
header_style = ParagraphStyle(
name=f"Header{level}",
parent=self.styles[style_map.get(level, "Heading1")],
spaceAfter=20 - level * 2,
)
if "font_size" in header_spec:
header_style.fontSize = header_spec["font_size"]
if "align" in header_spec:
align_map = {"left": 0, "center": 1, "right": 2, "c": 1, "l": 0, "r": 2}
header_style.alignment = align_map.get(header_spec["align"].lower(), 0)
return Paragraph(text, header_style)
def _create_link_element(self, link_spec):
"""Build a standalone clickable-link flowable from a link element.
Wraps the link text in a blue, underlined :class:`LinkFlowable` pointing
at the given URL, honoring an optional font size. Returns ``None`` when
either the text or URL is missing so the caller can skip the element.
Pure in-memory layout work with no I/O. Called within this module by
:meth:`generate_pdf`; no external callers were found.
Args:
link_spec: The element dict carrying ``text``, ``url``, and optional
``font_size``.
Returns:
A :class:`LinkFlowable`, or ``None`` when text or URL is missing.
"""
text = link_spec.get("text", "")
url = link_spec.get("url", "")
if not text or not url:
return None
link_style = ParagraphStyle(
name="Link",
parent=self.styles["Normal"],
textColor=colors.blue,
underline=True,
)
if "font_size" in link_spec:
link_style.fontSize = link_spec["font_size"]
return LinkFlowable(text, url, link_style)
def _create_image_element(self, image_spec):
"""Build an Image flowable from a ``url`` or ``base64`` source.
The legacy ``path`` source was removed: it let any LLM caller open
arbitrary files via ``open(image_spec['path'], 'rb')``, which is a
path-traversal primitive even when the bytes themselves were not
valid images. Provide a URL or base64 payload instead.
Args:
image_spec: The image spec value.
"""
try:
image_data = None
if "url" in image_spec:
image_data = self._download_image(image_spec["url"])
elif "base64" in image_spec:
import base64
image_data = base64.b64decode(image_spec["base64"])
elif "path" in image_spec:
logger.warning(
"PDF image_spec 'path' is no longer supported "
"(use 'url' or 'base64'); skipping image element.",
)
return None
if not image_data:
return None
temp_file = tempfile.NamedTemporaryFile(suffix=".png", delete=False)
try:
pil_image = PILImage.open(io.BytesIO(image_data))
pil_image.save(temp_file.name, "PNG")
temp_file.close()
img = Image(temp_file.name)
if "width" in image_spec:
img.drawWidth = image_spec["width"]
if "height" in image_spec:
img.drawHeight = image_spec["height"]
caption = image_spec.get("caption")
if caption:
caption_style = ParagraphStyle(
name="ImageCaption",
parent=self.styles["Normal"],
fontSize=10,
spaceBefore=5,
fontName="Helvetica-Oblique",
)
return KeepTogether([img, Paragraph(caption, caption_style)])
return img
finally:
try:
os.unlink(temp_file.name)
except Exception:
pass
except Exception as e:
logger.error(f"Failed to create image element: {e}")
return None
def _create_table_element(self, table_spec):
"""Build a styled ReportLab table flowable from a table element.
Assembles header and data rows into a ``Table`` and applies a default
style (grey header band with bold light text, beige body, and a black
grid). When no explicit headers are supplied but data is present, the
first data row is promoted to the header. Returns ``None`` for an empty
spec or if construction raises (the error is logged). Pure in-memory
layout work with no I/O. Called within this module by
:meth:`generate_pdf`; no external callers were found.
Args:
table_spec: The element dict carrying optional ``headers`` and
``data`` row lists.
Returns:
A ``Table`` flowable, or ``None`` when the spec is empty or
construction fails.
"""
try:
headers = table_spec.get("headers", [])
data = table_spec.get("data", [])
if not headers and not data:
return None
if not headers and data:
headers = data[0]
data = data[1:] if len(data) > 1 else []
table_data = [headers] + data if headers else data
table = Table(table_data)
style_commands = []
if headers:
style_commands.extend(
[
("BACKGROUND", (0, 0), (-1, 0), colors.grey),
("TEXTCOLOR", (0, 0), (-1, 0), colors.whitesmoke),
("ALIGN", (0, 0), (-1, 0), "CENTER"),
("FONTNAME", (0, 0), (-1, 0), "Helvetica-Bold"),
("FONTSIZE", (0, 0), (-1, 0), 14),
("BOTTOMPADDING", (0, 0), (-1, 0), 12),
("BACKGROUND", (0, 1), (-1, -1), colors.beige),
]
)
style_commands.append(("GRID", (0, 0), (-1, -1), 1, colors.black))
table.setStyle(TableStyle(style_commands))
return table
except Exception as e:
logger.error(f"Failed to create table element: {e}")
return None
[docs]
def generate_pdf(self, pdf_spec):
"""Render a full PDF spec into PDF bytes.
The orchestration method of the class: it walks every page and element
in the spec, dispatches each typed element to the matching ``_create_*``
builder (title, header, text/paragraph, image, table, spacer, link,
newpage), assembles the resulting flowables into a story with page
breaks between pages, and builds the document into an in-memory buffer.
Returns the raw PDF bytes, or ``None`` if the build raises (logged with
a traceback).
It works entirely in memory via a ``BytesIO`` buffer and ReportLab's
``SimpleDocTemplate``; the only external I/O is image downloads done by
the image builder it calls. Called within this module by the nested
``_build_pdf`` helper inside :func:`run`; no external callers were found.
Args:
pdf_spec: The validated PDF specification dict, expected to contain a
``pages`` list of element dicts.
Returns:
bytes | None: The rendered PDF content, or ``None`` on failure.
"""
try:
buffer = io.BytesIO()
doc = SimpleDocTemplate(
buffer,
pagesize=self.page_size,
rightMargin=72,
leftMargin=72,
topMargin=72,
bottomMargin=18,
)
story = []
pages = pdf_spec.get("pages", [{"elements": []}])
for page_spec in pages:
for element in page_spec.get("elements", []):
element_type = element.get("type", "").lower()
if element_type in ("text", "paragraph"):
text_elem = self._create_text_element(
element.get("text", element.get("content", "")), element
)
if isinstance(text_elem, list):
story.extend(text_elem)
else:
story.append(text_elem)
elif element_type == "title":
elem = self._create_title_element(element)
if elem:
story.append(elem)
elif element_type == "header":
elem = self._create_header_element(element)
if elem:
story.append(elem)
elif element_type == "image":
elem = self._create_image_element(element)
if elem:
story.append(elem)
elif element_type == "table":
elem = self._create_table_element(element)
if elem:
story.append(elem)
elif element_type == "spacer":
story.append(Spacer(1, element.get("height", 12)))
elif element_type == "link":
elem = self._create_link_element(element)
if elem:
story.append(elem)
elif element_type == "newpage":
story.append(PageBreak())
continue
story.append(Spacer(1, 12))
if page_spec != pages[-1]:
story.append(PageBreak())
doc.build(story)
pdf_content = buffer.getvalue()
buffer.close()
return pdf_content
except Exception as e:
logger.error(f"Failed to generate PDF: {e}", exc_info=True)
return None
def _validate_pdf_spec(pdf_spec):
"""Validate and lightly normalize a parsed PDF specification.
Guards the generator against malformed model-supplied specs before any
rendering work happens. It accepts a flat ``elements`` list by wrapping it
into a single-page ``pages`` structure in place, then verifies that
``pages`` is a list, each page is a dict with an ``elements`` list, and
every element is a dict with a ``type`` drawn from the supported element
set. This is a pure function (aside from the in-place normalization of its
argument) with no I/O. Called within this module by :func:`run` before
constructing a :class:`PDFGenerator`; no external callers were found.
Args:
pdf_spec: The parsed specification dict to validate; may be mutated to
promote a top-level ``elements`` list into a ``pages`` list.
Returns:
tuple: ``(True, "Valid")`` when the spec is well-formed, otherwise
``(False, message)`` describing the first problem found.
"""
if not isinstance(pdf_spec, dict):
return False, "PDF specification must be a dictionary"
if "elements" in pdf_spec:
pdf_spec["pages"] = [{"elements": pdf_spec.pop("elements")}]
if "pages" not in pdf_spec:
return False, "PDF specification must contain 'pages' key"
pages = pdf_spec["pages"]
if not isinstance(pages, list):
return False, "'pages' must be a list"
supported = {
"title",
"header",
"paragraph",
"text",
"image",
"table",
"spacer",
"newpage",
"link",
}
for i, page in enumerate(pages):
if not isinstance(page, dict) or "elements" not in page:
return False, f"Page {i} must be a dictionary with 'elements' key"
for j, element in enumerate(page["elements"]):
if not isinstance(element, dict) or "type" not in element:
return False, f"Element {j} on page {i} must have 'type' key"
if element["type"].lower() not in supported:
return False, f"Unsupported element type '{element['type']}'"
return True, "Valid"
TOOL_NAME = "generate_pdf"
TOOL_DESCRIPTION = (
"Generate a PDF from a JSON specification with pages containing "
"text, titles, headers, images, tables, links, spacers, and page breaks. "
"Returns the file path to the generated PDF."
)
TOOL_PARAMETERS = {
"type": "object",
"properties": {
"pdf_spec": {
"type": "string",
"description": (
"JSON string containing PDF specification with 'pages' array. "
"Element types: title, header, paragraph/text, image, table, spacer, link, newpage. "
"Image elements take 'url' or 'base64' (the 'path' source was "
"removed for security; do not include it)."
),
},
"filename": {
"type": "string",
"description": "Optional filename for the PDF (defaults to auto-generated).",
},
},
"required": ["pdf_spec"],
}
[docs]
async def run(
pdf_spec: str, filename: str = None, ctx: ToolContext | None = None
) -> str:
"""Execute this tool and return the result.
Args:
pdf_spec (str): The pdf spec value.
filename (str): The filename value.
ctx (ToolContext | None): Tool execution context providing access to bot internals.
Returns:
str: Result string.
"""
try:
try:
if len(pdf_spec) >= 256 * 1024:
spec_dict = await asyncio.to_thread(json.loads, pdf_spec)
else:
spec_dict = json.loads(pdf_spec)
except json.JSONDecodeError as e:
return f"ERROR: Invalid JSON specification: {e}"
is_valid, error_msg = _validate_pdf_spec(spec_dict)
if not is_valid:
return f"ERROR: Invalid PDF specification: {error_msg}"
if not filename:
title = spec_dict.get("title", "generated_pdf")
safe_title = "".join(
c for c in title if c.isalnum() or c in (" ", "-", "_")
).rstrip()
filename = f"{safe_title}_{uuid.uuid4().hex[:8]}.pdf"
if not filename.lower().endswith(".pdf"):
filename += ".pdf"
page_size = spec_dict.get("page_size", "A4")
def _build_pdf() -> tuple[str, int]:
"""Internal helper: build pdf.
Returns:
tuple[str, int]: The result.
"""
generator = PDFGenerator(page_size=page_size)
pdf_content = generator.generate_pdf(spec_dict)
if not pdf_content:
raise RuntimeError("Failed to generate PDF content")
tmp_dir = tempfile.mkdtemp()
filepath = os.path.join(tmp_dir, filename)
with open(filepath, "wb") as f:
f.write(pdf_content)
return filepath, len(pdf_content)
filepath, size = await asyncio.to_thread(_build_pdf)
return f"PDF generated: {filepath} ({size} bytes)"
except Exception as e:
logger.error(f"Unexpected error in PDF generation: {e}", exc_info=True)
return f"ERROR: Unexpected error occurred: {e}"