364 lines
12 KiB
Python
364 lines
12 KiB
Python
"""LaTeX Compile for Opus Orchestrator.
|
|
|
|
Handles conversion to LaTeX and PDF compilation.
|
|
"""
|
|
|
|
import os
|
|
import re
|
|
import subprocess
|
|
import shutil
|
|
from pathlib import Path
|
|
from typing import Optional
|
|
from dataclasses import dataclass, field
|
|
from datetime import datetime
|
|
|
|
from opus_orchestrator.schemas import Manuscript, Chapter
|
|
|
|
|
|
@dataclass
|
|
class CompileOptions:
|
|
"""Options for LaTeX compilation."""
|
|
template: str = "memoir" # memoir, academic, base
|
|
output_format: str = "pdf" # pdf, tex
|
|
theme: str = "light" # light, dark, sepia
|
|
font: str = "serif" # serif, sans, mono
|
|
include_toc: bool = True
|
|
include_index: bool = False
|
|
dedication: str = ""
|
|
epigraph: str = ""
|
|
acknowledgments: str = ""
|
|
abstract: str = ""
|
|
bibliography: str = ""
|
|
author: str = "Opus Orchestrator"
|
|
publisher: str = ""
|
|
isbn: str = ""
|
|
edition: str = ""
|
|
series: str = ""
|
|
date: str = ""
|
|
|
|
def __post_init__(self):
|
|
if not self.date:
|
|
self.date = datetime.now().strftime("%Y")
|
|
|
|
|
|
class LaTeXExporter:
|
|
"""Export manuscript to LaTeX and compile to PDF."""
|
|
|
|
TEMPLATES = {
|
|
# KDP Templates (print-ready)
|
|
"kdp-pocket": "kdp-pocket.tex", # 5x8 mass market
|
|
"kdp-trade": "kdp-trade.tex", # 5.5x8.5 standard
|
|
"kdp-6x9": "kdp-6x9.tex", # 6x9 popular
|
|
"kdp-square": "kdp-square.tex", # 8x8 art/photo
|
|
"kdp-large": "kdp-large.tex", # 8.5x11 workbook
|
|
|
|
# Book Types
|
|
"novel": "novel.tex", # Fiction
|
|
"memoir": "memoir.tex", # Personal narrative
|
|
"hardcover": "hardcover.tex", # Premium
|
|
"poetry": "poetry.tex", # Poetry
|
|
"childrens": "childrens.tex", # Picture books
|
|
|
|
# Technical/Educational
|
|
"academic": "academic.tex", # Papers
|
|
"textbook": "textbook.tex", # With exercises
|
|
"journal": "journal.tex", # Workbooks
|
|
|
|
# Popular Templates
|
|
"classicthesis": "classicthesis.tex", # Classic typographic
|
|
"legrand": "legrand.tex", # Orange book style
|
|
"cleanthesis": "cleanthesis.tex", # Minimalist
|
|
"university-press": "university-press.tex", # Cambridge/Oxford
|
|
"tufte": "tufte.tex", # Tufte side-notes
|
|
"sci-fi": "sci-fi.tex", # Orbit/DAW style
|
|
"business": "business.tex", # Professional
|
|
"minimal": "minimal.tex", # Bare-bones
|
|
"romance": "romance.tex", # Romance novels
|
|
"thriller": "thriller.tex", # Mystery/Thriller
|
|
"koma": "koma.tex", # KOMA-Script
|
|
"cookbook": "cookbook.tex", # Recipes
|
|
|
|
# RPG/Game Books
|
|
"rpg-rulebook": "rpg-rulebook.tex", # Game system
|
|
"rpg-adventure": "rpg-adventure.tex", # Dungeon/Module
|
|
"cyoa": "cyoa.tex", # Choose Your Own Adventure
|
|
|
|
# Specialty
|
|
"screenplay": "screenplay.tex", # Film/TV
|
|
|
|
# Base
|
|
"base": "base.tex", # Minimal
|
|
}
|
|
|
|
def __init__(self, template_dir: Optional[str] = None):
|
|
if template_dir:
|
|
self.template_dir = Path(template_dir)
|
|
else:
|
|
# Default to package templates
|
|
self.template_dir = Path(__file__).parent / "templates" / "latex"
|
|
|
|
def export(
|
|
self,
|
|
manuscript: Manuscript,
|
|
book_title: str,
|
|
options: Optional[CompileOptions] = None,
|
|
) -> dict:
|
|
"""Export manuscript to LaTeX.
|
|
|
|
Args:
|
|
manuscript: The Manuscript to export
|
|
book_title: Title for the book
|
|
options: CompileOptions
|
|
|
|
Returns:
|
|
Export metadata with file paths
|
|
"""
|
|
opts = options or CompileOptions(template="memoir")
|
|
|
|
# Get template
|
|
template_file = self.TEMPLATES.get(opts.template, "memoir.tex")
|
|
template_path = self.template_dir / template_file
|
|
|
|
if not template_path.exists():
|
|
raise FileNotFoundError(f"Template not found: {template_path}")
|
|
|
|
# Read template
|
|
template_content = template_path.read_text()
|
|
|
|
# Build body from chapters
|
|
body = self._build_body(manuscript)
|
|
|
|
# Fill template
|
|
latex_content = self._fill_template(
|
|
template_content,
|
|
body,
|
|
book_title,
|
|
opts,
|
|
)
|
|
|
|
return {
|
|
"latex": latex_content,
|
|
"template": opts.template,
|
|
"options": opts,
|
|
}
|
|
|
|
def export_to_file(
|
|
self,
|
|
manuscript: Manuscript,
|
|
book_title: str,
|
|
output_path: str,
|
|
options: Optional[CompileOptions] = None,
|
|
) -> dict:
|
|
"""Export to .tex file."""
|
|
result = self.export(manuscript, book_title, options)
|
|
|
|
output_file = Path(output_path)
|
|
output_file.parent.mkdir(parents=True, exist_ok=True)
|
|
output_file.write_text(result["latex"])
|
|
|
|
result["output_file"] = str(output_file)
|
|
return result
|
|
|
|
def compile(
|
|
self,
|
|
manuscript: Manuscript,
|
|
book_title: str,
|
|
output_path: str,
|
|
options: Optional[CompileOptions] = None,
|
|
) -> dict:
|
|
"""Export and compile to PDF.
|
|
|
|
Args:
|
|
manuscript: The Manuscript
|
|
book_title: Book title
|
|
output_path: Output .pdf path
|
|
options: CompileOptions
|
|
|
|
Returns:
|
|
Compilation result with paths
|
|
"""
|
|
# First export to tex
|
|
tex_path = output_path.replace(".pdf", ".tex")
|
|
result = self.export_to_file(manuscript, book_title, tex_path, options)
|
|
|
|
# Try to compile
|
|
compile_result = self._compile_latex(tex_path)
|
|
|
|
result.update(compile_result)
|
|
result["pdf_path"] = output_path if compile_result.get("success") else None
|
|
|
|
return result
|
|
|
|
def _build_body(self, manuscript: Manuscript) -> str:
|
|
"""Build LaTeX body from chapters."""
|
|
parts = []
|
|
|
|
for chapter in manuscript.chapters:
|
|
# Chapter heading
|
|
parts.append(f"\\chapter{{{chapter.title}}}")
|
|
parts.append("")
|
|
|
|
# Content (convert markdown to latex)
|
|
content = self._markdown_to_latex(chapter.content or "")
|
|
parts.append(content)
|
|
parts.append("")
|
|
|
|
return "\n".join(parts)
|
|
|
|
def _markdown_to_latex(self, text: str) -> str:
|
|
"""Convert basic markdown to LaTeX."""
|
|
# Headers
|
|
text = re.sub(r'^### (.+)$', r'\\subsection{\1}', text, flags=re.MULTILINE)
|
|
text = re.sub(r'^## (.+)$', r'\\section{\1}', text, flags=re.MULTILINE)
|
|
text = re.sub(r'^# (.+)$', r'\\chapter{\1}', text, flags=re.MULTILINE)
|
|
|
|
# Bold/italic
|
|
text = re.sub(r'\*\*\*(.+?)\*\*\*', r'\\textbf{\\textit{\1}}', text)
|
|
text = re.sub(r'\*\*(.+?)\*\*', r'\\textbf{\1}', text)
|
|
text = re.sub(r'\*(.+?)\*', r'\\textit{\1}', text)
|
|
|
|
# Code blocks
|
|
text = re.sub(r'```(\w+)?\n(.+?)```', r'\\begin{verbatim}\2\\end{verbatim}', text, flags=re.DOTALL)
|
|
|
|
# Inline code
|
|
text = re.sub(r'`(.+?)`', r'\\texttt{\1}', text)
|
|
|
|
# Lists
|
|
text = re.sub(r'^- (.+)$', r'\\item \1', text, flags=re.MULTILINE)
|
|
text = re.sub(r'^\d+\. (.+)$', r'\\item \1', text, flags=re.MULTILINE)
|
|
|
|
# Blockquotes
|
|
text = re.sub(r'^> (.+)$', r'\\begin{quote}\1\\end{quote}', text, flags=re.MULTILINE)
|
|
|
|
# Horizontal rule
|
|
text = re.sub(r'^---$', r'\\hrulefill', text, flags=re.MULTILINE)
|
|
|
|
return text
|
|
|
|
def _fill_template(
|
|
self,
|
|
template: str,
|
|
body: str,
|
|
book_title: str,
|
|
options: CompileOptions,
|
|
) -> str:
|
|
"""Fill template with content."""
|
|
# Build replacements
|
|
replacements = {
|
|
"book_title": book_title,
|
|
"author": options.author,
|
|
"date": options.date,
|
|
"publisher": options.publisher,
|
|
"isbn": options.isbn,
|
|
"edition": options.edition,
|
|
"series": options.series,
|
|
"dedication": options.dedication,
|
|
"epigraph": options.epigraph,
|
|
"acknowledgments": options.acknowledgments,
|
|
"abstract": options.abstract,
|
|
"bibliography": options.bibliography,
|
|
"body": body,
|
|
}
|
|
|
|
# Fill template - handle both ${var} and $var
|
|
content = template
|
|
for key, value in replacements.items():
|
|
# Replace ${var}
|
|
content = content.replace(f"${{{key}}}", str(value))
|
|
# Replace standalone $var
|
|
dollar_key = f"${key}"
|
|
content = content.replace(dollar_key, str(value))
|
|
|
|
return content
|
|
|
|
def _compile_latex(self, tex_path: str) -> dict:
|
|
"""Compile LaTeX to PDF."""
|
|
tex_file = Path(tex_path)
|
|
if not tex_file.exists():
|
|
return {"success": False, "error": "TeX file not found"}
|
|
|
|
# Check for xelatex
|
|
xelatex = shutil.which("xelatex")
|
|
if not xelatex:
|
|
return {
|
|
"success": False,
|
|
"error": "xelatex not found. Install with: brew install texlab or apt install texlive-xelatex",
|
|
"tex_file": str(tex_file),
|
|
}
|
|
|
|
# Compile
|
|
work_dir = tex_file.parent
|
|
|
|
try:
|
|
# Run xelatex
|
|
result = subprocess.run(
|
|
[xelatex, "-interaction=nonstopmode", tex_file.name],
|
|
cwd=work_dir,
|
|
capture_output=True,
|
|
text=True,
|
|
timeout=120,
|
|
)
|
|
|
|
success = result.returncode == 0
|
|
|
|
return {
|
|
"success": success,
|
|
"returncode": result.returncode,
|
|
"stdout": result.stdout[-2000:] if result.stdout else "",
|
|
"stderr": result.stderr[-2000:] if result.stderr else "",
|
|
"tex_file": str(tex_file),
|
|
}
|
|
|
|
except subprocess.TimeoutExpired:
|
|
return {"success": False, "error": "Compilation timeout"}
|
|
except Exception as e:
|
|
return {"success": False, "error": str(e)}
|
|
|
|
|
|
def export_to_latex(
|
|
manuscript: Manuscript,
|
|
book_title: str,
|
|
output_path: str,
|
|
template: str = "memoir",
|
|
**options,
|
|
) -> dict:
|
|
"""Convenience function to export to LaTeX.
|
|
|
|
Args:
|
|
manuscript: The Manuscript
|
|
book_title: Book title
|
|
output_path: Output .tex path
|
|
template: Template name (memoir, academic, base)
|
|
**options: Additional CompileOptions
|
|
|
|
Returns:
|
|
Export result
|
|
"""
|
|
opts = CompileOptions(template=template, **options)
|
|
exporter = LaTeXExporter()
|
|
return exporter.export_to_file(manuscript, book_title, output_path, opts)
|
|
|
|
|
|
def compile_pdf(
|
|
manuscript: Manuscript,
|
|
book_title: str,
|
|
output_path: str,
|
|
template: str = "memoir",
|
|
**options,
|
|
) -> dict:
|
|
"""Convenience function to compile to PDF.
|
|
|
|
Args:
|
|
manuscript: The Manuscript
|
|
book_title: Book title
|
|
output_path: Output .pdf path
|
|
template: Template name
|
|
**options: Additional CompileOptions
|
|
|
|
Returns:
|
|
Compilation result
|
|
"""
|
|
opts = CompileOptions(template=template, **options)
|
|
exporter = LaTeXExporter()
|
|
return exporter.compile(manuscript, book_title, output_path, opts)
|