"""
Advanced Forms Mixin - Extended PDF form field operations
Uses official fastmcp.contrib.mcp_mixin pattern
"""
import asyncio
import time
import json
from pathlib import Path
from typing import Dict, Any, Optional, List
import logging
# PDF processing libraries
import fitz # PyMuPDF
# Official FastMCP mixin
from fastmcp.contrib.mcp_mixin import MCPMixin, mcp_tool
from ..security import validate_pdf_path, validate_output_path, sanitize_error_message
logger = logging.getLogger(__name__)
class AdvancedFormsMixin(MCPMixin):
"""
Handles advanced PDF form operations including radio groups, textareas, and date fields.
Uses the official FastMCP mixin pattern.
"""
def __init__(self):
super().__init__()
self.max_file_size = 100 * 1024 * 1024 # 100MB
@mcp_tool(
name="add_form_fields",
description="Add form fields to an existing PDF"
)
async def add_form_fields(
self,
input_path: str,
output_path: str,
fields: str
) -> Dict[str, Any]:
"""
Add interactive form fields to an existing PDF document.
Args:
input_path: Path to input PDF file
output_path: Path where modified PDF will be saved
fields: JSON string describing form fields to add
Returns:
Dictionary containing operation results
"""
start_time = time.time()
try:
# Validate paths
input_pdf_path = await validate_pdf_path(input_path)
output_pdf_path = validate_output_path(output_path)
# Parse fields data
try:
field_definitions = json.loads(fields)
except json.JSONDecodeError as e:
return {
"success": False,
"error": f"Invalid JSON in fields: {e}",
"processing_time": round(time.time() - start_time, 2)
}
# Open existing PDF
doc = fitz.open(str(input_pdf_path))
fields_added = 0
for field_def in field_definitions:
try:
page_num = field_def.get("page", 1) - 1 # Convert to 0-based
if page_num < 0 or page_num >= len(doc):
continue
page = doc[page_num]
field_type = field_def.get("type", "text")
field_name = field_def.get("name", f"field_{fields_added + 1}")
# Get position and size
x = field_def.get("x", 50)
y = field_def.get("y", 100)
width = field_def.get("width", 200)
height = field_def.get("height", 20)
# Create field rectangle
field_rect = fitz.Rect(x, y, x + width, y + height)
if field_type == "text":
widget = page.add_widget(fitz.Widget())
widget.field_name = field_name
widget.field_type = fitz.PDF_WIDGET_TYPE_TEXT
widget.rect = field_rect
widget.update()
elif field_type == "checkbox":
widget = page.add_widget(fitz.Widget())
widget.field_name = field_name
widget.field_type = fitz.PDF_WIDGET_TYPE_CHECKBOX
widget.rect = field_rect
widget.update()
fields_added += 1
except Exception as e:
logger.warning(f"Failed to add field {field_def}: {e}")
# Save modified PDF
doc.save(str(output_pdf_path))
output_size = output_pdf_path.stat().st_size
doc.close()
return {
"success": True,
"fields_summary": {
"fields_requested": len(field_definitions),
"fields_added": fields_added,
"output_size_bytes": output_size
},
"output_info": {
"output_path": str(output_pdf_path)
},
"processing_time": round(time.time() - start_time, 2)
}
except Exception as e:
error_msg = sanitize_error_message(str(e))
logger.error(f"Adding form fields failed: {error_msg}")
return {
"success": False,
"error": error_msg,
"processing_time": round(time.time() - start_time, 2)
}
@mcp_tool(
name="add_radio_group",
description="Add a radio button group with mutual exclusion to PDF"
)
async def add_radio_group(
self,
input_path: str,
output_path: str,
group_name: str,
options: str,
page: int = 1,
x: int = 50,
y: int = 100,
spacing: int = 30
) -> Dict[str, Any]:
"""
Add a radio button group to PDF with mutual exclusion.
Args:
input_path: Path to input PDF file
output_path: Path where modified PDF will be saved
group_name: Name of the radio button group
options: JSON array of option labels
page: Page number (1-based)
x: X coordinate for first radio button
y: Y coordinate for first radio button
spacing: Vertical spacing between options
Returns:
Dictionary containing operation results
"""
start_time = time.time()
try:
# Validate paths
input_pdf_path = await validate_pdf_path(input_path)
output_pdf_path = validate_output_path(output_path)
# Parse options
try:
option_list = json.loads(options)
except json.JSONDecodeError as e:
return {
"success": False,
"error": f"Invalid JSON in options: {e}",
"processing_time": round(time.time() - start_time, 2)
}
# Open PDF
doc = fitz.open(str(input_pdf_path))
page_num = page - 1 # Convert to 0-based
if page_num < 0 or page_num >= len(doc):
doc.close()
return {
"success": False,
"error": f"Page {page} out of range",
"processing_time": round(time.time() - start_time, 2)
}
pdf_page = doc[page_num]
buttons_added = 0
# Add radio buttons
for i, option_label in enumerate(option_list):
try:
button_y = y + (i * spacing)
button_rect = fitz.Rect(x, button_y, x + 15, button_y + 15)
# Create radio button widget
widget = pdf_page.add_widget(fitz.Widget())
widget.field_name = f"{group_name}_{i}"
widget.field_type = fitz.PDF_WIDGET_TYPE_RADIOBUTTON
widget.rect = button_rect
widget.update()
# Add label text next to radio button
text_point = fitz.Point(x + 20, button_y + 10)
pdf_page.insert_text(text_point, option_label, fontsize=10)
buttons_added += 1
except Exception as e:
logger.warning(f"Failed to add radio button {i}: {e}")
# Save modified PDF
doc.save(str(output_pdf_path))
output_size = output_pdf_path.stat().st_size
doc.close()
return {
"success": True,
"radio_group_summary": {
"group_name": group_name,
"options_requested": len(option_list),
"buttons_added": buttons_added,
"page": page,
"output_size_bytes": output_size
},
"output_info": {
"output_path": str(output_pdf_path)
},
"processing_time": round(time.time() - start_time, 2)
}
except Exception as e:
error_msg = sanitize_error_message(str(e))
logger.error(f"Adding radio group failed: {error_msg}")
return {
"success": False,
"error": error_msg,
"processing_time": round(time.time() - start_time, 2)
}
@mcp_tool(
name="add_textarea_field",
description="Add a multi-line text area with word limits to PDF"
)
async def add_textarea_field(
self,
input_path: str,
output_path: str,
field_name: str,
x: int = 50,
y: int = 100,
width: int = 400,
height: int = 100,
page: int = 1,
word_limit: int = 500,
label: str = "",
show_word_count: bool = True
) -> Dict[str, Any]:
"""
Add a multi-line text area field with word counting capabilities.
Args:
input_path: Path to input PDF file
output_path: Path where modified PDF will be saved
field_name: Name of the textarea field
x: X coordinate
y: Y coordinate
width: Field width
height: Field height
page: Page number (1-based)
word_limit: Maximum word count
label: Optional field label
show_word_count: Whether to show word count indicator
Returns:
Dictionary containing operation results
"""
start_time = time.time()
try:
# Validate paths
input_pdf_path = await validate_pdf_path(input_path)
output_pdf_path = validate_output_path(output_path)
# Open PDF
doc = fitz.open(str(input_pdf_path))
page_num = page - 1 # Convert to 0-based
if page_num < 0 or page_num >= len(doc):
doc.close()
return {
"success": False,
"error": f"Page {page} out of range",
"processing_time": round(time.time() - start_time, 2)
}
pdf_page = doc[page_num]
# Add label if provided
if label:
label_point = fitz.Point(x, y - 15)
pdf_page.insert_text(label_point, label, fontsize=10, color=(0, 0, 0))
# Create textarea field rectangle
field_rect = fitz.Rect(x, y, x + width, y + height)
# Add textarea widget
widget = pdf_page.add_widget(fitz.Widget())
widget.field_name = field_name
widget.field_type = fitz.PDF_WIDGET_TYPE_TEXT
widget.rect = field_rect
widget.update()
# Add word count indicator if requested
if show_word_count:
count_text = f"Max words: {word_limit}"
count_point = fitz.Point(x + width - 100, y + height + 15)
pdf_page.insert_text(count_point, count_text, fontsize=8, color=(0.5, 0.5, 0.5))
# Save modified PDF
doc.save(str(output_pdf_path))
output_size = output_pdf_path.stat().st_size
doc.close()
return {
"success": True,
"textarea_summary": {
"field_name": field_name,
"dimensions": f"{width}x{height}",
"word_limit": word_limit,
"has_label": bool(label),
"page": page,
"output_size_bytes": output_size
},
"output_info": {
"output_path": str(output_pdf_path)
},
"processing_time": round(time.time() - start_time, 2)
}
except Exception as e:
error_msg = sanitize_error_message(str(e))
logger.error(f"Adding textarea field failed: {error_msg}")
return {
"success": False,
"error": error_msg,
"processing_time": round(time.time() - start_time, 2)
}
@mcp_tool(
name="add_date_field",
description="Add a date field with format validation to PDF"
)
async def add_date_field(
self,
input_path: str,
output_path: str,
field_name: str,
x: int = 50,
y: int = 100,
width: int = 150,
height: int = 25,
page: int = 1,
date_format: str = "MM/DD/YYYY",
label: str = "",
show_format_hint: bool = True
) -> Dict[str, Any]:
"""
Add a date input field with format validation hints.
Args:
input_path: Path to input PDF file
output_path: Path where modified PDF will be saved
field_name: Name of the date field
x: X coordinate
y: Y coordinate
width: Field width
height: Field height
page: Page number (1-based)
date_format: Expected date format
label: Optional field label
show_format_hint: Whether to show format hint
Returns:
Dictionary containing operation results
"""
start_time = time.time()
try:
# Validate paths
input_pdf_path = await validate_pdf_path(input_path)
output_pdf_path = validate_output_path(output_path)
# Open PDF
doc = fitz.open(str(input_pdf_path))
page_num = page - 1 # Convert to 0-based
if page_num < 0 or page_num >= len(doc):
doc.close()
return {
"success": False,
"error": f"Page {page} out of range",
"processing_time": round(time.time() - start_time, 2)
}
pdf_page = doc[page_num]
# Add label if provided
if label:
label_point = fitz.Point(x, y - 15)
pdf_page.insert_text(label_point, label, fontsize=10, color=(0, 0, 0))
# Create date field rectangle
field_rect = fitz.Rect(x, y, x + width, y + height)
# Add date input widget
widget = pdf_page.add_widget(fitz.Widget())
widget.field_name = field_name
widget.field_type = fitz.PDF_WIDGET_TYPE_TEXT
widget.rect = field_rect
widget.update()
# Add format hint if requested
if show_format_hint:
hint_text = f"Format: {date_format}"
hint_point = fitz.Point(x + width + 10, y + height/2)
pdf_page.insert_text(hint_point, hint_text, fontsize=8, color=(0.5, 0.5, 0.5))
# Save modified PDF
doc.save(str(output_pdf_path))
output_size = output_pdf_path.stat().st_size
doc.close()
return {
"success": True,
"date_field_summary": {
"field_name": field_name,
"date_format": date_format,
"dimensions": f"{width}x{height}",
"has_label": bool(label),
"has_format_hint": show_format_hint,
"page": page,
"output_size_bytes": output_size
},
"output_info": {
"output_path": str(output_pdf_path)
},
"processing_time": round(time.time() - start_time, 2)
}
except Exception as e:
error_msg = sanitize_error_message(str(e))
logger.error(f"Adding date field failed: {error_msg}")
return {
"success": False,
"error": error_msg,
"processing_time": round(time.time() - start_time, 2)
}
@mcp_tool(
name="validate_form_data",
description="Validate form data against rules and constraints"
)
async def validate_form_data(
self,
pdf_path: str,
form_data: str,
validation_rules: str = "{}"
) -> Dict[str, Any]:
"""
Validate form data against specified rules and constraints.
Args:
pdf_path: Path to PDF with form fields
form_data: JSON string containing form data to validate
validation_rules: JSON string with validation rules
Returns:
Dictionary containing validation results
"""
start_time = time.time()
try:
# Validate PDF path
input_pdf_path = await validate_pdf_path(pdf_path)
# Parse form data and rules
try:
data = json.loads(form_data)
rules = json.loads(validation_rules)
except json.JSONDecodeError as e:
return {
"success": False,
"error": f"Invalid JSON: {e}",
"validation_time": round(time.time() - start_time, 2)
}
validation_results = []
errors = []
warnings = []
# Basic validation logic
for field_name, field_value in data.items():
field_rules = rules.get(field_name, {})
field_result = {"field": field_name, "value": field_value, "valid": True, "messages": []}
# Required field validation
if field_rules.get("required", False) and not field_value:
field_result["valid"] = False
field_result["messages"].append("Field is required")
errors.append(f"{field_name}: Required field is empty")
# Length validation
if "max_length" in field_rules and len(str(field_value)) > field_rules["max_length"]:
field_result["valid"] = False
field_result["messages"].append(f"Exceeds maximum length of {field_rules['max_length']}")
errors.append(f"{field_name}: Value too long")
# Pattern validation (basic)
if "pattern" in field_rules and field_value:
import re
if not re.match(field_rules["pattern"], str(field_value)):
field_result["valid"] = False
field_result["messages"].append("Does not match required pattern")
errors.append(f"{field_name}: Invalid format")
validation_results.append(field_result)
# Overall validation status
is_valid = len(errors) == 0
return {
"success": True,
"validation_summary": {
"is_valid": is_valid,
"total_fields": len(data),
"valid_fields": len([r for r in validation_results if r["valid"]]),
"invalid_fields": len([r for r in validation_results if not r["valid"]]),
"total_errors": len(errors),
"total_warnings": len(warnings)
},
"field_results": validation_results,
"errors": errors,
"warnings": warnings,
"file_info": {
"path": str(input_pdf_path)
},
"validation_time": round(time.time() - start_time, 2)
}
except Exception as e:
error_msg = sanitize_error_message(str(e))
logger.error(f"Form validation failed: {error_msg}")
return {
"success": False,
"error": error_msg,
"validation_time": round(time.time() - start_time, 2)
}