diff --git a/.github/workflows/nightlies.yml b/.github/workflows/nightlies.yml index 47d1de50781c..8e8958a961ed 100644 --- a/.github/workflows/nightlies.yml +++ b/.github/workflows/nightlies.yml @@ -60,7 +60,7 @@ jobs: ls -l dist/ - name: Upload wheels to Anaconda Cloud as nightlies - uses: scientific-python/upload-nightly-action@e76cfec8a4611fd02808a801b0ff5a7d7c1b2d99 # 0.6.4 + uses: scientific-python/upload-nightly-action@16fa02eacee1655195143de09f03676e60ef2bf5 # 0.6.5 with: artifacts_path: dist anaconda_nightly_upload_token: ${{ secrets.ANACONDA_ORG_UPLOAD_TOKEN }} diff --git a/.github/workflows/wasm.yml b/.github/workflows/wasm.yml index d6bfe7bf8845..4400d36f76ae 100644 --- a/.github/workflows/wasm.yml +++ b/.github/workflows/wasm.yml @@ -47,6 +47,7 @@ jobs: - name: Build wheels for wasm uses: pypa/cibuildwheel@1828c10ab37f080699c7b81cea34097c684a7074 # v4.2.0 env: + CIBW_SKIP: "cp315-*" CIBW_PLATFORM: "pyodide" CIBW_TEST_COMMAND: "true" diff --git a/.github/workflows/zizmor.yml b/.github/workflows/zizmor.yml index 62d4c1d0e9da..853bef83e04a 100644 --- a/.github/workflows/zizmor.yml +++ b/.github/workflows/zizmor.yml @@ -27,4 +27,4 @@ jobs: persist-credentials: false - name: Run zizmor - uses: zizmorcore/zizmor-action@3dc1ecc9bcb9e94e9b2c709687979e1298497054 # v0.6.2 + uses: zizmorcore/zizmor-action@70fb788f84895a7701f5643d103d587e460b5c99 # v0.6.3 diff --git a/ci/mypy-stubtest-allowlist.txt b/ci/mypy-stubtest-allowlist.txt index 0dcbdc17505f..6db1d6be923e 100644 --- a/ci/mypy-stubtest-allowlist.txt +++ b/ci/mypy-stubtest-allowlist.txt @@ -54,5 +54,8 @@ matplotlib\.animation\.EventSourceProtocol # https://github.com/python/mypy/issues/19877 matplotlib\.ft2font\.GlyphIndexType\.__init__ +# getitem method only exists for 3.11 deprecation backcompatability +matplotlib.container.PieContainer.__getitem__ + # 3.12 deprecation matplotlib\.axes\._base\._AxesBase\.ArtistList diff --git a/doc/api/typing_api.rst b/doc/api/typing_api.rst index 0eafe849a0d0..8cd4023167e6 100644 --- a/doc/api/typing_api.rst +++ b/doc/api/typing_api.rst @@ -25,6 +25,7 @@ Artist styles ============= .. autodata:: matplotlib.typing.BlendModeType +.. autodata:: matplotlib.typing.FillRuleType .. autodata:: matplotlib.typing.LineStyleType .. autodata:: matplotlib.typing.DrawStyleType .. autodata:: matplotlib.typing.MarkEveryType diff --git a/doc/conf.py b/doc/conf.py index b23e57f256a9..d88b1ae88c89 100644 --- a/doc/conf.py +++ b/doc/conf.py @@ -277,7 +277,7 @@ def autodoc_process_bases(app, name, obj, options, bases): 'pandas': ('https://pandas.pydata.org/docs/', None), 'pytest': ('https://pytest.org/en/stable/', None), 'python': ('https://docs.python.org/3/', None), - 'scipy': ('https://static.scipy.org/doc/scipy/', None), + 'scipy': ('https://docs.scipy.org/doc/scipy/', 'https://static.scipy.org/doc/scipy/objects.inv'), 'tornado': ('https://www.tornadoweb.org/en/stable/', None), 'wx': ('https://docs.wxpython.org/', None), 'xarray': ('https://docs.xarray.dev/en/stable/', None), diff --git a/doc/release/next_whats_new/fill_rules.rst b/doc/release/next_whats_new/fill_rules.rst new file mode 100644 index 000000000000..ccbc3669d535 --- /dev/null +++ b/doc/release/next_whats_new/fill_rules.rst @@ -0,0 +1,10 @@ +Option to use the even-odd fill rule for patches +------------------------------------------------ + +By default, patches such as `~.patches.Polygon` are filled according to the +`non-zero winding fill rule `__. +There is now the option to instead use the +`even-odd fill rule `__, +which is specified by setting the patch's ``fill_rule`` property to "evenodd". +See :doc:`/gallery/shapes_and_collections/fill_rule_demo` for more details and +an illustration of the difference. diff --git a/doc/release/next_whats_new/new_barcontainer_properties.rst b/doc/release/next_whats_new/new_barcontainer_properties.rst new file mode 100644 index 000000000000..bd23cfc78357 --- /dev/null +++ b/doc/release/next_whats_new/new_barcontainer_properties.rst @@ -0,0 +1,16 @@ +``BarContainer`` properties and attributes +------------------------------------------ + +`.BarContainer` gained a new `~.BarContainer.widths` property. It returns a +list of the widths of the individual bars in the container (the dimension +perpendicular to the bar height). + +For standard bar plots (e.g. created by `.Axes.bar` or `.Axes.barh`), this +reflects the width of each bar in the plot. For grouped bar plots (e.g. created +by `.Axes.grouped_bar`), each `~.BarContainer` represents one group of bars across +categories, so `~.BarContainer.widths` returns the width of each +individual bar in that group, rather than the total width of the entire group. + +Additionally, `.BarContainer` gained a new ``group_positions`` attribute, which +exposes the center positions of the bar groups if the container is part of a +grouped bar plot (e.g. created by `.Axes.grouped_bar`), or ``None`` otherwise. diff --git a/extern/agg24-svn/src/agg_curves.cpp b/extern/agg24-svn/src/agg_curves.cpp index 470173471881..f907d45cbeaa 100644 --- a/extern/agg24-svn/src/agg_curves.cpp +++ b/extern/agg24-svn/src/agg_curves.cpp @@ -21,7 +21,6 @@ namespace agg { //------------------------------------------------------------------------ - const double curve_distance_epsilon = 1e-30; const double curve_collinearity_epsilon = 1e-30; const double curve_angle_tolerance_epsilon = 0.01; enum curve_recursion_limit_e { curve_recursion_limit = 32 }; diff --git a/galleries/examples/axisartist/demo_ticklabel_alignment.py b/galleries/examples/axisartist/demo_ticklabel_alignment.py index b68b8263f2ed..ca54997717d8 100644 --- a/galleries/examples/axisartist/demo_ticklabel_alignment.py +++ b/galleries/examples/axisartist/demo_ticklabel_alignment.py @@ -3,6 +3,11 @@ Ticklabel alignment =================== +Because axisartist groups all ticks into a single object, the global alignment +of the ticks can be set directly. + +See also :doc:`/gallery/ticks/align_ticklabels` for some workarounds that can +be used when working with standard axes. """ diff --git a/galleries/examples/shapes_and_collections/fill_rule_demo.py b/galleries/examples/shapes_and_collections/fill_rule_demo.py new file mode 100644 index 000000000000..09782401d1b0 --- /dev/null +++ b/galleries/examples/shapes_and_collections/fill_rule_demo.py @@ -0,0 +1,67 @@ +""" +============== +Fill rule demo +============== + +By default, patches such as `~.patches.Polygon` are filled according to the +`non-zero winding fill rule `__. +Any given point has a winding number, which is the number of times the path +wraps around the point in the clockwise direction. For this fill rule, the +filled regions are where the winding number is non-zero. See +:doc:`/gallery/shapes_and_collections/donut` for an example of how to leverage +the winding directions in multiple segments of a path under this fill rule. + +The other option for fill rule is the +`even-odd fill rule `__, +which is specified by setting the patch's ``fill_rule`` property to "evenodd". +For this fill rule, the filled regions are where the winding number is an odd +number. This fill rule allows for the construction of patterns of fill +regions that would otherwise take many more vertices to construct under the +non-zero winding fill rule. + +This example demonstrates the difference between the two fill rules for a single +`~.patches.Polygon` that intersects itself multiple times. The winding number +for each closed region is labeled. + +""" + +import matplotlib.pyplot as plt +import numpy as np + +from matplotlib.patches import Polygon + +fig, axs = plt.subplots(1, 2) + +vertices = np.array([[0, 0, 6, 6, 1, 1, 5, 5, 2, 2, 4, 4, 3, 3, 5, 5], + [2, 5, 5, 0, 0, 7, 7, 3, 3, 4, 4, 6, 6, 1, 1, 2]]).T + +labels = ['1', '0', '1', '2', '3', '2', '1', '1', '0'] +label_xys = np.array([[2.0, 4.0, 0.5, 1.5, 2.5, 4.0, 3.5, 2.0, 3.5], + [1.5, 1.5, 3.5, 3.5, 3.5, 3.5, 4.5, 5.5, 5.5]]).T + +for ax, fill_rule in zip(axs, ['nonzero', 'evenodd']): + polygon = Polygon(vertices, facecolor='green', edgecolor='red', + fill_rule=fill_rule) + ax.add_patch(polygon) + + ax.plot(*vertices.T, '.', markersize=10, color='red') + + for label, label_xy in zip(labels, label_xys): + ax.text(*label_xy, label, ha='center', va='center') + + ax.set_axis_off() + ax.set_title(f'fill_rule={fill_rule}') + +plt.show() + +# %% +# +# .. admonition:: References +# +# The use of the following functions, methods, classes and modules is shown +# in this example: +# +# - `matplotlib.patches` +# - `matplotlib.patches.Polygon` +# - `matplotlib.axes.Axes.add_patch` +# - `matplotlib.patches.Patch.set_fill_rule` diff --git a/galleries/examples/ticks/align_ticklabels.py b/galleries/examples/ticks/align_ticklabels.py index ec36e0db4d07..5e4072eabce5 100644 --- a/galleries/examples/ticks/align_ticklabels.py +++ b/galleries/examples/ticks/align_ticklabels.py @@ -1,14 +1,24 @@ -""" -================= -Align tick labels -================= +r""" +========================== +Left-aligned y tick labels +========================== By default, tick labels are aligned towards the axis. This means the set of -*y* tick labels appear right-aligned. Because the alignment reference point -is on the axis, left-aligned tick labels would overlap the plotting area. -To achieve a good-looking left-alignment, you have to additionally increase -the padding. +y tick labels appear right-aligned. + +To obtain left-aligned y tick labels, a solution is to force their +horizontal-alignment to "left". However, because the alignment reference point +is on the axis, such labels would overlap the plotting area, so the label +padding needs to be additionally increased. + +An alternate solution is to use the mathtext commands ``\rlap`` and +``\phantom`` to manipulate the widths of the tick labels as seen by Matplotlib. +See https://www.tug.org/TUGboat/tb22-4/tb72perlS.pdf for a detailed description +of this approach. + +See also :doc:`/gallery/axisartist/demo_ticklabel_alignment`. """ + import matplotlib.pyplot as plt population = { @@ -20,13 +30,34 @@ "Shanghai": 21.9, } -fig, ax = plt.subplots(layout="constrained") +fig, axs = plt.subplots(1, 2, layout="constrained") + +# First solution: Force the horizontal-alignment of y tick labels to "left", +# and increase the padding (to a manually chosen value). + +ax = axs[0] ax.barh(population.keys(), population.values()) ax.set_xlabel('Population (in millions)') - -# left-align all ticklabels for ticklabel in ax.get_yticklabels(): ticklabel.set_horizontalalignment("left") - -# increase padding ax.tick_params("y", pad=70) + + +# Second solution: Use mathtext to manipulate the width of ylabels as seen +# by Matplotlib. Here, \rlap means "draw this text, but don't advance the +# cursor", whereas \phantom means "advance the cursor by the width of the +# enclosed text, but without actually drawing the text". The end result is +# that the labels get aligned as if "Mexico City" was written every time, but +# the real labels are actually drawn. + +# Note that the widest label ("Mexico City") is still hard-coded here (it is +# the widest *rendered* label, which is not necessarily the longest label in +# characters). +def left_aligned_label(s): + return r"$\rlap{\text{%s}}\phantom{\text{Mexico City}}$" % s + +ax = axs[1] +ax.barh([*map(left_aligned_label, population)], population.values()) +ax.set_xlabel('Population (in millions)') + +plt.show() diff --git a/galleries/tutorials/artists.py b/galleries/tutorials/artists.py index 21ba6ee0a9de..08f65079fd89 100644 --- a/galleries/tutorials/artists.py +++ b/galleries/tutorials/artists.py @@ -205,6 +205,7 @@ class in the Matplotlib API, and the one you will be working with most # animated = False # antialiased or aa = False # bbox = Bbox(x0=0.0, y0=0.0, x1=1.0, y1=1.0) +# blend_mode = normal # capstyle = butt # children = [] # clip_box = None @@ -217,6 +218,7 @@ class in the Matplotlib API, and the one you will be working with most # facecolor or fc = (1.0, 1.0, 1.0, 1.0) # figure = Figure(640x480) # fill = True +# fill_rule = nonzero # gid = None # hatch = None # height = 1 diff --git a/galleries/users_explain/figure/interactive_guide.rst b/galleries/users_explain/figure/interactive_guide.rst index 21658bb5849b..41f54e4b20a7 100644 --- a/galleries/users_explain/figure/interactive_guide.rst +++ b/galleries/users_explain/figure/interactive_guide.rst @@ -405,17 +405,18 @@ The hook functions typically exhaust all pending events on the GUI event queue, run the main loop for a short fixed amount of time, or run the event loop until a key is pressed on stdin. -Matplotlib does not currently do any management of :c:data:`PyOS_InputHook` due -to the wide range of ways that Matplotlib is used. This management is left to -downstream libraries -- either user code or the shell. Interactive figures, -even with Matplotlib in "interactive mode", may not work in the vanilla python -repl if an appropriate :c:data:`PyOS_InputHook` is not registered. - -Input hooks, and helpers to install them, are usually included with -the python bindings for GUI toolkits and may be registered on import. +Interactive figures, even with Matplotlib in "interactive mode", may not work +in REPL if an appropriate :c:data:`PyOS_InputHook` is not registered. This +management is left to upstream libraries or downstream code -- either explicit +user code or shell initialization -- in all toolkits but native macOS. Input +hooks, and helpers to install them, are usually included with the Python +bindings for GUI toolkits and may be registered on import. For the macOS +native toolkit Matplotlib owns code that exposes the toolkit to Python and thus +we register :c:data:`PyOS_InputHook` on GUI application initialization. + IPython also ships input hook functions for all of the GUI frameworks -Matplotlib supports which can be installed via ``%matplotlib``. This -is the recommended method of integrating Matplotlib and a prompt. +Matplotlib supports which can be installed via ``%matplotlib``. This is the +recommended method of integrating Matplotlib and a prompt. IPython / prompt_toolkit diff --git a/lib/matplotlib/artist.py b/lib/matplotlib/artist.py index 5123817507e0..7b7947b2cf90 100644 --- a/lib/matplotlib/artist.py +++ b/lib/matplotlib/artist.py @@ -245,6 +245,7 @@ def __init__(self): self._animated = False self._alpha = None self._blend_mode = "normal" + self._fill_rule = "nonzero" self.clipbox = None self._clippath = None self._clipon = True @@ -1278,6 +1279,7 @@ def update_from(self, other): self._visible = other._visible self._alpha = other._alpha self._blend_mode = other._blend_mode + self._fill_rule = other._fill_rule self.clipbox = other.clipbox self._clipon = other._clipon self._clippath = other._clippath diff --git a/lib/matplotlib/axes/_axes.py b/lib/matplotlib/axes/_axes.py index c71c260b9ed1..276d7b61b852 100644 --- a/lib/matplotlib/axes/_axes.py +++ b/lib/matplotlib/axes/_axes.py @@ -3363,6 +3363,7 @@ def grouped_bar(self, heights, *, positions=None, group_spacing=1.5, bar_spacing else: bc = self.barh(lefts, hs, height=bar_width, align="edge", label=label, color=color, **styles, **kwargs) + bc.group_positions = group_centers bar_containers.append(bc) if tick_labels is not None: diff --git a/lib/matplotlib/backend_bases.py b/lib/matplotlib/backend_bases.py index d73dd81c32fc..ddb1425b02cf 100644 --- a/lib/matplotlib/backend_bases.py +++ b/lib/matplotlib/backend_bases.py @@ -757,6 +757,7 @@ def __init__(self): self._alpha = 1.0 self._forced_alpha = False # if True, _alpha overrides A from RGBA self._blend_mode = "normal" + self._fill_rule = "nonzero" self._antialiased = 1 # use 0, 1 not True, False for extension code self._capstyle = CapStyle('butt') self._cliprect = None @@ -779,6 +780,7 @@ def copy_properties(self, gc): self._alpha = gc._alpha self._forced_alpha = gc._forced_alpha self._blend_mode = gc._blend_mode + self._fill_rule = gc._fill_rule self._antialiased = gc._antialiased self._capstyle = gc._capstyle self._cliprect = gc._cliprect @@ -813,6 +815,9 @@ def get_blend_mode(self): """Return the blend mode for compositing - not supported on all backends.""" return self._blend_mode + def get_fill_rule(self): + return self._fill_rule + def get_antialiased(self): """Return whether the object should try to do antialiased rendering.""" return self._antialiased @@ -924,6 +929,9 @@ def set_blend_mode(self, blend_mode): # Backend-independent input validation is done in Artist.set_blend_mode() self._blend_mode = blend_mode + def set_fill_rule(self, fill_rule): + self._fill_rule = fill_rule + def set_antialiased(self, b): """Set whether object should be drawn with antialiased rendering.""" # Use ints to make life easier on extension code trying to read the gc. diff --git a/lib/matplotlib/backend_bases.pyi b/lib/matplotlib/backend_bases.pyi index 399607063f15..64cea7f8da7f 100644 --- a/lib/matplotlib/backend_bases.pyi +++ b/lib/matplotlib/backend_bases.pyi @@ -6,7 +6,7 @@ from matplotlib import ( widgets, _api, ) -from matplotlib.artist import Artist, BlendMode +from matplotlib.artist import Artist from matplotlib.axes import Axes from matplotlib.backend_managers import ToolManager from matplotlib.backend_tools import Cursors, ToolBase @@ -27,6 +27,7 @@ from .typing import ( CloseEventType, ColorType, DrawEventType, + FillRuleType, JoinStyleType, KeyEventType, LineStyleType, @@ -159,6 +160,7 @@ class GraphicsContextBase: def restore(self) -> None: ... def get_alpha(self) -> float: ... def get_blend_mode(self) -> str: ... + def get_fill_rule(self) -> FillRuleType: ... def get_antialiased(self) -> int: ... def get_capstyle(self) -> Literal["butt", "projecting", "round"]: ... def get_clip_rectangle(self) -> Bbox | None: ... @@ -174,7 +176,8 @@ class GraphicsContextBase: def get_gid(self) -> int | None: ... def get_snap(self) -> bool | None: ... def set_alpha(self, alpha: float) -> None: ... - def set_blend_mode(self, blend_mode: str | BlendMode) -> None: ... + def set_blend_mode(self, blend_mode: BlendModeType) -> None: ... + def set_fill_rule(self, fill_rule: FillRuleType) -> None: ... def set_antialiased(self, b: bool) -> None: ... def set_capstyle(self, cs: CapStyleType) -> None: ... def set_clip_rectangle(self, rectangle: Bbox | None) -> None: ... diff --git a/lib/matplotlib/backends/backend_cairo.py b/lib/matplotlib/backends/backend_cairo.py index c034949c6a07..be17fa693ae3 100644 --- a/lib/matplotlib/backends/backend_cairo.py +++ b/lib/matplotlib/backends/backend_cairo.py @@ -423,6 +423,11 @@ class GraphicsContextCairo(GraphicsContextBase): 'luminosity': cairo.OPERATOR_HSL_LUMINOSITY, } + _filld = { + 'nonzero': cairo.FILL_RULE_WINDING, + 'evenodd': cairo.FILL_RULE_EVEN_ODD, + } + def __init__(self, renderer): super().__init__() self.renderer = renderer @@ -501,6 +506,11 @@ def set_blend_mode(self, blend_mode): self.ctx.set_operator(_api.getitem_checked(self._operatord, blend_mode=self._blend_mode)) + def set_fill_rule(self, fill_rule): + super().set_fill_rule(fill_rule) + self.ctx.set_fill_rule(_api.getitem_checked(self._filld, + fill_rule=self._fill_rule)) + class _CairoRegion: def __init__(self, slices, data): diff --git a/lib/matplotlib/backends/backend_pdf.py b/lib/matplotlib/backends/backend_pdf.py index 7e480f84fc67..67ff38fb325f 100644 --- a/lib/matplotlib/backends/backend_pdf.py +++ b/lib/matplotlib/backends/backend_pdf.py @@ -440,7 +440,9 @@ class Op(Enum): close_fill_stroke = b'b' fill_stroke = b'B' + fill_evenodd_stroke = b'B*' fill = b'f' + fill_evenodd = b'f*' closepath = b'h' close_stroke = b's' stroke = b'S' @@ -481,7 +483,7 @@ def pdfRepr(self): return self.value @classmethod - def paint_path(cls, fill, stroke): + def paint_path(cls, fill, stroke, *, fill_rule="nonzero"): """ Return the PDF operator to paint a path. @@ -494,11 +496,15 @@ def paint_path(cls, fill, stroke): """ if stroke: if fill: + if fill_rule == "evenodd": + return cls.fill_evenodd_stroke return cls.fill_stroke else: return cls.stroke else: if fill: + if fill_rule == "evenodd": + return cls.fill_evenodd return cls.fill else: return cls.endpath @@ -1983,7 +1989,7 @@ def draw_path(self, gc, path, transform, rgbFace=None): path, transform, rgbFace is None and gc.get_hatch_path() is None, gc.get_sketch_params()) - self.file.output(self.gc.paint()) + self.file.output(self.gc.paint(fill_rule=gc._fill_rule)) def draw_path_collection(self, gc, master_transform, paths, all_transforms, offsets, offset_trans, facecolors, edgecolors, @@ -2489,12 +2495,12 @@ def fill(self, *args): (_fillcolor is not None and (len(_fillcolor) <= 3 or _fillcolor[3] != 0.0))) - def paint(self): + def paint(self, *, fill_rule="nonzero"): """ Return the appropriate pdf operator to cause the path to be stroked, filled, or both. """ - return Op.paint_path(self.fill(), self.stroke()) + return Op.paint_path(self.fill(), self.stroke(), fill_rule=fill_rule) capstyles = {'butt': 0, 'round': 1, 'projecting': 2} joinstyles = {'miter': 0, 'round': 1, 'bevel': 2} diff --git a/lib/matplotlib/backends/backend_pgf.py b/lib/matplotlib/backends/backend_pgf.py index 06853b13dba7..7ea6eb09b557 100644 --- a/lib/matplotlib/backends/backend_pgf.py +++ b/lib/matplotlib/backends/backend_pgf.py @@ -556,8 +556,10 @@ def _print_pgf_path_styles(self, gc, rgbFace): r"\definecolor{currentfill}{rgb}{%f,%f,%f}" % tuple(rgbFace[:3])) _writeln(self.fh, r"\pgfsetfillcolor{currentfill}") - if has_fill and fillopacity != 1.0: - _writeln(self.fh, r"\pgfsetfillopacity{%f}" % fillopacity) + if fillopacity != 1.0: + _writeln(self.fh, r"\pgfsetfillopacity{%f}" % fillopacity) + if gc.get_fill_rule() == "evenodd": + _writeln(self.fh, r"\pgfseteorule") # linewidth and color lw = gc.get_linewidth() * mpl_pt_to_in * latex_in_to_pt diff --git a/lib/matplotlib/backends/backend_ps.py b/lib/matplotlib/backends/backend_ps.py index 67a02b3fb064..7061ee6e2758 100644 --- a/lib/matplotlib/backends/backend_ps.py +++ b/lib/matplotlib/backends/backend_ps.py @@ -924,6 +924,7 @@ def _draw_ps(self, ps, gc, rgbFace, *, fill=True, stroke=True): if self._is_transparent(rgbFace): fill = False hatch = gc.get_hatch() + fill_op = "eofill" if gc.get_fill_rule() == "evenodd" else "fill" if mightstroke: self.set_linewidth(gc.get_linewidth()) @@ -943,7 +944,7 @@ def _draw_ps(self, ps, gc, rgbFace, *, fill=True, stroke=True): if stroke or hatch: write("gsave\n") self.set_color(*rgbFace[:3], store=False) - write("fill\n") + write(f"{fill_op}\n") if stroke or hatch: write("grestore\n") @@ -951,7 +952,7 @@ def _draw_ps(self, ps, gc, rgbFace, *, fill=True, stroke=True): hatch_name = self.create_hatch(hatch, gc.get_hatch_linewidth()) write("gsave\n") write(_nums_to_str(*gc.get_hatch_color()[:3])) - write(f" {hatch_name} setpattern fill grestore\n") + write(f" {hatch_name} setpattern {fill_op} grestore\n") if stroke: write("stroke\n") diff --git a/lib/matplotlib/backends/backend_svg.py b/lib/matplotlib/backends/backend_svg.py index 8373ffecb5b9..00790ab698de 100644 --- a/lib/matplotlib/backends/backend_svg.py +++ b/lib/matplotlib/backends/backend_svg.py @@ -620,6 +620,8 @@ def _get_style_dict(self, gc, rgbFace): attrib['opacity'] = _short_float_fmt(gc.get_alpha()) if (blend_mode := _svg_blend_mode(gc.get_blend_mode())) != "normal": attrib["mix-blend-mode"] = blend_mode + if (fill_rule := gc.get_fill_rule()) != "nonzero": + attrib["fill-rule"] = fill_rule offset, seq = gc.get_dashes() if seq is not None: diff --git a/lib/matplotlib/backends/backend_tkcairo.py b/lib/matplotlib/backends/backend_tkcairo.py index 6ecfcfcb8cf0..cb69ab2102ca 100644 --- a/lib/matplotlib/backends/backend_tkcairo.py +++ b/lib/matplotlib/backends/backend_tkcairo.py @@ -1,7 +1,6 @@ -import sys - import numpy as np +from .. import cbook from . import _backend_tk from .backend_cairo import cairo, FigureCanvasCairo from ._backend_tk import _BackendTk, FigureCanvasTk @@ -14,10 +13,9 @@ def draw(self): self._renderer.set_context(cairo.Context(surface)) self._renderer.dpi = self.figure.dpi self.figure.draw(self._renderer) - buf = np.reshape(surface.get_data(), (height, width, 4)) - _backend_tk.blit( - self._tkphoto, buf, - (2, 1, 0, 3) if sys.byteorder == "little" else (1, 2, 3, 0)) + premult_argb = np.reshape(surface.get_data(), (height, width, 4)) + unmult_rgba = cbook._premultiplied_argb32_to_unmultiplied_rgba8888(premult_argb) + _backend_tk.blit(self._tkphoto, unmult_rgba, (0, 1, 2, 3)) @_BackendTk.export diff --git a/lib/matplotlib/cbook.py b/lib/matplotlib/cbook.py index 7aaf900d4e61..3d49c0819257 100644 --- a/lib/matplotlib/cbook.py +++ b/lib/matplotlib/cbook.py @@ -1265,7 +1265,8 @@ def _compute_conf_interval(data, med, iqr, bootstrap): if labels is None: labels = itertools.repeat(None) elif len(labels) != ncols: - raise ValueError("Dimensions of labels and X must be compatible") + raise ValueError(f"The number of labels ({len(labels)}) must match the" + f" number of columns ({ncols}).") input_whis = whis for ii, (x, label) in enumerate(zip(X, labels)): @@ -2209,6 +2210,8 @@ def _premultiplied_argb32_to_unmultiplied_rgba8888(buf): [2, 1, 0, 3] if sys.byteorder == "little" else [1, 2, 3, 0], axis=2) rgb = rgba[..., :-1] alpha = rgba[..., -1] + if alpha.min() == 0xff: + return rgba # Un-premultiply alpha. The formula is the same as in cairo-png.c. mask = alpha != 0 for channel in np.rollaxis(rgb, -1): diff --git a/lib/matplotlib/container.py b/lib/matplotlib/container.py index 96b14cfd26f7..36e686a16592 100644 --- a/lib/matplotlib/container.py +++ b/lib/matplotlib/container.py @@ -63,14 +63,20 @@ class BarContainer(Container): If 'vertical', the bars are assumed to be vertical. If 'horizontal', the bars are assumed to be horizontal. + group_positions : None or array-like + The center positions of the bar groups if the container is part of a + grouped bar plot (e.g. created by `.Axes.grouped_bar`). *None* otherwise. + + .. versionadded:: 3.12 """ def __init__(self, patches, errorbar=None, *, datavalues=None, - orientation=None, **kwargs): + orientation=None, group_positions=None, **kwargs): self.patches = patches self.errorbar = errorbar self.datavalues = datavalues self.orientation = orientation + self.group_positions = group_positions super().__init__(patches, **kwargs) @property @@ -115,6 +121,20 @@ def position_centers(self): else: raise ValueError("orientation must be 'vertical' or 'horizontal'.") + @property + def widths(self): + """ + Return the widths of the bars. + + .. versionadded:: 3.12 + """ + if self.orientation == 'vertical': + return [p.get_width() for p in self.patches] + elif self.orientation == 'horizontal': + return [p.get_height() for p in self.patches] + else: + raise ValueError("orientation must be 'vertical' or 'horizontal'.") + class ErrorbarContainer(Container): """ diff --git a/lib/matplotlib/container.pyi b/lib/matplotlib/container.pyi index 772801b16d6d..753fe518b9ef 100644 --- a/lib/matplotlib/container.pyi +++ b/lib/matplotlib/container.pyi @@ -25,6 +25,7 @@ class BarContainer(Container): errorbar: None | ErrorbarContainer datavalues: None | ArrayLike orientation: None | Literal["vertical", "horizontal"] + group_positions: None | ArrayLike def __init__( self, patches: list[Rectangle], @@ -32,6 +33,7 @@ class BarContainer(Container): *, datavalues: ArrayLike | None = ..., orientation: Literal["vertical", "horizontal"] | None = ..., + group_positions: ArrayLike | None = ..., **kwargs ) -> None: ... @property @@ -40,6 +42,8 @@ class BarContainer(Container): def tops(self) -> list[float]: ... @property def position_centers(self) -> list[float]: ... + @property + def widths(self) -> list[float]: ... class ErrorbarContainer(Container): lines: tuple[Line2D, tuple[Line2D, ...], tuple[LineCollection, ...]] @@ -53,7 +57,7 @@ class ErrorbarContainer(Container): **kwargs ) -> None: ... -class PieContainer(Container): +class PieContainer: wedges: list[Wedge] def __init__( self, @@ -70,6 +74,7 @@ class PieContainer(Container): def add_texts(self, texts: list[Text], ) -> None: ... + def remove(self) -> None: ... class StemContainer(Container): markerline: Line2D diff --git a/lib/matplotlib/patches.py b/lib/matplotlib/patches.py index cae9e43a752a..c88d98f94a77 100644 --- a/lib/matplotlib/patches.py +++ b/lib/matplotlib/patches.py @@ -634,6 +634,29 @@ def get_hatch_linewidth(self): """Return the hatch linewidth.""" return self._hatch_linewidth + def set_fill_rule(self, fill_rule): + """ + Set the rule for filling a shape. + + See :doc:`/gallery/shapes_and_collections/fill_rule_demo`. + + Parameters + ---------- + fill_rule : {'nonzero', 'evenodd'} + 'nonzero' for the non-zero winding rule (the default), or + 'evenodd' for the even-odd rule + + References + ---------- + * `Wikipedia: Non-zero winding rule `__ + * `Wikipedia: Even-odd rule `__ + """ + _api.check_in_list(["nonzero", "evenodd"], fill_rule=fill_rule) + self._fill_rule = fill_rule + + def get_fill_rule(self): + return self._fill_rule + def _has_dashed_edge(self): """ Return whether the patch edge has a dashed linestyle. @@ -673,6 +696,7 @@ def _draw_paths_with_artist_properties( gc.set_alpha(self._alpha) gc.set_blend_mode(self.get_blend_mode()) + gc.set_fill_rule(self.get_fill_rule()) if self._hatch: gc.set_hatch(self._hatch) diff --git a/lib/matplotlib/patches.pyi b/lib/matplotlib/patches.pyi index 60b03b0f1e0c..8dd128ec7462 100644 --- a/lib/matplotlib/patches.pyi +++ b/lib/matplotlib/patches.pyi @@ -8,7 +8,7 @@ from typing import Any, Literal, overload import numpy as np from numpy.typing import ArrayLike -from .typing import ColorType, LineStyleType, CapStyleType, JoinStyleType +from .typing import ColorType, FillRuleType, LineStyleType, CapStyleType, JoinStyleType class Patch(artist.Artist): zorder: float @@ -68,6 +68,8 @@ class Patch(artist.Artist): def set_hatch_linewidth(self, lw: float) -> None: ... def get_hatch_linewidth(self) -> float: ... def get_hatch(self) -> str: ... + def set_fill_rule(self, fill_rule: FillRuleType) -> None: ... + def get_fill_rule(self) -> FillRuleType: ... def get_path(self) -> Path: ... class Shadow(Patch): diff --git a/lib/matplotlib/tests/baseline_images/test_backends_rendering/fill_rules.pdf b/lib/matplotlib/tests/baseline_images/test_backends_rendering/fill_rules.pdf new file mode 100644 index 000000000000..3882344bb9fb Binary files /dev/null and b/lib/matplotlib/tests/baseline_images/test_backends_rendering/fill_rules.pdf differ diff --git a/lib/matplotlib/tests/baseline_images/test_backends_rendering/fill_rules.png b/lib/matplotlib/tests/baseline_images/test_backends_rendering/fill_rules.png new file mode 100644 index 000000000000..746b706225f8 Binary files /dev/null and b/lib/matplotlib/tests/baseline_images/test_backends_rendering/fill_rules.png differ diff --git a/lib/matplotlib/tests/baseline_images/test_backends_rendering/fill_rules.svg b/lib/matplotlib/tests/baseline_images/test_backends_rendering/fill_rules.svg new file mode 100644 index 000000000000..188ac12f3fd8 --- /dev/null +++ b/lib/matplotlib/tests/baseline_images/test_backends_rendering/fill_rules.svg @@ -0,0 +1,240 @@ + + + + + + + + 2026-09-03T23:38:18.563086 + image/svg+xml + + + Matplotlib v3.12.0.dev540+gbdec8821e.d20260904, https://matplotlib.org/ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/lib/matplotlib/tests/baseline_images/test_backends_rendering/fill_rules_cairo.png b/lib/matplotlib/tests/baseline_images/test_backends_rendering/fill_rules_cairo.png new file mode 100644 index 000000000000..0b0d1658518c Binary files /dev/null and b/lib/matplotlib/tests/baseline_images/test_backends_rendering/fill_rules_cairo.png differ diff --git a/lib/matplotlib/tests/baseline_images/test_backends_rendering/fill_rules_pgf.pdf b/lib/matplotlib/tests/baseline_images/test_backends_rendering/fill_rules_pgf.pdf new file mode 100644 index 000000000000..d7ad8aa5b72b Binary files /dev/null and b/lib/matplotlib/tests/baseline_images/test_backends_rendering/fill_rules_pgf.pdf differ diff --git a/lib/matplotlib/tests/test_axes.py b/lib/matplotlib/tests/test_axes.py index 968c41d61c02..531eae23db30 100644 --- a/lib/matplotlib/tests/test_axes.py +++ b/lib/matplotlib/tests/test_axes.py @@ -2296,6 +2296,8 @@ def test_grouped_bar_return_value(): for bc in ret.bar_containers: assert isinstance(bc, BarContainer) assert bc in ax.containers + np.testing.assert_array_equal(bc.group_positions, [0, 1, 2]) + assert bc.widths == [p.get_width() for p in bc.patches] ret.remove() for bc in ret.bar_containers: diff --git a/lib/matplotlib/tests/test_backends_rendering.py b/lib/matplotlib/tests/test_backends_rendering.py index 48d20054c92b..72de3238706c 100644 --- a/lib/matplotlib/tests/test_backends_rendering.py +++ b/lib/matplotlib/tests/test_backends_rendering.py @@ -11,7 +11,8 @@ from matplotlib.backends.backend_pgf import RendererPgf from matplotlib.backends.backend_svg import RendererSVG from matplotlib.figure import Figure -from matplotlib.patches import Circle, Rectangle +from matplotlib.patches import Circle, PathPatch, Polygon, Rectangle +from matplotlib.path import Path from matplotlib.testing._markers import needs_pgf_pdflatex from matplotlib.testing.decorators import image_comparison @@ -277,3 +278,56 @@ def test_group_invalid_blend_mode(renderer): with pytest.raises(ValueError, match="not a valid value for blend_mode"): renderer_instance.open_blend_group("invalid_blend_mode") + + +def plot_fill_rule_comparison(): + deg = np.arange(6) * 144 + x = np.sin(deg * np.pi / 180) + y = np.cos(deg * np.pi / 180) + star = np.stack([x, y], axis=1) + + square_vertices = np.array([[-1, -1], [-1, 1], [1, 1], [1, -1], [-1, -1]]) + square_codes = [Path.MOVETO, Path.LINETO, Path.LINETO, Path.LINETO, Path.LINETO] + + fig, axs = plt.subplots(1, 2, figsize=(4, 5)) + + for ax, fill_rule in zip(axs, ['nonzero', 'evenodd']): + stroked_star = Polygon(star + [0, 4], closed=False, + ec='b', lw=5, ls=(0, (5, 1)), + fc='r', hatch='xx', fill_rule=fill_rule) + ax.add_patch(stroked_star) + + nonstroked_star = Polygon(star + [0, 2], closed=False, ec='none', + fc='r', hatch='xx', fill_rule=fill_rule) + ax.add_patch(nonstroked_star) + + squares = Path(np.vstack([square_vertices * 0.9, + square_vertices / 3 + [0, 0.5], + square_vertices / 3 + [0.3, 0], + (square_vertices / 3)[::-1, :] + [0, -0.5]]), + square_codes * 4) + + ax.add_patch(PathPatch(squares, fc='g', ec='m', fill_rule=fill_rule)) + + ax.set_xlim(-1, 1) + ax.set_ylim(-1, 5.1) + ax.set_aspect('equal') + ax.set_axis_off() + + +@image_comparison(['fill_rules'], extensions=['png', 'svg', 'pdf'], style='mpl20') +def test_fill_rules(): + plot_fill_rule_comparison() + + +@pytest.mark.backend('cairo') +@image_comparison(['fill_rules_cairo.png'], style='mpl20') +def test_fill_rules_cairo(): + plot_fill_rule_comparison() + + +@needs_pgf_pdflatex +@pytest.mark.backend('pgf') +@image_comparison(['fill_rules_pgf.pdf'], style='mpl20') +def test_fill_rules_pgf(): + plot_fill_rule_comparison() diff --git a/lib/matplotlib/typing.py b/lib/matplotlib/typing.py index 4b87915ce303..1e2bec055395 100644 --- a/lib/matplotlib/typing.py +++ b/lib/matplotlib/typing.py @@ -61,6 +61,9 @@ ) """Blend modes. See :ref:`blend-modes`.""" +type FillRuleType = Literal["nonzero", "evenodd"] +"""Fill rule options.""" + type LineStyleType = ( Literal["-", "solid", "--", "dashed", "-.", "dashdot", ":", "dotted", "", "none", " ", "None"] | diff --git a/src/_backend_agg.h b/src/_backend_agg.h index 36b0dc49c20a..78b4b2fdfc2e 100644 --- a/src/_backend_agg.h +++ b/src/_backend_agg.h @@ -295,6 +295,8 @@ template inline void RendererAgg::_draw_path(path_t &path, bool has_clippath, const std::optional &face, GCAgg &gc) { + theRasterizer.filling_rule(gc.filling_rule); + // Render face if (face) { theRasterizer.add_path(path); @@ -380,6 +382,8 @@ RendererAgg::_draw_path(path_t &path, bool has_clippath, const std::optional struct type_caster { + public: + PYBIND11_TYPE_CASTER(agg::filling_rule_e, const_name("filling_rule_e")); + + bool load(handle src, bool) { + const std::unordered_map enum_values = { + {"nonzero", agg::fill_non_zero}, + {"evenodd", agg::fill_even_odd}, + }; + value = enum_values.at(src.cast()); + return true; + } + }; + template <> struct type_caster { public: PYBIND11_TYPE_CASTER(agg::line_cap_e, const_name("line_cap_e")); @@ -278,6 +293,7 @@ namespace PYBIND11_NAMESPACE { namespace detail { value.alpha = src.attr("_alpha").cast(); value.forced_alpha = src.attr("_forced_alpha").cast(); value.comp_op = src.attr("_blend_mode").cast(); + value.filling_rule = src.attr("_fill_rule").cast(); value.color = src.attr("_rgb").cast(); value.isaa = src.attr("_antialiased").cast(); value.cap = src.attr("_capstyle").cast(); diff --git a/subprojects/harfbuzz.wrap b/subprojects/harfbuzz.wrap index 3fd5915fa839..452ec45bc5da 100644 --- a/subprojects/harfbuzz.wrap +++ b/subprojects/harfbuzz.wrap @@ -6,5 +6,8 @@ source_hash = ee0eb3a1da2c5a28147f12dff55f6c7d60aeeeb29ac7ef334eabe84c8476c105 source_fallback_url = https://github.com/mesonbuild/wrapdb/releases/download/harfbuzz_14.1.0-1/harfbuzz-14.1.0.tar.xz wrapdb_version = 14.1.0-1 +# Stop making some warnings into errors to avoid errors on latest clang. +diff_files = 0001-Don-t-force-any-diagnostics-to-be-errors.patch + [provide] dependency_names = harfbuzz, harfbuzz-cairo, harfbuzz-gobject, harfbuzz-gpu, harfbuzz-icu, harfbuzz-raster, harfbuzz-subset, harfbuzz-vector diff --git a/subprojects/packagefiles/0001-Don-t-force-any-diagnostics-to-be-errors.patch b/subprojects/packagefiles/0001-Don-t-force-any-diagnostics-to-be-errors.patch new file mode 100644 index 000000000000..8d4aaf999c2f --- /dev/null +++ b/subprojects/packagefiles/0001-Don-t-force-any-diagnostics-to-be-errors.patch @@ -0,0 +1,51 @@ +From 7d0428070322d13d6bb187cf549e8e21d4bb1d4b Mon Sep 17 00:00:00 2001 +From: Elliott Sales de Andrade +Date: Wed, 9 Sep 2026 00:47:33 -0400 +Subject: [PATCH] Don't force any diagnostics to be errors + +We can't be certain that compilers won't add or change which diagnostics +work for a flag. So disable Harfbuzz's pragmas that turn diagnostics +into errors, as this is really something that only upstream should worry +about. + +Also, backport the ignoring of `-Wunused-template`, or it spams a lot of +the build log. + +Signed-off-by: Elliott Sales de Andrade +--- + meson.build | 6 ++++++ + src/hb.hh | 1 + + 2 files changed, 7 insertions(+) + +diff --git a/meson.build b/meson.build +index 78d1d41e6..08c078fbb 100644 +--- a/meson.build ++++ b/meson.build +@@ -31,6 +31,12 @@ pkgmod = import('pkgconfig') + cpp = meson.get_compiler('cpp') + null_dep = dependency('', required: false) + ++# Matplotlib patch: Don't force any diagnostics to be errors. We can't be ++# certain that compilers won't add or change which diagnostics work for a flag. ++# So disable Harfbuzz's pragmas that turn diagnostics into errors, as this is ++# really something that only upstream should worry about. ++add_project_arguments('-DHB_NO_PRAGMA_GCC_DIAGNOSTIC_ERROR', language: 'cpp') ++ + # Includes Microsoft Clang compiler with GNU arguments, see + # https://github.com/harfbuzz/harfbuzz/pull/4394 + cpp_is_microsoft_compiler = host_machine.system() == 'windows' and cpp.get_define('_MSC_FULL_VER') != '' +diff --git a/src/hb.hh b/src/hb.hh +index 4a21ca90b..3e5c35aa4 100644 +--- a/src/hb.hh ++++ b/src/hb.hh +@@ -151,6 +151,7 @@ + #pragma GCC diagnostic ignored "-Wrange-loop-analysis" // https://github.com/harfbuzz/harfbuzz/issues/2834 + #pragma GCC diagnostic ignored "-Wstrict-aliasing" + #pragma GCC diagnostic ignored "-Wtype-limits" ++#pragma GCC diagnostic ignored "-Wunused-template" + #pragma GCC diagnostic ignored "-Wc++11-compat" // only gcc raises it + #endif + +-- +2.55.0 +