diff --git a/src/article_cli/cli.py b/src/article_cli/cli.py index b5fdb68..6e71fa6 100644 --- a/src/article_cli/cli.py +++ b/src/article_cli/cli.py @@ -94,9 +94,9 @@ def create_parser() -> argparse.ArgumentParser: ) compile_parser.add_argument( "--engine", - choices=["latexmk", "pdflatex"], + choices=["latexmk", "pdflatex", "xelatex", "lualatex"], default="latexmk", - help="LaTeX engine to use (default: latexmk)", + help="LaTeX engine to use (default: latexmk). Use xelatex/lualatex for custom fonts.", ) compile_parser.add_argument( "--shell-escape", diff --git a/src/article_cli/config.py b/src/article_cli/config.py index 0e7aceb..5bf65e1 100644 --- a/src/article_cli/config.py +++ b/src/article_cli/config.py @@ -266,7 +266,9 @@ def create_sample_config(self, path: Optional[Path] = None) -> Path: build_dir = "." # Default LaTeX engine -engine = "latexmk" # Options: "latexmk", "pdflatex" +# Options: "latexmk", "pdflatex", "xelatex", "lualatex" +# Use xelatex or lualatex for documents with custom fonts (e.g., Beamer presentations) +engine = "latexmk" # Enable shell escape by default shell_escape = false diff --git a/src/article_cli/latex_compiler.py b/src/article_cli/latex_compiler.py index 136a211..820d342 100644 --- a/src/article_cli/latex_compiler.py +++ b/src/article_cli/latex_compiler.py @@ -68,6 +68,10 @@ def _compile_once( return self._run_latexmk(tex_path, shell_escape, output_dir) elif engine == "pdflatex": return self._run_pdflatex(tex_path, shell_escape, output_dir) + elif engine == "xelatex": + return self._run_xelatex(tex_path, shell_escape, output_dir) + elif engine == "lualatex": + return self._run_lualatex(tex_path, shell_escape, output_dir) else: print_error(f"Unknown engine: {engine}") return False @@ -76,8 +80,11 @@ def _compile_watch( self, tex_path: Path, engine: str, shell_escape: bool, output_dir: Optional[str] ) -> bool: """Compile document and watch for changes""" - if engine == "pdflatex": - print_error("Watch mode is only supported with latexmk engine") + if engine in ["pdflatex", "xelatex", "lualatex"]: + print_error( + "Watch mode is only supported with latexmk engine. " + "Use: article-cli compile --engine latexmk --watch" + ) return False print_info("Starting watch mode. Press Ctrl+C to stop.") @@ -230,17 +237,33 @@ def _build_latexmk_command( shell_escape: bool, output_dir: Optional[str], continuous: bool = False, + pdf_mode: str = "pdf", ) -> List[str]: - """Build latexmk command based on LaTeX Workshop configuration""" + """Build latexmk command based on LaTeX Workshop configuration + + Args: + tex_path: Path to .tex file + shell_escape: Enable shell escape + output_dir: Output directory + continuous: Enable preview continuous mode + pdf_mode: PDF generation mode ("pdf", "xelatex", "lualatex") + """ cmd = ["latexmk"] # Core options (from LaTeX Workshop) if shell_escape: cmd.append("--shell-escape") + # Select PDF generation engine + if pdf_mode == "xelatex": + cmd.append("-xelatex") + elif pdf_mode == "lualatex": + cmd.append("-lualatex") + else: + cmd.append("-pdf") # Default: pdflatex + cmd.extend( [ - "-pdf", "-interaction=nonstopmode", "-synctex=1", ] @@ -281,6 +304,164 @@ def _build_pdflatex_command( return cmd + def _run_xelatex( + self, tex_path: Path, shell_escape: bool, output_dir: Optional[str] + ) -> bool: + """Run xelatex compilation (multiple passes for cross-references)""" + cmd = self._build_xelatex_command(tex_path, shell_escape, output_dir) + + try: + # Run multiple passes for cross-references, bibliography, etc. + passes = ["First pass", "Second pass", "Third pass"] + + for i, pass_name in enumerate(passes): + print_info(f"{pass_name} (xelatex)...") + result = subprocess.run( + cmd, + cwd=tex_path.parent, + capture_output=True, + text=True, + timeout=120, # 2 minute timeout per pass + ) + + if result.returncode != 0: + print_error(f"❌ {pass_name} failed") + if result.stdout: + print("STDOUT:") + print(result.stdout) + if result.stderr: + print("STDERR:") + print(result.stderr) + return False + + # Check if we need to run bibtex/biber + if i == 0: # After first pass + self._run_bibliography_if_needed(tex_path, result.stdout) + + pdf_name = tex_path.with_suffix(".pdf").name + if output_dir: + pdf_path = Path(output_dir) / pdf_name + else: + pdf_path = tex_path.with_suffix(".pdf") + + if pdf_path.exists(): + print_success(f"✅ Compilation successful: {pdf_path}") + self._show_pdf_info(pdf_path) + return True + else: + print_error("Compilation reported success but PDF not found") + return False + + except subprocess.TimeoutExpired: + print_error("Compilation timed out") + return False + except Exception as e: + print_error(f"Compilation error: {e}") + return False + + def _build_xelatex_command( + self, tex_path: Path, shell_escape: bool, output_dir: Optional[str] + ) -> List[str]: + """Build xelatex command""" + cmd = ["xelatex"] + + if shell_escape: + cmd.append("--shell-escape") + + cmd.extend( + [ + "-synctex=1", + "-interaction=nonstopmode", + "-file-line-error", + ] + ) + + if output_dir: + cmd.extend(["-output-directory", output_dir]) + + cmd.append(str(tex_path)) + + return cmd + + def _run_lualatex( + self, tex_path: Path, shell_escape: bool, output_dir: Optional[str] + ) -> bool: + """Run lualatex compilation (multiple passes for cross-references)""" + cmd = self._build_lualatex_command(tex_path, shell_escape, output_dir) + + try: + # Run multiple passes for cross-references, bibliography, etc. + passes = ["First pass", "Second pass", "Third pass"] + + for i, pass_name in enumerate(passes): + print_info(f"{pass_name} (lualatex)...") + result = subprocess.run( + cmd, + cwd=tex_path.parent, + capture_output=True, + text=True, + timeout=120, # 2 minute timeout per pass + ) + + if result.returncode != 0: + print_error(f"❌ {pass_name} failed") + if result.stdout: + print("STDOUT:") + print(result.stdout) + if result.stderr: + print("STDERR:") + print(result.stderr) + return False + + # Check if we need to run bibtex/biber + if i == 0: # After first pass + self._run_bibliography_if_needed(tex_path, result.stdout) + + pdf_name = tex_path.with_suffix(".pdf").name + if output_dir: + pdf_path = Path(output_dir) / pdf_name + else: + pdf_path = tex_path.with_suffix(".pdf") + + if pdf_path.exists(): + print_success(f"✅ Compilation successful: {pdf_path}") + self._show_pdf_info(pdf_path) + return True + else: + print_error("Compilation reported success but PDF not found") + return False + + except subprocess.TimeoutExpired: + print_error("Compilation timed out") + return False + except Exception as e: + print_error(f"Compilation error: {e}") + return False + + def _build_lualatex_command( + self, tex_path: Path, shell_escape: bool, output_dir: Optional[str] + ) -> List[str]: + """Build lualatex command""" + cmd = ["lualatex"] + + if shell_escape: + cmd.append("--shell-escape") + + cmd.extend( + [ + "-synctex=1", + "-interaction=nonstopmode", + "-file-line-error", + ] + ) + + if output_dir: + cmd.extend(["-output-directory", output_dir]) + + cmd.append(str(tex_path)) + + return cmd + def _run_bibliography_if_needed(self, tex_path: Path, latex_output: str) -> None: """Run bibtex or biber if needed based on latex output""" base_name = tex_path.stem @@ -332,6 +513,8 @@ def check_dependencies(self) -> Dict[str, bool]: tools = { "latexmk": self._check_command("latexmk"), "pdflatex": self._check_command("pdflatex"), + "xelatex": self._check_command("xelatex"), + "lualatex": self._check_command("lualatex"), "bibtex": self._check_command("bibtex"), } diff --git a/tests/test_latex_compiler.py b/tests/test_latex_compiler.py new file mode 100644 index 0000000..9e965e5 --- /dev/null +++ b/tests/test_latex_compiler.py @@ -0,0 +1,308 @@ +""" +Tests for article-cli LaTeX compiler module + +Tests for Issue #1: XeLaTeX and LuaLaTeX engine support +""" + +import subprocess +from pathlib import Path +from unittest.mock import patch, MagicMock +import pytest + +from article_cli.latex_compiler import LaTeXCompiler +from article_cli.config import Config + + +class TestLaTeXCompilerEngines: + """Test cases for LaTeX engine support (Issue #1)""" + + @pytest.fixture + def compiler(self): + """Create a LaTeXCompiler instance with default config""" + config = Config() + return LaTeXCompiler(config) + + @pytest.fixture + def mock_tex_path(self, tmp_path): + """Create a mock .tex file""" + tex_file = tmp_path / "test.tex" + tex_file.write_text(r"\documentclass{article}\begin{document}Hello\end{document}") + return tex_file + + # --- Test _build_latexmk_command with different pdf_modes --- + + def test_build_latexmk_command_default_pdf(self, compiler, mock_tex_path): + """Test latexmk command builds with -pdf flag by default""" + cmd = compiler._build_latexmk_command(mock_tex_path, shell_escape=False, output_dir=None) + + assert "latexmk" in cmd + assert "-pdf" in cmd + assert "-xelatex" not in cmd + assert "-lualatex" not in cmd + + def test_build_latexmk_command_xelatex_mode(self, compiler, mock_tex_path): + """Test latexmk command builds with -xelatex flag""" + cmd = compiler._build_latexmk_command( + mock_tex_path, shell_escape=False, output_dir=None, pdf_mode="xelatex" + ) + + assert "latexmk" in cmd + assert "-xelatex" in cmd + assert "-pdf" not in cmd + assert "-lualatex" not in cmd + + def test_build_latexmk_command_lualatex_mode(self, compiler, mock_tex_path): + """Test latexmk command builds with -lualatex flag""" + cmd = compiler._build_latexmk_command( + mock_tex_path, shell_escape=False, output_dir=None, pdf_mode="lualatex" + ) + + assert "latexmk" in cmd + assert "-lualatex" in cmd + assert "-pdf" not in cmd + assert "-xelatex" not in cmd + + def test_build_latexmk_command_with_shell_escape(self, compiler, mock_tex_path): + """Test latexmk command includes --shell-escape when requested""" + cmd = compiler._build_latexmk_command(mock_tex_path, shell_escape=True, output_dir=None) + + assert "--shell-escape" in cmd + + def test_build_latexmk_command_with_output_dir(self, compiler, mock_tex_path): + """Test latexmk command includes output directory""" + cmd = compiler._build_latexmk_command( + mock_tex_path, shell_escape=False, output_dir="build" + ) + + assert "-outdir" in cmd + assert "build" in cmd + + def test_build_latexmk_command_continuous_mode(self, compiler, mock_tex_path): + """Test latexmk command includes -pvc for continuous mode""" + cmd = compiler._build_latexmk_command( + mock_tex_path, shell_escape=False, output_dir=None, continuous=True + ) + + assert "-pvc" in cmd + + # --- Test _build_xelatex_command --- + + def test_build_xelatex_command_basic(self, compiler, mock_tex_path): + """Test xelatex command builds correctly""" + cmd = compiler._build_xelatex_command(mock_tex_path, shell_escape=False, output_dir=None) + + assert cmd[0] == "xelatex" + assert "-synctex=1" in cmd + assert "-interaction=nonstopmode" in cmd + assert "-file-line-error" in cmd + assert str(mock_tex_path) in cmd + + def test_build_xelatex_command_with_shell_escape(self, compiler, mock_tex_path): + """Test xelatex command includes --shell-escape""" + cmd = compiler._build_xelatex_command(mock_tex_path, shell_escape=True, output_dir=None) + + assert "--shell-escape" in cmd + + def test_build_xelatex_command_with_output_dir(self, compiler, mock_tex_path): + """Test xelatex command includes output directory""" + cmd = compiler._build_xelatex_command(mock_tex_path, shell_escape=False, output_dir="build") + + assert "-output-directory" in cmd + assert "build" in cmd + + # --- Test _build_lualatex_command --- + + def test_build_lualatex_command_basic(self, compiler, mock_tex_path): + """Test lualatex command builds correctly""" + cmd = compiler._build_lualatex_command(mock_tex_path, shell_escape=False, output_dir=None) + + assert cmd[0] == "lualatex" + assert "-synctex=1" in cmd + assert "-interaction=nonstopmode" in cmd + assert "-file-line-error" in cmd + assert str(mock_tex_path) in cmd + + def test_build_lualatex_command_with_shell_escape(self, compiler, mock_tex_path): + """Test lualatex command includes --shell-escape""" + cmd = compiler._build_lualatex_command(mock_tex_path, shell_escape=True, output_dir=None) + + assert "--shell-escape" in cmd + + def test_build_lualatex_command_with_output_dir(self, compiler, mock_tex_path): + """Test lualatex command includes output directory""" + cmd = compiler._build_lualatex_command(mock_tex_path, shell_escape=False, output_dir="build") + + assert "-output-directory" in cmd + assert "build" in cmd + + # --- Test _compile_once routing --- + + def test_compile_once_routes_to_latexmk(self, compiler, mock_tex_path): + """Test that 'latexmk' engine routes to _run_latexmk""" + with patch.object(compiler, '_run_latexmk', return_value=True) as mock_run: + result = compiler._compile_once(mock_tex_path, "latexmk", False, None) + + mock_run.assert_called_once_with(mock_tex_path, False, None) + assert result is True + + def test_compile_once_routes_to_pdflatex(self, compiler, mock_tex_path): + """Test that 'pdflatex' engine routes to _run_pdflatex""" + with patch.object(compiler, '_run_pdflatex', return_value=True) as mock_run: + result = compiler._compile_once(mock_tex_path, "pdflatex", False, None) + + mock_run.assert_called_once_with(mock_tex_path, False, None) + assert result is True + + def test_compile_once_routes_to_xelatex(self, compiler, mock_tex_path): + """Test that 'xelatex' engine routes to _run_xelatex""" + with patch.object(compiler, '_run_xelatex', return_value=True) as mock_run: + result = compiler._compile_once(mock_tex_path, "xelatex", False, None) + + mock_run.assert_called_once_with(mock_tex_path, False, None) + assert result is True + + def test_compile_once_routes_to_lualatex(self, compiler, mock_tex_path): + """Test that 'lualatex' engine routes to _run_lualatex""" + with patch.object(compiler, '_run_lualatex', return_value=True) as mock_run: + result = compiler._compile_once(mock_tex_path, "lualatex", False, None) + + mock_run.assert_called_once_with(mock_tex_path, False, None) + assert result is True + + def test_compile_once_unknown_engine_returns_false(self, compiler, mock_tex_path): + """Test that unknown engine returns False""" + result = compiler._compile_once(mock_tex_path, "unknown_engine", False, None) + + assert result is False + + # --- Test _compile_watch restrictions --- + + def test_compile_watch_rejects_pdflatex(self, compiler, mock_tex_path): + """Test that watch mode rejects pdflatex engine""" + result = compiler._compile_watch(mock_tex_path, "pdflatex", False, None) + + assert result is False + + def test_compile_watch_rejects_xelatex(self, compiler, mock_tex_path): + """Test that watch mode rejects xelatex engine""" + result = compiler._compile_watch(mock_tex_path, "xelatex", False, None) + + assert result is False + + def test_compile_watch_rejects_lualatex(self, compiler, mock_tex_path): + """Test that watch mode rejects lualatex engine""" + result = compiler._compile_watch(mock_tex_path, "lualatex", False, None) + + assert result is False + + # --- Test check_dependencies --- + + def test_check_dependencies_includes_xelatex(self, compiler): + """Test that dependency check includes xelatex""" + with patch.object(compiler, '_check_command', return_value=True): + deps = compiler.check_dependencies() + + assert "xelatex" in deps + + def test_check_dependencies_includes_lualatex(self, compiler): + """Test that dependency check includes lualatex""" + with patch.object(compiler, '_check_command', return_value=True): + deps = compiler.check_dependencies() + + assert "lualatex" in deps + + def test_check_dependencies_all_engines(self, compiler): + """Test that all expected engines are in dependency check""" + with patch.object(compiler, '_check_command', return_value=True): + deps = compiler.check_dependencies() + + expected_tools = ["latexmk", "pdflatex", "xelatex", "lualatex", "bibtex"] + for tool in expected_tools: + assert tool in deps, f"Missing tool: {tool}" + + +class TestLaTeXCompilerCommandExecution: + """Test actual command execution (with mocking)""" + + @pytest.fixture + def compiler(self): + """Create a LaTeXCompiler instance with default config""" + config = Config() + return LaTeXCompiler(config) + + @pytest.fixture + def mock_tex_path(self, tmp_path): + """Create a mock .tex file""" + tex_file = tmp_path / "test.tex" + tex_file.write_text(r"\documentclass{article}\begin{document}Hello\end{document}") + return tex_file + + @patch('subprocess.run') + def test_run_xelatex_success(self, mock_run, compiler, mock_tex_path, tmp_path): + """Test successful xelatex compilation""" + # Create mock PDF file + pdf_file = tmp_path / "test.pdf" + pdf_file.write_bytes(b"%PDF-1.4 mock pdf content") + + # Mock successful subprocess run + mock_run.return_value = MagicMock(returncode=0, stdout="", stderr="") + + result = compiler._run_xelatex(mock_tex_path, shell_escape=False, output_dir=None) + + # Should have been called 3 times (3 passes) + assert mock_run.call_count == 3 + assert result is True + + @patch('subprocess.run') + def test_run_lualatex_success(self, mock_run, compiler, mock_tex_path, tmp_path): + """Test successful lualatex compilation""" + # Create mock PDF file + pdf_file = tmp_path / "test.pdf" + pdf_file.write_bytes(b"%PDF-1.4 mock pdf content") + + # Mock successful subprocess run + mock_run.return_value = MagicMock(returncode=0, stdout="", stderr="") + + result = compiler._run_lualatex(mock_tex_path, shell_escape=False, output_dir=None) + + # Should have been called 3 times (3 passes) + assert mock_run.call_count == 3 + assert result is True + + @patch('subprocess.run') + def test_run_xelatex_failure(self, mock_run, compiler, mock_tex_path): + """Test xelatex compilation failure""" + # Mock failed subprocess run + mock_run.return_value = MagicMock(returncode=1, stdout="Error!", stderr="") + + result = compiler._run_xelatex(mock_tex_path, shell_escape=False, output_dir=None) + + assert result is False + + @patch('subprocess.run') + def test_run_lualatex_failure(self, mock_run, compiler, mock_tex_path): + """Test lualatex compilation failure""" + # Mock failed subprocess run + mock_run.return_value = MagicMock(returncode=1, stdout="Error!", stderr="") + + result = compiler._run_lualatex(mock_tex_path, shell_escape=False, output_dir=None) + + assert result is False + + @patch('subprocess.run') + def test_run_xelatex_timeout(self, mock_run, compiler, mock_tex_path): + """Test xelatex compilation timeout""" + mock_run.side_effect = subprocess.TimeoutExpired(cmd="xelatex", timeout=120) + + result = compiler._run_xelatex(mock_tex_path, shell_escape=False, output_dir=None) + + assert result is False + + @patch('subprocess.run') + def test_run_lualatex_timeout(self, mock_run, compiler, mock_tex_path): + """Test lualatex compilation timeout""" + mock_run.side_effect = subprocess.TimeoutExpired(cmd="lualatex", timeout=120) + + result = compiler._run_lualatex(mock_tex_path, shell_escape=False, output_dir=None) + + assert result is False