From 0582ab3416e65cc55e2698181d563ab08f57b56c Mon Sep 17 00:00:00 2001 From: Christophe Prud'homme Date: Sun, 18 Jan 2026 10:47:04 +0100 Subject: [PATCH 1/3] feat: add full Typst document compilation support and enhance theme installation Add full Typst document compilation support Fixes #14 --- CHANGELOG.md | 26 +++ README.md | 78 ++++++- pyproject.toml | 6 +- src/article_cli/__init__.py | 11 +- src/article_cli/cli.py | 149 ++++++++---- src/article_cli/config.py | 19 ++ src/article_cli/repository_setup.py | 345 ++++++++++++++++++++++++---- src/article_cli/themes.py | 19 +- tests/test_themes.py | 64 ++++++ 9 files changed, 609 insertions(+), 108 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 69d94e0..4301b4b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,31 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [1.4.0] - 2026-01-18 + +### Added +- **Typst Support**: Full support for Typst document compilation + - New `TypstCompiler` class for Typst document compilation + - `article-cli compile presentation.typ` - Compile Typst documents + - `article-cli compile --engine typst --watch` - Watch mode for Typst + - `article-cli compile --font-path fonts/` - Custom font paths for Typst + - Auto-detection of `.typ` files and automatic engine selection + - Font path configuration via `[typst]` section in config +- **Typst Project Types**: Initialize Typst projects with templates + - `article-cli init --type typst-presentation` - Create Typst presentation + - `article-cli init --type typst-poster` - Create Typst poster + - Theme support for Typst presentations (e.g., numpex theme) +- **Enhanced Theme Installation**: Themes now include Typst files + - `numpex.typ` included alongside LaTeX `.sty` files + - Usage instructions shown for both LaTeX and Typst +- New `get_typst_config()` method in Config class +- 23 new tests for Typst functionality + +### Changed +- Theme sources now include `typst_files` list alongside `files` +- Updated CLI help with Typst examples +- Description updated to mention Typst support + ## [1.3.2] - 2025-12-08 ### Fixed @@ -195,6 +220,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - `article-cli config show` - Show current configuration - `article-cli config create` - Create sample configuration +[1.4.0]: https://github.com/feelpp/article.cli/releases/tag/v1.4.0 [1.3.2]: https://github.com/feelpp/article.cli/releases/tag/v1.3.2 [1.3.1]: https://github.com/feelpp/article.cli/releases/tag/v1.3.1 [1.3.0]: https://github.com/feelpp/article.cli/releases/tag/v1.3.0 diff --git a/README.md b/README.md index b3fdb4e..06ef1a3 100644 --- a/README.md +++ b/README.md @@ -4,13 +4,14 @@ [![PyPI version](https://badge.fury.io/py/article-cli.svg)](https://badge.fury.io/py/article-cli) [![Python Support](https://img.shields.io/pypi/pyversions/article-cli.svg)](https://pypi.org/project/article-cli/) -A command-line tool for managing LaTeX articles and presentations with git integration and Zotero bibliography synchronization. +A command-line tool for managing LaTeX and Typst documents with git integration and Zotero bibliography synchronization. ## Features -- **Repository Initialization**: Complete setup for LaTeX article or presentation projects with one command -- **Project Types**: Support for articles, Beamer presentations, and posters +- **Repository Initialization**: Complete setup for LaTeX or Typst projects with one command +- **Project Types**: Support for articles, Beamer presentations, posters, and Typst documents - **LaTeX Compilation**: Compile documents with latexmk/pdflatex/xelatex/lualatex, watch mode, shell escape support +- **Typst Compilation**: Full support for Typst documents with watch mode and custom font paths - **Font Installation**: Download and install fonts for XeLaTeX projects (Marianne, Roboto Mono, etc.) - **GitHub Actions Workflows**: Automated PDF compilation with XeLaTeX support, artifact upload, and GitHub releases - **Git Release Management**: Create, list, and delete releases with gitinfo2 support @@ -131,8 +132,13 @@ directory = "." # url = "https://example.com/theme.zip" # description = "My custom theme" # files = ["beamerthememytheme.sty"] +# typst_files = ["mytheme.typ"] # requires_fonts = false # engine = "pdflatex" + +[typst] +font_paths = ["fonts/", "~/.fonts/"] +build_dir = "build" ``` ## Usage @@ -149,6 +155,12 @@ article-cli init --title "My Presentation" --authors "Author" --type presentatio # Initialize with numpex theme (requires theme files from presentation.template.d) article-cli init --title "NumPEx Talk" --authors "Author" --type presentation --theme numpex +# Initialize a Typst presentation project +article-cli init --title "My Typst Talk" --authors "Author" --type typst-presentation + +# Initialize a Typst poster project +article-cli init --title "My Typst Poster" --authors "Author" --type typst-poster + # Specify custom Zotero group ID article-cli init --title "My Article" --authors "Author" --group-id 1234567 @@ -224,6 +236,32 @@ article-cli compile --clean-first --clean-after article-cli compile --output-dir build/ ``` +### Typst Compilation + +```bash +# Compile a Typst document (auto-detects .typ files) +article-cli compile presentation.typ + +# Compile with explicit Typst engine +article-cli compile --engine typst document.typ + +# Watch for changes and auto-recompile +article-cli compile presentation.typ --watch + +# Specify custom font paths +article-cli compile presentation.typ --font-path fonts/ + +# Multiple font paths +article-cli compile presentation.typ --font-path fonts/ --font-path ~/.fonts/ + +# Specify output directory +article-cli compile presentation.typ --output-dir build/ +``` + +**Note:** The engine is automatically detected from the file extension: +- `.tex` files use LaTeX engines (latexmk by default) +- `.typ` files use the Typst engine + ### Font Installation Install fonts for XeLaTeX projects (useful for custom Beamer themes): @@ -268,9 +306,11 @@ article-cli install-theme my-theme --url https://example.com/theme.zip ``` **Available themes:** -- **numpex**: NumPEx Beamer theme following French government visual identity (requires XeLaTeX and custom fonts) +- **numpex**: NumPEx theme following French government visual identity + - LaTeX: Beamer theme files (requires XeLaTeX and custom fonts) + - Typst: `numpex.typ` theme file for Typst presentations -**Complete presentation setup:** +**Complete LaTeX presentation setup:** ```bash # 1. Install the theme article-cli install-theme numpex @@ -282,6 +322,15 @@ article-cli install-fonts article-cli compile presentation.tex --engine xelatex ``` +**Complete Typst presentation setup:** +```bash +# 1. Install the theme (includes numpex.typ) +article-cli install-theme numpex + +# 2. Compile with Typst +article-cli compile presentation.typ --font-path fonts/ +``` + ### Project Setup ```bash @@ -313,6 +362,7 @@ Release versions must follow the semantic versioning format: - Python 3.8+ - Git repository with gitinfo2 package (for LaTeX integration) - Zotero account with API access (for bibliography features) +- Typst CLI (for Typst compilation) - install from https://typst.app/ ## License @@ -328,12 +378,24 @@ MIT License - see LICENSE file for details. ## Changelog -### v1.2.0 -- Add font installation command (`install-fonts`) for XeLaTeX projects -- Support Marianne and Roboto Mono fonts by default +### v1.4.0 +- Add full Typst document compilation support +- New `TypstCompiler` class for Typst documents +- Auto-detection of `.typ` files with automatic engine selection +- Watch mode for Typst with live recompilation +- Custom font path support (`--font-path` option) +- New project types: `typst-presentation` and `typst-poster` +- Theme installation now includes Typst files (e.g., `numpex.typ`) +- Typst configuration section in config files + +### v1.3.0 - Add theme installation command (`install-theme`) for Beamer presentations - Built-in support for numpex theme with automatic download - Extended GitHub Actions workflow with XeLaTeX and multi-document support + +### v1.2.0 +- Add font installation command (`install-fonts`) for XeLaTeX projects +- Support Marianne and Roboto Mono fonts by default - Add presentation project type with Beamer template support - Add `--engine` option for xelatex and lualatex compilation - Improved CI/CD with font installation steps diff --git a/pyproject.toml b/pyproject.toml index a578e15..8bd85ac 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,11 +4,11 @@ build-backend = "hatchling.build" [project] name = "article-cli" -version = "1.3.2" +version = "1.4.0" authors = [ {name = "Christophe Prud'homme", email = "prudhomm@cemosis.fr"}, ] -description = "CLI tool for managing LaTeX articles with git integration and Zotero bibliography" +description = "CLI tool for managing LaTeX and Typst documents with git integration and Zotero bibliography" readme = "README.md" license = {file = "LICENSE"} requires-python = ">=3.8" @@ -26,7 +26,7 @@ classifiers = [ "Topic :: Scientific/Engineering", "Topic :: Text Processing :: Markup :: LaTeX", ] -keywords = ["latex", "git", "zotero", "bibliography", "academic", "research"] +keywords = ["latex", "typst", "git", "zotero", "bibliography", "academic", "research", "presentations"] dependencies = [ "requests>=2.28.0", ] diff --git a/src/article_cli/__init__.py b/src/article_cli/__init__.py index 5140407..443dd42 100644 --- a/src/article_cli/__init__.py +++ b/src/article_cli/__init__.py @@ -1,14 +1,15 @@ """ -Article CLI - A command-line tool for managing LaTeX articles +Article CLI - A command-line tool for managing LaTeX and Typst documents This package provides tools for: - Git release management with gitinfo2 support - Zotero bibliography synchronization -- LaTeX build file cleanup - - Git hooks setup +- LaTeX and Typst compilation +- Theme installation for presentations +- Git hooks setup """ -__version__ = "1.3.2" +__version__ = "1.4.0" __author__ = "Christophe Prud'homme" __email__ = "prudhomm@cemosis.fr" @@ -18,6 +19,7 @@ from .git_manager import GitManager from .repository_setup import RepositorySetup from .latex_compiler import LaTeXCompiler +from .typst_compiler import TypstCompiler __all__ = [ "main", @@ -26,4 +28,5 @@ "GitManager", "RepositorySetup", "LaTeXCompiler", + "TypstCompiler", ] diff --git a/src/article_cli/cli.py b/src/article_cli/cli.py index 11cabf6..22a7230 100644 --- a/src/article_cli/cli.py +++ b/src/article_cli/cli.py @@ -31,6 +31,9 @@ def create_parser() -> argparse.ArgumentParser: %(prog)s compile --engine pdflatex # Compile with pdflatex %(prog)s compile --shell-escape # Enable shell escape %(prog)s compile --watch # Watch and auto-recompile + %(prog)s compile presentation.typ # Compile Typst document + %(prog)s compile --engine typst --watch # Watch Typst file + %(prog)s compile --font-path fonts/ # Typst with custom fonts %(prog)s create v1.0.0 # Create release v1.0.0 %(prog)s list --count 10 # List 10 recent releases %(prog)s delete v1.0.0 # Delete release @@ -38,7 +41,7 @@ def create_parser() -> argparse.ArgumentParser: %(prog)s config create # Create sample config file %(prog)s install-fonts # Install fonts for XeLaTeX %(prog)s install-fonts --list # List installed fonts - %(prog)s install-theme numpex # Install numpex Beamer theme + %(prog)s install-theme numpex # Install numpex Beamer/Typst theme %(prog)s install-theme --list # List available themes Environment variables: @@ -82,14 +85,14 @@ def create_parser() -> argparse.ArgumentParser: ) init_parser.add_argument( "--type", - choices=["article", "presentation", "poster"], + choices=["article", "presentation", "poster", "typst-presentation", "typst-poster"], default="article", - help="Project type (default: article). Use 'presentation' for Beamer slides.", + help="Project type (default: article). Use 'presentation' for Beamer, 'typst-presentation' for Typst slides.", ) init_parser.add_argument( "--theme", default="", - help="Beamer theme for presentations (e.g., 'numpex', 'metropolis').", + help="Theme for presentations (e.g., 'numpex'). Works with both Beamer and Typst.", ) init_parser.add_argument( "--aspect-ratio", @@ -111,13 +114,19 @@ def create_parser() -> argparse.ArgumentParser: compile_parser.add_argument( "tex_file", nargs="?", - help="LaTeX file to compile (auto-detected if not specified)", + help="Document file to compile (.tex or .typ, auto-detected if not specified)", ) compile_parser.add_argument( "--engine", - choices=["latexmk", "pdflatex", "xelatex", "lualatex"], + choices=["latexmk", "pdflatex", "xelatex", "lualatex", "typst"], default="latexmk", - help="LaTeX engine to use (default: latexmk). Use xelatex/lualatex for custom fonts.", + help="Compilation engine (default: latexmk). Use typst for .typ files, xelatex/lualatex for custom fonts.", + ) + compile_parser.add_argument( + "--font-path", + action="append", + dest="font_paths", + help="Additional font path for Typst (can be specified multiple times)", ) compile_parser.add_argument( "--shell-escape", @@ -294,48 +303,82 @@ def handle_clean_command(config: Config) -> int: def handle_compile_command(args: argparse.Namespace, config: Config) -> int: """Handle the compile command""" try: - from .latex_compiler import LaTeXCompiler + # Determine engine from args or auto-detect from file extension + engine = args.engine + doc_file = args.tex_file + + # Auto-detect file if not provided + if not doc_file: + # If engine is typst, look for .typ files first + if engine == "typst": + doc_file = _auto_detect_typ_file() + if not doc_file: + doc_file = _auto_detect_tex_file() + else: + doc_file = _auto_detect_tex_file() + if not doc_file: + doc_file = _auto_detect_typ_file() - # Auto-detect tex file if not provided - tex_file = args.tex_file - if not tex_file: - tex_file = _auto_detect_tex_file() - if not tex_file: + if not doc_file: print_error( - "No .tex file specified and none found in current directory" + "No .tex or .typ file specified and none found in current directory" ) return 1 - # Validate tex file exists - tex_path = Path(tex_file) - if not tex_path.exists(): - print_error(f"LaTeX file not found: {tex_file}") + # Validate file exists + doc_path = Path(doc_file) + if not doc_path.exists(): + print_error(f"Document file not found: {doc_file}") return 1 - compiler = LaTeXCompiler(config) + # Auto-detect engine from file extension if not explicitly set to typst + if doc_path.suffix == ".typ" and engine != "typst": + print_info(f"Detected Typst file, switching engine to typst") + engine = "typst" + elif doc_path.suffix == ".tex" and engine == "typst": + print_error(f"Cannot use Typst engine with .tex file: {doc_file}") + return 1 - # Clean before compilation if requested - if args.clean_first: - print_info("Cleaning build files before compilation...") - git_manager = GitManager() - latex_config = config.get_latex_config() - git_manager.clean_latex_files(latex_config["clean_extensions"]) - - # Compile the document - success = compiler.compile( - tex_file=tex_file, - engine=args.engine, - shell_escape=args.shell_escape, - output_dir=args.output_dir, - watch=args.watch, - ) + # Use appropriate compiler + if engine == "typst": + from .typst_compiler import TypstCompiler - # Clean after compilation if requested - if args.clean_after and success: - print_info("Cleaning build files after compilation...") - git_manager = GitManager() - latex_config = config.get_latex_config() - git_manager.clean_latex_files(latex_config["clean_extensions"]) + compiler = TypstCompiler(config) + + # Compile the document + success = compiler.compile( + typ_file=doc_file, + output_dir=args.output_dir, + font_paths=args.font_paths, + watch=args.watch, + ) + else: + from .latex_compiler import LaTeXCompiler + + compiler = LaTeXCompiler(config) + + # Clean before compilation if requested + if args.clean_first: + print_info("Cleaning build files before compilation...") + git_manager = GitManager() + latex_config = config.get_latex_config() + git_manager.clean_latex_files(latex_config["clean_extensions"]) + + # Compile the document + success = compiler.compile( + tex_file=doc_file, + engine=engine, + shell_escape=args.shell_escape, + output_dir=args.output_dir, + watch=args.watch, + ) + + # Clean after compilation if requested + if args.clean_after and success: + print_info("Cleaning build files after compilation...") + git_manager = GitManager() + latex_config = config.get_latex_config() + git_manager.clean_latex_files(latex_config["clean_extensions"]) return 0 if success else 1 @@ -365,6 +408,32 @@ def _auto_detect_tex_file() -> Optional[str]: return tex_files[0].name +def _auto_detect_typ_file() -> Optional[str]: + """Auto-detect main .typ file in current directory""" + current_dir = Path.cwd() + typ_files = list(current_dir.glob("*.typ")) + + if not typ_files: + return None + + if len(typ_files) == 1: + return typ_files[0].name + + # Multiple .typ files - prefer common patterns + for pattern in [ + "main.typ", + "presentation.typ", + "presentation.template.typ", + f"{current_dir.name}.typ", + ]: + if (current_dir / pattern).exists(): + return pattern + + # Return first .typ file found + print_info(f"Multiple .typ files found, using: {typ_files[0].name}") + return typ_files[0].name + + def handle_create_command(args: argparse.Namespace, config: Config) -> int: """Handle the create command""" try: diff --git a/src/article_cli/config.py b/src/article_cli/config.py index 71c3f8c..118d37a 100644 --- a/src/article_cli/config.py +++ b/src/article_cli/config.py @@ -234,6 +234,9 @@ def get_themes_config(self) -> Dict[str, Any]: "beamercolorthemenumpex.sty", "beamerfontthemenumpex.sty", ], + "typst_files": [ + "numpex.typ", + ], "directories": ["images"], "requires_fonts": True, "engine": "xelatex", @@ -245,6 +248,13 @@ def get_themes_config(self) -> Dict[str, Any]: "sources": self.get("themes", "sources", default_sources), } + def get_typst_config(self) -> Dict[str, Any]: + """Get Typst-specific configuration""" + return { + "font_paths": self.get("typst", "font_paths", []), + "build_dir": self.get("typst", "build_dir", ""), + } + def validate_zotero_config( self, args: argparse.Namespace ) -> Dict[str, Optional[str]]: @@ -422,9 +432,18 @@ def create_sample_config(self, path: Optional[Path] = None) -> Path: # url = "https://github.com/numpex/presentation.template.d/archive/refs/heads/main.zip" # description = "NumPEx Beamer theme" # files = ["beamerthemenumpex.sty", "beamercolorthemenumpex.sty", "beamerfontthemenumpex.sty"] +# typst_files = ["numpex.typ"] # directories = ["images"] # requires_fonts = true # engine = "xelatex" + +# Typst compilation settings +[typst] +# Font paths for Typst compiler (relative to project root) +# font_paths = ["fonts/Marianne/desktop", "fonts/Roboto/static"] + +# Output directory for Typst builds +# build_dir = "build/typst" """ try: diff --git a/src/article_cli/repository_setup.py b/src/article_cli/repository_setup.py index 4adee6d..f7d9975 100644 --- a/src/article_cli/repository_setup.py +++ b/src/article_cli/repository_setup.py @@ -172,23 +172,27 @@ def _detect_or_create_tex_file( aspect_ratio: str = "169", ) -> Optional[str]: """ - Detect main .tex file in repository or create one if missing + Detect main .tex/.typ file in repository or create one if missing Args: specified: User-specified filename (takes priority) - title: Document title (for creating new .tex file) - authors: List of author names (for creating new .tex file) + title: Document title (for creating new file) + authors: List of author names (for creating new file) force: Overwrite existing file if True - project_type: Type of project ("article", "presentation", "poster") - theme: Beamer theme for presentations + project_type: Type of project ("article", "presentation", "poster", "typst-presentation") + theme: Beamer/Typst theme for presentations aspect_ratio: Aspect ratio for presentations Returns: - Main .tex filename or None on failure + Main document filename or None on failure """ + # Handle Typst project types + is_typst = project_type.startswith("typst-") + file_ext = ".typ" if is_typst else ".tex" + if specified: - tex_path = self.repo_path / specified - if tex_path.exists(): + doc_path = self.repo_path / specified + if doc_path.exists(): return specified # Specified file doesn't exist - create it if self._create_tex_file( @@ -197,44 +201,45 @@ def _detect_or_create_tex_file( return specified return None - # Auto-detect .tex files - tex_files = list(self.repo_path.glob("*.tex")) + # Auto-detect files + doc_files = list(self.repo_path.glob(f"*{file_ext}")) - if not tex_files: - # No .tex files found - create default based on project type - if project_type == "presentation": - default_name = "presentation.tex" - elif project_type == "poster": - default_name = "poster.tex" + if not doc_files: + # No files found - create default based on project type + if project_type in ("presentation", "typst-presentation"): + default_name = f"presentation{file_ext}" + elif project_type in ("poster", "typst-poster"): + default_name = f"poster{file_ext}" else: - default_name = "main.tex" - print_info(f"No .tex file found, creating {default_name}") + default_name = f"main{file_ext}" + print_info(f"No {file_ext} file found, creating {default_name}") if self._create_tex_file( default_name, title, authors, force, project_type, theme, aspect_ratio ): return default_name return None - if len(tex_files) == 1: - return tex_files[0].name - - # Multiple .tex files - prefer common patterns - for pattern in [ - "main.tex", - "article.tex", - "presentation.tex", - "poster.tex", - f"{self.repo_path.name}.tex", - ]: + if len(doc_files) == 1: + return doc_files[0].name + + # Multiple files - prefer common patterns + patterns = [ + f"main{file_ext}", + f"article{file_ext}", + f"presentation{file_ext}", + f"poster{file_ext}", + f"{self.repo_path.name}{file_ext}", + ] + for pattern in patterns: if (self.repo_path / pattern).exists(): return pattern - # Return first .tex file found + # Return first file found print_info( - f"Multiple .tex files found, using: {tex_files[0].name} " + f"Multiple {file_ext} files found, using: {doc_files[0].name} " "(use --tex-file to specify different file)" ) - return tex_files[0].name + return doc_files[0].name def _create_tex_file( self, @@ -247,40 +252,58 @@ def _create_tex_file( aspect_ratio: str = "169", ) -> bool: """ - Create a LaTeX file based on project type + Create a LaTeX or Typst file based on project type Args: - filename: Name of the .tex file to create + filename: Name of the file to create (.tex or .typ) title: Document title authors: List of author names force: Overwrite if exists - project_type: Type of project ("article", "presentation", "poster") - theme: Beamer theme for presentations + project_type: Type of project ("article", "presentation", "poster", "typst-presentation") + theme: Beamer/Typst theme for presentations aspect_ratio: Aspect ratio for presentations Returns: True if successful """ - tex_path = self.repo_path / filename + doc_path = self.repo_path / filename - if tex_path.exists() and not force: + if doc_path.exists() and not force: print_info(f"{filename} already exists (use --force to overwrite)") return True - # Format authors for LaTeX - authors_latex = " \\\\and ".join(authors) + # Determine if this is a Typst project + is_typst = project_type.startswith("typst-") or filename.endswith(".typ") - if project_type == "presentation": - tex_content = self._get_presentation_template( - title, authors_latex, theme, aspect_ratio - ) - elif project_type == "poster": - tex_content = self._get_poster_template(title, authors_latex) + if is_typst: + # Format authors for Typst + authors_typst = ", ".join([f'"{author}"' for author in authors]) + + if project_type in ("typst-presentation", "presentation") and filename.endswith(".typ"): + doc_content = self._get_typst_presentation_template( + title, authors_typst, theme + ) + elif project_type in ("typst-poster", "poster") and filename.endswith(".typ"): + doc_content = self._get_typst_poster_template(title, authors_typst) + else: + doc_content = self._get_typst_presentation_template( + title, authors_typst, theme + ) else: - tex_content = self._get_article_template(title, authors_latex) + # Format authors for LaTeX + authors_latex = " \\\\and ".join(authors) + + if project_type == "presentation": + doc_content = self._get_presentation_template( + title, authors_latex, theme, aspect_ratio + ) + elif project_type == "poster": + doc_content = self._get_poster_template(title, authors_latex) + else: + doc_content = self._get_article_template(title, authors_latex) - tex_path.write_text(tex_content) - print_success(f"Created: {tex_path.relative_to(self.repo_path)}") + doc_path.write_text(doc_content) + print_success(f"Created: {doc_path.relative_to(self.repo_path)}") return True def _get_article_template(self, title: str, authors_latex: str) -> str: @@ -478,6 +501,230 @@ def _get_poster_template(self, title: str, authors_latex: str) -> str: }} \\end{{document}} +""" + + def _get_typst_presentation_template( + self, title: str, authors_typst: str, theme: str + ) -> str: + """Get Typst presentation template content""" + if theme: + theme_import = f'#import "{theme}.typ": *' + theme_show = f"#show: {theme}-theme.with(" + else: + theme_import = "// No theme specified - using basic Typst presentation" + theme_show = "#set page(paper: \"presentation-16-9\")\n#set text(size: 24pt)\n\n// Document metadata\n#let title = " + + if theme: + return f"""{theme_import} + +{theme_show} + title: "{title}", + author: [{authors_typst}], + date: datetime.today().display("[month repr:long] [day], [year]"), + institution: "Your Institution", +) + +// Title slide is automatically generated by the theme + +#slide(title: "Outline")[ + #outline() +] + += Introduction + +#slide(title: "Introduction")[ + - First point + - Second point + - Third point +] + += Main Content + +#slide(title: "Main Content")[ + Your main content goes here. +] + += Conclusion + +#slide(title: "Conclusion")[ + - Summary point 1 + - Summary point 2 +] + +#slide(title: "Questions?")[ + #align(center)[ + #text(size: 36pt)[Thank you for your attention!] + ] +] +""" + else: + return f"""// Typst Presentation +// Title: {title} +// Authors: {authors_typst} + +#set page(paper: "presentation-16-9", margin: 2cm) +#set text(font: "Helvetica Neue", size: 24pt) + +// Title slide +#page[ + #align(center + horizon)[ + #text(size: 48pt, weight: "bold")[{title}] + + #v(1cm) + + #text(size: 28pt)[{authors_typst.replace('"', '')}] + + #v(0.5cm) + + Your Institution + + #v(0.5cm) + + #datetime.today().display("[month repr:long] [day], [year]") + ] +] + +// Introduction +#page[ + #text(size: 36pt, weight: "bold")[Introduction] + + #v(1cm) + + - First point + - Second point + - Third point +] + +// Main Content +#page[ + #text(size: 36pt, weight: "bold")[Main Content] + + #v(1cm) + + Your main content goes here. +] + +// Conclusion +#page[ + #text(size: 36pt, weight: "bold")[Conclusion] + + #v(1cm) + + - Summary point 1 + - Summary point 2 +] + +// Questions +#page[ + #align(center + horizon)[ + #text(size: 48pt)[Questions?] + + #v(1cm) + + Thank you for your attention! + ] +] +""" + + def _get_typst_poster_template(self, title: str, authors_typst: str) -> str: + """Get Typst poster template content""" + return f"""// Typst Poster +// Title: {title} +// Authors: {authors_typst} + +#set page(paper: "a0", margin: 2cm) +#set text(font: "Helvetica Neue", size: 24pt) + +// Header +#align(center)[ + #text(size: 72pt, weight: "bold")[{title}] + + #v(1cm) + + #text(size: 36pt)[{authors_typst.replace('"', '')}] + + #text(size: 28pt)[Your Institution] +] + +#v(2cm) + +#columns(3, gutter: 2cm)[ + // Column 1 + #block( + fill: rgb("#f0f0f0"), + inset: 1cm, + radius: 10pt, + width: 100%, + )[ + #text(size: 36pt, weight: "bold")[Introduction] + + #v(0.5cm) + + Your introduction goes here. + ] + + #v(1cm) + + #block( + fill: rgb("#f0f0f0"), + inset: 1cm, + radius: 10pt, + width: 100%, + )[ + #text(size: 36pt, weight: "bold")[Methods] + + #v(0.5cm) + + Your methods description goes here. + ] + + #colbreak() + + // Column 2 + #block( + fill: rgb("#f0f0f0"), + inset: 1cm, + radius: 10pt, + width: 100%, + )[ + #text(size: 36pt, weight: "bold")[Results] + + #v(0.5cm) + + Your results go here. + ] + + #colbreak() + + // Column 3 + #block( + fill: rgb("#f0f0f0"), + inset: 1cm, + radius: 10pt, + width: 100%, + )[ + #text(size: 36pt, weight: "bold")[Conclusions] + + #v(0.5cm) + + Your conclusions go here. + ] + + #v(1cm) + + #block( + fill: rgb("#e8f4ea"), + inset: 1cm, + radius: 10pt, + width: 100%, + )[ + #text(size: 36pt, weight: "bold")[References] + + #v(0.5cm) + + Your references go here. + ] +] """ def _create_directories(self) -> None: diff --git a/src/article_cli/themes.py b/src/article_cli/themes.py index 179bc2a..1e0b29c 100644 --- a/src/article_cli/themes.py +++ b/src/article_cli/themes.py @@ -20,12 +20,15 @@ DEFAULT_THEME_SOURCES: Dict[str, Dict[str, Any]] = { "numpex": { "url": "https://github.com/numpex/presentation.template.d/archive/refs/heads/main.zip", - "description": "NumPEx Beamer theme following French government visual identity", + "description": "NumPEx Beamer/Typst theme following French government visual identity", "files": [ "beamerthemenumpex.sty", "beamercolorthemenumpex.sty", "beamerfontthemenumpex.sty", ], + "typst_files": [ + "numpex.typ", + ], "directories": ["images"], "requires_fonts": True, "engine": "xelatex", @@ -121,8 +124,12 @@ def install_theme(self, name: str, force: bool = False) -> bool: theme_info = self.sources[name] theme_files = theme_info.get("files", []) + typst_files = theme_info.get("typst_files", []) theme_dirs = theme_info.get("directories", []) + # Combine LaTeX and Typst files for extraction + all_files = theme_files + typst_files + # Check if already installed if theme_files and not force: main_file = self.themes_dir / theme_files[0] @@ -141,7 +148,7 @@ def install_theme(self, name: str, force: bool = False) -> bool: print_info(f" {description}") try: - self._download_and_extract_theme(name, url, theme_files, theme_dirs) + self._download_and_extract_theme(name, url, all_files, theme_dirs) print_success(f"Theme '{name}' installed successfully") # Show additional info @@ -360,10 +367,10 @@ def _extract_theme_files( def _print_usage_instructions(self, name: str) -> None: """Print instructions for using installed theme""" print_info("") - print_info("To use this theme in your presentation:") + print_info("To use this theme in your LaTeX presentation:") print_info(f" \\usetheme{{{name}}}") print_info("") - print_info("Example document:") + print_info("Example LaTeX document:") print_info(" \\documentclass[aspectratio=169]{beamer}") print_info(f" \\usetheme{{{name}}}") print_info(" \\title{Your Title}") @@ -371,6 +378,10 @@ def _print_usage_instructions(self, name: str) -> None: print_info(" \\begin{document}") print_info(" \\maketitle") print_info(" \\end{document}") + print_info("") + print_info("To use this theme in Typst:") + print_info(f' #import "{name}.typ": *') + print_info(f" #show: {name}-theme.with(title: \"Your Title\", author: \"Your Name\")") def get_available_themes() -> Dict[str, Dict[str, Any]]: diff --git a/tests/test_themes.py b/tests/test_themes.py index 2258328..57cf06c 100644 --- a/tests/test_themes.py +++ b/tests/test_themes.py @@ -287,6 +287,70 @@ def test_numpex_theme_engine(self): numpex = DEFAULT_THEME_SOURCES["numpex"] assert numpex.get("engine") == "xelatex" + def test_numpex_theme_has_typst_files(self): + """Test numpex theme specifies Typst files""" + numpex = DEFAULT_THEME_SOURCES["numpex"] + assert "typst_files" in numpex + typst_files = numpex["typst_files"] + assert "numpex.typ" in typst_files + + +class TestTypstThemeSupport: + """Tests for Typst theme support""" + + def test_extract_typst_files(self): + """Test extracting Typst files from archive""" + with tempfile.TemporaryDirectory() as tmp_dir: + tmp_path = Path(tmp_dir) + + # Create test zip with Typst and LaTeX files + zip_path = tmp_path / "test.zip" + with zipfile.ZipFile(zip_path, "w") as zf: + zf.writestr("repo-main/beamerthemenumpex.sty", "% LaTeX style") + zf.writestr("repo-main/numpex.typ", "// Typst theme") + zf.writestr("repo-main/other.txt", "other") + + output_dir = tmp_path / "output" + output_dir.mkdir() + + installer = ThemeInstaller(themes_dir=output_dir) + installer._extract_theme_files( + zip_path, ["beamerthemenumpex.sty", "numpex.typ"], [] + ) + + # Both files should be extracted + assert (output_dir / "beamerthemenumpex.sty").exists() + assert (output_dir / "numpex.typ").exists() + assert not (output_dir / "other.txt").exists() + + def test_install_theme_includes_typst_files(self): + """Test that install_theme extracts both LaTeX and Typst files""" + with tempfile.TemporaryDirectory() as tmp_dir: + tmp_path = Path(tmp_dir) + + # Create theme with Typst files + custom_sources = { + "test-theme": { + "url": "https://example.com/theme.zip", + "description": "Test theme", + "files": ["theme.sty"], + "typst_files": ["theme.typ"], + "directories": [], + } + } + + installer = ThemeInstaller(themes_dir=tmp_path, sources=custom_sources) + + # Mock the download + with patch.object(installer, "_download_and_extract_theme") as mock_download: + installer.install_theme("test-theme", force=True) + + # Check that all_files includes both LaTeX and Typst files + call_args = mock_download.call_args + all_files = call_args[0][2] # Third positional argument + assert "theme.sty" in all_files + assert "theme.typ" in all_files + class TestHelperFunctions: """Tests for module-level helper functions""" From 14b515e34b57ca7637e1535b51cbd23b18e09b22 Mon Sep 17 00:00:00 2001 From: Christophe Prud'homme Date: Sun, 18 Jan 2026 11:04:19 +0100 Subject: [PATCH 2/3] clean up code formatting and improve variable naming for Typst support --- src/article_cli/cli.py | 18 ++++++++++++------ src/article_cli/fonts.py | 2 -- src/article_cli/repository_setup.py | 12 ++++++++---- src/article_cli/themes.py | 7 +++---- tests/test_fonts.py | 6 +----- tests/test_latex_compiler.py | 1 - tests/test_repository_setup.py | 3 --- tests/test_themes.py | 5 +++-- 8 files changed, 27 insertions(+), 27 deletions(-) diff --git a/src/article_cli/cli.py b/src/article_cli/cli.py index 22a7230..3d40f58 100644 --- a/src/article_cli/cli.py +++ b/src/article_cli/cli.py @@ -85,7 +85,13 @@ def create_parser() -> argparse.ArgumentParser: ) init_parser.add_argument( "--type", - choices=["article", "presentation", "poster", "typst-presentation", "typst-poster"], + choices=[ + "article", + "presentation", + "poster", + "typst-presentation", + "typst-poster", + ], default="article", help="Project type (default: article). Use 'presentation' for Beamer, 'typst-presentation' for Typst slides.", ) @@ -343,10 +349,10 @@ def handle_compile_command(args: argparse.Namespace, config: Config) -> int: if engine == "typst": from .typst_compiler import TypstCompiler - compiler = TypstCompiler(config) + typst_compiler = TypstCompiler(config) # Compile the document - success = compiler.compile( + success = typst_compiler.compile( typ_file=doc_file, output_dir=args.output_dir, font_paths=args.font_paths, @@ -355,7 +361,7 @@ def handle_compile_command(args: argparse.Namespace, config: Config) -> int: else: from .latex_compiler import LaTeXCompiler - compiler = LaTeXCompiler(config) + latex_compiler = LaTeXCompiler(config) # Clean before compilation if requested if args.clean_first: @@ -365,7 +371,7 @@ def handle_compile_command(args: argparse.Namespace, config: Config) -> int: git_manager.clean_latex_files(latex_config["clean_extensions"]) # Compile the document - success = compiler.compile( + success = latex_compiler.compile( tex_file=doc_file, engine=engine, shell_escape=args.shell_escape, @@ -560,7 +566,7 @@ def handle_config_command(args: argparse.Namespace, config: Config) -> int: def handle_install_fonts_command(args: argparse.Namespace, config: Config) -> int: """Handle the install-fonts command""" try: - from .fonts import FontInstaller, install_fonts_from_config + from .fonts import FontInstaller fonts_config = config.get_fonts_config() diff --git a/src/article_cli/fonts.py b/src/article_cli/fonts.py index 361faf8..7604975 100644 --- a/src/article_cli/fonts.py +++ b/src/article_cli/fonts.py @@ -4,7 +4,6 @@ Provides functionality to download and install fonts for XeLaTeX projects. """ -import os import zipfile import tempfile from pathlib import Path @@ -14,7 +13,6 @@ from .zotero import print_error, print_info, print_success, print_warning - # Default font sources for common themes # Note: Marianne font from French government requires manual download due to Cloudflare protection. # Configure custom URL in pyproject.toml if you have a mirror or local copy. diff --git a/src/article_cli/repository_setup.py b/src/article_cli/repository_setup.py index f7d9975..1a5fa3c 100644 --- a/src/article_cli/repository_setup.py +++ b/src/article_cli/repository_setup.py @@ -279,11 +279,16 @@ def _create_tex_file( # Format authors for Typst authors_typst = ", ".join([f'"{author}"' for author in authors]) - if project_type in ("typst-presentation", "presentation") and filename.endswith(".typ"): + if project_type in ( + "typst-presentation", + "presentation", + ) and filename.endswith(".typ"): doc_content = self._get_typst_presentation_template( title, authors_typst, theme ) - elif project_type in ("typst-poster", "poster") and filename.endswith(".typ"): + elif project_type in ("typst-poster", "poster") and filename.endswith( + ".typ" + ): doc_content = self._get_typst_poster_template(title, authors_typst) else: doc_content = self._get_typst_presentation_template( @@ -512,7 +517,7 @@ def _get_typst_presentation_template( theme_show = f"#show: {theme}-theme.with(" else: theme_import = "// No theme specified - using basic Typst presentation" - theme_show = "#set page(paper: \"presentation-16-9\")\n#set text(size: 24pt)\n\n// Document metadata\n#let title = " + theme_show = '#set page(paper: "presentation-16-9")\n#set text(size: 24pt)\n\n// Document metadata\n#let title = ' if theme: return f"""{theme_import} @@ -874,7 +879,6 @@ def _get_workflow_template( # Build additional document compilation steps additional_compile_steps = "" - additional_rename_steps = "" additional_artifact_files = "" additional_release_files = "" diff --git a/src/article_cli/themes.py b/src/article_cli/themes.py index 1e0b29c..b0eed2c 100644 --- a/src/article_cli/themes.py +++ b/src/article_cli/themes.py @@ -4,10 +4,8 @@ Provides functionality to download and install Beamer themes for presentations. """ -import os import zipfile import tempfile -import shutil from pathlib import Path from typing import List, Dict, Optional, Any from urllib.request import urlopen, Request @@ -15,7 +13,6 @@ from .zotero import print_error, print_info, print_success, print_warning - # Default theme sources DEFAULT_THEME_SOURCES: Dict[str, Dict[str, Any]] = { "numpex": { @@ -381,7 +378,9 @@ def _print_usage_instructions(self, name: str) -> None: print_info("") print_info("To use this theme in Typst:") print_info(f' #import "{name}.typ": *') - print_info(f" #show: {name}-theme.with(title: \"Your Title\", author: \"Your Name\")") + print_info( + f' #show: {name}-theme.with(title: "Your Title", author: "Your Name")' + ) def get_available_themes() -> Dict[str, Dict[str, Any]]: diff --git a/tests/test_fonts.py b/tests/test_fonts.py index 40dbfd8..546ae0a 100644 --- a/tests/test_fonts.py +++ b/tests/test_fonts.py @@ -3,11 +3,9 @@ """ import pytest -import tempfile from pathlib import Path from unittest.mock import patch, MagicMock import zipfile -import io from article_cli.fonts import FontInstaller, DEFAULT_FONT_SOURCES @@ -180,9 +178,7 @@ def test_install_font_force(self, mock_download, tmp_path): font_dir.mkdir() installer = FontInstaller(fonts_dir=fonts_dir) - result = installer.install_font( - "TestFont", "http://example.com/test.zip", force=True - ) + installer.install_font("TestFont", "http://example.com/test.zip", force=True) assert mock_download.called diff --git a/tests/test_latex_compiler.py b/tests/test_latex_compiler.py index e6d466a..cec6539 100644 --- a/tests/test_latex_compiler.py +++ b/tests/test_latex_compiler.py @@ -5,7 +5,6 @@ """ import subprocess -from pathlib import Path from unittest.mock import patch, MagicMock import pytest diff --git a/tests/test_repository_setup.py b/tests/test_repository_setup.py index bddce17..a16db82 100644 --- a/tests/test_repository_setup.py +++ b/tests/test_repository_setup.py @@ -4,11 +4,8 @@ Tests project type support including article, presentation, and poster templates. """ -import os import tempfile from pathlib import Path -from unittest.mock import patch, MagicMock -import pytest from article_cli.repository_setup import RepositorySetup diff --git a/tests/test_themes.py b/tests/test_themes.py index 57cf06c..f044e4a 100644 --- a/tests/test_themes.py +++ b/tests/test_themes.py @@ -7,7 +7,6 @@ import zipfile from pathlib import Path from unittest.mock import patch, MagicMock -from io import BytesIO from article_cli.themes import ( ThemeInstaller, @@ -342,7 +341,9 @@ def test_install_theme_includes_typst_files(self): installer = ThemeInstaller(themes_dir=tmp_path, sources=custom_sources) # Mock the download - with patch.object(installer, "_download_and_extract_theme") as mock_download: + with patch.object( + installer, "_download_and_extract_theme" + ) as mock_download: installer.install_theme("test-theme", force=True) # Check that all_files includes both LaTeX and Typst files From 57c7d70435ebffb42c1a720c56a9c5d788d86676 Mon Sep 17 00:00:00 2001 From: Christophe Prud'homme Date: Sun, 18 Jan 2026 11:10:17 +0100 Subject: [PATCH 3/3] Add missing typst_compiler.py and tests Co-Authored-By: Claude Opus 4.5 --- src/article_cli/typst_compiler.py | 287 ++++++++++++++++++++++++++++++ tests/test_typst_compiler.py | 286 +++++++++++++++++++++++++++++ 2 files changed, 573 insertions(+) create mode 100644 src/article_cli/typst_compiler.py create mode 100644 tests/test_typst_compiler.py diff --git a/src/article_cli/typst_compiler.py b/src/article_cli/typst_compiler.py new file mode 100644 index 0000000..924c7c1 --- /dev/null +++ b/src/article_cli/typst_compiler.py @@ -0,0 +1,287 @@ +""" +Typst compilation module for article-cli + +Provides compilation functionality for Typst documents with support for +watch mode and custom font paths. +""" + +import subprocess +import time +from pathlib import Path +from typing import Dict, List, Optional + +from .config import Config +from .zotero import print_error, print_info, print_success + + +class TypstCompiler: + """Handles Typst document compilation with various options""" + + def __init__(self, config: Config): + """ + Initialize Typst compiler + + Args: + config: Configuration instance + """ + self.config = config + self.typst_config = config.get_typst_config() + + def compile( + self, + typ_file: str, + output_dir: Optional[str] = None, + font_paths: Optional[List[str]] = None, + watch: bool = False, + ) -> bool: + """ + Compile Typst document + + Args: + typ_file: Path to .typ file + output_dir: Output directory for compiled files + font_paths: List of font directories to search + watch: Watch for changes and recompile automatically + + Returns: + True if compilation successful, False otherwise + """ + typ_path = Path(typ_file) + if not typ_path.exists(): + print_error(f"Typst file not found: {typ_file}") + return False + + print_info(f"Compiling {typ_file} with Typst...") + + # Merge font paths from config and arguments + all_font_paths = list(self.typst_config.get("font_paths", [])) + if font_paths: + all_font_paths.extend(font_paths) + + if watch: + return self._compile_watch(typ_path, output_dir, all_font_paths) + else: + return self._compile_once(typ_path, output_dir, all_font_paths) + + def _compile_once( + self, + typ_path: Path, + output_dir: Optional[str], + font_paths: List[str], + ) -> bool: + """Compile document once""" + cmd = self._build_compile_command(typ_path, output_dir, font_paths) + + try: + print_info(f"Running: {' '.join(cmd)}") + result = subprocess.run( + cmd, + cwd=typ_path.parent, + capture_output=True, + text=True, + timeout=120, # 2 minute timeout + ) + + if result.returncode == 0: + pdf_name = typ_path.with_suffix(".pdf").name + if output_dir: + pdf_path = Path(output_dir) / pdf_name + else: + pdf_path = typ_path.with_suffix(".pdf") + + if pdf_path.exists(): + print_success(f"✅ Compilation successful: {pdf_path}") + self._show_pdf_info(pdf_path) + else: + print_error("Compilation reported success but PDF not found") + return False + + return True + else: + print_error("❌ Compilation failed") + if result.stdout: + print("STDOUT:") + print(result.stdout) + if result.stderr: + print("STDERR:") + print(result.stderr) + return False + + except subprocess.TimeoutExpired: + print_error("Compilation timed out after 2 minutes") + return False + except FileNotFoundError: + print_error( + "Typst not found. Install it with: brew install typst (macOS) " + "or cargo install typst-cli" + ) + return False + except Exception as e: + print_error(f"Compilation error: {e}") + return False + + def _compile_watch( + self, + typ_path: Path, + output_dir: Optional[str], + font_paths: List[str], + ) -> bool: + """Compile document and watch for changes""" + print_info("Starting watch mode. Press Ctrl+C to stop.") + print_info("Watching for changes to .typ files...") + + try: + cmd = self._build_watch_command(typ_path, output_dir, font_paths) + + process = subprocess.Popen( + cmd, + cwd=typ_path.parent, + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + universal_newlines=True, + bufsize=1, + ) + + # Stream output in real time + try: + while True: + if process.stdout is None: + break + output = process.stdout.readline() + if output == "" and process.poll() is not None: + break + if output: + print(output.strip()) + + except KeyboardInterrupt: + print_info("\nStopping watch mode...") + process.terminate() + process.wait() + return True + + return process.returncode == 0 + + except FileNotFoundError: + print_error( + "Typst not found. Install it with: brew install typst (macOS) " + "or cargo install typst-cli" + ) + return False + except Exception as e: + print_error(f"Watch mode failed: {e}") + return False + + def _build_compile_command( + self, + typ_path: Path, + output_dir: Optional[str], + font_paths: List[str], + ) -> List[str]: + """Build typst compile command + + Args: + typ_path: Path to .typ file + output_dir: Output directory + font_paths: List of font directories + """ + cmd = ["typst", "compile"] + + # Add font paths + for font_path in font_paths: + cmd.extend(["--font-path", font_path]) + + # Add input file + cmd.append(str(typ_path)) + + # Add output file if output_dir specified + if output_dir: + output_path = Path(output_dir) / typ_path.with_suffix(".pdf").name + cmd.append(str(output_path)) + + return cmd + + def _build_watch_command( + self, + typ_path: Path, + output_dir: Optional[str], + font_paths: List[str], + ) -> List[str]: + """Build typst watch command + + Args: + typ_path: Path to .typ file + output_dir: Output directory + font_paths: List of font directories + """ + cmd = ["typst", "watch"] + + # Add font paths + for font_path in font_paths: + cmd.extend(["--font-path", font_path]) + + # Add input file + cmd.append(str(typ_path)) + + # Add output file if output_dir specified + if output_dir: + output_path = Path(output_dir) / typ_path.with_suffix(".pdf").name + cmd.append(str(output_path)) + + return cmd + + def _show_pdf_info(self, pdf_path: Path) -> None: + """Show information about the generated PDF""" + try: + size = pdf_path.stat().st_size + size_mb = size / (1024 * 1024) + print_info(f"PDF size: {size_mb:.2f} MB") + + # Show modification time + mtime = pdf_path.stat().st_mtime + mtime_str = time.strftime("%Y-%m-%d %H:%M:%S", time.localtime(mtime)) + print_info(f"Generated: {mtime_str}") + + except Exception: + pass # Silently ignore errors getting file info + + def check_dependencies(self) -> Dict[str, bool]: + """ + Check if Typst is available + + Returns: + Dictionary with tool availability status + """ + tools = { + "typst": self._check_command("typst"), + } + + return tools + + def _check_command(self, command: str) -> bool: + """Check if a command is available in PATH""" + try: + result = subprocess.run( + [command, "--version"], + capture_output=True, + text=True, + timeout=10, + ) + return result.returncode == 0 + except (subprocess.TimeoutExpired, FileNotFoundError): + return False + + def print_dependency_status(self) -> None: + """Print status of Typst dependencies""" + print_info("Checking Typst dependencies...") + + deps = self.check_dependencies() + + for tool, available in deps.items(): + status = "✅ Available" if available else "❌ Not found" + print(f" {tool}: {status}") + + if not all(deps.values()): + print_info("\nTypst is not installed. Install it with:") + print_info(" macOS: brew install typst") + print_info(" Cargo: cargo install typst-cli") + print_info(" Or download from: https://github.com/typst/typst/releases") diff --git a/tests/test_typst_compiler.py b/tests/test_typst_compiler.py new file mode 100644 index 0000000..5dc742c --- /dev/null +++ b/tests/test_typst_compiler.py @@ -0,0 +1,286 @@ +""" +Tests for article-cli Typst compiler module +""" + +import subprocess +from unittest.mock import patch, MagicMock +import pytest + +from article_cli.typst_compiler import TypstCompiler +from article_cli.config import Config + + +class TestTypstCompilerInit: + """Test cases for TypstCompiler initialization""" + + def test_init_with_default_config(self): + """Test TypstCompiler initializes with default config""" + config = Config() + compiler = TypstCompiler(config) + + assert compiler.config == config + assert compiler.typst_config is not None + + def test_init_loads_typst_config(self): + """Test TypstCompiler loads Typst-specific config""" + config = Config() + compiler = TypstCompiler(config) + + # Should have font_paths and build_dir from config + assert "font_paths" in compiler.typst_config + assert "build_dir" in compiler.typst_config + + +class TestTypstCompilerCommands: + """Test cases for Typst command building""" + + @pytest.fixture + def compiler(self): + """Create a TypstCompiler instance with default config""" + config = Config() + return TypstCompiler(config) + + @pytest.fixture + def mock_typ_path(self, tmp_path): + """Create a mock .typ file""" + typ_file = tmp_path / "test.typ" + typ_file.write_text('#set page(paper: "a4")\nHello World') + return typ_file + + # --- Test _build_compile_command --- + + def test_build_compile_command_basic(self, compiler, mock_typ_path): + """Test basic typst compile command""" + cmd = compiler._build_compile_command(mock_typ_path, None, []) + + assert cmd[0] == "typst" + assert cmd[1] == "compile" + assert str(mock_typ_path) in cmd + + def test_build_compile_command_with_font_paths(self, compiler, mock_typ_path): + """Test typst compile command with font paths""" + font_paths = ["fonts/Marianne", "fonts/Roboto"] + cmd = compiler._build_compile_command(mock_typ_path, None, font_paths) + + assert "--font-path" in cmd + assert "fonts/Marianne" in cmd + assert "fonts/Roboto" in cmd + + def test_build_compile_command_with_output_dir(self, compiler, mock_typ_path): + """Test typst compile command with output directory""" + cmd = compiler._build_compile_command(mock_typ_path, "build/typst", []) + + # Should include output path + assert any("build/typst" in str(arg) for arg in cmd) + + def test_build_compile_command_multiple_font_paths(self, compiler, mock_typ_path): + """Test typst compile command with multiple font paths""" + font_paths = ["fonts/A", "fonts/B", "fonts/C"] + cmd = compiler._build_compile_command(mock_typ_path, None, font_paths) + + # Count --font-path occurrences + font_path_count = cmd.count("--font-path") + assert font_path_count == 3 + + # --- Test _build_watch_command --- + + def test_build_watch_command_basic(self, compiler, mock_typ_path): + """Test basic typst watch command""" + cmd = compiler._build_watch_command(mock_typ_path, None, []) + + assert cmd[0] == "typst" + assert cmd[1] == "watch" + assert str(mock_typ_path) in cmd + + def test_build_watch_command_with_font_paths(self, compiler, mock_typ_path): + """Test typst watch command with font paths""" + font_paths = ["fonts/Marianne"] + cmd = compiler._build_watch_command(mock_typ_path, None, font_paths) + + assert "--font-path" in cmd + assert "fonts/Marianne" in cmd + + def test_build_watch_command_with_output_dir(self, compiler, mock_typ_path): + """Test typst watch command with output directory""" + cmd = compiler._build_watch_command(mock_typ_path, "build", []) + + # Should include output path + assert any("build" in str(arg) for arg in cmd) + + +class TestTypstCompilerCompilation: + """Test cases for Typst compilation""" + + @pytest.fixture + def compiler(self): + """Create a TypstCompiler instance with default config""" + config = Config() + return TypstCompiler(config) + + @pytest.fixture + def mock_typ_path(self, tmp_path): + """Create a mock .typ file""" + typ_file = tmp_path / "test.typ" + typ_file.write_text('#set page(paper: "a4")\nHello World') + return typ_file + + def test_compile_nonexistent_file(self, compiler, tmp_path): + """Test compiling a file that doesn't exist""" + result = compiler.compile(str(tmp_path / "nonexistent.typ")) + assert result is False + + @patch("subprocess.run") + def test_compile_once_success(self, mock_run, compiler, mock_typ_path, tmp_path): + """Test successful Typst 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._compile_once(mock_typ_path, None, []) + + mock_run.assert_called_once() + assert result is True + + @patch("subprocess.run") + def test_compile_once_failure(self, mock_run, compiler, mock_typ_path): + """Test Typst compilation failure""" + mock_run.return_value = MagicMock( + returncode=1, stdout="Error!", stderr="Compilation failed" + ) + + result = compiler._compile_once(mock_typ_path, None, []) + + assert result is False + + @patch("subprocess.run") + def test_compile_once_timeout(self, mock_run, compiler, mock_typ_path): + """Test Typst compilation timeout""" + mock_run.side_effect = subprocess.TimeoutExpired(cmd="typst", timeout=120) + + result = compiler._compile_once(mock_typ_path, None, []) + + assert result is False + + @patch("subprocess.run") + def test_compile_once_typst_not_found(self, mock_run, compiler, mock_typ_path): + """Test Typst not installed""" + mock_run.side_effect = FileNotFoundError() + + result = compiler._compile_once(mock_typ_path, None, []) + + assert result is False + + @patch("subprocess.run") + def test_compile_with_font_paths(self, mock_run, compiler, mock_typ_path, tmp_path): + """Test compilation with custom font paths""" + pdf_file = tmp_path / "test.pdf" + pdf_file.write_bytes(b"%PDF-1.4 mock pdf content") + mock_run.return_value = MagicMock(returncode=0, stdout="", stderr="") + + result = compiler.compile( + str(mock_typ_path), font_paths=["fonts/custom"], watch=False + ) + + # Check that font path was included in command + call_args = mock_run.call_args[0][0] + assert "--font-path" in call_args + assert "fonts/custom" in call_args + assert result is True + + +class TestTypstCompilerDependencies: + """Test cases for dependency checking""" + + @pytest.fixture + def compiler(self): + """Create a TypstCompiler instance with default config""" + config = Config() + return TypstCompiler(config) + + def test_check_dependencies_structure(self, compiler): + """Test dependency check returns expected structure""" + with patch.object(compiler, "_check_command", return_value=True): + deps = compiler.check_dependencies() + + assert "typst" in deps + + @patch("subprocess.run") + def test_check_command_available(self, mock_run, compiler): + """Test _check_command when command is available""" + mock_run.return_value = MagicMock(returncode=0) + + result = compiler._check_command("typst") + + assert result is True + + @patch("subprocess.run") + def test_check_command_not_found(self, mock_run, compiler): + """Test _check_command when command is not found""" + mock_run.side_effect = FileNotFoundError() + + result = compiler._check_command("typst") + + assert result is False + + @patch("subprocess.run") + def test_check_command_timeout(self, mock_run, compiler): + """Test _check_command when command times out""" + mock_run.side_effect = subprocess.TimeoutExpired(cmd="typst", timeout=10) + + result = compiler._check_command("typst") + + assert result is False + + +class TestTypstCompilerIntegration: + """Integration tests for TypstCompiler""" + + @pytest.fixture + def compiler(self): + """Create a TypstCompiler instance with default config""" + config = Config() + return TypstCompiler(config) + + @pytest.fixture + def mock_typ_file(self, tmp_path): + """Create a mock .typ file""" + typ_file = tmp_path / "presentation.typ" + typ_file.write_text(""" +#set page(paper: "presentation-16-9") +#set text(size: 24pt) + += Title Slide + +Hello, World! +""") + return typ_file + + def test_compile_merges_config_font_paths(self, tmp_path): + """Test that compile merges font paths from config and arguments""" + # Create a config with font paths + config = Config() + + # Patch get_typst_config to return font paths + with patch.object( + config, + "get_typst_config", + return_value={"font_paths": ["fonts/A"], "build_dir": ""}, + ): + compiler = TypstCompiler(config) + + typ_file = tmp_path / "test.typ" + typ_file.write_text('#set page(paper: "a4")\nTest') + + with patch.object( + compiler, "_compile_once", return_value=True + ) as mock_compile: + compiler.compile(str(typ_file), font_paths=["fonts/B"]) + + # Should have been called with merged font paths + call_args = mock_compile.call_args + font_paths_arg = call_args[0][2] # Third positional argument + assert "fonts/A" in font_paths_arg + assert "fonts/B" in font_paths_arg