From 8c496e283319200d24ca1de12b4bd57caf460e74 Mon Sep 17 00:00:00 2001 From: Saurav4359 Date: Sat, 28 Feb 2026 04:27:14 +0530 Subject: [PATCH] Add generate_svg function using pdf2svg --- README.md | 21 +++++-- src/draw_tree/__init__.py | 2 + src/draw_tree/cli.py | 27 +++++++-- src/draw_tree/core.py | 123 ++++++++++++++++++++++++++++++++++++-- tests/test_drawtree.py | 95 ++++++++++++++++++++++++++--- 5 files changed, 244 insertions(+), 24 deletions(-) diff --git a/README.md b/README.md index 49ad6a3..9adac5e 100644 --- a/README.md +++ b/README.md @@ -9,7 +9,7 @@ - [Developer docs: Testing](#developer-docs-testing) `draw_tree` is a game tree drawing tool for publication-ready extensive form games in Game Theory. -It can generate TikZ code, LaTeX documents, PDFs, and PNGs from game specifications. +It can generate TikZ code, LaTeX documents, PDFs, PNGs, and SVGs from game specifications. Games can be specified via `.ef` format files which include layout formatting. These can be created via [Game Theory Explorer](https://gametheoryexplorer-a68c7.web.app/), or by hand, see [specs.pdf](specs.pdf) for details. @@ -33,8 +33,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 @@ -60,6 +61,15 @@ 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: Download binaries from GitHub or use WSL + ## CLI By default, `draw_tree` generates TikZ code and prints it to standard output. @@ -71,6 +81,7 @@ draw_tree games/example.ef --tex # Creates example.tex draw_tree games/example.ef --output=custom.tex # Creates custom.tex draw_tree games/example.ef --pdf # Creates example.pdf draw_tree games/example.ef --png # Creates example.png +draw_tree games/example.ef --svg # Creates example.svg draw_tree games/example.ef --png --dpi=600 # Creates high-res example.png (72-2400, default: 300) draw_tree games/example.ef --output=mygame.png scale=0.8 # Creates mygame.png with 0.8 scaling (0.01 to 100) ``` @@ -80,11 +91,12 @@ 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_svg('games/example.ef') # Creates example.svg 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) ``` @@ -111,12 +123,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 cd3a305..5ffad7f 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/cli.py b/src/draw_tree/cli.py index 05a60d6..8e76e3d 100644 --- a/src/draw_tree/cli.py +++ b/src/draw_tree/cli.py @@ -5,7 +5,7 @@ import sys from pathlib import Path from .core import ( - commandline, draw_tree, generate_pdf, generate_png, generate_tex + commandline, draw_tree, generate_pdf, generate_png, generate_svg, generate_tex ) @@ -19,16 +19,18 @@ def main(): print(" draw_tree [options] # Generate TikZ code") print(" draw_tree --pdf [options] # Generate PDF (requires pdflatex)") print(" draw_tree --png [options] # Generate PNG (requires pdflatex + imagemagick/ghostscript)") + print(" draw_tree --svg [options] # Generate SVG (requires pdflatex + pdf2svg)") print(" draw_tree --tex [options] # Generate LaTeX document") - print(" draw_tree --output=name.ext # Generate with custom filename (.pdf, .png, or .tex)") + print(" draw_tree --output=name.ext # Generate with custom filename (.pdf, .png, .svg, or .tex)") print() print("Options:") print(" scale=X.X Set scale factor (0.01 to 100)") print(" grid Show helper grid") print(" --pdf Generate PDF output instead of TikZ") print(" --png Generate PNG output instead of TikZ") + print(" --svg Generate SVG output instead of TikZ") print(" --tex Generate LaTeX document instead of TikZ") - print(" --output=X Specify output filename (.pdf, .png, or .tex extension determines format)") + print(" --output=X Specify output filename (.pdf, .png, .svg, or .tex extension determines format)") print(" --dpi=X Set PNG resolution in DPI (72-2400, default: 300)") print() print("Examples:") @@ -37,11 +39,11 @@ def main(): print(" draw_tree games/example.ef --tex") print(" draw_tree games/example.ef --output=mygame.tex scale=0.8") print() - print("Note: PDF/PNG generation requires pdflatex. PNG also needs ImageMagick or Ghostscript.") + print("Note: PDF/PNG/SVG generation requires pdflatex. PNG also needs ImageMagick or Ghostscript. SVG needs pdf2svg.") sys.exit(0) # Process command-line arguments - output_mode, pdf_requested, png_requested, tex_requested, output_file, dpi = commandline(sys.argv) + output_mode, pdf_requested, png_requested, svg_requested, tex_requested, output_file, dpi = commandline(sys.argv) # Import the core module to access global variables after commandline() has set them from . import core @@ -79,7 +81,20 @@ def main(): dpi=dpi if dpi is not None else 300 ) print(f"PNG generated successfully: {png_path}") - + + elif output_mode == "svg": + if output_file.endswith(".svg"): + print(f"Generating SVG: {output_file}") + else: + print(f"Generating SVG: {output_file}.svg") + svg_path = generate_svg( + game=current_ef_file, + save_to=output_file, + scale_factor=current_scale, + show_grid=current_grid, + ) + print(f"SVG generated successfully: {svg_path}") + elif output_mode == "tex": if output_file.endswith(".tex"): print(f"Generating LaTeX: {output_file}") diff --git a/src/draw_tree/core.py b/src/draw_tree/core.py index 779945c..ff23ffc 100644 --- a/src/draw_tree/core.py +++ b/src/draw_tree/core.py @@ -1313,7 +1313,7 @@ def isetgen(words: List[str], color_scheme: str = "default") -> None: ########### command-line arguments -def commandline(argv: List[str]) -> tuple[str, bool, bool, bool, Optional[str], Optional[int]]: +def commandline(argv: List[str]) -> tuple[str, bool, bool, bool, bool, Optional[str], Optional[int]]: """ Process command-line arguments to set global configuration. @@ -1324,10 +1324,11 @@ def commandline(argv: List[str]) -> tuple[str, bool, bool, bool, Optional[str], argv: List of command-line arguments (including script name). Returns: - Tuple of (output_mode, pdf_requested, png_requested, tex_requested, output_file, dpi) where: - - output_mode: 'tikz', 'pdf', 'png', or 'tex' + Tuple of (output_mode, pdf_requested, png_requested, svg_requested, tex_requested, output_file, dpi) where: + - output_mode: 'tikz', 'pdf', 'png', 'svg', or 'tex' - pdf_requested: True if --pdf flag was provided - png_requested: True if --png flag was provided + - svg_requested: True if --svg flag was provided - tex_requested: True if --tex flag was provided - output_file: Custom output filename if specified - dpi: DPI setting for PNG output (None if not specified) @@ -1338,6 +1339,7 @@ def commandline(argv: List[str]) -> tuple[str, bool, bool, bool, Optional[str], pdf_requested = False png_requested = False + svg_requested = False tex_requested = False output_file = None dpi = None @@ -1359,6 +1361,8 @@ def commandline(argv: List[str]) -> tuple[str, bool, bool, bool, Optional[str], pdf_requested = True elif arg == "--png": png_requested = True + elif arg == "--svg": + svg_requested = True elif arg == "--tex": tex_requested = True elif arg.startswith("--output="): @@ -1367,6 +1371,8 @@ def commandline(argv: List[str]) -> tuple[str, bool, bool, bool, Optional[str], pdf_requested = True elif output_file.endswith('.png'): png_requested = True + elif output_file.endswith('.svg'): + svg_requested = True elif output_file.endswith('.tex'): tex_requested = True elif arg.startswith("--dpi="): @@ -1387,6 +1393,8 @@ def commandline(argv: List[str]) -> tuple[str, bool, bool, bool, Optional[str], # Determine output mode if png_requested: output_mode = "png" + elif svg_requested: + output_mode = "svg" elif pdf_requested: output_mode = "pdf" elif tex_requested: @@ -1394,7 +1402,7 @@ def commandline(argv: List[str]) -> tuple[str, bool, bool, bool, Optional[str], else: output_mode = "tikz" - return (output_mode, pdf_requested, png_requested, tex_requested, output_file, dpi) + return (output_mode, pdf_requested, png_requested, svg_requested, tex_requested, output_file, dpi) def ef_to_tex( ef_file: str, @@ -1992,7 +2000,7 @@ def generate_png( # Generate PDF using existing function generate_pdf( game=game, - save_to=save_to, + save_to=str(temp_pdf.with_suffix('')), scale_factor=scale_factor, level_scaling=level_scaling, sublevel_scaling=sublevel_scaling, @@ -2092,6 +2100,111 @@ 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, +) -> str: + """ + Generate an SVG image directly from an extensive form (.ef) file. + + Creates a PDF first, then converts it to SVG using pdf2svg. + Requires pdflatex and pdf2svg to be installed. + + Args: + game: Path to the .ef or .efg file, or a pygambit.gambit.Game object. + save_to: Output path (without extension) or full filename. + scale_factor: Scale factor for the diagram. + level_scaling: Level spacing multiplier (pygambit only). + sublevel_scaling: Sublevel spacing multiplier (pygambit only). + width_scaling: Width spacing multiplier (pygambit only). + hide_action_labels: Hide action labels (pygambit only). + shared_terminal_depth: Enforce shared terminal depth (pygambit only). + 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. + + Returns: + Absolute path to the generated SVG file. + """ + 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 isinstance(game, str) and game.lower().endswith(".efg"): + try: + game = efg_dl_ef(game) + except Exception: + pass + + with tempfile.TemporaryDirectory() as temp_dir: + temp_pdf = Path(temp_dir) / "temp_output.pdf" + + try: + generate_pdf( + game=game, + save_to=str(temp_pdf.with_suffix('')), + 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, + ) + + # Convert PDF to SVG using pdf2svg + final_svg_path = Path(output_svg) + + try: + subprocess.run([ + 'pdf2svg', + str(temp_pdf), + str(final_svg_path) + ], capture_output=True, text=True, check=True) + except (subprocess.CalledProcessError, FileNotFoundError) as e: + raise RuntimeError( + "SVG conversion failed. Please install pdf2svg.\n\n" + "Installation examples:\n" + " macOS: brew install pdf2svg\n" + " Ubuntu: sudo apt-get install pdf2svg\n" + " Windows: Download binaries from GitHub or use WSL" + ) + + if final_svg_path.exists(): + return str(final_svg_path.absolute()) + else: + raise RuntimeError("SVG was not generated successfully") + + except FileNotFoundError: + raise + except RuntimeError: + 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..7d97b89 100644 --- a/tests/test_drawtree.py +++ b/tests/test_drawtree.py @@ -457,6 +457,53 @@ 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): + with pytest.raises(FileNotFoundError): + draw_tree.generate_svg("nonexistent.ef") + + @patch('draw_tree.core.subprocess.run') + def test_generate_svg_pdflatex_not_found(self, mock_run): + mock_run.side_effect = FileNotFoundError("pdflatex 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="pdflatex not found"): + draw_tree.generate_svg(ef_file_path) + finally: + os.unlink(ef_file_path) + + def test_generate_svg_default_parameters(self): + 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 patch('draw_tree.core.subprocess.run') as mock_run: + mock_run.side_effect = FileNotFoundError("Command not found") + with pytest.raises(RuntimeError): + draw_tree.generate_svg(ef_file_path) + finally: + os.unlink(ef_file_path) + + def test_generate_svg_output_filename(self): + 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 patch('draw_tree.core.subprocess.run') as mock_run: + mock_run.side_effect = FileNotFoundError("Command not found") + with pytest.raises(RuntimeError): + draw_tree.generate_svg(ef_file_path, save_to="custom_name.svg") + finally: + os.unlink(ef_file_path) + + class TestTexGeneration: """Test LaTeX document generation functionality.""" @@ -549,10 +596,11 @@ class TestCommandlineArguments: def test_commandline_png_flag(self): """Test --png flag parsing.""" result = draw_tree.commandline(['draw_tree.py', 'test.ef', '--png']) - output_mode, pdf_requested, png_requested, tex_requested, output_file, dpi = result + output_mode, pdf_requested, png_requested, svg_requested, tex_requested, output_file, dpi = result assert output_mode == "png" assert not pdf_requested assert png_requested + assert not svg_requested assert not tex_requested assert output_file is None assert dpi is None @@ -560,10 +608,11 @@ def test_commandline_png_flag(self): def test_commandline_png_with_dpi(self): """Test --png flag with --dpi option.""" result = draw_tree.commandline(['draw_tree.py', 'test.ef', '--png', '--dpi=600']) - output_mode, pdf_requested, png_requested, tex_requested, output_file, dpi = result + output_mode, pdf_requested, png_requested, svg_requested, tex_requested, output_file, dpi = result assert output_mode == "png" assert not pdf_requested assert png_requested + assert not svg_requested assert not tex_requested assert output_file is None assert dpi == 600 @@ -571,10 +620,11 @@ def test_commandline_png_with_dpi(self): def test_commandline_png_output_file(self): """Test PNG output with custom filename.""" result = draw_tree.commandline(['draw_tree.py', 'test.ef', '--output=custom.png']) - output_mode, pdf_requested, png_requested, tex_requested, output_file, dpi = result + output_mode, pdf_requested, png_requested, svg_requested, tex_requested, output_file, dpi = result assert output_mode == "png" assert not pdf_requested assert png_requested + assert not svg_requested assert not tex_requested assert output_file == "custom.png" assert dpi is None @@ -582,10 +632,11 @@ def test_commandline_png_output_file(self): def test_commandline_pdf_output_file(self): """Test PDF output with custom filename.""" result = draw_tree.commandline(['draw_tree.py', 'test.ef', '--output=custom.pdf']) - output_mode, pdf_requested, png_requested, tex_requested, output_file, dpi = result + output_mode, pdf_requested, png_requested, svg_requested, tex_requested, output_file, dpi = result assert output_mode == "pdf" assert pdf_requested assert not png_requested + assert not svg_requested assert not tex_requested assert output_file == "custom.pdf" assert dpi is None @@ -593,10 +644,11 @@ def test_commandline_pdf_output_file(self): def test_commandline_tex_flag(self): """Test --tex flag parsing.""" result = draw_tree.commandline(['draw_tree.py', 'test.ef', '--tex']) - output_mode, pdf_requested, png_requested, tex_requested, output_file, dpi = result + output_mode, pdf_requested, png_requested, svg_requested, tex_requested, output_file, dpi = result assert output_mode == "tex" assert not pdf_requested assert not png_requested + assert not svg_requested assert tex_requested assert output_file is None assert dpi is None @@ -604,10 +656,11 @@ def test_commandline_tex_flag(self): def test_commandline_tex_output_file(self): """Test LaTeX output with custom filename.""" result = draw_tree.commandline(['draw_tree.py', 'test.ef', '--output=custom.tex']) - output_mode, pdf_requested, png_requested, tex_requested, output_file, dpi = result + output_mode, pdf_requested, png_requested, svg_requested, tex_requested, output_file, dpi = result assert output_mode == "tex" assert not pdf_requested assert not png_requested + assert not svg_requested assert tex_requested assert output_file == "custom.tex" assert dpi is None @@ -616,20 +669,44 @@ def test_commandline_invalid_dpi(self): """Test invalid DPI values.""" # Too low DPI should default to 300 result = draw_tree.commandline(['draw_tree.py', 'test.ef', '--png', '--dpi=50']) - output_mode, pdf_requested, png_requested, tex_requested, output_file, dpi = result + output_mode, pdf_requested, png_requested, svg_requested, tex_requested, output_file, dpi = result assert dpi == 300 # Should default to 300 for out-of-range values # Too high DPI should default to 300 result = draw_tree.commandline(['draw_tree.py', 'test.ef', '--png', '--dpi=5000']) - output_mode, pdf_requested, png_requested, tex_requested, output_file, dpi = result + output_mode, pdf_requested, png_requested, svg_requested, tex_requested, output_file, dpi = result assert dpi == 300 # Should default to 300 for out-of-range values def test_commandline_invalid_dpi_string(self): """Test non-numeric DPI values.""" result = draw_tree.commandline(['draw_tree.py', 'test.ef', '--png', '--dpi=high']) - output_mode, pdf_requested, png_requested, tex_requested, output_file, dpi = result + output_mode, pdf_requested, png_requested, svg_requested, tex_requested, output_file, dpi = result assert dpi == 300 # Should default to 300 for invalid values + def test_commandline_svg_flag(self): + """Test --svg flag parsing.""" + result = draw_tree.commandline(['draw_tree.py', 'test.ef', '--svg']) + output_mode, pdf_requested, png_requested, svg_requested, tex_requested, output_file, dpi = result + assert output_mode == "svg" + assert not pdf_requested + assert not png_requested + assert svg_requested + assert not tex_requested + assert output_file is None + assert dpi is None + + def test_commandline_svg_output_file(self): + """Test SVG output with custom filename.""" + result = draw_tree.commandline(['draw_tree.py', 'test.ef', '--output=custom.svg']) + output_mode, pdf_requested, png_requested, svg_requested, tex_requested, output_file, dpi = result + assert output_mode == "svg" + assert not pdf_requested + assert not png_requested + assert svg_requested + assert not tex_requested + assert output_file == "custom.svg" + assert dpi is None + def test_efg_dl_ef_conversion_examples(): """Integration test: convert the repository's example .efg files and