diff --git a/README.md b/README.md index dc2502f..333804d 100644 --- a/README.md +++ b/README.md @@ -42,8 +42,9 @@ pip install -e . ## Requirements - Python 3.10+ (tested on 3.13) -- LaTeX with TikZ (for PDF/PNG generation) +- LaTeX with TikZ (for PDF/PNG/SVG generation) - (optional) ImageMagick or Ghostscript or Poppler (for PNG generation) +- (optional) pdf2svg (for SVG generation) ### Installing LaTeX @@ -69,6 +70,17 @@ PNG generation will default to using any of ImageMagick or Ghostscript or Popple - `sudo apt-get install poppler-utils` - Windows: Install ImageMagick or Ghostscript from their websites +### SVG generation + +SVG generation requires `pdf2svg` to be installed and available in PATH. +- macOS: + - `brew install pdf2svg` +- Ubuntu: + - `sudo apt-get install pdf2svg` +- Windows: + - Install a `pdf2svg` binary and add it to `PATH` + - For example, see the Windows builds at `https://github.com/jalios/pdf2svg-windows` + ## CLI By default, `draw_tree` generates TikZ code and prints it to standard output. @@ -89,13 +101,15 @@ draw_tree games/example.ef --output=mygame.png scale=0.8 # Creates mygame.png You can also use `draw_tree` as a Python library: ```python -from draw_tree import generate_tex, generate_pdf, generate_png +from draw_tree import generate_tex, generate_pdf, generate_png, generate_svg generate_tex('games/example.ef') # Creates example.tex generate_tex('games/example.ef', save_to='custom') # Creates custom.tex generate_pdf('games/example.ef') # Creates example.pdf generate_png('games/example.ef') # Creates example.png generate_png('games/example.ef', dpi=600) # Creates high-res example.png (72-2400, default: 300) generate_png('games/example.ef', save_to='mygame', scale_factor=0.8) # Creates mygame.png with 0.8 scaling (0.01 to 100) +generate_svg('games/example.ef') # Creates example.svg +generate_svg('games/example.ef', save_to='mygame', scale_factor=0.8) # Creates mygame.svg with 0.8 scaling ``` ### Rendering in Jupyter Notebooks @@ -120,12 +134,13 @@ In particular read [Tutorial 4) Creating publication-ready game images](https:// In short, you can do: ```python import pygambit as gbt -from draw_tree import draw_tree, generate_tex, generate_pdf, generate_png +from draw_tree import draw_tree, generate_tex, generate_pdf, generate_png, generate_svg g = gbt.read_efg('somegame.efg') draw_tree(g) generate_tex(g) generate_pdf(g) generate_png(g) +generate_svg(g) ``` > Note: Without setting the `save_to` parameter, the saved file will be based on the title field of the pygambit game object. diff --git a/src/draw_tree/__init__.py b/src/draw_tree/__init__.py index 7ab6c95..4f05c99 100644 --- a/src/draw_tree/__init__.py +++ b/src/draw_tree/__init__.py @@ -13,6 +13,7 @@ generate_tex, generate_pdf, generate_png, + generate_svg, ef_to_tex, latex_wrapper, efg_dl_ef, @@ -26,6 +27,7 @@ "generate_tex", "generate_pdf", "generate_png", + "generate_svg", "ef_to_tex", "latex_wrapper", "efg_dl_ef", diff --git a/src/draw_tree/core.py b/src/draw_tree/core.py index cdcdc3b..b5b22d1 100644 --- a/src/draw_tree/core.py +++ b/src/draw_tree/core.py @@ -2164,6 +2164,132 @@ def generate_png( raise RuntimeError(f"PNG generation failed: {e}") +def generate_svg( + game: str | "pygambit.gambit.Game", + save_to: Optional[str] = None, + scale_factor: float = 1.0, + level_scaling: int = 1, + sublevel_scaling: int = 1, + width_scaling: int = 1, + hide_action_labels: bool = False, + shared_terminal_depth: bool = False, + show_grid: bool = False, + color_scheme: str = "default", + edge_thickness: float = 1.0, + action_label_position: float = 0.5, + dpi: int = 300, +) -> str: + """ + Generate an SVG image directly from an extensive form (.ef) file. + + This function works similarly to generate_png(): it first creates a PDF + via generate_pdf(), then converts that PDF to SVG using pdf2svg. + The `dpi` argument is accepted to keep the same public signature as + generate_png(), but is not used for SVG output. + + Args: + game: Path to the .ef or .efg file to process, or a pygambit.gambit.Game object. + save_to: path to save intermediate .ef file when generating from a pygambit.gambit.Game object and output svg file. + scale_factor: Scale factor for the diagram. + level_scaling: Level spacing multiplier used when generating from a pygambit.gambit.Game object. + sublevel_scaling: Sublevel spacing multiplier used when generating from a pygambit.gambit.Game object. + width_scaling: Width spacing multiplier used when generating from a pygambit.gambit.Game object. + hide_action_labels: Whether to hide action labels when generating from a pygambit.gambit.Game object. + shared_terminal_depth: Whether to enforce shared terminal depth when generating from a pygambit.gambit.Game object. + show_grid: Whether to show grid lines. + color_scheme: Color scheme for player nodes. + edge_thickness: Thickness of edges. + action_label_position: Position of action labels along edges. + dpi: Unused for SVG output; accepted for API consistency. + + Returns: + Path to the generated SVG file. + + Raises: + FileNotFoundError: If the .ef file doesn't exist. + RuntimeError: If PDF generation or SVG conversion fails. + """ + # Keep the same public signature as generate_png for API consistency. + _ = dpi + + # Determine output filename + if save_to is None: + if isinstance(game, str): + game_path = Path(game) + else: + game_path = Path(game.title + ".ef") + output_svg = game_path.with_suffix(".svg").name + else: + if not save_to.endswith(".svg"): + output_svg = save_to + ".svg" + else: + output_svg = save_to + + # If game is an EFG file, convert it first + if isinstance(game, str) and game.lower().endswith(".efg"): + try: + game = efg_dl_ef(game) + except Exception: + pass + + # Step 1: Generate PDF first + with tempfile.TemporaryDirectory() as temp_dir: + temp_pdf = str(Path(temp_dir) / "temp_output.pdf") + + try: + generate_pdf( + game=game, + save_to=temp_pdf, + scale_factor=scale_factor, + level_scaling=level_scaling, + sublevel_scaling=sublevel_scaling, + width_scaling=width_scaling, + hide_action_labels=hide_action_labels, + shared_terminal_depth=shared_terminal_depth, + show_grid=show_grid, + color_scheme=color_scheme, + edge_thickness=edge_thickness, + action_label_position=action_label_position, + ) + + final_svg_path = Path(output_svg) + pdf_path = str(temp_pdf) + svg_path = str(final_svg_path) + + try: + subprocess.run( + ["pdf2svg", pdf_path, svg_path], + capture_output=True, + text=True, + check=True, + ) + except FileNotFoundError: + raise RuntimeError( + "pdf2svg not found. Please install pdf2svg and make sure it is available in your PATH.\n\n" + "Installation examples:\n" + " macOS: brew install pdf2svg\n" + " Ubuntu: sudo apt-get install pdf2svg\n" + " Windows: Install pdf2svg or use another PDF-to-SVG conversion tool." + ) + except subprocess.CalledProcessError as e: + error_msg = e.stderr.strip() if e.stderr else str(e) + raise RuntimeError(f"SVG conversion failed:\n{error_msg}") + + if final_svg_path.exists(): + return str(final_svg_path.absolute()) + else: + raise RuntimeError("SVG was not generated successfully") + + except FileNotFoundError: + # Re-raise file not found errors directly + raise + except RuntimeError: + # Re-raise PDF generation and SVG conversion errors + raise + except Exception as e: + raise RuntimeError(f"SVG generation failed: {e}") + + def efg_dl_ef(efg_file: str) -> str: """Convert a Gambit .efg file to the `.ef` format used by generate_tikz. diff --git a/tests/test_drawtree.py b/tests/test_drawtree.py index 98c03cb..4197875 100644 --- a/tests/test_drawtree.py +++ b/tests/test_drawtree.py @@ -457,6 +457,63 @@ def test_generate_png_output_filename(self): os.unlink(ef_file_path) +class TestSvgGeneration: + """Test SVG generation functionality.""" + + def test_generate_svg_missing_file(self): + """Test SVG generation with missing .ef file.""" + with pytest.raises(FileNotFoundError): + draw_tree.generate_svg("nonexistent.ef") + + @patch('draw_tree.core.generate_pdf') + @patch('draw_tree.core.subprocess.run') + def test_generate_svg_pdf2svg_not_found(self, mock_run, mock_generate_pdf): + """Test SVG generation when pdf2svg is not available.""" + mock_generate_pdf.return_value = "/tmp/temp_output.pdf" + mock_run.side_effect = FileNotFoundError("pdf2svg not found") + + with tempfile.NamedTemporaryFile(mode='w', delete=False, suffix='.ef') as ef_file: + ef_file.write("player 1\nlevel 0 node root player 1\n") + ef_file_path = ef_file.name + + try: + with pytest.raises(RuntimeError, match="pdf2svg not found"): + draw_tree.generate_svg(ef_file_path) + finally: + os.unlink(ef_file_path) + + @patch('draw_tree.core.generate_pdf') + @patch('draw_tree.core.subprocess.run') + def test_generate_svg_output_filename(self, mock_run, mock_generate_pdf): + """Test SVG generation with custom output filename.""" + mock_generate_pdf.return_value = "/tmp/temp_output.pdf" + + def fake_pdf2svg(command, capture_output, text, check): + output_path = command[2] + with open(output_path, 'w', encoding='utf-8') as f: + f.write("") + return None + + mock_run.side_effect = fake_pdf2svg + + with tempfile.NamedTemporaryFile(mode='w', delete=False, suffix='.ef') as ef_file: + ef_file.write("player 1\nlevel 0 node root player 1\n") + ef_file_path = ef_file.name + + try: + custom_filename = "custom_output.svg" + svg_path = draw_tree.generate_svg(ef_file_path, save_to=custom_filename) + + assert svg_path.endswith(custom_filename) + assert os.path.exists(custom_filename) + mock_generate_pdf.assert_called_once() + mock_run.assert_called_once() + + os.unlink(custom_filename) + finally: + os.unlink(ef_file_path) + + class TestTexGeneration: """Test LaTeX document generation functionality."""