#!/usr/bin/env python3
# -*- coding: utf-8 -*-
# Timestamp: 2026-01-20
# File: src/scitex_writer/_mcp/handlers.py
"""
MCP Handler implementations for SciTeX Writer.
Provides handlers for LaTeX manuscript operations:
- clone_project: Create new writer project
- compile_manuscript/supplementary/revision: Compile documents
- get_project_info: Get project structure
- get_pdf: Get compiled PDF path
- list_document_types: List document types
- csv_to_latex/latex_to_csv: Table conversions
- pdf_to_images: Render PDF pages
- list_figures: List project figures
- convert_figure: Convert figure formats
"""
from __future__ import annotations
import re
import shutil
import subprocess
import tempfile
from pathlib import Path
from typing import List, Literal, Optional, Union
from .utils import resolve_project_path, run_compile_script
def clone_project(
project_dir: str,
git_strategy: Literal["child", "parent", "origin", "none"] = "child",
branch: Optional[str] = None,
tag: Optional[str] = None,
) -> dict:
"""Create a new writer project from template."""
try:
project_path = resolve_project_path(project_dir)
if project_path.exists():
return {
"success": False,
"error": f"Directory already exists: {project_path}",
}
repo_url = "https://github.com/ywatanabe1989/scitex-writer.git"
cmd = ["git", "clone"]
if branch:
cmd.extend(["--branch", branch])
elif tag:
cmd.extend(["--branch", tag])
cmd.extend([repo_url, str(project_path)])
result = subprocess.run(cmd, capture_output=True, text=True)
if result.returncode != 0:
return {"success": False, "error": f"Git clone failed: {result.stderr}"}
if git_strategy == "none":
git_dir = project_path / ".git"
if git_dir.exists():
shutil.rmtree(git_dir)
elif git_strategy == "child":
git_dir = project_path / ".git"
if git_dir.exists():
shutil.rmtree(git_dir)
subprocess.run(["git", "init"], cwd=str(project_path), capture_output=True)
subprocess.run(
["git", "add", "."], cwd=str(project_path), capture_output=True
)
subprocess.run(
["git", "commit", "-m", "Initial commit from scitex-writer template"],
cwd=str(project_path),
capture_output=True,
)
return {
"success": True,
"project_path": str(project_path),
"git_strategy": git_strategy,
"structure": {
"00_shared": "Shared resources",
"01_manuscript": "Main manuscript",
"02_supplementary": "Supplementary materials",
"03_revision": "Revision documents",
},
"message": f"Successfully created writer project at {project_path}",
}
except Exception as e:
return {"success": False, "error": str(e)}
def compile_manuscript(
project_dir: str,
timeout: int = 300,
no_figs: bool = False,
no_tables: bool = False,
no_diff: bool = False,
draft: bool = False,
dark_mode: bool = False,
quiet: bool = False,
verbose: bool = False,
force: bool = False,
) -> dict:
"""Compile manuscript to PDF."""
project_path = resolve_project_path(project_dir)
return run_compile_script(
project_path,
"manuscript",
timeout=timeout,
no_figs=no_figs,
no_tables=no_tables,
no_diff=no_diff,
draft=draft,
dark_mode=dark_mode,
quiet=quiet,
verbose=verbose,
)
def compile_supplementary(
project_dir: str,
timeout: int = 300,
no_figs: bool = False,
no_tables: bool = False,
no_diff: bool = False,
draft: bool = False,
quiet: bool = False,
) -> dict:
"""Compile supplementary materials to PDF."""
project_path = resolve_project_path(project_dir)
return run_compile_script(
project_path,
"supplementary",
timeout=timeout,
no_figs=no_figs,
no_tables=no_tables,
no_diff=no_diff,
draft=draft,
quiet=quiet,
)
def compile_revision(
project_dir: str,
track_changes: bool = False,
timeout: int = 300,
no_diff: bool = True,
draft: bool = False,
quiet: bool = False,
) -> dict:
"""Compile revision document to PDF."""
project_path = resolve_project_path(project_dir)
return run_compile_script(
project_path,
"revision",
timeout=timeout,
no_diff=no_diff,
draft=draft,
quiet=quiet,
track_changes=track_changes,
)
def get_project_info(project_dir: str) -> dict:
"""Get writer project information."""
try:
project_path = resolve_project_path(project_dir)
if not project_path.exists():
return {"success": False, "error": f"Project not found: {project_path}"}
dirs = {
"shared": project_path / "00_shared",
"manuscript": project_path / "01_manuscript",
"supplementary": project_path / "02_supplementary",
"revision": project_path / "03_revision",
"scripts": project_path / "scripts",
}
pdfs = {
"manuscript": project_path / "01_manuscript" / "manuscript.pdf",
"supplementary": project_path / "02_supplementary" / "supplementary.pdf",
"revision": project_path / "03_revision" / "revision.pdf",
}
compiled_pdfs = {k: str(v) if v.exists() else None for k, v in pdfs.items()}
git_root = None
try:
result = subprocess.run(
["git", "rev-parse", "--show-toplevel"],
cwd=str(project_path),
capture_output=True,
text=True,
)
if result.returncode == 0:
git_root = result.stdout.strip()
except Exception:
pass
return {
"success": True,
"project_name": project_path.name,
"project_dir": str(project_path),
"git_root": git_root,
"documents": {k: str(v) for k, v in dirs.items() if v.exists()},
"compiled_pdfs": compiled_pdfs,
}
except Exception as e:
return {"success": False, "error": str(e)}
def get_pdf(
project_dir: str,
doc_type: Literal["manuscript", "supplementary", "revision"] = "manuscript",
) -> dict:
"""Get path to compiled PDF."""
try:
project_path = resolve_project_path(project_dir)
pdf_paths = {
"manuscript": project_path / "01_manuscript" / "manuscript.pdf",
"supplementary": project_path / "02_supplementary" / "supplementary.pdf",
"revision": project_path / "03_revision" / "revision.pdf",
}
pdf_path = pdf_paths.get(doc_type)
if pdf_path and pdf_path.exists():
return {
"success": True,
"exists": True,
"doc_type": doc_type,
"pdf_path": str(pdf_path),
}
else:
return {
"success": True,
"exists": False,
"doc_type": doc_type,
"pdf_path": None,
"message": f"No compiled PDF found for {doc_type}",
}
except Exception as e:
return {"success": False, "error": str(e)}
def list_document_types() -> dict:
"""List available document types."""
return {
"success": True,
"document_types": [
{"id": "manuscript", "name": "Manuscript", "directory": "01_manuscript"},
{
"id": "supplementary",
"name": "Supplementary",
"directory": "02_supplementary",
},
{
"id": "revision",
"name": "Revision",
"directory": "03_revision",
"supports_track_changes": True,
},
],
"shared_directory": {"id": "shared", "directory": "00_shared"},
}
def csv_to_latex(
csv_path: str,
output_path: Optional[str] = None,
caption: Optional[str] = None,
label: Optional[str] = None,
longtable: bool = False,
) -> dict:
"""Convert CSV file to LaTeX table."""
try:
import pandas as pd
csv_file = Path(csv_path)
if not csv_file.exists():
return {"success": False, "error": f"CSV file not found: {csv_path}"}
df = pd.read_csv(csv_file)
base_name = csv_file.stem
alignments = []
for col in df.columns:
try:
pd.to_numeric(df[col], errors="raise")
alignments.append("r")
except Exception:
alignments.append("l")
lines = []
if longtable:
lines.append("\\begin{longtable}{" + "".join(alignments) + "}")
else:
lines.extend(
[
"\\begin{table}[htbp]",
"\\centering",
"\\begin{tabular}{" + "".join(alignments) + "}",
]
)
lines.append("\\toprule")
lines.append(" & ".join([f"\\textbf{{{col}}}" for col in df.columns]) + " \\\\")
lines.append("\\midrule")
for _, row in df.iterrows():
values = [str(v) if pd.notna(v) else "--" for v in row]
lines.append(" & ".join(values) + " \\\\")
lines.append("\\bottomrule")
if longtable:
if caption:
lines.append(f"\\caption{{{caption}}}")
if label:
lines.append(f"\\label{{{label}}}")
lines.append("\\end{longtable}")
else:
lines.append("\\end{tabular}")
if caption:
lines.append(f"\\caption{{{caption}}}")
lines.append(f"\\label{{{label or 'tab:' + base_name}}}")
lines.append("\\end{table}")
latex_content = "\n".join(lines)
if output_path:
Path(output_path).write_text(latex_content, encoding="utf-8")
return {
"success": True,
"latex_content": latex_content,
"output_path": output_path,
"rows": len(df),
"columns": len(df.columns),
}
except ImportError:
return {"success": False, "error": "pandas required: pip install pandas"}
except Exception as e:
return {"success": False, "error": str(e)}
def latex_to_csv(
latex_path: str,
output_path: Optional[str] = None,
table_index: int = 0,
) -> dict:
"""Convert LaTeX table to CSV."""
try:
import pandas as pd
latex_file = Path(latex_path)
if not latex_file.exists():
return {"success": False, "error": f"LaTeX file not found: {latex_path}"}
content = latex_file.read_text(encoding="utf-8")
pattern = r"\\begin\{tabular\}.*?\\end\{tabular\}"
matches = list(re.finditer(pattern, content, re.DOTALL))
if not matches:
return {"success": False, "error": "No tabular environment found"}
if table_index >= len(matches):
return {
"success": False,
"error": f"Table index {table_index} out of range",
}
table_content = matches[table_index].group()
rows = []
for line in table_content.split("\\\\"):
if any(
x in line
for x in ["\\begin", "\\end", "\\toprule", "\\midrule", "\\bottomrule"]
):
continue
cells = [
re.sub(r"\\[a-zA-Z]+\{([^}]*)\}", r"\1", c.strip())
for c in line.split("&")
]
if any(cells):
rows.append(cells)
if not rows:
return {"success": False, "error": "Could not parse table"}
df = pd.DataFrame(rows[1:], columns=rows[0] if rows else None)
if output_path:
df.to_csv(output_path, index=False)
return {
"success": True,
"rows": len(df),
"columns": list(df.columns),
"preview": df.head(5).to_dict(),
"output_path": output_path,
}
except ImportError:
return {"success": False, "error": "pandas required: pip install pandas"}
except Exception as e:
return {"success": False, "error": str(e)}
def pdf_to_images(
pdf_path: str,
output_dir: Optional[str] = None,
pages: Optional[Union[int, List[int]]] = None,
dpi: int = 150,
format: Literal["png", "jpg"] = "png",
) -> dict:
"""Render PDF pages as images."""
try:
from pdf2image import convert_from_path
pdf_file = Path(pdf_path)
if not pdf_file.exists():
return {"success": False, "error": f"PDF not found: {pdf_path}"}
out_dir = (
Path(output_dir)
if output_dir
else Path(tempfile.mkdtemp(prefix="pdf_images_"))
)
out_dir.mkdir(parents=True, exist_ok=True)
if pages is not None:
if isinstance(pages, int):
pages = [pages]
first_page, last_page = min(pages) + 1, max(pages) + 1
else:
first_page = last_page = None
images = convert_from_path(
pdf_file, dpi=dpi, first_page=first_page, last_page=last_page
)
image_paths = []
for i, image in enumerate(images):
page_num = pages[i] if pages else i
filename = f"{pdf_file.stem}_page_{page_num:03d}.{format}"
image_path = out_dir / filename
image.save(str(image_path), format.upper())
image_paths.append(str(image_path))
return {
"success": True,
"images": image_paths,
"count": len(image_paths),
"output_dir": str(out_dir),
}
except ImportError:
return {"success": False, "error": "pdf2image required: pip install pdf2image"}
except Exception as e:
return {"success": False, "error": str(e)}
def list_figures(project_dir: str, extensions: Optional[List[str]] = None) -> dict:
"""List figures in project."""
try:
project_path = resolve_project_path(project_dir)
if extensions is None:
extensions = [
".png",
".pdf",
".jpg",
".jpeg",
".tif",
".tiff",
".eps",
".svg",
]
extensions = [ext if ext.startswith(".") else f".{ext}" for ext in extensions]
figure_dirs = [
project_path / "01_manuscript" / "contents" / "figures",
project_path / "02_supplementary" / "contents" / "figures",
project_path / "00_shared" / "figures",
]
figures = []
for fig_dir in figure_dirs:
if fig_dir.exists():
for ext in extensions:
for fig_path in fig_dir.rglob(f"*{ext}"):
figures.append(
{
"path": str(fig_path),
"name": fig_path.name,
"extension": fig_path.suffix,
"size_bytes": fig_path.stat().st_size,
}
)
return {"success": True, "figures": figures, "count": len(figures)}
except Exception as e:
return {"success": False, "error": str(e)}
def convert_figure(
input_path: str, output_path: str, dpi: int = 300, quality: int = 95
) -> dict:
"""Convert figure between formats."""
try:
from PIL import Image
input_file, output_file = Path(input_path), Path(output_path)
if not input_file.exists():
return {"success": False, "error": f"Input not found: {input_path}"}
output_file.parent.mkdir(parents=True, exist_ok=True)
if input_file.suffix.lower() == ".pdf":
try:
from pdf2image import convert_from_path
images = convert_from_path(input_file, dpi=dpi)
img = images[0] if images else None
if not img:
return {"success": False, "error": "Could not read PDF"}
except ImportError:
return {
"success": False,
"error": "pdf2image required for PDF conversion",
}
else:
img = Image.open(input_file)
if output_file.suffix.lower() in [".jpg", ".jpeg"] and img.mode in [
"RGBA",
"P",
]:
img = img.convert("RGB")
if output_file.suffix.lower() in [".jpg", ".jpeg"]:
img.save(output_file, quality=quality)
else:
img.save(output_file)
return {
"success": True,
"input_path": str(input_file),
"output_path": str(output_file),
}
except ImportError:
return {"success": False, "error": "Pillow required: pip install Pillow"}
except Exception as e:
return {"success": False, "error": str(e)}
__all__ = [
"clone_project",
"compile_manuscript",
"compile_supplementary",
"compile_revision",
"get_project_info",
"get_pdf",
"list_document_types",
"csv_to_latex",
"latex_to_csv",
"pdf_to_images",
"list_figures",
"convert_figure",
]
# EOF