Skip to content
Closed
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
21 changes: 18 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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.
Expand All @@ -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
Expand All @@ -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.
Expand Down
2 changes: 2 additions & 0 deletions src/draw_tree/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
generate_tex,
generate_pdf,
generate_png,
generate_svg,
ef_to_tex,
latex_wrapper,
efg_dl_ef,
Expand All @@ -26,6 +27,7 @@
"generate_tex",
"generate_pdf",
"generate_png",
"generate_svg",
"ef_to_tex",
"latex_wrapper",
"efg_dl_ef",
Expand Down
126 changes: 126 additions & 0 deletions src/draw_tree/core.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
57 changes: 57 additions & 0 deletions tests/test_drawtree.py
Original file line number Diff line number Diff line change
Expand Up @@ -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("<svg></svg>")
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."""

Expand Down
Loading