diff --git a/README.md b/README.md index 7378192..7c8b24c 100644 --- a/README.md +++ b/README.md @@ -62,7 +62,7 @@ In [11]: print(planarity.ascii(edgelist)) ```python -In [12]: planarity.draw(edgelist, outfileName='K5-minus-edge.png') +In [12]: planarity.draw(edgelist, outfileName='K5-minus-edge.png', vertex_bordercolor='black') ``` ![K_5-minus-edge](/examples/classic/output/K5-minus-edge.png "Matplotlib render of $K_5$-minus-edge") @@ -93,4 +93,4 @@ license](https://github.com/graph-algorithms/planarity/blob/master/LICENSE.txt).     Planarity includes the Edge Addition Planarity Suite, which is
    Copyright (c) 1997-2026, John M. Boyer.
    The BSD-3-Clause license for the Edge Additional Planarity Suite
-    included in Planarity appears [here](https://github.com/graph-algorithms/planarity/blob/master/planarity/c/LICENSE.TXT). \ No newline at end of file +    included in Planarity appears [here](https://github.com/graph-algorithms/planarity/blob/master/planarity/c/LICENSE.TXT). diff --git a/examples/classic/draw.py b/examples/classic/draw.py index 0d920ad..2765dfa 100644 --- a/examples/classic/draw.py +++ b/examples/classic/draw.py @@ -34,13 +34,13 @@ print(planar_rendition) # Output Matplotlib rendering - P2.draw(outfileName='K5-minus-edge.png') + P2.draw(outfileName='K5-minus-edge.png', vertex_bordercolor='black') # Verify fig_kwargs (#87): generate 300dpi and 600dpi variants P3 = planarity.PGraph(edgelist) - P3.draw(outfileName='K5-minus-edge-300dpi.png', dpi=300) + P3.draw(outfileName='K5-minus-edge-300dpi.png', dpi=300, vertex_bordercolor='black') P4 = planarity.PGraph(edgelist) - P4.draw(outfileName='K5-minus-edge-600dpi.png', dpi=600) + P4.draw(outfileName='K5-minus-edge-600dpi.png', dpi=600, vertex_bordercolor='black') else: print("The graph cannot be drawn because it is non-planar.") diff --git a/examples/classic/draw_planar_colors.py b/examples/classic/draw_planar_colors.py new file mode 100644 index 0000000..a815d4d --- /dev/null +++ b/examples/classic/draw_planar_colors.py @@ -0,0 +1,24 @@ +"""Run from the desired output directory to generate seven color examples.""" + +import planarity + + +edgelist = [('a', 'c'), ('a', 'd'), ('a', 'e'), ('b', 'c'), ('b', 'd'), + ('b', 'e'), ('c', 'd'), ('c', 'e'), ('d', 'e')] + +colors = { + 'facecolor': 'lightyellow', + 'transparent': True, + 'vertex_facecolor': 'lightgreen', + 'vertex_bordercolor': 'black', + 'vertex_label_facecolor': 'lavender', + 'vertex_label_bordercolor': 'red', + 'edge_linecolor': 'darkorange', +} + +for property_name, value in colors.items(): + planarity.draw( + edgelist, + outfileName=f'K5-minus-edge_{property_name}.png', + **{property_name: value}, + ) diff --git a/examples/classic/output/K5-minus-edge.png b/examples/classic/output/K5-minus-edge.png index 27922e4..87ad4a5 100644 Binary files a/examples/classic/output/K5-minus-edge.png and b/examples/classic/output/K5-minus-edge.png differ diff --git a/planarity/classic/planarity.pyx b/planarity/classic/planarity.pyx index bc6b5cd..eff7644 100644 --- a/planarity/classic/planarity.pyx +++ b/planarity/classic/planarity.pyx @@ -499,11 +499,21 @@ cdef class PGraph: outfileName (:obj:`str`): File to which to output a Matplotlib rendering of the planar graph. If not given, then the caller can call :external+matplotlib:py:func:`matplotlib.pyplot.savefig`. - **kwargs: Optional figure-level parameters to apply to the - current figure after it is cleared. Supported keys are - ``figsize`` and ``dpi``. If none are given, Matplotlib's - default figure size and DPI are used, matching prior - behavior. + **kwargs: Optional drawing parameters. ``figsize`` and ``dpi`` + control the figure size and resolution. ``facecolor`` sets + the figure background (default ``'#ffffff'``). + ``vertex_facecolor`` and ``vertex_bordercolor`` set vertex + rectangle colors; omitted values use Matplotlib's patch + defaults. ``vertex_label_facecolor`` and + ``vertex_label_bordercolor`` set label rectangle colors + (defaults ``'white'`` and ``'black'``). ``edge_linecolor`` + sets edge colors (default Matplotlib line color). All color + values accept Matplotlib color specifications. + ``transparent`` (default ``False``) makes the figure and + axes backgrounds transparent when saving to ``outfileName``; + it does not remove matching colors from vertices or labels. + When saving the figure later yourself, pass ``transparent`` + to :external+matplotlib:py:func:`matplotlib.pyplot.savefig`. Raises: ValueError: if an unsupported keyword argument is given. @@ -531,18 +541,24 @@ cdef class PGraph: plt.clf() fig = plt.gcf() - valid_fig_kwargs = ('figsize', 'dpi') + valid_draw_kwargs = ( + 'figsize', 'dpi', 'facecolor', 'transparent', 'vertex_facecolor', + 'vertex_bordercolor', 'vertex_label_facecolor', + 'vertex_label_bordercolor', 'edge_linecolor', + ) for key, value in kwargs.items(): - if key not in valid_fig_kwargs: + if key not in valid_draw_kwargs: raise ValueError( f"planarity: '{key}' is not a supported draw() keyword " - f"argument. Supported options are: {valid_fig_kwargs}." + f"argument. Supported options are: {valid_draw_kwargs}." ) if key == 'figsize': fig.set_size_inches(value) elif key == 'dpi': fig.set_dpi(value) + fig.set_facecolor(kwargs.get('facecolor', '#ffffff')) + self.embed_drawplanar() if self._embedding_workflow_status != cplanarity.OK: @@ -582,9 +598,14 @@ cdef class PGraph: ye = drawplanar_edge_info['edge_end'] ys.extend([yb, ye]) xs.append(x) - plt.vlines([x], [yb], [ye]) + plt.vlines([x], [yb], [ye], colors=kwargs.get('edge_linecolor'), zorder=1) - p = PatchCollection(patches) + p = PatchCollection( + patches, + facecolors=kwargs.get('vertex_facecolor'), + edgecolors=kwargs.get('vertex_bordercolor'), + zorder = 2, + ) ax = plt.gca() ax.add_collection(p) plt.axis('equal') @@ -614,8 +635,8 @@ cdef class PGraph: verticalalignment='center', bbox = dict( boxstyle='round', - ec=(0.0, 0.0, 0.0), - fc=(1.0, 1.0, 1.0), + ec=kwargs.get('vertex_label_bordercolor', 'black'), + fc=kwargs.get('vertex_label_facecolor', 'white'), ) ) @@ -634,7 +655,11 @@ cdef class PGraph: ).transformed(inv) if outfileName: - plt.savefig(outfileName, dpi=fig.dpi) + transparent = kwargs.get('transparent', False) + plt.savefig( + outfileName, dpi=fig.dpi, transparent=transparent, + facecolor='none' if transparent else fig.get_facecolor(), + ) def write( self, str path='stdout', int writeMode=cplanarity.WRITE_ADJLIST diff --git a/planarity/classic/planarity_functions.py b/planarity/classic/planarity_functions.py index 47312de..7d46abc 100644 --- a/planarity/classic/planarity_functions.py +++ b/planarity/classic/planarity_functions.py @@ -120,9 +120,12 @@ def draw(graph, labels=True, outfileName=None, **kwargs): outfileName (:obj:`str`): File to which to output a Matplotlib rendering of the planar graph. If not given, then the caller can call :external+matplotlib:py:func:`matplotlib.pyplot.savefig`. - **kwargs: Optional figure-level parameters forwarded to + **kwargs: Optional drawing parameters forwarded to :py:meth:`~planarity.classic.planarity.PGraph.draw`. Supported - keys are ``figsize`` and ``dpi``. + keys are ``figsize``, ``dpi``, ``facecolor``, ``transparent``, + ``vertex_facecolor``, ``vertex_bordercolor``, + ``vertex_label_facecolor``, ``vertex_label_bordercolor`` and + ``edge_linecolor``. See the method documentation for defaults. Raises: ValueError: if the given graph is already a diff --git a/tests/test_draw_colors.py b/tests/test_draw_colors.py new file mode 100644 index 0000000..f19bffc --- /dev/null +++ b/tests/test_draw_colors.py @@ -0,0 +1,116 @@ +"""Exercise color controls on real artists and rendered PNGs.""" + +import matplotlib + +matplotlib.use('Agg') + +import matplotlib.pyplot as plt +from matplotlib.collections import LineCollection, PatchCollection +from matplotlib.colors import to_rgba +from PIL import Image +import pytest + +import planarity + + +EDGES = [('a', 'c'), ('a', 'd'), ('a', 'e'), ('b', 'c'), ('b', 'd'), + ('b', 'e'), ('c', 'd'), ('c', 'e'), ('d', 'e')] + + +@pytest.fixture(autouse=True) +def isolated_figures(): + with matplotlib.rc_context(): + yield + plt.close('all') + + +@pytest.fixture(params=['function', 'method']) +def draw(request): + if request.param == 'function': + return lambda **kwargs: planarity.draw(EDGES, **kwargs) + return lambda **kwargs: planarity.PGraph(EDGES).draw(**kwargs) + + +@pytest.mark.parametrize('option,color', [ + ('facecolor', '#abcdef'), + ('vertex_facecolor', 'gold'), + ('vertex_bordercolor', (0.3, 0.2, 0.8)), + ('vertex_label_facecolor', 'lightgreen'), + ('vertex_label_bordercolor', '#aabbcc'), + ('edge_linecolor', 'magenta'), +]) +def test_individual_colors_reach_artists(draw, option, color): + draw(**{option: color}) + ax = plt.gca() + if option == 'facecolor': + actual = [plt.gcf().get_facecolor()] + elif option.startswith('vertex_label_'): + method = ('get_facecolor' if option.endswith('facecolor') + else 'get_edgecolor') + actual = [getattr(text.get_bbox_patch(), method)() for text in ax.texts] + assert len(actual) == 5 + elif option.startswith('vertex_'): + vertices = next(c for c in ax.collections if isinstance(c, PatchCollection)) + actual = (vertices.get_facecolors() if option.endswith('facecolor') + else vertices.get_edgecolors()) + else: + actual = [c.get_colors()[0] for c in ax.collections + if isinstance(c, LineCollection)] + assert len(actual) == 9 + assert len(actual) > 0 + for rgba in actual: + assert tuple(rgba) == pytest.approx(to_rgba(color)) + + +@pytest.mark.parametrize('transparent', [False, True]) +def test_png_background_and_transparency(draw, tmp_path, transparent): + output = tmp_path / 'colors.png' + # A user's savefig style must not override an explicit draw() facecolor. + matplotlib.rcParams['savefig.facecolor'] = 'red' + draw(outfileName=str(output), facecolor='#abcdef', transparent=transparent, + figsize=(4, 3), dpi=100) + with Image.open(output) as image: + assert image.size == (400, 300) + rgba = image.convert('RGBA') + if transparent: + assert rgba.getpixel((0, 0))[3] == 0 + assert rgba.getextrema()[3][1] == 255 # Graph remains visible. + else: + assert rgba.getpixel((0, 0)) == (171, 205, 239, 255) + assert plt.gcf().get_facecolor() == pytest.approx(to_rgba('#abcdef')) + + +def test_color_controls_combine_without_labels(draw, tmp_path): + draw(labels=False, outfileName=str(tmp_path / 'no-labels.png'), + facecolor='white', transparent=True, vertex_facecolor='yellow', + vertex_bordercolor='black', vertex_label_facecolor='pink', + vertex_label_bordercolor='green', edge_linecolor='red') + assert not plt.gca().texts + vertices = next(c for c in plt.gca().collections if isinstance(c, PatchCollection)) + assert tuple(vertices.get_facecolors()[0]) == pytest.approx(to_rgba('yellow')) + assert tuple(vertices.get_edgecolors()[0]) == pytest.approx(to_rgba('black')) + + +def test_default_background_resets_after_custom_draw(draw): + draw(facecolor='red') + draw() + assert plt.gcf().get_facecolor() == pytest.approx(to_rgba('#ffffff')) + for text in plt.gca().texts: + assert text.get_bbox_patch().get_facecolor() == to_rgba('white') + assert text.get_bbox_patch().get_edgecolor() == to_rgba('black') + + +def test_existing_figure_options(draw): + draw(figsize=(4, 3), dpi=120) + assert tuple(plt.gcf().get_size_inches()) == pytest.approx((4, 3)) + assert plt.gcf().dpi == 120 + + +def test_unknown_keyword_still_rejected(draw): + with pytest.raises(ValueError, match='not a supported draw'): + draw(vertex_colour='red') + + +def test_invalid_color_rejected(draw): + with pytest.raises(ValueError, match='not-a-color'): + draw(vertex_facecolor='not-a-color')