content_tools.py•19.6 kB
"""
Content tools for Word Document Server.
These tools add various types of content to Word documents,
including headings, paragraphs, tables, images, and page breaks.
"""
import os
from typing import List, Optional, Dict, Any
from docx import Document
from docx.shared import Inches, Pt, RGBColor
from word_document_server.utils.file_utils import check_file_writeable, ensure_docx_extension
from word_document_server.utils.document_utils import find_and_replace_text, insert_header_near_text, insert_numbered_list_near_text, insert_line_or_paragraph_near_text, replace_paragraph_block_below_header, replace_block_between_manual_anchors
from word_document_server.core.styles import ensure_heading_style, ensure_table_style
async def add_heading(filename: str, text: str, level: int = 1,
font_name: Optional[str] = None, font_size: Optional[int] = None,
bold: Optional[bool] = None, italic: Optional[bool] = None,
border_bottom: bool = False) -> str:
"""Add a heading to a Word document with optional formatting.
Args:
filename: Path to the Word document
text: Heading text
level: Heading level (1-9, where 1 is the highest level)
font_name: Font family (e.g., 'Helvetica')
font_size: Font size in points (e.g., 14)
bold: True/False for bold text
italic: True/False for italic text
border_bottom: True to add bottom border (for section headers)
"""
filename = ensure_docx_extension(filename)
# Ensure level is converted to integer
try:
level = int(level)
except (ValueError, TypeError):
return "Invalid parameter: level must be an integer between 1 and 9"
# Validate level range
if level < 1 or level > 9:
return f"Invalid heading level: {level}. Level must be between 1 and 9."
if not os.path.exists(filename):
return f"Document {filename} does not exist"
# Check if file is writeable
is_writeable, error_message = check_file_writeable(filename)
if not is_writeable:
# Suggest creating a copy
return f"Cannot modify document: {error_message}. Consider creating a copy first or creating a new document."
try:
doc = Document(filename)
# Ensure heading styles exist
ensure_heading_style(doc)
# Try to add heading with style
try:
heading = doc.add_heading(text, level=level)
except Exception as style_error:
# If style-based approach fails, use direct formatting
heading = doc.add_paragraph(text)
heading.style = doc.styles['Normal']
if heading.runs:
run = heading.runs[0]
run.bold = True
# Adjust size based on heading level
if level == 1:
run.font.size = Pt(16)
elif level == 2:
run.font.size = Pt(14)
else:
run.font.size = Pt(12)
# Apply formatting to all runs in the heading
if any([font_name, font_size, bold is not None, italic is not None]):
for run in heading.runs:
if font_name:
run.font.name = font_name
if font_size:
run.font.size = Pt(font_size)
if bold is not None:
run.font.bold = bold
if italic is not None:
run.font.italic = italic
# Add bottom border if requested
if border_bottom:
from docx.oxml import OxmlElement
from docx.oxml.ns import qn
pPr = heading._element.get_or_add_pPr()
pBdr = OxmlElement('w:pBdr')
bottom = OxmlElement('w:bottom')
bottom.set(qn('w:val'), 'single')
bottom.set(qn('w:sz'), '4') # 0.5pt border
bottom.set(qn('w:space'), '0')
bottom.set(qn('w:color'), '000000')
pBdr.append(bottom)
pPr.append(pBdr)
doc.save(filename)
return f"Heading '{text}' (level {level}) added to {filename}"
except Exception as e:
return f"Failed to add heading: {str(e)}"
async def add_paragraph(filename: str, text: str, style: Optional[str] = None,
font_name: Optional[str] = None, font_size: Optional[int] = None,
bold: Optional[bool] = None, italic: Optional[bool] = None,
color: Optional[str] = None) -> str:
"""Add a paragraph to a Word document with optional formatting.
Args:
filename: Path to the Word document
text: Paragraph text
style: Optional paragraph style name
font_name: Font family (e.g., 'Helvetica', 'Times New Roman')
font_size: Font size in points (e.g., 14, 36)
bold: True/False for bold text
italic: True/False for italic text
color: RGB color as hex string (e.g., '000000' for black)
"""
filename = ensure_docx_extension(filename)
if not os.path.exists(filename):
return f"Document {filename} does not exist"
# Check if file is writeable
is_writeable, error_message = check_file_writeable(filename)
if not is_writeable:
# Suggest creating a copy
return f"Cannot modify document: {error_message}. Consider creating a copy first or creating a new document."
try:
doc = Document(filename)
paragraph = doc.add_paragraph(text)
if style:
try:
paragraph.style = style
except KeyError:
# Style doesn't exist, use normal and report it
paragraph.style = doc.styles['Normal']
doc.save(filename)
return f"Style '{style}' not found, paragraph added with default style to {filename}"
# Apply formatting to all runs in the paragraph
if any([font_name, font_size, bold is not None, italic is not None, color]):
for run in paragraph.runs:
if font_name:
run.font.name = font_name
if font_size:
run.font.size = Pt(font_size)
if bold is not None:
run.font.bold = bold
if italic is not None:
run.font.italic = italic
if color:
# Remove any '#' prefix if present
color_hex = color.lstrip('#')
run.font.color.rgb = RGBColor.from_string(color_hex)
doc.save(filename)
return f"Paragraph added to {filename}"
except Exception as e:
return f"Failed to add paragraph: {str(e)}"
async def add_table(filename: str, rows: int, cols: int, data: Optional[List[List[str]]] = None) -> str:
"""Add a table to a Word document.
Args:
filename: Path to the Word document
rows: Number of rows in the table
cols: Number of columns in the table
data: Optional 2D array of data to fill the table
"""
filename = ensure_docx_extension(filename)
if not os.path.exists(filename):
return f"Document {filename} does not exist"
# Check if file is writeable
is_writeable, error_message = check_file_writeable(filename)
if not is_writeable:
# Suggest creating a copy
return f"Cannot modify document: {error_message}. Consider creating a copy first or creating a new document."
try:
doc = Document(filename)
table = doc.add_table(rows=rows, cols=cols)
# Try to set the table style
try:
table.style = 'Table Grid'
except KeyError:
# If style doesn't exist, add basic borders
pass
# Fill table with data if provided
if data:
for i, row_data in enumerate(data):
if i >= rows:
break
for j, cell_text in enumerate(row_data):
if j >= cols:
break
table.cell(i, j).text = str(cell_text)
doc.save(filename)
return f"Table ({rows}x{cols}) added to {filename}"
except Exception as e:
return f"Failed to add table: {str(e)}"
async def add_picture(filename: str, image_path: str, width: Optional[float] = None) -> str:
"""Add an image to a Word document.
Args:
filename: Path to the Word document
image_path: Path to the image file
width: Optional width in inches (proportional scaling)
"""
filename = ensure_docx_extension(filename)
# Validate document existence
if not os.path.exists(filename):
return f"Document {filename} does not exist"
# Get absolute paths for better diagnostics
abs_filename = os.path.abspath(filename)
abs_image_path = os.path.abspath(image_path)
# Validate image existence with improved error message
if not os.path.exists(abs_image_path):
return f"Image file not found: {abs_image_path}"
# Check image file size
try:
image_size = os.path.getsize(abs_image_path) / 1024 # Size in KB
if image_size <= 0:
return f"Image file appears to be empty: {abs_image_path} (0 KB)"
except Exception as size_error:
return f"Error checking image file: {str(size_error)}"
# Check if file is writeable
is_writeable, error_message = check_file_writeable(abs_filename)
if not is_writeable:
return f"Cannot modify document: {error_message}. Consider creating a copy first or creating a new document."
try:
doc = Document(abs_filename)
# Additional diagnostic info
diagnostic = f"Attempting to add image ({abs_image_path}, {image_size:.2f} KB) to document ({abs_filename})"
try:
if width:
doc.add_picture(abs_image_path, width=Inches(width))
else:
doc.add_picture(abs_image_path)
doc.save(abs_filename)
return f"Picture {image_path} added to {filename}"
except Exception as inner_error:
# More detailed error for the specific operation
error_type = type(inner_error).__name__
error_msg = str(inner_error)
return f"Failed to add picture: {error_type} - {error_msg or 'No error details available'}\nDiagnostic info: {diagnostic}"
except Exception as outer_error:
# Fallback error handling
error_type = type(outer_error).__name__
error_msg = str(outer_error)
return f"Document processing error: {error_type} - {error_msg or 'No error details available'}"
async def add_page_break(filename: str) -> str:
"""Add a page break to the document.
Args:
filename: Path to the Word document
"""
filename = ensure_docx_extension(filename)
if not os.path.exists(filename):
return f"Document {filename} does not exist"
# Check if file is writeable
is_writeable, error_message = check_file_writeable(filename)
if not is_writeable:
return f"Cannot modify document: {error_message}. Consider creating a copy first."
try:
doc = Document(filename)
doc.add_page_break()
doc.save(filename)
return f"Page break added to {filename}."
except Exception as e:
return f"Failed to add page break: {str(e)}"
async def add_table_of_contents(filename: str, title: str = "Table of Contents", max_level: int = 3) -> str:
"""Add a table of contents to a Word document based on heading styles.
Args:
filename: Path to the Word document
title: Optional title for the table of contents
max_level: Maximum heading level to include (1-9)
"""
filename = ensure_docx_extension(filename)
if not os.path.exists(filename):
return f"Document {filename} does not exist"
# Check if file is writeable
is_writeable, error_message = check_file_writeable(filename)
if not is_writeable:
return f"Cannot modify document: {error_message}. Consider creating a copy first."
try:
# Ensure max_level is within valid range
max_level = max(1, min(max_level, 9))
doc = Document(filename)
# Collect headings and their positions
headings = []
for i, paragraph in enumerate(doc.paragraphs):
# Check if paragraph style is a heading
if paragraph.style and paragraph.style.name.startswith('Heading '):
try:
# Extract heading level from style name
level = int(paragraph.style.name.split(' ')[1])
if level <= max_level:
headings.append({
'level': level,
'text': paragraph.text,
'position': i
})
except (ValueError, IndexError):
# Skip if heading level can't be determined
pass
if not headings:
return f"No headings found in document {filename}. Table of contents not created."
# Create a new document with the TOC
toc_doc = Document()
# Add title
if title:
toc_doc.add_heading(title, level=1)
# Add TOC entries
for heading in headings:
# Indent based on level (using tab characters)
indent = ' ' * (heading['level'] - 1)
toc_doc.add_paragraph(f"{indent}{heading['text']}")
# Add page break
toc_doc.add_page_break()
# Get content from original document
for paragraph in doc.paragraphs:
p = toc_doc.add_paragraph(paragraph.text)
# Copy style if possible
try:
if paragraph.style:
p.style = paragraph.style.name
except:
pass
# Copy tables
for table in doc.tables:
# Create a new table with the same dimensions
new_table = toc_doc.add_table(rows=len(table.rows), cols=len(table.columns))
# Copy cell contents
for i, row in enumerate(table.rows):
for j, cell in enumerate(row.cells):
for paragraph in cell.paragraphs:
new_table.cell(i, j).text = paragraph.text
# Save the new document with TOC
toc_doc.save(filename)
return f"Table of contents with {len(headings)} entries added to {filename}"
except Exception as e:
return f"Failed to add table of contents: {str(e)}"
async def delete_paragraph(filename: str, paragraph_index: int) -> str:
"""Delete a paragraph from a document.
Args:
filename: Path to the Word document
paragraph_index: Index of the paragraph to delete (0-based)
"""
filename = ensure_docx_extension(filename)
if not os.path.exists(filename):
return f"Document {filename} does not exist"
# Check if file is writeable
is_writeable, error_message = check_file_writeable(filename)
if not is_writeable:
return f"Cannot modify document: {error_message}. Consider creating a copy first."
try:
doc = Document(filename)
# Validate paragraph index
if paragraph_index < 0 or paragraph_index >= len(doc.paragraphs):
return f"Invalid paragraph index. Document has {len(doc.paragraphs)} paragraphs (0-{len(doc.paragraphs)-1})."
# Delete the paragraph (by removing its content and setting it empty)
# Note: python-docx doesn't support true paragraph deletion, this is a workaround
paragraph = doc.paragraphs[paragraph_index]
p = paragraph._p
p.getparent().remove(p)
doc.save(filename)
return f"Paragraph at index {paragraph_index} deleted successfully."
except Exception as e:
return f"Failed to delete paragraph: {str(e)}"
async def search_and_replace(filename: str, find_text: str, replace_text: str) -> str:
"""Search for text and replace all occurrences.
Args:
filename: Path to the Word document
find_text: Text to search for
replace_text: Text to replace with
"""
filename = ensure_docx_extension(filename)
if not os.path.exists(filename):
return f"Document {filename} does not exist"
# Check if file is writeable
is_writeable, error_message = check_file_writeable(filename)
if not is_writeable:
return f"Cannot modify document: {error_message}. Consider creating a copy first."
try:
doc = Document(filename)
# Perform find and replace
count = find_and_replace_text(doc, find_text, replace_text)
if count > 0:
doc.save(filename)
return f"Replaced {count} occurrence(s) of '{find_text}' with '{replace_text}'."
else:
return f"No occurrences of '{find_text}' found."
except Exception as e:
return f"Failed to search and replace: {str(e)}"
async def insert_header_near_text_tool(filename: str, target_text: str = None, header_title: str = "", position: str = 'after', header_style: str = 'Heading 1', target_paragraph_index: int = None) -> str:
"""Insert a header (with specified style) before or after the target paragraph. Specify by text or paragraph index."""
return insert_header_near_text(filename, target_text, header_title, position, header_style, target_paragraph_index)
async def insert_numbered_list_near_text_tool(filename: str, target_text: str = None, list_items: list = None, position: str = 'after', target_paragraph_index: int = None, bullet_type: str = 'bullet') -> str:
"""Insert a bulleted or numbered list before or after the target paragraph. Specify by text or paragraph index."""
return insert_numbered_list_near_text(filename, target_text, list_items, position, target_paragraph_index, bullet_type)
async def insert_line_or_paragraph_near_text_tool(filename: str, target_text: str = None, line_text: str = "", position: str = 'after', line_style: str = None, target_paragraph_index: int = None) -> str:
"""Insert a new line or paragraph (with specified or matched style) before or after the target paragraph. Specify by text or paragraph index."""
return insert_line_or_paragraph_near_text(filename, target_text, line_text, position, line_style, target_paragraph_index)
async def replace_paragraph_block_below_header_tool(filename: str, header_text: str, new_paragraphs: list, detect_block_end_fn=None) -> str:
"""Reemplaza el bloque de párrafos debajo de un encabezado, evitando modificar TOC."""
return replace_paragraph_block_below_header(filename, header_text, new_paragraphs, detect_block_end_fn)
async def replace_block_between_manual_anchors_tool(filename: str, start_anchor_text: str, new_paragraphs: list, end_anchor_text: str = None, match_fn=None, new_paragraph_style: str = None) -> str:
"""Replace all content between start_anchor_text and end_anchor_text (or next logical header if not provided)."""
return replace_block_between_manual_anchors(filename, start_anchor_text, new_paragraphs, end_anchor_text, match_fn, new_paragraph_style)