This is page 2 of 2. Use http://codebase.md/gongrzhe/office-word-mcp-server?page={x} to view the full context.
# Directory Structure
```
├── __init__.py
├── .gitignore
├── Dockerfile
├── LICENSE
├── mcp-config.json
├── office_word_mcp_server
│ └── __init__.py
├── pyproject.toml
├── README.md
├── RENDER_DEPLOYMENT.md
├── requirements.txt
├── setup_mcp.py
├── smithery.yaml
├── test_formatting.py
├── tests
│ └── test_convert_to_pdf.py
├── uv.lock
├── word_document_server
│ ├── __init__.py
│ ├── core
│ │ ├── __init__.py
│ │ ├── comments.py
│ │ ├── footnotes.py
│ │ ├── protection.py
│ │ ├── styles.py
│ │ ├── tables.py
│ │ └── unprotect.py
│ ├── main.py
│ ├── tools
│ │ ├── __init__.py
│ │ ├── comment_tools.py
│ │ ├── content_tools.py
│ │ ├── document_tools.py
│ │ ├── extended_document_tools.py
│ │ ├── footnote_tools.py
│ │ ├── format_tools.py
│ │ └── protection_tools.py
│ └── utils
│ ├── __init__.py
│ ├── document_utils.py
│ ├── extended_document_utils.py
│ └── file_utils.py
└── word_mcp_server.py
```
# Files
--------------------------------------------------------------------------------
/word_document_server/core/tables.py:
--------------------------------------------------------------------------------
```python
"""
Table-related operations for Word Document Server.
"""
from docx.oxml.shared import OxmlElement, qn
from docx.oxml.ns import nsdecls
from docx.oxml import parse_xml
from docx.shared import RGBColor, Inches, Cm, Pt
from docx.enum.text import WD_ALIGN_PARAGRAPH
from docx.enum.table import WD_CELL_VERTICAL_ALIGNMENT
def set_cell_border(cell, **kwargs):
"""
Set cell border properties.
Args:
cell: The cell to modify
**kwargs: Border properties (top, bottom, left, right, val, color)
"""
tc = cell._tc
tcPr = tc.get_or_add_tcPr()
# Create border elements
for key, value in kwargs.items():
if key in ['top', 'left', 'bottom', 'right']:
tag = 'w:{}'.format(key)
element = OxmlElement(tag)
element.set(qn('w:val'), kwargs.get('val', 'single'))
element.set(qn('w:sz'), kwargs.get('sz', '4'))
element.set(qn('w:space'), kwargs.get('space', '0'))
element.set(qn('w:color'), kwargs.get('color', 'auto'))
tcBorders = tcPr.first_child_found_in("w:tcBorders")
if tcBorders is None:
tcBorders = OxmlElement('w:tcBorders')
tcPr.append(tcBorders)
tcBorders.append(element)
def apply_table_style(table, has_header_row=False, border_style=None, shading=None):
"""
Apply formatting to a table.
Args:
table: The table to format
has_header_row: If True, formats the first row as a header
border_style: Style for borders ('none', 'single', 'double', 'thick')
shading: 2D list of cell background colors (by row and column)
Returns:
True if successful, False otherwise
"""
try:
# Format header row if requested
if has_header_row and table.rows:
header_row = table.rows[0]
for cell in header_row.cells:
for paragraph in cell.paragraphs:
if paragraph.runs:
for run in paragraph.runs:
run.bold = True
# Apply border style if specified
if border_style:
val_map = {
'none': 'nil',
'single': 'single',
'double': 'double',
'thick': 'thick'
}
val = val_map.get(border_style.lower(), 'single')
# Apply to all cells
for row in table.rows:
for cell in row.cells:
set_cell_border(
cell,
top=True,
bottom=True,
left=True,
right=True,
val=val,
color="000000"
)
# Apply cell shading if specified
if shading:
for i, row_colors in enumerate(shading):
if i >= len(table.rows):
break
for j, color in enumerate(row_colors):
if j >= len(table.rows[i].cells):
break
try:
# Apply shading to cell
cell = table.rows[i].cells[j]
shading_elm = parse_xml(f'<w:shd {nsdecls("w")} w:fill="{color}"/>')
cell._tc.get_or_add_tcPr().append(shading_elm)
except:
# Skip if color format is invalid
pass
return True
except Exception:
return False
def copy_table(source_table, target_doc):
"""
Copy a table from one document to another.
Args:
source_table: The table to copy
target_doc: The document to copy the table to
Returns:
The new table in the target document
"""
# Create a new table with the same dimensions
new_table = target_doc.add_table(rows=len(source_table.rows), cols=len(source_table.columns))
# Try to apply the same style
try:
if source_table.style:
new_table.style = source_table.style
except:
# Fall back to default grid style
try:
new_table.style = 'Table Grid'
except:
pass
# Copy cell contents
for i, row in enumerate(source_table.rows):
for j, cell in enumerate(row.cells):
for paragraph in cell.paragraphs:
if paragraph.text:
new_table.cell(i, j).text = paragraph.text
return new_table
def set_cell_shading(cell, fill_color=None, pattern="clear", pattern_color="auto"):
"""
Apply shading/filling to a table cell.
Args:
cell: The table cell to format
fill_color: Background color (hex string like "FF0000" or RGBColor)
pattern: Shading pattern ("clear", "solid", "pct10", "pct20", etc.)
pattern_color: Pattern color for patterned fills
Returns:
True if successful, False otherwise
"""
try:
# Get or create table cell properties
tc_pr = cell._tc.get_or_add_tcPr()
# Remove existing shading
existing_shd = tc_pr.find(qn('w:shd'))
if existing_shd is not None:
tc_pr.remove(existing_shd)
# Create shading element
shd_attrs = {
'w:val': pattern,
'w:color': pattern_color if pattern_color != "auto" else "auto"
}
# Set fill color
if fill_color:
if isinstance(fill_color, str):
# Hex color string - remove # if present
fill_color = fill_color.lstrip('#').upper()
if len(fill_color) == 6: # Valid hex color
shd_attrs['w:fill'] = fill_color
elif isinstance(fill_color, RGBColor):
# RGBColor object
hex_color = f"{fill_color.r:02X}{fill_color.g:02X}{fill_color.b:02X}"
shd_attrs['w:fill'] = hex_color
# Build XML string
attr_str = ' '.join([f'{k}="{v}"' for k, v in shd_attrs.items()])
shd_xml = f'<w:shd {nsdecls("w")} {attr_str}/>'
# Parse and append shading element
shading_elm = parse_xml(shd_xml)
tc_pr.append(shading_elm)
return True
except Exception as e:
print(f"Error setting cell shading: {e}")
return False
def apply_alternating_row_shading(table, color1="FFFFFF", color2="F2F2F2"):
"""
Apply alternating row colors for better readability.
Args:
table: The table to format
color1: Color for odd rows (hex string)
color2: Color for even rows (hex string)
Returns:
True if successful, False otherwise
"""
try:
for i, row in enumerate(table.rows):
fill_color = color1 if i % 2 == 0 else color2
for cell in row.cells:
set_cell_shading(cell, fill_color=fill_color)
return True
except Exception as e:
print(f"Error applying alternating row shading: {e}")
return False
def highlight_header_row(table, header_color="4472C4", text_color="FFFFFF"):
"""
Apply special shading to header row.
Args:
table: The table to format
header_color: Background color for header (hex string)
text_color: Text color for header (hex string)
Returns:
True if successful, False otherwise
"""
try:
if table.rows:
for cell in table.rows[0].cells:
# Apply background shading
set_cell_shading(cell, fill_color=header_color)
# Apply text formatting
for paragraph in cell.paragraphs:
for run in paragraph.runs:
run.bold = True
if text_color and text_color != "auto":
# Convert hex to RGB
try:
text_color = text_color.lstrip('#')
r = int(text_color[0:2], 16)
g = int(text_color[2:4], 16)
b = int(text_color[4:6], 16)
run.font.color.rgb = RGBColor(r, g, b)
except:
pass # Skip if color format is invalid
return True
except Exception as e:
print(f"Error highlighting header row: {e}")
return False
def set_cell_shading_by_position(table, row_index, col_index, fill_color, pattern="clear"):
"""
Apply shading to a specific cell by row/column position.
Args:
table: The table containing the cell
row_index: Row index (0-based)
col_index: Column index (0-based)
fill_color: Background color (hex string)
pattern: Shading pattern
Returns:
True if successful, False otherwise
"""
try:
if (0 <= row_index < len(table.rows) and
0 <= col_index < len(table.rows[row_index].cells)):
cell = table.rows[row_index].cells[col_index]
return set_cell_shading(cell, fill_color=fill_color, pattern=pattern)
else:
return False
except Exception as e:
print(f"Error setting cell shading by position: {e}")
return False
def merge_cells(table, start_row, start_col, end_row, end_col):
"""
Merge cells in a rectangular area.
Args:
table: The table containing cells to merge
start_row: Starting row index (0-based)
start_col: Starting column index (0-based)
end_row: Ending row index (0-based, inclusive)
end_col: Ending column index (0-based, inclusive)
Returns:
True if successful, False otherwise
"""
try:
# Validate indices
if (start_row < 0 or start_col < 0 or end_row < 0 or end_col < 0 or
start_row >= len(table.rows) or end_row >= len(table.rows) or
start_row > end_row or start_col > end_col):
return False
# Check if all rows have enough columns
for row_idx in range(start_row, end_row + 1):
if (start_col >= len(table.rows[row_idx].cells) or
end_col >= len(table.rows[row_idx].cells)):
return False
# Get the start and end cells
start_cell = table.cell(start_row, start_col)
end_cell = table.cell(end_row, end_col)
# Merge the cells
start_cell.merge(end_cell)
return True
except Exception as e:
print(f"Error merging cells: {e}")
return False
def merge_cells_horizontal(table, row_index, start_col, end_col):
"""
Merge cells horizontally in a single row.
Args:
table: The table containing cells to merge
row_index: Row index (0-based)
start_col: Starting column index (0-based)
end_col: Ending column index (0-based, inclusive)
Returns:
True if successful, False otherwise
"""
return merge_cells(table, row_index, start_col, row_index, end_col)
def merge_cells_vertical(table, col_index, start_row, end_row):
"""
Merge cells vertically in a single column.
Args:
table: The table containing cells to merge
col_index: Column index (0-based)
start_row: Starting row index (0-based)
end_row: Ending row index (0-based, inclusive)
Returns:
True if successful, False otherwise
"""
return merge_cells(table, start_row, col_index, end_row, col_index)
def set_cell_alignment(cell, horizontal="left", vertical="top"):
"""
Set text alignment within a cell.
Args:
cell: The table cell to format
horizontal: Horizontal alignment ("left", "center", "right", "justify")
vertical: Vertical alignment ("top", "center", "bottom")
Returns:
True if successful, False otherwise
"""
try:
# Set horizontal alignment for all paragraphs in the cell
for paragraph in cell.paragraphs:
if horizontal.lower() == "center":
paragraph.alignment = WD_ALIGN_PARAGRAPH.CENTER
elif horizontal.lower() == "right":
paragraph.alignment = WD_ALIGN_PARAGRAPH.RIGHT
elif horizontal.lower() == "justify":
paragraph.alignment = WD_ALIGN_PARAGRAPH.JUSTIFY
else: # default to left
paragraph.alignment = WD_ALIGN_PARAGRAPH.LEFT
# Set vertical alignment for the cell using XML manipulation
tc_pr = cell._tc.get_or_add_tcPr()
# Remove existing vertical alignment
existing_valign = tc_pr.find(qn('w:vAlign'))
if existing_valign is not None:
tc_pr.remove(existing_valign)
# Create vertical alignment element
valign_element = OxmlElement('w:vAlign')
if vertical.lower() == "center":
valign_element.set(qn('w:val'), 'center')
elif vertical.lower() == "bottom":
valign_element.set(qn('w:val'), 'bottom')
else: # default to top
valign_element.set(qn('w:val'), 'top')
tc_pr.append(valign_element)
return True
except Exception as e:
print(f"Error setting cell alignment: {e}")
return False
def set_cell_alignment_by_position(table, row_index, col_index, horizontal="left", vertical="top"):
"""
Set text alignment for a specific cell by position.
Args:
table: The table containing the cell
row_index: Row index (0-based)
col_index: Column index (0-based)
horizontal: Horizontal alignment ("left", "center", "right", "justify")
vertical: Vertical alignment ("top", "center", "bottom")
Returns:
True if successful, False otherwise
"""
try:
if (0 <= row_index < len(table.rows) and
0 <= col_index < len(table.rows[row_index].cells)):
cell = table.rows[row_index].cells[col_index]
return set_cell_alignment(cell, horizontal, vertical)
else:
return False
except Exception as e:
print(f"Error setting cell alignment by position: {e}")
return False
def set_table_alignment(table, horizontal="left", vertical="top"):
"""
Set text alignment for all cells in a table.
Args:
table: The table to format
horizontal: Horizontal alignment ("left", "center", "right", "justify")
vertical: Vertical alignment ("top", "center", "bottom")
Returns:
True if successful, False otherwise
"""
try:
for row in table.rows:
for cell in row.cells:
set_cell_alignment(cell, horizontal, vertical)
return True
except Exception as e:
print(f"Error setting table alignment: {e}")
return False
def set_column_width(table, col_index, width, width_type="dxa"):
"""
Set the width of a specific column in a table.
Args:
table: The table to modify
col_index: Column index (0-based)
width: Column width value
width_type: Width type ("dxa" for points*20, "pct" for percentage*50, "auto")
Returns:
True if successful, False otherwise
"""
try:
# Validate column index
if col_index < 0 or col_index >= len(table.columns):
return False
# Convert width based on type
if width_type == "dxa":
# DXA units (twentieths of a point)
if isinstance(width, (int, float)):
width_value = str(int(width * 20))
else:
width_value = str(width)
elif width_type == "pct":
# Percentage (multiply by 50 for Word format)
if isinstance(width, (int, float)):
width_value = str(int(width * 50))
else:
width_value = str(width)
else:
width_value = str(width)
# Iterate through all rows and set width for cells in the specified column
for row in table.rows:
if col_index < len(row.cells):
cell = row.cells[col_index]
tc_pr = cell._tc.get_or_add_tcPr()
# Remove existing width
existing_width = tc_pr.find(qn('w:tcW'))
if existing_width is not None:
tc_pr.remove(existing_width)
# Create new width element
width_element = OxmlElement('w:tcW')
width_element.set(qn('w:w'), width_value)
width_element.set(qn('w:type'), width_type)
tc_pr.append(width_element)
return True
except Exception as e:
print(f"Error setting column width: {e}")
return False
def set_column_width_by_position(table, col_index, width, width_type="dxa"):
"""
Set the width of a specific column by position.
Args:
table: The table containing the column
col_index: Column index (0-based)
width: Column width value
width_type: Width type ("dxa" for points*20, "pct" for percentage*50, "auto")
Returns:
True if successful, False otherwise
"""
return set_column_width(table, col_index, width, width_type)
def set_column_widths(table, widths, width_type="dxa"):
"""
Set widths for multiple columns in a table.
Args:
table: The table to modify
widths: List of width values for each column
width_type: Width type ("dxa" for points*20, "pct" for percentage*50, "auto")
Returns:
True if successful, False otherwise
"""
try:
for col_index, width in enumerate(widths):
if col_index >= len(table.columns):
break
if not set_column_width(table, col_index, width, width_type):
return False
return True
except Exception as e:
print(f"Error setting column widths: {e}")
return False
def set_table_width(table, width, width_type="dxa"):
"""
Set the overall width of a table.
Args:
table: The table to modify
width: Table width value
width_type: Width type ("dxa" for points*20, "pct" for percentage*50, "auto")
Returns:
True if successful, False otherwise
"""
try:
# Convert width based on type
if width_type == "dxa":
# DXA units (twentieths of a point)
if isinstance(width, (int, float)):
width_value = str(int(width * 20))
else:
width_value = str(width)
elif width_type == "pct":
# Percentage (multiply by 50 for Word format)
if isinstance(width, (int, float)):
width_value = str(int(width * 50))
else:
width_value = str(width)
else:
width_value = str(width)
# Get table element and properties
tbl = table._tbl
# Get or create table properties
tbl_pr = tbl.find(qn('w:tblPr'))
if tbl_pr is None:
tbl_pr = OxmlElement('w:tblPr')
tbl.insert(0, tbl_pr)
# Remove existing table width
existing_width = tbl_pr.find(qn('w:tblW'))
if existing_width is not None:
tbl_pr.remove(existing_width)
# Create new table width element
width_element = OxmlElement('w:tblW')
width_element.set(qn('w:w'), width_value)
width_element.set(qn('w:type'), width_type)
tbl_pr.append(width_element)
return True
except Exception as e:
print(f"Error setting table width: {e}")
return False
def auto_fit_table(table):
"""
Set table to auto-fit columns based on content.
Args:
table: The table to modify
Returns:
True if successful, False otherwise
"""
try:
# Get table element and properties
tbl = table._tbl
# Get or create table properties
tbl_pr = tbl.find(qn('w:tblPr'))
if tbl_pr is None:
tbl_pr = OxmlElement('w:tblPr')
tbl.insert(0, tbl_pr)
# Remove existing layout
existing_layout = tbl_pr.find(qn('w:tblLayout'))
if existing_layout is not None:
tbl_pr.remove(existing_layout)
# Create auto layout element
layout_element = OxmlElement('w:tblLayout')
layout_element.set(qn('w:type'), 'autofit')
tbl_pr.append(layout_element)
# Set all column widths to auto
for col_index in range(len(table.columns)):
set_column_width(table, col_index, 0, "auto")
return True
except Exception as e:
print(f"Error setting auto-fit table: {e}")
return False
def format_cell_text(cell, text_content=None, bold=None, italic=None, underline=None,
color=None, font_size=None, font_name=None):
"""
Format text within a table cell.
Args:
cell: The table cell to format
text_content: Optional new text content for the cell
bold: Set text bold (True/False)
italic: Set text italic (True/False)
underline: Set text underlined (True/False)
color: Text color (hex string like "FF0000" or color name)
font_size: Font size in points
font_name: Font name/family
Returns:
True if successful, False otherwise
"""
try:
# Set text content if provided
if text_content is not None:
cell.text = str(text_content)
# Apply formatting to all paragraphs and runs in the cell
for paragraph in cell.paragraphs:
for run in paragraph.runs:
if bold is not None:
run.bold = bold
if italic is not None:
run.italic = italic
if underline is not None:
run.underline = underline
if font_size is not None:
from docx.shared import Pt
run.font.size = Pt(font_size)
if font_name is not None:
run.font.name = font_name
if color is not None:
from docx.shared import RGBColor
# Define common RGB colors
color_map = {
'red': RGBColor(255, 0, 0),
'blue': RGBColor(0, 0, 255),
'green': RGBColor(0, 128, 0),
'yellow': RGBColor(255, 255, 0),
'black': RGBColor(0, 0, 0),
'gray': RGBColor(128, 128, 128),
'grey': RGBColor(128, 128, 128),
'white': RGBColor(255, 255, 255),
'purple': RGBColor(128, 0, 128),
'orange': RGBColor(255, 165, 0)
}
try:
if color.lower() in color_map:
# Use predefined RGB color
run.font.color.rgb = color_map[color.lower()]
elif color.startswith('#'):
# Hex color string
hex_color = color.lstrip('#')
if len(hex_color) == 6:
r = int(hex_color[0:2], 16)
g = int(hex_color[2:4], 16)
b = int(hex_color[4:6], 16)
run.font.color.rgb = RGBColor(r, g, b)
else:
# Try hex without #
if len(color) == 6:
r = int(color[0:2], 16)
g = int(color[2:4], 16)
b = int(color[4:6], 16)
run.font.color.rgb = RGBColor(r, g, b)
except Exception:
# If color parsing fails, default to black
run.font.color.rgb = RGBColor(0, 0, 0)
return True
except Exception as e:
print(f"Error formatting cell text: {e}")
return False
def format_cell_text_by_position(table, row_index, col_index, text_content=None,
bold=None, italic=None, underline=None, color=None,
font_size=None, font_name=None):
"""
Format text in a specific table cell by position.
Args:
table: The table containing the cell
row_index: Row index (0-based)
col_index: Column index (0-based)
text_content: Optional new text content for the cell
bold: Set text bold (True/False)
italic: Set text italic (True/False)
underline: Set text underlined (True/False)
color: Text color (hex string or color name)
font_size: Font size in points
font_name: Font name/family
Returns:
True if successful, False otherwise
"""
try:
if (0 <= row_index < len(table.rows) and
0 <= col_index < len(table.rows[row_index].cells)):
cell = table.rows[row_index].cells[col_index]
return format_cell_text(cell, text_content, bold, italic, underline,
color, font_size, font_name)
else:
return False
except Exception as e:
print(f"Error formatting cell text by position: {e}")
return False
def set_cell_padding(cell, top=None, bottom=None, left=None, right=None, unit="dxa"):
"""
Set padding/margins for a table cell.
Args:
cell: The table cell to format
top: Top padding value
bottom: Bottom padding value
left: Left padding value
right: Right padding value
unit: Unit type ("dxa" for twentieths of a point, "pct" for percentage)
Returns:
True if successful, False otherwise
"""
try:
# Get or create table cell properties
tc_pr = cell._tc.get_or_add_tcPr()
# Remove existing margins
existing_margins = tc_pr.find(qn('w:tcMar'))
if existing_margins is not None:
tc_pr.remove(existing_margins)
# Create margins element if any padding is specified
if any(p is not None for p in [top, bottom, left, right]):
margins_element = OxmlElement('w:tcMar')
# Add individual margin elements
margin_sides = {
'w:top': top,
'w:bottom': bottom,
'w:left': left,
'w:right': right
}
for side, value in margin_sides.items():
if value is not None:
margin_el = OxmlElement(side)
if unit == "dxa":
# DXA units (twentieths of a point)
margin_el.set(qn('w:w'), str(int(value * 20)))
margin_el.set(qn('w:type'), 'dxa')
elif unit == "pct":
# Percentage
margin_el.set(qn('w:w'), str(int(value * 50)))
margin_el.set(qn('w:type'), 'pct')
else:
# Default to DXA
margin_el.set(qn('w:w'), str(int(value * 20)))
margin_el.set(qn('w:type'), 'dxa')
margins_element.append(margin_el)
tc_pr.append(margins_element)
return True
except Exception as e:
print(f"Error setting cell padding: {e}")
return False
def set_cell_padding_by_position(table, row_index, col_index, top=None, bottom=None,
left=None, right=None, unit="dxa"):
"""
Set padding for a specific table cell by position.
Args:
table: The table containing the cell
row_index: Row index (0-based)
col_index: Column index (0-based)
top: Top padding value
bottom: Bottom padding value
left: Left padding value
right: Right padding value
unit: Unit type ("dxa" for twentieths of a point, "pct" for percentage)
Returns:
True if successful, False otherwise
"""
try:
if (0 <= row_index < len(table.rows) and
0 <= col_index < len(table.rows[row_index].cells)):
cell = table.rows[row_index].cells[col_index]
return set_cell_padding(cell, top, bottom, left, right, unit)
else:
return False
except Exception as e:
print(f"Error setting cell padding by position: {e}")
return False
```
--------------------------------------------------------------------------------
/word_document_server/core/footnotes.py:
--------------------------------------------------------------------------------
```python
"""
Consolidated footnote functionality for Word documents.
This module combines all footnote implementations with proper namespace handling and Word compliance.
"""
import os
import zipfile
import tempfile
from typing import Optional, Tuple, Dict, Any, List
from lxml import etree
from docx import Document
from docx.oxml.ns import qn
# Namespace definitions
W_NS = 'http://schemas.openxmlformats.org/wordprocessingml/2006/main'
R_NS = 'http://schemas.openxmlformats.org/officeDocument/2006/relationships'
CT_NS = 'http://schemas.openxmlformats.org/package/2006/content-types'
REL_NS = 'http://schemas.openxmlformats.org/package/2006/relationships'
# Constants
RESERVED_FOOTNOTE_IDS = {-1, 0, 1} # Reserved for separators and Word internals
MIN_FOOTNOTE_ID = -2147483648
MAX_FOOTNOTE_ID = 32767
MAX_RELATIONSHIP_ID_LENGTH = 255
FOOTNOTE_REF_STYLE_INDEX = 38
FOOTNOTE_TEXT_STYLE_INDEX = 29
# ============================================================================
# BASIC UTILITIES (from footnotes.py)
# ============================================================================
def find_footnote_references(doc):
"""Find all footnote references in the document."""
footnote_refs = []
for para_idx, para in enumerate(doc.paragraphs):
for run_idx, run in enumerate(para.runs):
# Check if this run has superscript formatting
if run.font.superscript:
# Check if it's likely a footnote reference
if run.text.isdigit() or run.text in "¹²³⁴⁵⁶⁷⁸⁹⁰†‡§¶":
footnote_refs.append({
'paragraph_index': para_idx,
'run_index': run_idx,
'text': run.text,
'paragraph': para,
'run': run
})
return footnote_refs
def get_format_symbols(format_type: str, count: int) -> list:
"""Generate format symbols for footnote numbering."""
symbols = []
if format_type == "1, 2, 3":
symbols = [str(i) for i in range(1, count + 1)]
elif format_type == "i, ii, iii":
# Roman numerals
roman_map = [(10, 'x'), (9, 'ix'), (5, 'v'), (4, 'iv'), (1, 'i')]
for i in range(1, count + 1):
result = ''
num = i
for value, numeral in roman_map:
count_sym, num = divmod(num, value)
result += numeral * count_sym
symbols.append(result)
elif format_type == "a, b, c":
# Alphabetic
for i in range(1, count + 1):
if i <= 26:
symbols.append(chr(96 + i))
else:
# For numbers > 26, use aa, ab, etc.
first = (i - 1) // 26
second = (i - 1) % 26 + 1
symbols.append(chr(96 + first) + chr(96 + second))
elif format_type == "*, †, ‡":
# Special symbols
special = ['*', '†', '‡', '§', '¶', '#']
for i in range(1, count + 1):
if i <= len(special):
symbols.append(special[i - 1])
else:
# Repeat symbols with numbers
symbols.append(special[(i - 1) % len(special)] + str((i - 1) // len(special) + 1))
else:
# Default to numeric
symbols = [str(i) for i in range(1, count + 1)]
return symbols
def customize_footnote_formatting(doc, footnote_refs, format_symbols, start_number, footnote_style):
"""Apply custom formatting to footnotes."""
count = 0
for i, ref in enumerate(footnote_refs):
if i < len(format_symbols):
# Update the footnote reference text
ref['run'].text = format_symbols[i]
ref['run'].font.superscript = True
# Apply style if available
if footnote_style:
try:
ref['paragraph'].style = footnote_style
except:
pass
count += 1
return count
# ============================================================================
# ROBUST IMPLEMENTATION (consolidated from footnotes_robust.py)
# ============================================================================
def _get_safe_footnote_id(footnotes_root) -> int:
"""Get a safe footnote ID avoiding conflicts and reserved values."""
nsmap = {'w': W_NS}
existing_footnotes = footnotes_root.xpath('//w:footnote', namespaces=nsmap)
used_ids = set()
for fn in existing_footnotes:
fn_id = fn.get(f'{{{W_NS}}}id')
if fn_id:
try:
used_ids.add(int(fn_id))
except ValueError:
pass
# Start from 2 to avoid reserved IDs
candidate_id = 2
while candidate_id in used_ids or candidate_id in RESERVED_FOOTNOTE_IDS:
candidate_id += 1
if candidate_id > MAX_FOOTNOTE_ID:
raise ValueError("No available footnote IDs")
return candidate_id
def _ensure_content_types(content_types_xml: bytes) -> bytes:
"""Ensure content types with proper namespace handling."""
ct_tree = etree.fromstring(content_types_xml)
# Content Types uses default namespace - must use namespace-aware XPath
nsmap = {'ct': CT_NS}
# Check for existing override with proper namespace
existing_overrides = ct_tree.xpath(
"//ct:Override[@PartName='/word/footnotes.xml']",
namespaces=nsmap
)
if existing_overrides:
return content_types_xml # Already exists
# Add override with proper namespace
override = etree.Element(f'{{{CT_NS}}}Override',
PartName='/word/footnotes.xml',
ContentType='application/vnd.openxmlformats-officedocument.wordprocessingml.footnotes+xml'
)
ct_tree.append(override)
return etree.tostring(ct_tree, encoding='UTF-8', xml_declaration=True, standalone="yes")
def _ensure_document_rels(document_rels_xml: bytes) -> bytes:
"""Ensure document relationships with proper namespace handling."""
rels_tree = etree.fromstring(document_rels_xml)
# Relationships uses default namespace - must use namespace-aware XPath
nsmap = {'r': REL_NS}
# Check for existing footnotes relationship with proper namespace
existing_footnote_rels = rels_tree.xpath(
"//r:Relationship[contains(@Type, 'footnotes')]",
namespaces=nsmap
)
if existing_footnote_rels:
return document_rels_xml # Already exists
# Generate unique rId using namespace-aware XPath
all_rels = rels_tree.xpath('//r:Relationship', namespaces=nsmap)
existing_ids = {rel.get('Id') for rel in all_rels if rel.get('Id')}
rid_num = 1
while f'rId{rid_num}' in existing_ids:
rid_num += 1
# Validate ID length
new_rid = f'rId{rid_num}'
if len(new_rid) > MAX_RELATIONSHIP_ID_LENGTH:
raise ValueError(f"Relationship ID too long: {new_rid}")
# Create relationship with proper namespace
rel = etree.Element(f'{{{REL_NS}}}Relationship',
Id=new_rid,
Type='http://schemas.openxmlformats.org/officeDocument/2006/relationships/footnotes',
Target='footnotes.xml'
)
rels_tree.append(rel)
return etree.tostring(rels_tree, encoding='UTF-8', xml_declaration=True, standalone="yes")
def _create_minimal_footnotes_xml() -> bytes:
"""Create minimal footnotes.xml with separators."""
xml = f'''<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<w:footnotes xmlns:w="{W_NS}">
<w:footnote w:type="separator" w:id="-1">
<w:p>
<w:pPr>
<w:spacing w:after="0" w:line="240" w:lineRule="auto"/>
</w:pPr>
<w:r>
<w:separator/>
</w:r>
</w:p>
</w:footnote>
<w:footnote w:type="continuationSeparator" w:id="0">
<w:p>
<w:pPr>
<w:spacing w:after="0" w:line="240" w:lineRule="auto"/>
</w:pPr>
<w:r>
<w:continuationSeparator/>
</w:r>
</w:p>
</w:footnote>
</w:footnotes>'''
return xml.encode('utf-8')
def _ensure_footnote_styles(styles_root):
"""Ensure both FootnoteReference and FootnoteText styles exist."""
nsmap = {'w': W_NS}
# Check for FootnoteReference style
ref_style = styles_root.xpath('//w:style[@w:styleId="FootnoteReference"]', namespaces=nsmap)
if not ref_style:
# Create FootnoteReference character style
style = etree.Element(f'{{{W_NS}}}style',
attrib={
f'{{{W_NS}}}type': 'character',
f'{{{W_NS}}}styleId': 'FootnoteReference'
}
)
name = etree.SubElement(style, f'{{{W_NS}}}name')
name.set(f'{{{W_NS}}}val', 'footnote reference')
base = etree.SubElement(style, f'{{{W_NS}}}basedOn')
base.set(f'{{{W_NS}}}val', 'DefaultParagraphFont')
rPr = etree.SubElement(style, f'{{{W_NS}}}rPr')
vert_align = etree.SubElement(rPr, f'{{{W_NS}}}vertAlign')
vert_align.set(f'{{{W_NS}}}val', 'superscript')
styles_root.append(style)
# Check for FootnoteText style
text_style = styles_root.xpath('//w:style[@w:styleId="FootnoteText"]', namespaces=nsmap)
if not text_style:
# Create FootnoteText paragraph style
style = etree.Element(f'{{{W_NS}}}style',
attrib={
f'{{{W_NS}}}type': 'paragraph',
f'{{{W_NS}}}styleId': 'FootnoteText'
}
)
name = etree.SubElement(style, f'{{{W_NS}}}name')
name.set(f'{{{W_NS}}}val', 'footnote text')
base = etree.SubElement(style, f'{{{W_NS}}}basedOn')
base.set(f'{{{W_NS}}}val', 'Normal')
pPr = etree.SubElement(style, f'{{{W_NS}}}pPr')
sz = etree.SubElement(pPr, f'{{{W_NS}}}sz')
sz.set(f'{{{W_NS}}}val', '20') # 10pt
styles_root.append(style)
def add_footnote_robust(
filename: str,
search_text: Optional[str] = None,
paragraph_index: Optional[int] = None,
footnote_text: str = "",
output_filename: Optional[str] = None,
position: str = "after",
validate_location: bool = True,
auto_repair: bool = False
) -> Tuple[bool, str, Optional[Dict[str, Any]]]:
"""
Add a footnote with robust validation and error handling.
This is the main production-ready function with all fixes applied.
"""
# Validate inputs
if not search_text and paragraph_index is None:
return False, "Must provide either search_text or paragraph_index", None
if search_text and paragraph_index is not None:
return False, "Cannot provide both search_text and paragraph_index", None
if not os.path.exists(filename):
return False, f"File not found: {filename}", None
# Set working file
working_file = output_filename if output_filename else filename
if output_filename and filename != output_filename:
import shutil
shutil.copy2(filename, output_filename)
try:
# Read document parts
doc_parts = {}
with zipfile.ZipFile(filename, 'r') as zin:
doc_parts['document'] = zin.read('word/document.xml')
doc_parts['content_types'] = zin.read('[Content_Types].xml')
doc_parts['document_rels'] = zin.read('word/_rels/document.xml.rels')
# Read or create footnotes.xml
if 'word/footnotes.xml' in zin.namelist():
doc_parts['footnotes'] = zin.read('word/footnotes.xml')
else:
doc_parts['footnotes'] = _create_minimal_footnotes_xml()
# Read styles
if 'word/styles.xml' in zin.namelist():
doc_parts['styles'] = zin.read('word/styles.xml')
else:
# Create minimal styles
doc_parts['styles'] = b'<?xml version="1.0" encoding="UTF-8" standalone="yes"?><w:styles xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main"/>'
# Parse XML documents
doc_root = etree.fromstring(doc_parts['document'])
footnotes_root = etree.fromstring(doc_parts['footnotes'])
styles_root = etree.fromstring(doc_parts['styles'])
# Find target location
nsmap = {'w': W_NS}
if search_text:
# Search for text in paragraphs
found = False
for para in doc_root.xpath('//w:p', namespaces=nsmap):
para_text = ''.join(para.xpath('.//w:t/text()', namespaces=nsmap))
if search_text in para_text:
target_para = para
found = True
break
if not found:
return False, f"Text '{search_text}' not found in document", None
else:
# Use paragraph index
paragraphs = doc_root.xpath('//w:p', namespaces=nsmap)
if paragraph_index >= len(paragraphs):
return False, f"Paragraph index {paragraph_index} out of range", None
target_para = paragraphs[paragraph_index]
# Validate location if requested
if validate_location:
# Check if paragraph is in header/footer
parent = target_para.getparent()
while parent is not None:
if parent.tag in [f'{{{W_NS}}}hdr', f'{{{W_NS}}}ftr']:
return False, "Cannot add footnote in header/footer", None
parent = parent.getparent()
# Get safe footnote ID
footnote_id = _get_safe_footnote_id(footnotes_root)
# Add footnote reference to document
if position == "after":
# Find last run in paragraph or create one
runs = target_para.xpath('.//w:r', namespaces=nsmap)
if runs:
last_run = runs[-1]
# Insert after last run
insert_pos = target_para.index(last_run) + 1
else:
insert_pos = len(target_para)
else: # before
# Find first run with text
runs = target_para.xpath('.//w:r[w:t]', namespaces=nsmap)
if runs:
first_run = runs[0]
insert_pos = target_para.index(first_run)
else:
insert_pos = 0
# Create footnote reference run
ref_run = etree.Element(f'{{{W_NS}}}r')
# Add run properties with superscript
rPr = etree.SubElement(ref_run, f'{{{W_NS}}}rPr')
rStyle = etree.SubElement(rPr, f'{{{W_NS}}}rStyle')
rStyle.set(f'{{{W_NS}}}val', 'FootnoteReference')
# Add footnote reference
fn_ref = etree.SubElement(ref_run, f'{{{W_NS}}}footnoteReference')
fn_ref.set(f'{{{W_NS}}}id', str(footnote_id))
# Insert the reference run
target_para.insert(insert_pos, ref_run)
# Add footnote content
new_footnote = etree.Element(f'{{{W_NS}}}footnote',
attrib={f'{{{W_NS}}}id': str(footnote_id)}
)
# Add paragraph to footnote
fn_para = etree.SubElement(new_footnote, f'{{{W_NS}}}p')
# Add paragraph properties
pPr = etree.SubElement(fn_para, f'{{{W_NS}}}pPr')
pStyle = etree.SubElement(pPr, f'{{{W_NS}}}pStyle')
pStyle.set(f'{{{W_NS}}}val', 'FootnoteText')
# Add the footnote reference marker
marker_run = etree.SubElement(fn_para, f'{{{W_NS}}}r')
marker_rPr = etree.SubElement(marker_run, f'{{{W_NS}}}rPr')
marker_rStyle = etree.SubElement(marker_rPr, f'{{{W_NS}}}rStyle')
marker_rStyle.set(f'{{{W_NS}}}val', 'FootnoteReference')
marker_ref = etree.SubElement(marker_run, f'{{{W_NS}}}footnoteRef')
# Add space after marker
space_run = etree.SubElement(fn_para, f'{{{W_NS}}}r')
space_text = etree.SubElement(space_run, f'{{{W_NS}}}t')
space_text.set(f'{{{XML_NS}}}space', 'preserve')
space_text.text = ' '
# Add footnote text
text_run = etree.SubElement(fn_para, f'{{{W_NS}}}r')
text_elem = etree.SubElement(text_run, f'{{{W_NS}}}t')
text_elem.text = footnote_text
# Append footnote to footnotes.xml
footnotes_root.append(new_footnote)
# Ensure styles exist
_ensure_footnote_styles(styles_root)
# Ensure coherence
content_types_xml = _ensure_content_types(doc_parts['content_types'])
document_rels_xml = _ensure_document_rels(doc_parts['document_rels'])
# Write modified document
temp_file = working_file + '.tmp'
with zipfile.ZipFile(temp_file, 'w', zipfile.ZIP_DEFLATED) as zout:
with zipfile.ZipFile(filename, 'r') as zin:
# Copy unchanged files
for item in zin.infolist():
if item.filename not in [
'word/document.xml', 'word/footnotes.xml', 'word/styles.xml',
'[Content_Types].xml', 'word/_rels/document.xml.rels'
]:
zout.writestr(item, zin.read(item.filename))
# Write modified files
zout.writestr('word/document.xml',
etree.tostring(doc_root, encoding='UTF-8', xml_declaration=True, standalone="yes"))
zout.writestr('word/footnotes.xml',
etree.tostring(footnotes_root, encoding='UTF-8', xml_declaration=True, standalone="yes"))
zout.writestr('word/styles.xml',
etree.tostring(styles_root, encoding='UTF-8', xml_declaration=True, standalone="yes"))
zout.writestr('[Content_Types].xml', content_types_xml)
zout.writestr('word/_rels/document.xml.rels', document_rels_xml)
# Replace original with temp file
os.replace(temp_file, working_file)
details = {
'footnote_id': footnote_id,
'location': 'search_text' if search_text else 'paragraph_index',
'styles_created': ['FootnoteReference', 'FootnoteText'],
'coherence_verified': True
}
return True, f"Successfully added footnote (ID: {footnote_id}) to {working_file}", details
except Exception as e:
# Clean up temp file if exists
temp_file = working_file + '.tmp'
if os.path.exists(temp_file):
os.remove(temp_file)
return False, f"Error adding footnote: {str(e)}", None
def delete_footnote_robust(
filename: str,
footnote_id: Optional[int] = None,
search_text: Optional[str] = None,
output_filename: Optional[str] = None,
clean_orphans: bool = True
) -> Tuple[bool, str, Optional[Dict[str, Any]]]:
"""Delete a footnote with comprehensive cleanup."""
if not footnote_id and not search_text:
return False, "Must provide either footnote_id or search_text", None
if not os.path.exists(filename):
return False, f"File not found: {filename}", None
# Set working file
working_file = output_filename if output_filename else filename
if output_filename and filename != output_filename:
import shutil
shutil.copy2(filename, output_filename)
try:
# Read document parts
with zipfile.ZipFile(filename, 'r') as zin:
doc_xml = zin.read('word/document.xml')
if 'word/footnotes.xml' not in zin.namelist():
return False, "No footnotes in document", None
footnotes_xml = zin.read('word/footnotes.xml')
# Parse documents
doc_root = etree.fromstring(doc_xml)
footnotes_root = etree.fromstring(footnotes_xml)
nsmap = {'w': W_NS}
# Find footnote to delete
if search_text:
# Find footnote reference near text
for para in doc_root.xpath('//w:p', namespaces=nsmap):
para_text = ''.join(para.xpath('.//w:t/text()', namespaces=nsmap))
if search_text in para_text:
# Look for footnote reference in this paragraph
fn_refs = para.xpath('.//w:footnoteReference', namespaces=nsmap)
if fn_refs:
footnote_id = int(fn_refs[0].get(f'{{{W_NS}}}id'))
break
if not footnote_id:
return False, f"No footnote found near text '{search_text}'", None
# Remove footnote reference from document
refs_removed = 0
for fn_ref in doc_root.xpath(f'//w:footnoteReference[@w:id="{footnote_id}"]', namespaces=nsmap):
# Remove the entire run containing the reference
run = fn_ref.getparent()
if run is not None and run.tag == f'{{{W_NS}}}r':
para = run.getparent()
if para is not None:
para.remove(run)
refs_removed += 1
if refs_removed == 0:
return False, f"Footnote {footnote_id} not found", None
# Remove footnote content
content_removed = 0
for fn in footnotes_root.xpath(f'//w:footnote[@w:id="{footnote_id}"]', namespaces=nsmap):
footnotes_root.remove(fn)
content_removed += 1
# Clean orphans if requested
orphans_removed = []
if clean_orphans:
# Find all referenced IDs
referenced_ids = set()
for ref in doc_root.xpath('//w:footnoteReference', namespaces=nsmap):
ref_id = ref.get(f'{{{W_NS}}}id')
if ref_id:
referenced_ids.add(ref_id)
# Remove unreferenced footnotes (except separators)
for fn in footnotes_root.xpath('//w:footnote', namespaces=nsmap):
fn_id = fn.get(f'{{{W_NS}}}id')
if fn_id and fn_id not in referenced_ids and fn_id not in ['-1', '0']:
footnotes_root.remove(fn)
orphans_removed.append(fn_id)
# Write modified document
temp_file = working_file + '.tmp'
with zipfile.ZipFile(temp_file, 'w', zipfile.ZIP_DEFLATED) as zout:
with zipfile.ZipFile(filename, 'r') as zin:
for item in zin.infolist():
if item.filename == 'word/document.xml':
zout.writestr(item,
etree.tostring(doc_root, encoding='UTF-8', xml_declaration=True, standalone="yes"))
elif item.filename == 'word/footnotes.xml':
zout.writestr(item,
etree.tostring(footnotes_root, encoding='UTF-8', xml_declaration=True, standalone="yes"))
else:
zout.writestr(item, zin.read(item.filename))
os.replace(temp_file, working_file)
details = {
'footnote_id': footnote_id,
'references_removed': refs_removed,
'content_removed': content_removed,
'orphans_removed': orphans_removed
}
message = f"Successfully deleted footnote {footnote_id}"
if orphans_removed:
message += f" and {len(orphans_removed)} orphaned footnotes"
return True, message, details
except Exception as e:
return False, f"Error deleting footnote: {str(e)}", None
def validate_document_footnotes(filename: str) -> Tuple[bool, str, Dict[str, Any]]:
"""Validate all footnotes in a document for coherence and compliance."""
if not os.path.exists(filename):
return False, f"File not found: {filename}", {}
report = {
'total_references': 0,
'total_content': 0,
'id_conflicts': [],
'orphaned_content': [],
'missing_references': [],
'invalid_locations': [],
'missing_styles': [],
'coherence_issues': []
}
try:
with zipfile.ZipFile(filename, 'r') as zf:
# Check document.xml
doc_xml = zf.read('word/document.xml')
doc_root = etree.fromstring(doc_xml)
nsmap = {'w': W_NS}
# Get all footnote references
ref_ids = set()
for ref in doc_root.xpath('//w:footnoteReference', namespaces=nsmap):
ref_id = ref.get(f'{{{W_NS}}}id')
if ref_id:
ref_ids.add(ref_id)
report['total_references'] += 1
# Check location
parent = ref.getparent()
while parent is not None:
if parent.tag in [f'{{{W_NS}}}hdr', f'{{{W_NS}}}ftr']:
report['invalid_locations'].append(ref_id)
break
parent = parent.getparent()
# Check footnotes.xml
if 'word/footnotes.xml' in zf.namelist():
footnotes_xml = zf.read('word/footnotes.xml')
footnotes_root = etree.fromstring(footnotes_xml)
content_ids = set()
for fn in footnotes_root.xpath('//w:footnote', namespaces=nsmap):
fn_id = fn.get(f'{{{W_NS}}}id')
if fn_id:
content_ids.add(fn_id)
if fn_id not in ['-1', '0']: # Exclude separators
report['total_content'] += 1
# Find orphans and missing
report['orphaned_content'] = list(content_ids - ref_ids - {'-1', '0'})
report['missing_references'] = list(ref_ids - content_ids)
else:
if report['total_references'] > 0:
report['coherence_issues'].append('References exist but no footnotes.xml')
# Check relationships
if 'word/_rels/document.xml.rels' in zf.namelist():
rels_xml = zf.read('word/_rels/document.xml.rels')
rels_root = etree.fromstring(rels_xml)
rel_nsmap = {'r': REL_NS}
fn_rels = rels_root.xpath(
"//r:Relationship[contains(@Type, 'footnotes')]",
namespaces=rel_nsmap
)
if report['total_content'] > 0 and len(fn_rels) == 0:
report['coherence_issues'].append('Missing footnotes relationship')
elif len(fn_rels) > 1:
report['coherence_issues'].append(f'Multiple footnote relationships: {len(fn_rels)}')
# Check content types
if '[Content_Types].xml' in zf.namelist():
ct_xml = zf.read('[Content_Types].xml')
ct_root = etree.fromstring(ct_xml)
ct_nsmap = {'ct': CT_NS}
fn_overrides = ct_root.xpath(
"//ct:Override[@PartName='/word/footnotes.xml']",
namespaces=ct_nsmap
)
if report['total_content'] > 0 and len(fn_overrides) == 0:
report['coherence_issues'].append('Missing footnotes content type')
elif len(fn_overrides) > 1:
report['coherence_issues'].append(f'Multiple footnote content types: {len(fn_overrides)}')
# Check styles
if 'word/styles.xml' in zf.namelist():
styles_xml = zf.read('word/styles.xml')
styles_root = etree.fromstring(styles_xml)
ref_style = styles_root.xpath('//w:style[@w:styleId="FootnoteReference"]', namespaces=nsmap)
text_style = styles_root.xpath('//w:style[@w:styleId="FootnoteText"]', namespaces=nsmap)
if not ref_style:
report['missing_styles'].append('FootnoteReference')
if not text_style:
report['missing_styles'].append('FootnoteText')
# Determine if valid
is_valid = (
len(report['id_conflicts']) == 0 and
len(report['orphaned_content']) == 0 and
len(report['missing_references']) == 0 and
len(report['invalid_locations']) == 0 and
len(report['coherence_issues']) == 0
)
if is_valid:
message = "Document footnotes are valid"
else:
message = "Document has footnote issues"
return is_valid, message, report
except Exception as e:
return False, f"Error validating document: {str(e)}", report
# ============================================================================
# COMPATIBILITY FUNCTIONS (for backward compatibility)
# ============================================================================
def add_footnote_at_paragraph_end(
filename: str,
paragraph_index: int,
footnote_text: str,
output_filename: Optional[str] = None
) -> Tuple[bool, str]:
"""Add footnote at the end of a specific paragraph (backward compatibility)."""
success, message, _ = add_footnote_robust(
filename=filename,
paragraph_index=paragraph_index,
footnote_text=footnote_text,
output_filename=output_filename,
position="after"
)
return success, message
def add_footnote_with_proper_formatting(
filename: str,
search_text: str,
footnote_text: str,
output_filename: Optional[str] = None,
position: str = "after"
) -> Tuple[bool, str]:
"""Add footnote with proper formatting (backward compatibility)."""
success, message, _ = add_footnote_robust(
filename=filename,
search_text=search_text,
footnote_text=footnote_text,
output_filename=output_filename,
position=position
)
return success, message
def delete_footnote(
filename: str,
footnote_id: Optional[int] = None,
search_text: Optional[str] = None,
output_filename: Optional[str] = None
) -> Tuple[bool, str]:
"""Delete a footnote (backward compatibility)."""
success, message, _ = delete_footnote_robust(
filename=filename,
footnote_id=footnote_id,
search_text=search_text,
output_filename=output_filename
)
return success, message
# ============================================================================
# LEGACY FUNCTIONS (for core/__init__.py compatibility)
# ============================================================================
def add_footnote(doc, paragraph_index: int, footnote_text: str):
"""Legacy function for adding footnotes to python-docx Document objects.
Note: This is a simplified version that doesn't create proper Word footnotes."""
if paragraph_index >= len(doc.paragraphs):
raise IndexError(f"Paragraph index {paragraph_index} out of range")
para = doc.paragraphs[paragraph_index]
# Add superscript number
run = para.add_run()
run.text = "¹"
run.font.superscript = True
# Add footnote text at document end
doc.add_paragraph("_" * 50)
footnote_para = doc.add_paragraph(f"¹ {footnote_text}")
footnote_para.style = "Caption"
return doc
def add_endnote(doc, paragraph_index: int, endnote_text: str):
"""Legacy function for adding endnotes."""
if paragraph_index >= len(doc.paragraphs):
raise IndexError(f"Paragraph index {paragraph_index} out of range")
para = doc.paragraphs[paragraph_index]
run = para.add_run()
run.text = "†"
run.font.superscript = True
# Endnotes go at the very end
doc.add_page_break()
doc.add_heading("Endnotes", level=1)
endnote_para = doc.add_paragraph(f"† {endnote_text}")
return doc
def convert_footnotes_to_endnotes(doc):
"""Legacy function to convert footnotes to endnotes in a Document object."""
# This is a placeholder - real conversion requires XML manipulation
return doc
# Define XML_NS if needed
XML_NS = 'http://www.w3.org/XML/1998/namespace'
```
--------------------------------------------------------------------------------
/word_document_server/tools/format_tools.py:
--------------------------------------------------------------------------------
```python
"""
Formatting tools for Word Document Server.
These tools handle formatting operations for Word documents,
including text formatting, table formatting, and custom styles.
"""
import os
from typing import List, Optional, Dict, Any
from docx import Document
from docx.shared import Pt, RGBColor
from docx.enum.text import WD_COLOR_INDEX
from docx.enum.style import WD_STYLE_TYPE
from word_document_server.utils.file_utils import check_file_writeable, ensure_docx_extension
from word_document_server.core.styles import create_style
from word_document_server.core.tables import (
apply_table_style, set_cell_shading_by_position, apply_alternating_row_shading,
highlight_header_row, merge_cells, merge_cells_horizontal, merge_cells_vertical,
set_cell_alignment_by_position, set_table_alignment, set_column_width_by_position,
set_column_widths, set_table_width as set_table_width_func, auto_fit_table,
format_cell_text_by_position, set_cell_padding_by_position
)
async def format_text(filename: str, paragraph_index: int, start_pos: int, end_pos: int,
bold: Optional[bool] = None, italic: Optional[bool] = None,
underline: Optional[bool] = None, color: Optional[str] = None,
font_size: Optional[int] = None, font_name: Optional[str] = None) -> str:
"""Format a specific range of text within a paragraph.
Args:
filename: Path to the Word document
paragraph_index: Index of the paragraph (0-based)
start_pos: Start position within the paragraph text
end_pos: End position within the paragraph text
bold: Set text bold (True/False)
italic: Set text italic (True/False)
underline: Set text underlined (True/False)
color: Text color (e.g., 'red', 'blue', etc.)
font_size: Font size in points
font_name: Font name/family
"""
filename = ensure_docx_extension(filename)
# Ensure numeric parameters are the correct type
try:
paragraph_index = int(paragraph_index)
start_pos = int(start_pos)
end_pos = int(end_pos)
if font_size is not None:
font_size = int(font_size)
except (ValueError, TypeError):
return "Invalid parameter: paragraph_index, start_pos, end_pos, and font_size must be integers"
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})."
paragraph = doc.paragraphs[paragraph_index]
text = paragraph.text
# Validate text positions
if start_pos < 0 or end_pos > len(text) or start_pos >= end_pos:
return f"Invalid text positions. Paragraph has {len(text)} characters."
# Get the text to format
target_text = text[start_pos:end_pos]
# Clear existing runs and create three runs: before, target, after
for run in paragraph.runs:
run.clear()
# Add text before target
if start_pos > 0:
run_before = paragraph.add_run(text[:start_pos])
# Add target text with formatting
run_target = paragraph.add_run(target_text)
if bold is not None:
run_target.bold = bold
if italic is not None:
run_target.italic = italic
if underline is not None:
run_target.underline = underline
if color:
# Define common RGB colors
color_map = {
'red': RGBColor(255, 0, 0),
'blue': RGBColor(0, 0, 255),
'green': RGBColor(0, 128, 0),
'yellow': RGBColor(255, 255, 0),
'black': RGBColor(0, 0, 0),
'gray': RGBColor(128, 128, 128),
'white': RGBColor(255, 255, 255),
'purple': RGBColor(128, 0, 128),
'orange': RGBColor(255, 165, 0)
}
try:
if color.lower() in color_map:
# Use predefined RGB color
run_target.font.color.rgb = color_map[color.lower()]
else:
# Try to set color by name
run_target.font.color.rgb = RGBColor.from_string(color)
except Exception as e:
# If all else fails, default to black
run_target.font.color.rgb = RGBColor(0, 0, 0)
if font_size:
run_target.font.size = Pt(font_size)
if font_name:
run_target.font.name = font_name
# Add text after target
if end_pos < len(text):
run_after = paragraph.add_run(text[end_pos:])
doc.save(filename)
return f"Text '{target_text}' formatted successfully in paragraph {paragraph_index}."
except Exception as e:
return f"Failed to format text: {str(e)}"
async def create_custom_style(filename: str, style_name: str,
bold: Optional[bool] = None, italic: Optional[bool] = None,
font_size: Optional[int] = None, font_name: Optional[str] = None,
color: Optional[str] = None, base_style: Optional[str] = None) -> str:
"""Create a custom style in the document.
Args:
filename: Path to the Word document
style_name: Name for the new style
bold: Set text bold (True/False)
italic: Set text italic (True/False)
font_size: Font size in points
font_name: Font name/family
color: Text color (e.g., 'red', 'blue')
base_style: Optional existing style to base this on
"""
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)
# Build font properties dictionary
font_properties = {}
if bold is not None:
font_properties['bold'] = bold
if italic is not None:
font_properties['italic'] = italic
if font_size is not None:
font_properties['size'] = font_size
if font_name is not None:
font_properties['name'] = font_name
if color is not None:
font_properties['color'] = color
# Create the style
new_style = create_style(
doc,
style_name,
WD_STYLE_TYPE.PARAGRAPH,
base_style=base_style,
font_properties=font_properties
)
doc.save(filename)
return f"Style '{style_name}' created successfully."
except Exception as e:
return f"Failed to create style: {str(e)}"
async def format_table(filename: str, table_index: int,
has_header_row: Optional[bool] = None,
border_style: Optional[str] = None,
shading: Optional[List[List[str]]] = None) -> str:
"""Format a table with borders, shading, and structure.
Args:
filename: Path to the Word document
table_index: Index of the table (0-based)
has_header_row: If True, formats the first row as a header
border_style: Style for borders ('none', 'single', 'double', 'thick')
shading: 2D list of cell background colors (by row and column)
"""
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 table index
if table_index < 0 or table_index >= len(doc.tables):
return f"Invalid table index. Document has {len(doc.tables)} tables (0-{len(doc.tables)-1})."
table = doc.tables[table_index]
# Apply formatting
success = apply_table_style(table, has_header_row or False, border_style, shading)
if success:
doc.save(filename)
return f"Table at index {table_index} formatted successfully."
else:
return f"Failed to format table at index {table_index}."
except Exception as e:
return f"Failed to format table: {str(e)}"
async def set_table_cell_shading(filename: str, table_index: int, row_index: int,
col_index: int, fill_color: str, pattern: str = "clear") -> str:
"""Apply shading/filling to a specific table cell.
Args:
filename: Path to the Word document
table_index: Index of the table (0-based)
row_index: Row index of the cell (0-based)
col_index: Column index of the cell (0-based)
fill_color: Background color (hex string like "FF0000" or "red")
pattern: Shading pattern ("clear", "solid", "pct10", "pct20", etc.)
"""
filename = ensure_docx_extension(filename)
# Ensure numeric parameters are the correct type
try:
table_index = int(table_index)
row_index = int(row_index)
col_index = int(col_index)
except (ValueError, TypeError):
return "Invalid parameter: table_index, row_index, and col_index must be integers"
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 table index
if table_index < 0 or table_index >= len(doc.tables):
return f"Invalid table index. Document has {len(doc.tables)} tables (0-{len(doc.tables)-1})."
table = doc.tables[table_index]
# Validate row and column indices
if row_index < 0 or row_index >= len(table.rows):
return f"Invalid row index. Table has {len(table.rows)} rows (0-{len(table.rows)-1})."
if col_index < 0 or col_index >= len(table.rows[row_index].cells):
return f"Invalid column index. Row has {len(table.rows[row_index].cells)} cells (0-{len(table.rows[row_index].cells)-1})."
# Apply cell shading
success = set_cell_shading_by_position(table, row_index, col_index, fill_color, pattern)
if success:
doc.save(filename)
return f"Cell shading applied successfully to table {table_index}, row {row_index}, column {col_index}."
else:
return f"Failed to apply cell shading."
except Exception as e:
return f"Failed to apply cell shading: {str(e)}"
async def apply_table_alternating_rows(filename: str, table_index: int,
color1: str = "FFFFFF", color2: str = "F2F2F2") -> str:
"""Apply alternating row colors to a table for better readability.
Args:
filename: Path to the Word document
table_index: Index of the table (0-based)
color1: Color for odd rows (hex string, default white)
color2: Color for even rows (hex string, default light gray)
"""
filename = ensure_docx_extension(filename)
# Ensure numeric parameters are the correct type
try:
table_index = int(table_index)
except (ValueError, TypeError):
return "Invalid parameter: table_index must be an integer"
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 table index
if table_index < 0 or table_index >= len(doc.tables):
return f"Invalid table index. Document has {len(doc.tables)} tables (0-{len(doc.tables)-1})."
table = doc.tables[table_index]
# Apply alternating row shading
success = apply_alternating_row_shading(table, color1, color2)
if success:
doc.save(filename)
return f"Alternating row shading applied successfully to table {table_index}."
else:
return f"Failed to apply alternating row shading."
except Exception as e:
return f"Failed to apply alternating row shading: {str(e)}"
async def highlight_table_header(filename: str, table_index: int,
header_color: str = "4472C4", text_color: str = "FFFFFF") -> str:
"""Apply special highlighting to table header row.
Args:
filename: Path to the Word document
table_index: Index of the table (0-based)
header_color: Background color for header (hex string, default blue)
text_color: Text color for header (hex string, default white)
"""
filename = ensure_docx_extension(filename)
# Ensure numeric parameters are the correct type
try:
table_index = int(table_index)
except (ValueError, TypeError):
return "Invalid parameter: table_index must be an integer"
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 table index
if table_index < 0 or table_index >= len(doc.tables):
return f"Invalid table index. Document has {len(doc.tables)} tables (0-{len(doc.tables)-1})."
table = doc.tables[table_index]
# Apply header highlighting
success = highlight_header_row(table, header_color, text_color)
if success:
doc.save(filename)
return f"Header highlighting applied successfully to table {table_index}."
else:
return f"Failed to apply header highlighting."
except Exception as e:
return f"Failed to apply header highlighting: {str(e)}"
async def merge_table_cells(filename: str, table_index: int, start_row: int, start_col: int,
end_row: int, end_col: int) -> str:
"""Merge cells in a rectangular area of a table.
Args:
filename: Path to the Word document
table_index: Index of the table (0-based)
start_row: Starting row index (0-based)
start_col: Starting column index (0-based)
end_row: Ending row index (0-based, inclusive)
end_col: Ending column index (0-based, inclusive)
"""
filename = ensure_docx_extension(filename)
# Ensure numeric parameters are the correct type
try:
table_index = int(table_index)
start_row = int(start_row)
start_col = int(start_col)
end_row = int(end_row)
end_col = int(end_col)
except (ValueError, TypeError):
return "Invalid parameter: all indices must be integers"
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 table index
if table_index < 0 or table_index >= len(doc.tables):
return f"Invalid table index. Document has {len(doc.tables)} tables (0-{len(doc.tables)-1})."
table = doc.tables[table_index]
# Validate merge parameters
if start_row > end_row or start_col > end_col:
return "Invalid merge range: start indices must be <= end indices"
if start_row == end_row and start_col == end_col:
return "Invalid merge range: cannot merge a single cell with itself"
# Apply cell merge
success = merge_cells(table, start_row, start_col, end_row, end_col)
if success:
doc.save(filename)
return f"Cells merged successfully in table {table_index} from ({start_row},{start_col}) to ({end_row},{end_col})."
else:
return f"Failed to merge cells. Check that indices are valid."
except Exception as e:
return f"Failed to merge cells: {str(e)}"
async def merge_table_cells_horizontal(filename: str, table_index: int, row_index: int,
start_col: int, end_col: int) -> str:
"""Merge cells horizontally in a single row.
Args:
filename: Path to the Word document
table_index: Index of the table (0-based)
row_index: Row index (0-based)
start_col: Starting column index (0-based)
end_col: Ending column index (0-based, inclusive)
"""
filename = ensure_docx_extension(filename)
# Ensure numeric parameters are the correct type
try:
table_index = int(table_index)
row_index = int(row_index)
start_col = int(start_col)
end_col = int(end_col)
except (ValueError, TypeError):
return "Invalid parameter: all indices must be integers"
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 table index
if table_index < 0 or table_index >= len(doc.tables):
return f"Invalid table index. Document has {len(doc.tables)} tables (0-{len(doc.tables)-1})."
table = doc.tables[table_index]
# Apply horizontal cell merge
success = merge_cells_horizontal(table, row_index, start_col, end_col)
if success:
doc.save(filename)
return f"Cells merged horizontally in table {table_index}, row {row_index}, columns {start_col}-{end_col}."
else:
return f"Failed to merge cells horizontally. Check that indices are valid."
except Exception as e:
return f"Failed to merge cells horizontally: {str(e)}"
async def merge_table_cells_vertical(filename: str, table_index: int, col_index: int,
start_row: int, end_row: int) -> str:
"""Merge cells vertically in a single column.
Args:
filename: Path to the Word document
table_index: Index of the table (0-based)
col_index: Column index (0-based)
start_row: Starting row index (0-based)
end_row: Ending row index (0-based, inclusive)
"""
filename = ensure_docx_extension(filename)
# Ensure numeric parameters are the correct type
try:
table_index = int(table_index)
col_index = int(col_index)
start_row = int(start_row)
end_row = int(end_row)
except (ValueError, TypeError):
return "Invalid parameter: all indices must be integers"
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 table index
if table_index < 0 or table_index >= len(doc.tables):
return f"Invalid table index. Document has {len(doc.tables)} tables (0-{len(doc.tables)-1})."
table = doc.tables[table_index]
# Apply vertical cell merge
success = merge_cells_vertical(table, col_index, start_row, end_row)
if success:
doc.save(filename)
return f"Cells merged vertically in table {table_index}, column {col_index}, rows {start_row}-{end_row}."
else:
return f"Failed to merge cells vertically. Check that indices are valid."
except Exception as e:
return f"Failed to merge cells vertically: {str(e)}"
async def set_table_cell_alignment(filename: str, table_index: int, row_index: int, col_index: int,
horizontal: str = "left", vertical: str = "top") -> str:
"""Set text alignment for a specific table cell.
Args:
filename: Path to the Word document
table_index: Index of the table (0-based)
row_index: Row index (0-based)
col_index: Column index (0-based)
horizontal: Horizontal alignment ("left", "center", "right", "justify")
vertical: Vertical alignment ("top", "center", "bottom")
"""
filename = ensure_docx_extension(filename)
# Ensure numeric parameters are the correct type
try:
table_index = int(table_index)
row_index = int(row_index)
col_index = int(col_index)
except (ValueError, TypeError):
return "Invalid parameter: table_index, row_index, and col_index must be integers"
# Validate alignment parameters
valid_horizontal = ["left", "center", "right", "justify"]
valid_vertical = ["top", "center", "bottom"]
if horizontal.lower() not in valid_horizontal:
return f"Invalid horizontal alignment. Valid options: {', '.join(valid_horizontal)}"
if vertical.lower() not in valid_vertical:
return f"Invalid vertical alignment. Valid options: {', '.join(valid_vertical)}"
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 table index
if table_index < 0 or table_index >= len(doc.tables):
return f"Invalid table index. Document has {len(doc.tables)} tables (0-{len(doc.tables)-1})."
table = doc.tables[table_index]
# Apply cell alignment
success = set_cell_alignment_by_position(table, row_index, col_index, horizontal, vertical)
if success:
doc.save(filename)
return f"Cell alignment set successfully for table {table_index}, cell ({row_index},{col_index}) to {horizontal}/{vertical}."
else:
return f"Failed to set cell alignment. Check that indices are valid."
except Exception as e:
return f"Failed to set cell alignment: {str(e)}"
async def set_table_alignment_all(filename: str, table_index: int,
horizontal: str = "left", vertical: str = "top") -> str:
"""Set text alignment for all cells in a table.
Args:
filename: Path to the Word document
table_index: Index of the table (0-based)
horizontal: Horizontal alignment ("left", "center", "right", "justify")
vertical: Vertical alignment ("top", "center", "bottom")
"""
filename = ensure_docx_extension(filename)
# Ensure numeric parameters are the correct type
try:
table_index = int(table_index)
except (ValueError, TypeError):
return "Invalid parameter: table_index must be an integer"
# Validate alignment parameters
valid_horizontal = ["left", "center", "right", "justify"]
valid_vertical = ["top", "center", "bottom"]
if horizontal.lower() not in valid_horizontal:
return f"Invalid horizontal alignment. Valid options: {', '.join(valid_horizontal)}"
if vertical.lower() not in valid_vertical:
return f"Invalid vertical alignment. Valid options: {', '.join(valid_vertical)}"
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 table index
if table_index < 0 or table_index >= len(doc.tables):
return f"Invalid table index. Document has {len(doc.tables)} tables (0-{len(doc.tables)-1})."
table = doc.tables[table_index]
# Apply table alignment
success = set_table_alignment(table, horizontal, vertical)
if success:
doc.save(filename)
return f"Table alignment set successfully for table {table_index} to {horizontal}/{vertical} for all cells."
else:
return f"Failed to set table alignment."
except Exception as e:
return f"Failed to set table alignment: {str(e)}"
async def set_table_column_width(filename: str, table_index: int, col_index: int,
width: float, width_type: str = "points") -> str:
"""Set the width of a specific table column.
Args:
filename: Path to the Word document
table_index: Index of the table (0-based)
col_index: Column index (0-based)
width: Column width value
width_type: Width type ("points", "inches", "cm", "percent", "auto")
"""
filename = ensure_docx_extension(filename)
# Ensure numeric parameters are the correct type
try:
table_index = int(table_index)
col_index = int(col_index)
if width_type != "auto":
width = float(width)
except (ValueError, TypeError):
return "Invalid parameter: table_index and col_index must be integers, width must be a number"
# Validate width type
valid_width_types = ["points", "inches", "cm", "percent", "auto"]
if width_type.lower() not in valid_width_types:
return f"Invalid width type. Valid options: {', '.join(valid_width_types)}"
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 table index
if table_index < 0 or table_index >= len(doc.tables):
return f"Invalid table index. Document has {len(doc.tables)} tables (0-{len(doc.tables)-1})."
table = doc.tables[table_index]
# Validate column index
if col_index < 0 or col_index >= len(table.columns):
return f"Invalid column index. Table has {len(table.columns)} columns (0-{len(table.columns)-1})."
# Convert width and type for Word format
if width_type.lower() == "points":
# Points to DXA (twentieths of a point)
word_width = width
word_type = "dxa"
elif width_type.lower() == "inches":
# Inches to points, then to DXA
word_width = width * 72 # 72 points per inch
word_type = "dxa"
elif width_type.lower() == "cm":
# CM to points, then to DXA
word_width = width * 28.35 # ~28.35 points per cm
word_type = "dxa"
elif width_type.lower() == "percent":
# Percentage (Word uses 50x the percentage value)
word_width = width
word_type = "pct"
else: # auto
word_width = 0
word_type = "auto"
# Apply column width
success = set_column_width_by_position(table, col_index, word_width, word_type)
if success:
doc.save(filename)
return f"Column width set successfully for table {table_index}, column {col_index} to {width} {width_type}."
else:
return f"Failed to set column width. Check that indices are valid."
except Exception as e:
return f"Failed to set column width: {str(e)}"
async def set_table_column_widths(filename: str, table_index: int, widths: list,
width_type: str = "points") -> str:
"""Set the widths of multiple table columns.
Args:
filename: Path to the Word document
table_index: Index of the table (0-based)
widths: List of width values for each column
width_type: Width type ("points", "inches", "cm", "percent", "auto")
"""
filename = ensure_docx_extension(filename)
# Ensure numeric parameters are the correct type
try:
table_index = int(table_index)
if width_type != "auto":
widths = [float(w) for w in widths]
except (ValueError, TypeError):
return "Invalid parameter: table_index must be an integer, widths must be a list of numbers"
# Validate width type
valid_width_types = ["points", "inches", "cm", "percent", "auto"]
if width_type.lower() not in valid_width_types:
return f"Invalid width type. Valid options: {', '.join(valid_width_types)}"
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 table index
if table_index < 0 or table_index >= len(doc.tables):
return f"Invalid table index. Document has {len(doc.tables)} tables (0-{len(doc.tables)-1})."
table = doc.tables[table_index]
# Convert widths and type for Word format
word_widths = []
for width in widths:
if width_type.lower() == "points":
word_widths.append(width)
elif width_type.lower() == "inches":
word_widths.append(width * 72) # 72 points per inch
elif width_type.lower() == "cm":
word_widths.append(width * 28.35) # ~28.35 points per cm
elif width_type.lower() == "percent":
word_widths.append(width)
else: # auto
word_widths.append(0)
# Determine Word type
if width_type.lower() == "percent":
word_type = "pct"
elif width_type.lower() == "auto":
word_type = "auto"
else:
word_type = "dxa"
# Apply column widths
success = set_column_widths(table, word_widths, word_type)
if success:
doc.save(filename)
return f"Column widths set successfully for table {table_index} with {len(widths)} columns in {width_type}."
else:
return f"Failed to set column widths."
except Exception as e:
return f"Failed to set column widths: {str(e)}"
async def set_table_width(filename: str, table_index: int, width: float,
width_type: str = "points") -> str:
"""Set the overall width of a table.
Args:
filename: Path to the Word document
table_index: Index of the table (0-based)
width: Table width value
width_type: Width type ("points", "inches", "cm", "percent", "auto")
"""
filename = ensure_docx_extension(filename)
# Ensure numeric parameters are the correct type
try:
table_index = int(table_index)
if width_type != "auto":
width = float(width)
except (ValueError, TypeError):
return "Invalid parameter: table_index must be an integer, width must be a number"
# Validate width type
valid_width_types = ["points", "inches", "cm", "percent", "auto"]
if width_type.lower() not in valid_width_types:
return f"Invalid width type. Valid options: {', '.join(valid_width_types)}"
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 table index
if table_index < 0 or table_index >= len(doc.tables):
return f"Invalid table index. Document has {len(doc.tables)} tables (0-{len(doc.tables)-1})."
table = doc.tables[table_index]
# Convert width and type for Word format
if width_type.lower() == "points":
word_width = width
word_type = "dxa"
elif width_type.lower() == "inches":
word_width = width * 72 # 72 points per inch
word_type = "dxa"
elif width_type.lower() == "cm":
word_width = width * 28.35 # ~28.35 points per cm
word_type = "dxa"
elif width_type.lower() == "percent":
word_width = width
word_type = "pct"
else: # auto
word_width = 0
word_type = "auto"
# Apply table width
success = set_table_width_func(table, word_width, word_type)
if success:
doc.save(filename)
return f"Table width set successfully for table {table_index} to {width} {width_type}."
else:
return f"Failed to set table width."
except Exception as e:
return f"Failed to set table width: {str(e)}"
async def auto_fit_table_columns(filename: str, table_index: int) -> str:
"""Set table columns to auto-fit based on content.
Args:
filename: Path to the Word document
table_index: Index of the table (0-based)
"""
filename = ensure_docx_extension(filename)
# Ensure numeric parameters are the correct type
try:
table_index = int(table_index)
except (ValueError, TypeError):
return "Invalid parameter: table_index must be an integer"
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 table index
if table_index < 0 or table_index >= len(doc.tables):
return f"Invalid table index. Document has {len(doc.tables)} tables (0-{len(doc.tables)-1})."
table = doc.tables[table_index]
# Apply auto-fit
success = auto_fit_table(table)
if success:
doc.save(filename)
return f"Table {table_index} set to auto-fit columns based on content."
else:
return f"Failed to set table auto-fit."
except Exception as e:
return f"Failed to set table auto-fit: {str(e)}"
async def format_table_cell_text(filename: str, table_index: int, row_index: int, col_index: int,
text_content: Optional[str] = None, bold: Optional[bool] = None, italic: Optional[bool] = None,
underline: Optional[bool] = None, color: Optional[str] = None, font_size: Optional[int] = None,
font_name: Optional[str] = None) -> str:
"""Format text within a specific table cell.
Args:
filename: Path to the Word document
table_index: Index of the table (0-based)
row_index: Row index (0-based)
col_index: Column index (0-based)
text_content: Optional new text content for the cell
bold: Set text bold (True/False)
italic: Set text italic (True/False)
underline: Set text underlined (True/False)
color: Text color (hex string like "FF0000" or color name like "red")
font_size: Font size in points
font_name: Font name/family
"""
filename = ensure_docx_extension(filename)
# Ensure numeric parameters are the correct type
try:
table_index = int(table_index)
row_index = int(row_index)
col_index = int(col_index)
if font_size is not None:
font_size = int(font_size)
except (ValueError, TypeError):
return "Invalid parameter: table_index, row_index, col_index must be integers, font_size must be integer"
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 table index
if table_index < 0 or table_index >= len(doc.tables):
return f"Invalid table index. Document has {len(doc.tables)} tables (0-{len(doc.tables)-1})."
table = doc.tables[table_index]
# Validate row and column indices
if row_index < 0 or row_index >= len(table.rows):
return f"Invalid row index. Table has {len(table.rows)} rows (0-{len(table.rows)-1})."
if col_index < 0 or col_index >= len(table.rows[row_index].cells):
return f"Invalid column index. Row has {len(table.rows[row_index].cells)} cells (0-{len(table.rows[row_index].cells)-1})."
# Apply cell text formatting
success = format_cell_text_by_position(table, row_index, col_index, text_content,
bold, italic, underline, color, font_size, font_name)
if success:
doc.save(filename)
format_desc = []
if text_content is not None:
format_desc.append(f"content='{text_content[:30]}{'...' if len(text_content) > 30 else ''}'")
if bold is not None:
format_desc.append(f"bold={bold}")
if italic is not None:
format_desc.append(f"italic={italic}")
if underline is not None:
format_desc.append(f"underline={underline}")
if color is not None:
format_desc.append(f"color={color}")
if font_size is not None:
format_desc.append(f"size={font_size}pt")
if font_name is not None:
format_desc.append(f"font={font_name}")
format_str = ", ".join(format_desc) if format_desc else "no changes"
return f"Cell text formatted successfully in table {table_index}, cell ({row_index},{col_index}): {format_str}."
else:
return f"Failed to format cell text. Check that indices are valid."
except Exception as e:
return f"Failed to format cell text: {str(e)}"
async def set_table_cell_padding(filename: str, table_index: int, row_index: int, col_index: int,
top: Optional[float] = None, bottom: Optional[float] = None, left: Optional[float] = None,
right: Optional[float] = None, unit: str = "points") -> str:
"""Set padding/margins for a specific table cell.
Args:
filename: Path to the Word document
table_index: Index of the table (0-based)
row_index: Row index (0-based)
col_index: Column index (0-based)
top: Top padding in specified units
bottom: Bottom padding in specified units
left: Left padding in specified units
right: Right padding in specified units
unit: Unit type ("points" or "percent")
"""
filename = ensure_docx_extension(filename)
# Ensure numeric parameters are the correct type
try:
table_index = int(table_index)
row_index = int(row_index)
col_index = int(col_index)
if top is not None:
top = float(top)
if bottom is not None:
bottom = float(bottom)
if left is not None:
left = float(left)
if right is not None:
right = float(right)
except (ValueError, TypeError):
return "Invalid parameter: indices must be integers, padding values must be numbers"
# Validate unit
valid_units = ["points", "percent"]
if unit.lower() not in valid_units:
return f"Invalid unit. Valid options: {', '.join(valid_units)}"
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 table index
if table_index < 0 or table_index >= len(doc.tables):
return f"Invalid table index. Document has {len(doc.tables)} tables (0-{len(doc.tables)-1})."
table = doc.tables[table_index]
# Validate row and column indices
if row_index < 0 or row_index >= len(table.rows):
return f"Invalid row index. Table has {len(table.rows)} rows (0-{len(table.rows)-1})."
if col_index < 0 or col_index >= len(table.rows[row_index].cells):
return f"Invalid column index. Row has {len(table.rows[row_index].cells)} cells (0-{len(table.rows[row_index].cells)-1})."
# Convert unit for Word format
word_unit = "dxa" if unit.lower() == "points" else "pct"
# Apply cell padding
success = set_cell_padding_by_position(table, row_index, col_index, top, bottom,
left, right, word_unit)
if success:
doc.save(filename)
padding_desc = []
if top is not None:
padding_desc.append(f"top={top}")
if bottom is not None:
padding_desc.append(f"bottom={bottom}")
if left is not None:
padding_desc.append(f"left={left}")
if right is not None:
padding_desc.append(f"right={right}")
padding_str = ", ".join(padding_desc) if padding_desc else "no padding"
return f"Cell padding set successfully for table {table_index}, cell ({row_index},{col_index}): {padding_str} {unit}."
else:
return f"Failed to set cell padding. Check that indices are valid."
except Exception as e:
return f"Failed to set cell padding: {str(e)}"
```