diff --git a/opus_orchestrator/__init__.py b/opus_orchestrator/__init__.py index 6521919..65e068c 100644 --- a/opus_orchestrator/__init__.py +++ b/opus_orchestrator/__init__.py @@ -174,3 +174,16 @@ def __getattr__(name): from opus_orchestrator.latex_compile import compile_pdf return compile_pdf raise AttributeError(f"module has no attribute {name!r}") + +# HTML Export +def __getattr__(name): + if name == "export_to_html": + from opus_orchestrator.html_export import export_to_html + return export_to_html + if name == "export_to_pdf": + from opus_orchestrator.html_export import export_to_pdf + return export_to_pdf + if name == "HTMLExporter": + from opus_orchestrator.html_export import HTMLExporter + return HTMLExporter + raise AttributeError(f"module has no attribute {name!r}") diff --git a/opus_orchestrator/html_export.py b/opus_orchestrator/html_export.py new file mode 100644 index 0000000..686a569 --- /dev/null +++ b/opus_orchestrator/html_export.py @@ -0,0 +1,418 @@ +"""HTML Export and Browser PDF for Opus Orchestrator. + +Uses browser for PDF generation - no LaTeX required! +""" + +import json +from pathlib import Path +from typing import Optional +from dataclasses import dataclass +from datetime import datetime + + +@dataclass +class HTMLOptions: + """Options for HTML export.""" + template: str = "memoir" # memoir, academic, minimal + theme: str = "light" # light, dark, sepia + font: str = "serif" # serif, sans + include_toc: bool = True + author: str = "" + dedication: str = "" + date: str = "" + + def __post_init__(self): + if not self.date: + self.date = datetime.now().strftime("%Y") + + +# HTML Templates +TEMPLATES = { + "memoir": { + "name": "Memoir", + "description": "Novel, memoir, personal narrative", + "fonts": ["Merriweather", "Lora"], + "background": "#fdfbf7", + "text": "#2c2c2c", + }, + "academic": { + "name": "Academic", + "description": "Technical, textbook, educational", + "fonts": ["Roboto", "Open Sans"], + "background": "#ffffff", + "text": "#1a1a1a", + }, + "minimal": { + "name": "Minimal", + "description": "Clean, simple design", + "fonts": ["Inter", "System UI"], + "background": "#ffffff", + "text": "#000000", + }, +} + + +class HTMLExporter: + """Export manuscript to HTML and PDF via browser.""" + + def __init__(self, template_dir: Optional[str] = None): + if template_dir: + self.template_dir = Path(template_dir) + else: + self.template_dir = Path(__file__).parent / "templates" / "html" + + def export( + self, + manuscript, + book_title: str, + options: Optional[HTMLOptions] = None, + ) -> str: + """Export manuscript to HTML. + + Args: + manuscript: The Manuscript to export + book_title: Title for the book + options: HTMLOptions + + Returns: + HTML string + """ + opts = options or HTMLOptions() + + template_info = TEMPLATES.get(opts.template, TEMPLATES["memoir"]) + + # Build HTML + html_parts = [ + self._build_head(book_title, template_info, opts), + self._build_body(manuscript, book_title, opts), + ] + + return "\n".join(html_parts) + + def export_to_file( + self, + manuscript, + book_title: str, + output_path: str, + options: Optional[HTMLOptions] = None, + ) -> dict: + """Export to HTML file.""" + html = self.export(manuscript, book_title, options) + + output_file = Path(output_path) + output_file.parent.mkdir(parents=True, exist_ok=True) + output_file.write_text(html) + + return { + "output_file": str(output_file), + "template": options.template if options else "memoir", + "size": len(html), + } + + def _build_head( + self, + book_title: str, + template_info: dict, + options: HTMLOptions, + ) -> str: + """Build HTML head with styles.""" + font_import = self._get_font_import(template_info["fonts"]) + + return f""" + + + + + {book_title} + {font_import} + +""" + + def _build_body( + self, + manuscript, + book_title: str, + options: HTMLOptions, + ) -> str: + """Build HTML body from chapters.""" + parts = [""] + + # Title page + parts.append('
') + parts.append(f"

{book_title}

") + if options.author: + parts.append(f'
by {options.author}
') + if options.date: + parts.append(f'
{options.date}
') + parts.append("
") + + # Dedication + if options.dedication: + parts.append(f'
{options.dedication}
') + + # Table of contents + if options.include_toc: + parts.append('

Contents

") + + # Chapters + for i, chapter in enumerate(manuscript.chapters, 1): + parts.append(f'
') + parts.append(f'Chapter {i}') + parts.append(f"

{chapter.title}

") + + # Content + content = self._markdown_to_html(chapter.content or "") + parts.append(content) + + parts.append("
") + + parts.append("") + + return "\n".join(parts) + + def _markdown_to_html(self, text: str) -> str: + """Convert basic markdown to HTML.""" + import re + + # Headers + text = re.sub(r'^#### (.+)$', r'

\1

', text, flags=re.MULTILINE) + text = re.sub(r'^### (.+)$', r'

\1

', text, flags=re.MULTILINE) + text = re.sub(r'^## (.+)$', r'

\1

', text, flags=re.MULTILINE) + text = re.sub(r'^# (.+)$', r'

\1

', text, flags=re.MULTILINE) + + # Bold/italic + text = re.sub(r'\*\*\*(.+?)\*\*\*', r'\1', text) + text = re.sub(r'\*\*(.+?)\*\*', r'\1', text) + text = re.sub(r'\*(.+?)\*', r'\1', text) + + # Code + text = re.sub(r'```(.+?)```', r'
\1
', text, flags=re.DOTALL) + text = re.sub(r'`(.+?)`', r'\1', text) + + # Lists + text = re.sub(r'^- (.+)$', r'
  • \1
  • ', text, flags=re.MULTILINE) + text = re.sub(r'^\d+\. (.+)$', r'
  • \1
  • ', text, flags=re.MULTILINE) + + # Wrap consecutive
  • in ') + text = '\n'.join(new_lines) + + # Paragraphs + paragraphs = [] + for para in text.split('\n\n'): + para = para.strip() + if para and not para.startswith('<'): + para = f'

    {para}

    ' + paragraphs.append(para) + text = '\n'.join(paragraphs) + + return text + + def _get_font_import(self, fonts: list) -> str: + """Get Google Fonts import URL.""" + font_query = "|".join(fonts).replace(" ", "+") + return f'' + + +def export_to_html( + manuscript, + book_title: str, + output_path: str = "", + template: str = "memoir", + **options, +) -> dict: + """Convenience function to export to HTML. + + Args: + manuscript: The Manuscript + book_title: Book title + output_path: Output .html path (optional) + template: Template name + **options: Additional HTMLOptions + + Returns: + Export result with HTML string or file path + """ + opts = HTMLOptions(template=template, **options) + exporter = HTMLExporter() + + if output_path: + return exporter.export_to_file(manuscript, book_title, output_path, opts) + else: + html = exporter.export(manuscript, book_title, opts) + return {"html": html, "template": template} + + +def export_to_pdf( + manuscript, + book_title: str, + output_path: str, + template: str = "memoir", + **options, +) -> dict: + """Export to PDF via browser. + + Args: + manuscript: The Manuscript + book_title: Book title + output_path: Output .pdf path + template: Template name + **options: Additional HTMLOptions + + Returns: + Export result + """ + # First export to HTML + opts = HTMLOptions(template=template, **options) + exporter = HTMLExporter() + + html = exporter.export(manuscript, book_title, opts) + + # Save HTML temporarily + html_path = output_path.replace(".pdf", ".html") + Path(html_path).write_text(html) + + return { + "html_file": html_path, + "pdf_file": output_path, + "template": template, + "html": html, + }