diff --git a/paperbanana/agents/tikz_exporter.py b/paperbanana/agents/tikz_exporter.py new file mode 100644 index 00000000..706430d6 --- /dev/null +++ b/paperbanana/agents/tikz_exporter.py @@ -0,0 +1,118 @@ +"""TikZ/PGFPlots exporter agent — converts generated images to LaTeX source.""" + +from __future__ import annotations + +import re +from typing import Optional + +import structlog + +from paperbanana.agents.base import BaseAgent +from paperbanana.core.types import DiagramType +from paperbanana.core.utils import load_image +from paperbanana.providers.base import VLMProvider + +logger = structlog.get_logger() + +_HEADER_TEMPLATE = """\ +% Generated by PaperBanana {version} +% Source: {source_label} +% Model: {model} +% Venue: {venue} +% Diagram type: {diagram_type} +""" + + +class TikZExporterAgent(BaseAgent): + """Converts a generated academic illustration to compilable LaTeX/TikZ source. + + For methodology diagrams the agent emits a standalone TikZ picture. + For statistical plots it emits PGFPlots markup. + """ + + def __init__( + self, + vlm_provider: VLMProvider, + prompt_dir: str = "prompts", + prompt_recorder=None, + ): + super().__init__(vlm_provider, prompt_dir, prompt_recorder=prompt_recorder) + + @property + def agent_name(self) -> str: + return "tikz_exporter" + + async def run( + self, + image_path: str, + source_context: str, + caption: str, + diagram_type: DiagramType = DiagramType.METHODOLOGY, + description: Optional[str] = None, + source_label: str = "", + model_label: str = "", + venue: str = "neurips", + version: str = "", + ) -> str: + """Convert a generated image to TikZ/PGFPlots source. + + Returns: + A string containing the complete LaTeX snippet with metadata header. + """ + image = load_image(image_path) + + prompt_type = "diagram" if diagram_type == DiagramType.METHODOLOGY else "plot" + template = self.load_prompt(prompt_type) + + desc_block = description.strip() if description else "(not available)" + prompt = self.format_prompt( + template, + prompt_label="tikz_exporter", + source_context=source_context, + caption=caption, + description=desc_block, + ) + + logger.info( + "Running TikZ exporter", + image_path=image_path, + diagram_type=diagram_type.value, + ) + + response = await self.vlm.generate( + prompt=prompt, + images=[image], + temperature=0.2, + max_tokens=8192, + ) + + tikz_code = self._extract_code(response) + + header = _HEADER_TEMPLATE.format( + version=version or "unknown", + source_label=source_label or image_path, + model=model_label or getattr(self.vlm, "model_name", "unknown"), + venue=venue, + diagram_type=diagram_type.value, + ) + + full_output = header + tikz_code + logger.info("TikZ export complete", output_length=len(full_output)) + return full_output + + @staticmethod + def _extract_code(response: str) -> str: + """Strip markdown code fences and return the raw LaTeX/TikZ snippet.""" + fenced = re.search( + r"```(?:latex|tikz|pgfplots|tex)?\s*\n(.*?)```", + response, + re.DOTALL | re.IGNORECASE, + ) + if fenced: + return fenced.group(1).strip() + + stripped = response.strip() + if stripped.startswith(("\\begin", "%", "\\tikz")): + return stripped + + return stripped diff --git a/paperbanana/cli.py b/paperbanana/cli.py index 4cfe8c45..f951d7e1 100644 --- a/paperbanana/cli.py +++ b/paperbanana/cli.py @@ -197,6 +197,11 @@ def generate( "--pdf-pages", help=("PDF input only: 1-based pages (e.g. '1-5', '3', '1-3,7,10-12'); default: all pages"), ), + export_tikz: bool = typer.Option( + False, + "--export-tikz", + help="Export a compilable TikZ/LaTeX source file alongside the generated image", + ), verbose: bool = typer.Option( False, "--verbose", "-v", help="Show detailed agent progress and timing" ), @@ -270,6 +275,8 @@ def generate( overrides["venue"] = venue if prompt_dir: overrides["prompt_dir"] = prompt_dir + if export_tikz: + overrides["export_tikz"] = True if config: settings = Settings.from_yaml(config, **overrides) @@ -595,6 +602,8 @@ def on_progress(event: PipelineProgressEvent) -> None: f" · {len(result.iterations)} iterations[/dim]\n" ) console.print(f" Output: [bold]{result.image_path}[/bold]") + if result.tikz_path: + console.print(f" TikZ: [bold]{result.tikz_path}[/bold]") console.print(f" Run ID: [dim]{result.metadata.get('run_id', 'unknown')}[/dim]") cost_data = result.metadata.get("cost") @@ -1564,6 +1573,11 @@ def plot( "--budget", help="Budget cap in USD; pipeline aborts gracefully when exceeded", ), + export_pgfplots: bool = typer.Option( + False, + "--export-pgfplots", + help="Export a compilable PGFPlots/LaTeX source file alongside the generated plot", + ), ): """Generate a statistical plot from data.""" if format not in ("png", "jpeg", "webp"): @@ -1602,6 +1616,7 @@ def plot( save_prompts=True if save_prompts is None else save_prompts, venue=venue, budget_usd=budget, + export_pgfplots=export_pgfplots, ) gen_input = GenerationInput( @@ -1651,6 +1666,8 @@ async def _run(): result = asyncio.run(_run()) console.print(f"\n[green]Done![/green] Plot saved to: [bold]{result.image_path}[/bold]") + if result.tikz_path: + console.print(f" PGFPlots: [bold]{result.tikz_path}[/bold]") cost_data = result.metadata.get("cost") if cost_data: @@ -2466,5 +2483,148 @@ def studio( ) +@app.command() +def tikz( + input: str = typer.Option( + ..., + "--input", + "-i", + help="Path to an existing generated image to convert to LaTeX/TikZ", + ), + output: Optional[str] = typer.Option( + None, + "--output", + "-o", + help="Output .tex file path (default: same directory as input, with .tex extension)", + ), + source_context: Optional[str] = typer.Option( + None, + "--source-context", + help="Path to a methodology text file for context (improves TikZ fidelity)", + ), + caption: str = typer.Option( + "", + "--caption", + "-c", + help="Figure caption / communicative intent (improves TikZ fidelity)", + ), + diagram_type: str = typer.Option( + "diagram", + "--diagram-type", + help="Type of illustration: diagram (TikZ) or plot (PGFPlots)", + ), + vlm_provider: Optional[str] = typer.Option( + None, "--vlm-provider", help="VLM provider (gemini, openai, anthropic, ...)" + ), + vlm_model: Optional[str] = typer.Option( + None, "--vlm-model", help="VLM model name override" + ), + venue: Optional[str] = typer.Option( + None, + "--venue", + help="Target venue style (neurips, icml, acl, ieee, custom)", + ), + config: Optional[str] = typer.Option( + None, "--config", help="Path to a YAML config file" + ), + verbose: bool = typer.Option( + False, "--verbose", "-v", help="Show detailed progress" + ), +): + """Convert an existing generated image to a compilable LaTeX/TikZ source file.""" + input_path = Path(input) + if not input_path.exists(): + console.print(f"[red]Error: Input file not found: {input}[/red]") + raise typer.Exit(1) + + if diagram_type not in ("diagram", "plot"): + console.print("[red]Error: --diagram-type must be 'diagram' or 'plot'[/red]") + raise typer.Exit(1) + + if venue and venue.lower() not in ("neurips", "icml", "acl", "ieee", "custom"): + console.print( + f"[red]Error: --venue must be neurips, icml, acl, ieee, or custom. Got: {venue}[/red]" + ) + raise typer.Exit(1) + + configure_logging(verbose=verbose) + + tex_path = Path(output) if output else input_path.with_suffix(".tex") + + context_text = "" + if source_context: + sc_path = Path(source_context) + if not sc_path.exists(): + console.print( + f"[red]Error: Source context file not found: {source_context}[/red]" + ) + raise typer.Exit(1) + context_text = sc_path.read_text(encoding="utf-8") + + overrides: dict = {} + if vlm_provider: + overrides["vlm_provider"] = vlm_provider + if vlm_model: + overrides["vlm_model"] = vlm_model + if venue: + overrides["venue"] = venue + + if config: + settings = Settings.from_yaml(config, **overrides) + else: + from dotenv import load_dotenv + + load_dotenv() + settings = Settings(**overrides) + + from paperbanana.agents.tikz_exporter import TikZExporterAgent + from paperbanana.providers.registry import ProviderRegistry + + dtype = ( + DiagramType.METHODOLOGY + if diagram_type == "diagram" + else DiagramType.STATISTICAL_PLOT + ) + + console.print( + Panel.fit( + f"[bold]PaperBanana[/bold] — Export to LaTeX/TikZ\n\n" + f"Input: {input_path}\n" + f"Output: {tex_path}\n" + f"Type: {'TikZ (methodology)' if dtype == DiagramType.METHODOLOGY else 'PGFPlots'}\n" + f"VLM: {settings.vlm_provider} / {settings.effective_vlm_model}", + border_style="blue", + ) + ) + + async def _run(): + vlm = ProviderRegistry.create_vlm(settings) + agent = TikZExporterAgent(vlm) + return await agent.run( + image_path=str(input_path), + source_context=context_text, + caption=caption, + diagram_type=dtype, + venue=settings.venue, + ) + + console.print() + console.print(" [dim]●[/dim] Generating TikZ source...", end="") + try: + tikz_source = asyncio.run(_run()) + except Exception as e: + console.print(f" [red]✗[/red]\n[red]Error: {e}[/red]") + raise typer.Exit(1) + + console.print(" [green]✓[/green]") + + tex_path.parent.mkdir(parents=True, exist_ok=True) + tex_path.write_text(tikz_source, encoding="utf-8") + + console.print( + f"\n[green]Done![/green] LaTeX source saved to: [bold]{tex_path}[/bold]" + ) + + if __name__ == "__main__": app() diff --git a/paperbanana/core/config.py b/paperbanana/core/config.py index 9a0ee8d4..a9794132 100644 --- a/paperbanana/core/config.py +++ b/paperbanana/core/config.py @@ -96,6 +96,8 @@ class Settings(BaseSettings): output_format: OutputFormat = "png" save_iterations: bool = True save_prompts: bool = True + export_tikz: bool = False + export_pgfplots: bool = False # Prompt settings prompt_dir: Optional[str] = None @@ -242,6 +244,8 @@ def _flatten_yaml(config: dict, prefix: str = "") -> dict: "output.format": "output_format", "output.save_iterations": "save_iterations", "output.save_prompts": "save_prompts", + "output.export_tikz": "export_tikz", + "output.export_pgfplots": "export_pgfplots", "cost.budget": "budget_usd", "pipeline.prompt_dir": "prompt_dir", } diff --git a/paperbanana/core/pipeline.py b/paperbanana/core/pipeline.py index 95e3e2f6..9ec87b56 100644 --- a/paperbanana/core/pipeline.py +++ b/paperbanana/core/pipeline.py @@ -14,6 +14,7 @@ from paperbanana.agents.planner import PlannerAgent from paperbanana.agents.retriever import RetrieverAgent from paperbanana.agents.stylist import StylistAgent +from paperbanana.agents.tikz_exporter import TikZExporterAgent from paperbanana.agents.visualizer import VisualizerAgent from paperbanana.core.config import Settings from paperbanana.core.cost_tracker import CostTracker @@ -48,6 +49,17 @@ logger = structlog.get_logger() + +def _get_version() -> str: + """Return the installed PaperBanana version, or 'unknown' if unavailable.""" + try: + from importlib.metadata import version + + return version("paperbanana") + except Exception: + return "unknown" + + _ssl_skip_applied = False @@ -204,6 +216,9 @@ def __init__( self.critic = CriticAgent( self._vlm, prompt_dir=prompt_dir, prompt_recorder=self._prompt_recorder ) + self.tikz_exporter = TikZExporterAgent( + self._vlm, prompt_dir=prompt_dir, prompt_recorder=self._prompt_recorder + ) logger.info( "Pipeline initialized", @@ -786,11 +801,67 @@ async def generate( # Always write metadata (including cost) to disk for every run save_json(metadata_dict, self._run_dir / "metadata.json") + # ── Optional: TikZ / PGFPlots export ────────────────────────── + tikz_path: str | None = None + should_export = ( + input.diagram_type == DiagramType.METHODOLOGY and self.settings.export_tikz + ) or ( + input.diagram_type == DiagramType.STATISTICAL_PLOT + and self.settings.export_pgfplots + ) + + if should_export and final_output_path: + if self._cost_tracker: + self._cost_tracker.set_agent("tikz_exporter") + _emit_progress( + progress_callback, + PipelineProgressEvent( + stage=PipelineProgressStage.TIKZ_EXPORTER_START, + message="Exporting to LaTeX/TikZ", + ), + ) + self._emit_progress("tikz_export_started") + tikz_start = time.perf_counter() + try: + tikz_source = await self.tikz_exporter.run( + image_path=final_output_path, + source_context=input.source_context, + caption=input.communicative_intent, + diagram_type=input.diagram_type, + description=current_description, + source_label=str(self._run_dir), + model_label=getattr(self._vlm, "model_name", ""), + venue=self.settings.venue, + version=_get_version(), + ) + tex_path = Path(final_output_path).with_suffix(".tex") + tex_path.write_text(tikz_source, encoding="utf-8") + tikz_path = str(tex_path) + logger.info("TikZ export saved", path=tikz_path) + except Exception: + logger.warning("TikZ export failed", exc_info=True) + tikz_seconds = time.perf_counter() - tikz_start + _emit_progress( + progress_callback, + PipelineProgressEvent( + stage=PipelineProgressStage.TIKZ_EXPORTER_END, + message="TikZ export done", + seconds=tikz_seconds, + extra={"tikz_path": tikz_path}, + ), + ) + self._emit_progress( + "tikz_export_completed", + seconds=round(tikz_seconds, 1), + tikz_path=tikz_path, + ) + output = GenerationOutput( image_path=final_output_path, description=current_description, iterations=iterations, metadata=metadata_dict, + tikz_path=tikz_path, ) logger.info( diff --git a/paperbanana/core/types.py b/paperbanana/core/types.py index a45b4fe3..8735a0de 100644 --- a/paperbanana/core/types.py +++ b/paperbanana/core/types.py @@ -35,6 +35,8 @@ class PipelineProgressStage(str, Enum): VISUALIZER_END = "visualizer_end" CRITIC_START = "critic_start" CRITIC_END = "critic_end" + TIKZ_EXPORTER_START = "tikz_exporter_start" + TIKZ_EXPORTER_END = "tikz_exporter_end" class PipelineProgressEvent(BaseModel): @@ -133,6 +135,10 @@ class GenerationOutput(BaseModel): default_factory=list, description="History of refinement iterations" ) metadata: dict[str, Any] = Field(default_factory=dict) + tikz_path: Optional[str] = Field( + default=None, + description="Path to the exported LaTeX/TikZ source file, if generated", + ) VALID_WINNERS = {"Model", "Human", "Both are good", "Both are bad"} diff --git a/prompts/diagram/tikz_exporter.txt b/prompts/diagram/tikz_exporter.txt new file mode 100644 index 00000000..7fbc77b7 --- /dev/null +++ b/prompts/diagram/tikz_exporter.txt @@ -0,0 +1,31 @@ +You are an expert LaTeX/TikZ author specialising in academic methodology diagrams. + +You are given: +1. A generated diagram image (attached). +2. The original methodology text that the diagram illustrates. +3. The figure caption. +4. An optimised description of the diagram layout (if available). + +Your task is to produce a **self-contained, compilable TikZ snippet** that faithfully reproduces the visual structure, labels, colours, and layout of the provided image. + +## Source context +{source_context} + +## Figure caption +{caption} + +## Planner description +{description} + +## Requirements +- Output a single `\begin{{tikzpicture}} ... \end{{tikzpicture}}` block. +- Use ONLY standard packages: `tikz`, `pgfplots`, `xcolor`, `calc`, `positioning`, `arrows.meta`, `shapes.geometric`, `fit`. +- Do NOT use any external image files or `\includegraphics`. +- Preserve all text labels exactly as they appear in the image. +- Match colours as closely as possible using named xcolor values or custom RGB definitions. +- Use `positioning` library (`right=of`, `below=of`, etc.) for relative placement. +- Include a brief comment above each major element describing what it represents. +- The output must compile under `pdflatex` without errors. + +## Output format +Return ONLY the LaTeX code inside a single code block. Do not include any explanation outside the code block. diff --git a/prompts/plot/tikz_exporter.txt b/prompts/plot/tikz_exporter.txt new file mode 100644 index 00000000..2172b020 --- /dev/null +++ b/prompts/plot/tikz_exporter.txt @@ -0,0 +1,32 @@ +You are an expert LaTeX/PGFPlots author specialising in academic statistical figures. + +You are given: +1. A generated statistical plot image (attached). +2. The source context describing what the plot shows. +3. The figure caption. +4. An optimised description of the plot layout (if available). + +Your task is to produce a **self-contained, compilable PGFPlots snippet** that faithfully reproduces the axes, data series, labels, legend, and styling of the provided image. + +## Source context +{source_context} + +## Figure caption +{caption} + +## Planner description +{description} + +## Requirements +- Output a single `\begin{{tikzpicture}} ... \end{{tikzpicture}}` block containing a `\begin{{axis}} ... \end{{axis}}` environment. +- Use ONLY standard packages: `pgfplots`, `pgfplotstable`, `tikz`, `xcolor`. +- Set `\pgfplotsset{{compat=1.18}}` or later. +- Reproduce axis labels, tick labels, title, and legend exactly as shown. +- Match line styles (solid, dashed, dotted), markers, and colours. +- For bar charts, use `ybar` or `xbar` with appropriate `bar width`. +- For scatter plots, use `only marks` with appropriate `mark` styles. +- Include inline `\addplot coordinates {{ ... }}` with representative data points read from the image. +- The output must compile under `pdflatex` without errors. + +## Output format +Return ONLY the LaTeX code inside a single code block. Do not include any explanation outside the code block. diff --git a/tests/test_tikz_exporter.py b/tests/test_tikz_exporter.py new file mode 100644 index 00000000..bc33e2b6 --- /dev/null +++ b/tests/test_tikz_exporter.py @@ -0,0 +1,230 @@ +"""Tests for TikZ/PGFPlots export agent and CLI integration.""" + +from __future__ import annotations + +from unittest.mock import AsyncMock + +import pytest + +from paperbanana.agents.tikz_exporter import TikZExporterAgent +from paperbanana.core.types import DiagramType + +# --------------------------------------------------------------------------- +# _extract_code +# --------------------------------------------------------------------------- + + +class TestExtractCode: + def test_strips_latex_fence(self): + raw = "```latex\n\\begin{tikzpicture}\n\\end{tikzpicture}\n```" + assert TikZExporterAgent._extract_code(raw) == ( + "\\begin{tikzpicture}\n\\end{tikzpicture}" + ) + + def test_strips_tikz_fence(self): + raw = "```tikz\n\\begin{tikzpicture}\n\\end{tikzpicture}\n```" + assert "\\begin{tikzpicture}" in TikZExporterAgent._extract_code(raw) + + def test_strips_pgfplots_fence(self): + raw = "```pgfplots\n\\begin{axis}\n\\end{axis}\n```" + assert "\\begin{axis}" in TikZExporterAgent._extract_code(raw) + + def test_strips_generic_fence(self): + raw = "```\n\\begin{tikzpicture}\n\\end{tikzpicture}\n```" + assert "\\begin{tikzpicture}" in TikZExporterAgent._extract_code(raw) + + def test_raw_latex_passthrough(self): + raw = "\\begin{tikzpicture}\n\\node {A};\n\\end{tikzpicture}" + assert TikZExporterAgent._extract_code(raw) == raw + + def test_comment_prefix_passthrough(self): + raw = "% PaperBanana\n\\begin{tikzpicture}\n\\end{tikzpicture}" + assert TikZExporterAgent._extract_code(raw) == raw + + def test_bare_text_returned_stripped(self): + raw = " some unexpected output " + assert TikZExporterAgent._extract_code(raw) == "some unexpected output" + + def test_case_insensitive_fence(self): + raw = "```LATEX\n\\begin{tikzpicture}\n\\end{tikzpicture}\n```" + assert "\\begin{tikzpicture}" in TikZExporterAgent._extract_code(raw) + + +# --------------------------------------------------------------------------- +# TikZExporterAgent.run +# --------------------------------------------------------------------------- + + +@pytest.fixture() +def mock_vlm(): + vlm = AsyncMock() + vlm.model_name = "gemini-2.0-flash" + vlm.generate = AsyncMock( + return_value="```latex\n\\begin{tikzpicture}\n\\node {Test};\n\\end{tikzpicture}\n```" + ) + return vlm + + +@pytest.mark.asyncio +async def test_run_produces_tikz_with_header(mock_vlm, tmp_path): + # Create a dummy image + from PIL import Image + + img = Image.new("RGB", (100, 100), "white") + img_path = tmp_path / "test.png" + img.save(str(img_path)) + + agent = TikZExporterAgent(mock_vlm, prompt_dir="prompts") + result = await agent.run( + image_path=str(img_path), + source_context="Test methodology", + caption="Test caption", + diagram_type=DiagramType.METHODOLOGY, + version="0.1.2", + venue="neurips", + ) + + assert "% Generated by PaperBanana 0.1.2" in result + assert "% Venue: neurips" in result + assert "\\begin{tikzpicture}" in result + assert "\\node {Test};" in result + mock_vlm.generate.assert_called_once() + + +@pytest.mark.asyncio +async def test_run_uses_plot_prompt_for_statistical_plots(mock_vlm, tmp_path): + from PIL import Image + + img = Image.new("RGB", (100, 100), "white") + img_path = tmp_path / "plot.png" + img.save(str(img_path)) + + agent = TikZExporterAgent(mock_vlm, prompt_dir="prompts") + result = await agent.run( + image_path=str(img_path), + source_context="Plot data", + caption="Accuracy chart", + diagram_type=DiagramType.STATISTICAL_PLOT, + ) + + assert "% Diagram type: statistical_plot" in result + + +@pytest.mark.asyncio +async def test_run_header_defaults(mock_vlm, tmp_path): + from PIL import Image + + img = Image.new("RGB", (100, 100), "white") + img_path = tmp_path / "test.png" + img.save(str(img_path)) + + agent = TikZExporterAgent(mock_vlm, prompt_dir="prompts") + result = await agent.run( + image_path=str(img_path), + source_context="ctx", + caption="cap", + ) + + # When no version/source_label provided, defaults to "unknown" / image_path + assert "% Generated by PaperBanana unknown" in result + assert str(img_path) in result + + +# --------------------------------------------------------------------------- +# Config settings +# --------------------------------------------------------------------------- + + +def test_export_tikz_setting_default(): + from paperbanana.core.config import Settings + + s = Settings() + assert s.export_tikz is False + assert s.export_pgfplots is False + + +def test_export_tikz_setting_override(): + from paperbanana.core.config import Settings + + s = Settings(export_tikz=True) + assert s.export_tikz is True + + +# --------------------------------------------------------------------------- +# Types +# --------------------------------------------------------------------------- + + +def test_generation_output_tikz_path_default(): + from paperbanana.core.types import GenerationOutput + + out = GenerationOutput(image_path="test.png", description="desc") + assert out.tikz_path is None + + +def test_generation_output_tikz_path_set(): + from paperbanana.core.types import GenerationOutput + + out = GenerationOutput( + image_path="test.png", description="desc", tikz_path="test.tex" + ) + assert out.tikz_path == "test.tex" + + +def test_progress_stages_exist(): + from paperbanana.core.types import PipelineProgressStage + + assert PipelineProgressStage.TIKZ_EXPORTER_START.value == "tikz_exporter_start" + assert PipelineProgressStage.TIKZ_EXPORTER_END.value == "tikz_exporter_end" + + +# --------------------------------------------------------------------------- +# CLI: tikz subcommand +# --------------------------------------------------------------------------- + + +def test_tikz_command_missing_input(): + from typer.testing import CliRunner + + from paperbanana.cli import app + + runner = CliRunner() + result = runner.invoke(app, ["tikz", "--input", "nonexistent.png"]) + assert result.exit_code == 1 + assert "not found" in result.output + + +def test_tikz_command_invalid_diagram_type(tmp_path): + from PIL import Image + from typer.testing import CliRunner + + from paperbanana.cli import app + + img = Image.new("RGB", (10, 10), "white") + img_path = tmp_path / "test.png" + img.save(str(img_path)) + + runner = CliRunner() + result = runner.invoke( + app, ["tikz", "--input", str(img_path), "--diagram-type", "flowchart"] + ) + assert result.exit_code == 1 + assert "diagram" in result.output or "plot" in result.output + + +def test_tikz_command_invalid_venue(tmp_path): + from PIL import Image + from typer.testing import CliRunner + + from paperbanana.cli import app + + img = Image.new("RGB", (10, 10), "white") + img_path = tmp_path / "test.png" + img.save(str(img_path)) + + runner = CliRunner() + result = runner.invoke( + app, ["tikz", "--input", str(img_path), "--venue", "invalid"] + ) + assert result.exit_code == 1 + assert "venue" in result.output.lower()