Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions src/article_cli/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
4 changes: 3 additions & 1 deletion src/article_cli/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
191 changes: 187 additions & 4 deletions src/article_cli/latex_compiler.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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.")
Expand Down Expand Up @@ -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")
"""
Comment on lines +242 to +250
Copy link

Copilot AI Dec 8, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The docstring mentions LaTeX Workshop configuration, but the function now has additional functionality (pdf_mode parameter) that isn't specifically related to LaTeX Workshop. Consider updating the main description to be more generic or explain that the pdf_mode extension allows for different LaTeX engines.

Copilot uses AI. Check for mistakes.
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",
]
Expand Down Expand Up @@ -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
Comment on lines +307 to +360
Copy link

Copilot AI Dec 8, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The _run_xelatex method is almost identical to _run_pdflatex (lines 179-232) with only the engine name and command builder method differing. Consider extracting the common logic into a shared helper method like _run_engine_multi_pass(engine_name, build_command_func, tex_path, shell_escape, output_dir) to reduce code duplication and improve maintainability.

Copilot uses AI. Check for mistakes.

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
Comment on lines +362 to +384
Copy link

Copilot AI Dec 8, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The _build_xelatex_command method is almost identical to _build_pdflatex_command with only the command name differing. Consider extracting the common logic into a shared helper method like _build_engine_command(engine_name, tex_path, shell_escape, output_dir) to reduce code duplication.

Copilot uses AI. Check for mistakes.

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
Comment on lines +386 to +439
Copy link

Copilot AI Dec 8, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The _run_lualatex method is almost identical to _run_xelatex and _run_pdflatex, with only the engine name and command builder method differing. This is a third instance of duplicated logic. Consider extracting the common logic into a shared helper method to reduce code duplication and improve maintainability.

Copilot uses AI. Check for mistakes.

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
Comment on lines +441 to +463
Copy link

Copilot AI Dec 8, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The _build_lualatex_command method is almost identical to _build_xelatex_command and _build_pdflatex_command with only the command name differing. This is a third instance of the same duplicated logic. Consider extracting the common logic into a shared helper method to reduce code duplication.

Copilot uses AI. Check for mistakes.

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
Expand Down Expand Up @@ -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"),
}

Expand Down
Loading