diff --git a/.appveyor.yml b/.appveyor.yml index a637fe545466..c3fcb0ea9591 100644 --- a/.appveyor.yml +++ b/.appveyor.yml @@ -17,7 +17,7 @@ skip_commits: clone_depth: 50 -image: Visual Studio 2019 +image: Visual Studio 2022 environment: diff --git a/.github/workflows/cibuildwheel.yml b/.github/workflows/cibuildwheel.yml index 9ced8e2f5060..fececb0dfc40 100644 --- a/.github/workflows/cibuildwheel.yml +++ b/.github/workflows/cibuildwheel.yml @@ -24,14 +24,16 @@ permissions: jobs: build_sdist: if: >- - github.event_name == 'push' || - github.event_name == 'pull_request' && ( - ( - github.event.action == 'labeled' && - github.event.label.name == 'CI: Run cibuildwheel' - ) || - contains(github.event.pull_request.labels.*.name, - 'CI: Run cibuildwheel') + github.repository == 'matplotlib/matplotlib' && ( + github.event_name == 'push' || + github.event_name == 'pull_request' && ( + ( + github.event.action == 'labeled' && + github.event.label.name == 'CI: Run cibuildwheel' + ) || + contains(github.event.pull_request.labels.*.name, + 'CI: Run cibuildwheel') + ) ) name: Build sdist runs-on: ubuntu-latest @@ -78,14 +80,16 @@ jobs: build_wheels: if: >- - github.event_name == 'push' || - github.event_name == 'pull_request' && ( - ( - github.event.action == 'labeled' && - github.event.label.name == 'CI: Run cibuildwheel' - ) || - contains(github.event.pull_request.labels.*.name, - 'CI: Run cibuildwheel') + github.repository == 'matplotlib/matplotlib' && ( + github.event_name == 'push' || + github.event_name == 'pull_request' && ( + ( + github.event.action == 'labeled' && + github.event.label.name == 'CI: Run cibuildwheel' + ) || + contains(github.event.pull_request.labels.*.name, + 'CI: Run cibuildwheel') + ) ) needs: build_sdist name: Build wheels on ${{ matrix.os }} for ${{ matrix.cibw_archs }} @@ -136,19 +140,31 @@ jobs: name: cibw-sdist path: dist/ + - name: Build wheels for CPython 3.14 + uses: pypa/cibuildwheel@5f22145df44122af0f5a201f93cf0207171beca7 # v3.0.0 + with: + package-dir: dist/${{ needs.build_sdist.outputs.SDIST_NAME }} + env: + CIBW_BUILD: "cp314-* cp314t-*" + CIBW_ENABLE: "cpython-freethreading cpython-prerelease" + CIBW_ARCHS: ${{ matrix.cibw_archs }} + CIBW_MANYLINUX_X86_64_IMAGE: manylinux_2_28 + CIBW_BEFORE_TEST: >- + python -m pip install + --index-url https://pypi.anaconda.org/scientific-python-nightly-wheels/simple + --upgrade --pre --only-binary=:all: contourpy numpy pillow + - name: Build wheels for CPython 3.13 - uses: pypa/cibuildwheel@faf86a6ed7efa889faf6996aa23820831055001a # v2.23.3 + uses: pypa/cibuildwheel@5f22145df44122af0f5a201f93cf0207171beca7 # v3.0.0 with: package-dir: dist/${{ needs.build_sdist.outputs.SDIST_NAME }} env: CIBW_BUILD: "cp313-* cp313t-*" CIBW_ENABLE: cpython-freethreading - # No free-threading wheels available for aarch64 on Pillow. - CIBW_TEST_SKIP: "cp313t-manylinux_aarch64" CIBW_ARCHS: ${{ matrix.cibw_archs }} - name: Build wheels for CPython 3.12 - uses: pypa/cibuildwheel@faf86a6ed7efa889faf6996aa23820831055001a # v2.23.3 + uses: pypa/cibuildwheel@5f22145df44122af0f5a201f93cf0207171beca7 # v3.0.0 with: package-dir: dist/${{ needs.build_sdist.outputs.SDIST_NAME }} env: @@ -156,24 +172,21 @@ jobs: CIBW_ARCHS: ${{ matrix.cibw_archs }} - name: Build wheels for CPython 3.11 - uses: pypa/cibuildwheel@faf86a6ed7efa889faf6996aa23820831055001a # v2.23.3 + uses: pypa/cibuildwheel@5f22145df44122af0f5a201f93cf0207171beca7 # v3.0.0 with: package-dir: dist/${{ needs.build_sdist.outputs.SDIST_NAME }} env: CIBW_BUILD: "cp311-*" CIBW_ARCHS: ${{ matrix.cibw_archs }} - - name: Build wheels for PyPy - uses: pypa/cibuildwheel@faf86a6ed7efa889faf6996aa23820831055001a # v2.23.3 + uses: pypa/cibuildwheel@5f22145df44122af0f5a201f93cf0207171beca7 # v3.0.0 with: package-dir: dist/${{ needs.build_sdist.outputs.SDIST_NAME }} env: CIBW_BUILD: "pp311-*" CIBW_ARCHS: ${{ matrix.cibw_archs }} CIBW_ENABLE: pypy - # No wheels available for Pillow with pp311 yet. - CIBW_TEST_SKIP: "pp311*" if: matrix.cibw_archs != 'aarch64' && matrix.os != 'windows-latest' - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 @@ -183,7 +196,7 @@ jobs: if-no-files-found: error publish: - if: github.event_name == 'push' && github.ref_type == 'tag' + if: github.repository == 'matplotlib/matplotlib' && github.event_name == 'push' && github.ref_type == 'tag' name: Upload release to PyPI needs: [build_sdist, build_wheels] runs-on: ubuntu-latest @@ -204,7 +217,7 @@ jobs: run: ls dist - name: Generate artifact attestation for sdist and wheel - uses: actions/attest-build-provenance@db473fddc028af60658334401dc6fa3ffd8669fd # v2.3.0 + uses: actions/attest-build-provenance@e8998f949152b193b063cb0ec769d69d929409be # v2.4.0 with: subject-path: dist/matplotlib-* diff --git a/.github/workflows/circleci.yml b/.github/workflows/circleci.yml index f0ae304882e7..d61db3f14345 100644 --- a/.github/workflows/circleci.yml +++ b/.github/workflows/circleci.yml @@ -11,7 +11,7 @@ jobs: steps: - name: GitHub Action step uses: - scientific-python/circleci-artifacts-redirector-action@4e13a10d89177f4bfc8007a7064bdbeda848d8d1 # v1.0.0 + scientific-python/circleci-artifacts-redirector-action@7eafdb60666f57706a5525a2f5eb76224dc8779b # v1.1.0 with: repo-token: ${{ secrets.GITHUB_TOKEN }} api-token: ${{ secrets.CIRCLECI_TOKEN }} diff --git a/.github/workflows/codeql-analysis.yml b/.github/workflows/codeql-analysis.yml index 774de9b116d8..d6d1eba02560 100644 --- a/.github/workflows/codeql-analysis.yml +++ b/.github/workflows/codeql-analysis.yml @@ -12,6 +12,7 @@ on: jobs: analyze: + if: github.repository == 'matplotlib/matplotlib' name: Analyze runs-on: ubuntu-latest permissions: @@ -31,7 +32,7 @@ jobs: persist-credentials: false - name: Initialize CodeQL - uses: github/codeql-action/init@60168efe1c415ce0f5521ea06d5c2062adbeed1b # v3.28.17 + uses: github/codeql-action/init@181d5eefc20863364f96762470ba6f862bdef56b # v3.29.2 with: languages: ${{ matrix.language }} @@ -42,4 +43,4 @@ jobs: pip install --user -v . - name: Perform CodeQL Analysis - uses: github/codeql-action/analyze@60168efe1c415ce0f5521ea06d5c2062adbeed1b # v3.28.17 + uses: github/codeql-action/analyze@181d5eefc20863364f96762470ba6f862bdef56b # v3.29.2 diff --git a/.github/workflows/conflictcheck.yml b/.github/workflows/conflictcheck.yml index c426c4d6c399..f4a687cd28d7 100644 --- a/.github/workflows/conflictcheck.yml +++ b/.github/workflows/conflictcheck.yml @@ -11,6 +11,7 @@ on: jobs: main: + if: github.repository == 'matplotlib/matplotlib' runs-on: ubuntu-latest permissions: pull-requests: write diff --git a/.github/workflows/reviewdog.yml b/.github/workflows/reviewdog.yml index c803fcc6ba38..bfad14923b82 100644 --- a/.github/workflows/reviewdog.yml +++ b/.github/workflows/reviewdog.yml @@ -6,6 +6,20 @@ permissions: contents: read jobs: + pre-commit: + name: precommit + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + with: + fetch-depth: 0 + - uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0 + with: + python-version: "3.x" + - uses: pre-commit/action@2c7b3805fd2a0fd8c1884dcaebf91fc102a13ecd # v3.0.1 + with: + extra_args: --hook-stage manual --all-files + ruff: name: ruff runs-on: ubuntu-latest diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 911fa69ec38b..85ace93445b6 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -52,17 +52,29 @@ jobs: python-version: '3.11' extra-requirements: '-c requirements/testing/minver.txt' delete-font-cache: true + # https://github.com/matplotlib/matplotlib/issues/29844 + pygobject-ver: '<3.52.0' - os: ubuntu-22.04 python-version: '3.11' CFLAGS: "-fno-lto" # Ensure that disabling LTO works. extra-requirements: '-r requirements/testing/extra.txt' + # https://github.com/matplotlib/matplotlib/issues/29844 + pygobject-ver: '<3.52.0' - os: ubuntu-22.04-arm python-version: '3.12' - - os: ubuntu-22.04 + # https://github.com/matplotlib/matplotlib/issues/29844 + pygobject-ver: '<3.52.0' + - name-suffix: "(Extra TeX packages)" + os: ubuntu-22.04 python-version: '3.13' + extra-packages: 'texlive-fonts-extra texlive-lang-cyrillic' + # https://github.com/matplotlib/matplotlib/issues/29844 + pygobject-ver: '<3.52.0' - name-suffix: "Free-threaded" os: ubuntu-22.04 python-version: '3.13t' + # https://github.com/matplotlib/matplotlib/issues/29844 + pygobject-ver: '<3.52.0' - os: ubuntu-24.04 python-version: '3.12' - os: macos-13 # This runner is on Intel chips. @@ -132,7 +144,8 @@ jobs: texlive-latex-recommended \ texlive-luatex \ texlive-pictures \ - texlive-xetex + texlive-xetex \ + ${{ matrix.extra-packages }} if [[ "${{ matrix.name-suffix }}" != '(Minimum Versions)' ]]; then sudo apt-get install -yy --no-install-recommends ffmpeg poppler-utils fi @@ -333,13 +346,15 @@ jobs: run: | function remove_files() { local extension=$1 - find ./result_images -type f -name "*-expected*.$extension" | while read file; do + find ./result_images -name "*-expected*.$extension" | while read file; do if [[ $file == *"-expected_pdf"* ]]; then base=${file%-expected_pdf.$extension}_pdf elif [[ $file == *"-expected_eps"* ]]; then base=${file%-expected_eps.$extension}_eps elif [[ $file == *"-expected_svg"* ]]; then base=${file%-expected_svg.$extension}_svg + elif [[ $file == *"-expected_gif"* ]]; then + base=${file%-expected_gif.$extension}_gif else base=${file%-expected.$extension} fi @@ -356,7 +371,7 @@ jobs: done } - remove_files "png"; remove_files "svg"; remove_files "pdf"; remove_files "eps"; + remove_files "png"; remove_files "svg"; remove_files "pdf"; remove_files "eps"; remove_files "gif"; if [ "$(find ./result_images -mindepth 1 -type d)" ]; then find ./result_images/* -type d -empty -delete @@ -386,7 +401,7 @@ jobs: fi - name: Upload code coverage if: ${{ !cancelled() && github.event_name != 'schedule' }} - uses: codecov/codecov-action@ad3126e916f78f00edff4ed0317cf185271ccc2d # v5.4.2 + uses: codecov/codecov-action@18283e04ce6e62d37312384ff67231eb8fd56d24 # v5.4.3 with: name: "${{ matrix.python-version }} ${{ matrix.os }} ${{ matrix.name-suffix }}" token: ${{ secrets.CODECOV_TOKEN }} diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index afcdc44c1b4a..86a9a0f45440 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -10,7 +10,7 @@ exclude: | doc/devel/gitwash| doc/users/prev| doc/api/prev| - lib/matplotlib/tests/tinypages + lib/matplotlib/tests/data/tinypages ) repos: - repo: https://github.com/pre-commit/pre-commit-hooks diff --git a/doc/_embedded_plots/figure_subplots_adjust.py b/doc/_embedded_plots/figure_subplots_adjust.py index b4b8d7d32a3d..6f99a3febcdc 100644 --- a/doc/_embedded_plots/figure_subplots_adjust.py +++ b/doc/_embedded_plots/figure_subplots_adjust.py @@ -1,28 +1,34 @@ import matplotlib.pyplot as plt -def arrow(p1, p2, **props): - axs[0, 0].annotate( - "", p1, p2, xycoords='figure fraction', - arrowprops=dict(arrowstyle="<->", shrinkA=0, shrinkB=0, **props)) - - fig, axs = plt.subplots(2, 2, figsize=(6.5, 4)) fig.set_facecolor('lightblue') fig.subplots_adjust(0.1, 0.1, 0.9, 0.9, 0.4, 0.4) + +overlay = fig.add_axes([0, 0, 1, 1], zorder=100) +overlay.axis("off") +xycoords='figure fraction' +arrowprops=dict(arrowstyle="<->", shrinkA=0, shrinkB=0) + for ax in axs.flat: ax.set(xticks=[], yticks=[]) -arrow((0, 0.75), (0.1, 0.75)) # left -arrow((0.435, 0.75), (0.565, 0.75)) # wspace -arrow((0.9, 0.75), (1, 0.75)) # right +overlay.annotate("", (0, 0.75), (0.1, 0.75), + xycoords=xycoords, arrowprops=arrowprops) # left +overlay.annotate("", (0.435, 0.25), (0.565, 0.25), + xycoords=xycoords, arrowprops=arrowprops) # wspace +overlay.annotate("", (0, 0.8), (0.9, 0.8), + xycoords=xycoords, arrowprops=arrowprops) # right fig.text(0.05, 0.7, "left", ha="center") -fig.text(0.5, 0.7, "wspace", ha="center") -fig.text(0.95, 0.7, "right", ha="center") +fig.text(0.5, 0.3, "wspace", ha="center") +fig.text(0.05, 0.83, "right", ha="center") -arrow((0.25, 0), (0.25, 0.1)) # bottom -arrow((0.25, 0.435), (0.25, 0.565)) # hspace -arrow((0.25, 0.9), (0.25, 1)) # top -fig.text(0.28, 0.05, "bottom", va="center") +overlay.annotate("", (0.75, 0), (0.75, 0.1), + xycoords=xycoords, arrowprops=arrowprops) # bottom +overlay.annotate("", (0.25, 0.435), (0.25, 0.565), + xycoords=xycoords, arrowprops=arrowprops) # hspace +overlay.annotate("", (0.8, 0), (0.8, 0.9), + xycoords=xycoords, arrowprops=arrowprops) # top +fig.text(0.65, 0.05, "bottom", va="center") fig.text(0.28, 0.5, "hspace", va="center") -fig.text(0.28, 0.95, "top", va="center") +fig.text(0.82, 0.05, "top", va="center") diff --git a/doc/_embedded_plots/grouped_bar.py b/doc/_embedded_plots/grouped_bar.py new file mode 100644 index 000000000000..f02e269328d2 --- /dev/null +++ b/doc/_embedded_plots/grouped_bar.py @@ -0,0 +1,15 @@ +import matplotlib.pyplot as plt + +categories = ['A', 'B'] +data0 = [1.0, 3.0] +data1 = [1.4, 3.4] +data2 = [1.8, 3.8] + +fig, ax = plt.subplots(figsize=(4, 2.2)) +ax.grouped_bar( + [data0, data1, data2], + tick_labels=categories, + labels=['dataset 0', 'dataset 1', 'dataset 2'], + colors=['#1f77b4', '#58a1cf', '#abd0e6'], +) +ax.legend() diff --git a/doc/_static/switcher.json b/doc/_static/switcher.json index 8798dae4b36b..62c8ed756824 100644 --- a/doc/_static/switcher.json +++ b/doc/_static/switcher.json @@ -1,7 +1,7 @@ [ { "name": "3.10 (stable)", - "version": "3.10.1", + "version": "3.10.3", "url": "https://matplotlib.org/stable/", "preferred": true }, diff --git a/doc/api/axes_api.rst b/doc/api/axes_api.rst index 4bbcbe081194..b742ce9b7a55 100644 --- a/doc/api/axes_api.rst +++ b/doc/api/axes_api.rst @@ -67,6 +67,7 @@ Basic Axes.bar Axes.barh Axes.bar_label + Axes.grouped_bar Axes.stem Axes.eventplot diff --git a/doc/api/bezier_api.rst b/doc/api/bezier_api.rst index b3764ad04b5a..45019153fa63 100644 --- a/doc/api/bezier_api.rst +++ b/doc/api/bezier_api.rst @@ -5,4 +5,5 @@ .. automodule:: matplotlib.bezier :members: :undoc-members: + :special-members: __call__ :show-inheritance: diff --git a/doc/api/colors_api.rst b/doc/api/colors_api.rst index 6b02f723d74d..49a42c8f9601 100644 --- a/doc/api/colors_api.rst +++ b/doc/api/colors_api.rst @@ -21,6 +21,7 @@ Color norms :toctree: _as_gen/ :template: autosummary.rst + Norm Normalize NoNorm AsinhNorm diff --git a/doc/api/next_api_changes/deprecations/ 29529-TH.rst b/doc/api/next_api_changes/deprecations/29529-TH.rst similarity index 100% rename from doc/api/next_api_changes/deprecations/ 29529-TH.rst rename to doc/api/next_api_changes/deprecations/29529-TH.rst diff --git a/doc/api/next_api_changes/deprecations/30027-AL.rst b/doc/api/next_api_changes/deprecations/30027-AL.rst index 1cbd0340fda6..ed65d9391371 100644 --- a/doc/api/next_api_changes/deprecations/30027-AL.rst +++ b/doc/api/next_api_changes/deprecations/30027-AL.rst @@ -1,3 +1,3 @@ -``PdfFile.fontNames``, ``PdfFile.dviFontNames``, ``PdfFile.type1Descriptors`` -~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ +``PdfFile.fontNames``, ``PdfFile.dviFontInfo``, ``PdfFile.type1Descriptors`` +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ ... are deprecated with no replacement. diff --git a/doc/api/next_api_changes/deprecations/30044-AL.rst b/doc/api/next_api_changes/deprecations/30044-AL.rst new file mode 100644 index 000000000000..e004d5f2730f --- /dev/null +++ b/doc/api/next_api_changes/deprecations/30044-AL.rst @@ -0,0 +1,12 @@ +``FT2Image`` +~~~~~~~~~~~~ +... is deprecated. Use 2D uint8 ndarrays instead. In particular: + +- The ``FT2Image`` constructor took ``width, height`` as separate parameters + but the ndarray constructor takes ``(height, width)`` as single tuple + parameter. +- `.FT2Font.draw_glyph_to_bitmap` now (also) takes 2D uint8 arrays as input. +- ``FT2Image.draw_rect_filled`` should be replaced by directly setting pixel + values to black. +- The ``image`` attribute of the object returned by ``MathTextParser("agg").parse`` + is now a 2D uint8 array. diff --git a/doc/api/next_api_changes/deprecations/30070-OG.rst b/doc/api/next_api_changes/deprecations/30070-OG.rst new file mode 100644 index 000000000000..98786bcfa1d2 --- /dev/null +++ b/doc/api/next_api_changes/deprecations/30070-OG.rst @@ -0,0 +1,4 @@ +``BezierSegment.point_at_t`` +~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +... is deprecated. Instead, it is possible to call the BezierSegment with an argument. diff --git a/doc/api/next_api_changes/deprecations/30088-AL.rst b/doc/api/next_api_changes/deprecations/30088-AL.rst new file mode 100644 index 000000000000..ae1338da7f85 --- /dev/null +++ b/doc/api/next_api_changes/deprecations/30088-AL.rst @@ -0,0 +1,4 @@ +*fontfile* parameter of ``PdfFile.createType1Descriptor`` +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ +This parameter is deprecated; all relevant pieces of information are now +directly extracted from the *t1font* argument. diff --git a/doc/api/next_api_changes/deprecations/30163-AL.rst b/doc/api/next_api_changes/deprecations/30163-AL.rst new file mode 100644 index 000000000000..15d0077375f2 --- /dev/null +++ b/doc/api/next_api_changes/deprecations/30163-AL.rst @@ -0,0 +1,9 @@ +``matplotlib.style.core`` +~~~~~~~~~~~~~~~~~~~~~~~~~ +The ``matplotlib.style.core`` module is deprecated. All APIs intended for +public use are now available in `matplotlib.style` directly (including +``USER_LIBRARY_PATHS``, which was previously not reexported). + +The following APIs of ``matplotlib.style.core`` have been deprecated with no +replacement: ``BASE_LIBRARY_PATH``, ``STYLE_EXTENSION``, ``STYLE_BLACKLIST``, +``update_user_library``, ``read_style_directory``, ``update_nested_dict``. diff --git a/doc/api/next_api_changes/removals/30004-DS.rst b/doc/api/next_api_changes/removals/30004-DS.rst new file mode 100644 index 000000000000..f5fdf214366c --- /dev/null +++ b/doc/api/next_api_changes/removals/30004-DS.rst @@ -0,0 +1,10 @@ +``apply_theta_transforms`` option in ``PolarTransform`` +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +Applying theta transforms in `~matplotlib.projections.polar.PolarTransform` and +`~matplotlib.projections.polar.InvertedPolarTransform` has been removed, and +the ``apply_theta_transforms`` keyword argument removed from both classes. + +If you need to retain the behaviour where theta values +are transformed, chain the ``PolarTransform`` with a `~matplotlib.transforms.Affine2D` +transform that performs the theta shift and/or sign shift. diff --git a/doc/api/next_api_changes/removals/30005-DS.rst b/doc/api/next_api_changes/removals/30005-DS.rst new file mode 100644 index 000000000000..a5ba482c848f --- /dev/null +++ b/doc/api/next_api_changes/removals/30005-DS.rst @@ -0,0 +1,11 @@ +``matplotlib.cm.get_cmap`` +~~~~~~~~~~~~~~~~~~~~~~~~~~ + +Colormaps are now available through the `.ColormapRegistry` accessible via +`matplotlib.colormaps` or `matplotlib.pyplot.colormaps`. + +If you have the name of a colormap as a string, you can use a direct lookup, +``matplotlib.colormaps[name]`` or ``matplotlib.pyplot.colormaps[name]`` . Alternatively, ``matplotlib.colormaps.get_cmap`` will +maintain the existing behavior of additionally passing through `.Colormap` instances +and converting ``None`` to the default colormap. `matplotlib.pyplot.get_cmap` will stay as a +shortcut to ``matplotlib.colormaps.get_cmap``. diff --git a/doc/api/next_api_changes/removals/30067-OG.rst b/doc/api/next_api_changes/removals/30067-OG.rst new file mode 100644 index 000000000000..1a8d8bc5c2c5 --- /dev/null +++ b/doc/api/next_api_changes/removals/30067-OG.rst @@ -0,0 +1,23 @@ +``TransformNode.is_bbox`` +^^^^^^^^^^^^^^^^^^^^^^^^^ + +... is removed. Instead check the object using ``isinstance(..., BboxBase)``. + +``rcsetup.interactive_bk``, ``rcsetup.non_interactive_bk`` and ``rcsetup.all_backends`` +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +... are removed and replaced by ``matplotlib.backends.backend_registry.list_builtin`` +with the following arguments + +- ``matplotlib.backends.BackendFilter.INTERACTIVE`` +- ``matplotlib.backends.BackendFilter.NON_INTERACTIVE`` +- ``None`` + +``BboxTransformToMaxOnly`` +^^^^^^^^^^^^^^^^^^^^^^^^^^ + +... is removed. It can be replaced by ``BboxTransformTo(LockableBbox(bbox, x0=0, y0=0))``. + +*interval* parameter of ``TimerBase.start`` +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ +The timer interval parameter can no longer be set while starting it. The interval can be specified instead in the timer constructor, or by setting the timer.interval attribute. diff --git a/doc/api/prev_api_changes/api_changes_3.5.0/deprecations.rst b/doc/api/prev_api_changes/api_changes_3.5.0/deprecations.rst index 05f42035f1ac..04836687f76a 100644 --- a/doc/api/prev_api_changes/api_changes_3.5.0/deprecations.rst +++ b/doc/api/prev_api_changes/api_changes_3.5.0/deprecations.rst @@ -282,7 +282,7 @@ Miscellaneous deprecations - The *format* parameter of ``dviread.find_tex_file`` is deprecated (with no replacement). - ``FancyArrowPatch.get_path_in_displaycoord`` and - ``ConnectionPath.get_path_in_displaycoord`` are deprecated. The path in + ``ConnectionPatch.get_path_in_displaycoord`` are deprecated. The path in display coordinates can still be obtained, as for other patches, using ``patch.get_transform().transform_path(patch.get_path())``. - The ``font_manager.win32InstalledFonts`` and diff --git a/doc/api/prev_api_changes/api_changes_3.7.0/removals.rst b/doc/api/prev_api_changes/api_changes_3.7.0/removals.rst index 03239be31057..56b3ad5c253e 100644 --- a/doc/api/prev_api_changes/api_changes_3.7.0/removals.rst +++ b/doc/api/prev_api_changes/api_changes_3.7.0/removals.rst @@ -323,7 +323,7 @@ Miscellaneous removals - The *format* parameter of ``dviread.find_tex_file`` is removed (with no replacement). - ``FancyArrowPatch.get_path_in_displaycoord`` and - ``ConnectionPath.get_path_in_displaycoord`` are removed. The path in + ``ConnectionPatch.get_path_in_displaycoord`` are removed. The path in display coordinates can still be obtained, as for other patches, using ``patch.get_transform().transform_path(patch.get_path())``. - The ``font_manager.win32InstalledFonts`` and diff --git a/doc/api/pyplot_summary.rst b/doc/api/pyplot_summary.rst index cdd57bfe6276..c4a860fd2590 100644 --- a/doc/api/pyplot_summary.rst +++ b/doc/api/pyplot_summary.rst @@ -60,6 +60,7 @@ Basic bar barh bar_label + grouped_bar stem eventplot pie diff --git a/doc/devel/api_changes.rst b/doc/devel/api_changes.rst index 61467f99f0c5..19bc530abf6b 100644 --- a/doc/devel/api_changes.rst +++ b/doc/devel/api_changes.rst @@ -67,6 +67,10 @@ have to learn new API and have to modify existing code). You can start simple and look at the search results, if there are too many incorrect matches, gradually refine your search criteria. + It can also be helpful to add ``NOT path:**/matplotlib/** NOT path:**/site-packages/**`` + to exclude matches where the matplotlib codebase is checked into another repo, + either as direct sources or as part of an environment. + *Example*: Calls of the method ``Figure.draw()`` could be matched using ``/\bfig(ure)?\.draw\(/``. This expression employs a number of patterns: diff --git a/doc/devel/document.rst b/doc/devel/document.rst index d40d281f8bb9..1119a265a80d 100644 --- a/doc/devel/document.rst +++ b/doc/devel/document.rst @@ -399,11 +399,14 @@ expression in the Matplotlib figure. In these cases, you can use the .. _writing-docstrings: -Write docstrings -================ +Write API documentation +======================= -Most of the API documentation is written in docstrings. These are comment -blocks in source code that explain how the code works. +The API reference documentation describes the library interfaces, e.g. inputs, outputs, +and expected behavior. Most of the API documentation is written in docstrings. These are +comment blocks in source code that explain how the code works. All docstrings should +conform to the `numpydoc docstring guide`_. Much of the ReST_ syntax discussed above +(:ref:`writing-rest-pages`) can be used for links and references. .. note:: @@ -412,11 +415,11 @@ blocks in source code that explain how the code works. you may see in the source code. Pull requests updating docstrings to the current style are very welcome. -All new or edited docstrings should conform to the `numpydoc docstring guide`_. -Much of the ReST_ syntax discussed above (:ref:`writing-rest-pages`) can be -used for links and references. These docstrings eventually populate the -:file:`doc/api` directory and form the reference documentation for the -library. +The pages in :file:`doc/api` are purely technical definitions of +layout; therefore new API reference documentation should be added to the module +docstrings. This placement keeps all API reference documentation about a module in the +same file. These module docstrings eventually populate the :file:`doc/api` directory +and form the reference documentation for the library. Example docstring ----------------- @@ -534,6 +537,10 @@ understandable by humans. If the possible types are too complex use a simplification for the type description and explain the type more precisely in the text. +We do not use formal type annotation syntax for type descriptions in +docstrings; e.g. we use ``list of str`` rather than ``list[str]``; we +use ``int or str`` rather than ``int | str`` or ``Union[int, str]``. + Generally, the `numpydoc docstring guide`_ conventions apply. The following rules expand on them where the numpydoc conventions are not specific. @@ -866,6 +873,26 @@ Plots can also be directly placed inside docstrings. Details are in An advantage of this style over referencing an example script is that the code will also appear in interactive docstrings. +.. _inheritance-diagrams: + +Generate inheritance diagrams +----------------------------- + +Class inheritance diagrams can be generated with the Sphinx +`inheritance-diagram`_ directive. + +.. _inheritance-diagram: https://www.sphinx-doc.org/en/master/usage/extensions/inheritance.html + +Example: + +.. code-block:: rst + + .. inheritance-diagram:: matplotlib.patches matplotlib.lines matplotlib.text + :parts: 2 + +.. inheritance-diagram:: matplotlib.patches matplotlib.lines matplotlib.text + :parts: 2 + .. _writing-examples-and-tutorials: Write examples and tutorials @@ -1115,6 +1142,28 @@ The current width limit (induced by *pydata-sphinx-theme*) is 720px, i.e. ``figsize=(7.2, ...)``, or 896px if the page does not have subsections and thus does not have the "On this page" navigation on the right-hand side. + +Plot types guidelines +--------------------- + +The :ref:`plot_types` gallery provides an overview of the types of visualizations that +Matplotlib provides out of the box, meaning that there is a high-level API for +generating each type of chart. Additions to this gallery are generally discouraged +because this gallery is heavily curated and tightly scoped to methods on +`matplotlib.axes.Axes`. + +Format +^^^^^^ +:title: Method signature with required arguments, e.g. ``plot(x, y)`` +:description: In one sentence, describe the visualization that the method produces and + link to the API documentation, e.g. *Draws a bar chart. See ~Axes.bar*. + When necessary, add an additional sentence explaining the use case for + this function vs a very similar one, e.g. stairs vs step. +:plot: Use data with a self explanatory structure to illustrate the type of data this + plotting method is typically used for. +:code: The code should be about 5-10 lines with minimal customization. Plots in + this gallery use the ``_mpl-gallery`` stylesheet for a uniform aesthetic. + Miscellaneous ============= @@ -1151,28 +1200,6 @@ Use the full path for this directive, relative to the doc root at found by users at ``http://matplotlib.org/stable/old_topic/old_info2``. For clarity, do not use relative links. - -.. _inheritance-diagrams: - -Generate inheritance diagrams ------------------------------ - -Class inheritance diagrams can be generated with the Sphinx -`inheritance-diagram`_ directive. - -.. _inheritance-diagram: https://www.sphinx-doc.org/en/master/usage/extensions/inheritance.html - -Example: - -.. code-block:: rst - - .. inheritance-diagram:: matplotlib.patches matplotlib.lines matplotlib.text - :parts: 2 - -.. inheritance-diagram:: matplotlib.patches matplotlib.lines matplotlib.text - :parts: 2 - - Navbar and style ---------------- diff --git a/doc/devel/release_guide.rst b/doc/devel/release_guide.rst index 0e0ebb98fd1d..6c45bfa56c64 100644 --- a/doc/devel/release_guide.rst +++ b/doc/devel/release_guide.rst @@ -233,15 +233,24 @@ Update :file:`doc/users/release_notes.rst`: ../api/prev_api_changes/api_changes_X.Y.Z.rst prev_whats_new/github_stats_X.Y.Z.rst +.. _update-version-switcher: + Update version switcher -^^^^^^^^^^^^^^^^^^^^^^^ +----------------------- + +The version switcher is populated from https://matplotlib.org/devdocs/_static/switcher.json. + +Since it's always taken from devdocs, update the file :file:`doc/_static/switcher.json` +on the main branch through a regular PR: -Update ``doc/_static/switcher.json``: +- If a micro release, update the version from :samp:`{X}.{Y}.{Z-1}` to :samp:`{X}.{Y}.{Z}` +- If a meso release :samp:`{X}.{Y}.0`: -- If a micro release, :samp:`{X}.{Y}.{Z}`, no changes are needed. -- If a meso release, :samp:`{X}.{Y}.0`, change the name of :samp:`name: {X}.{Y+1} (dev)` - and :samp:`name: {X}.{Y} (stable)` as well as adding a new version for the previous - stable (:samp:`name: {X}.{Y-1}`). + + update the dev entry to :samp:`name: {X}.{Y+1} (dev)` + + update the stable entry to :samp:`name: {X}.{Y} (stable)` + + add a new entry for the previous stable (:samp:`name: {X}.{Y-1}`). + +Once that PR is merged, the devdocs site will be updated automatically. Verify that docs build ---------------------- @@ -451,7 +460,7 @@ which will copy the built docs over. If this is a final release, link the rm stable ln -s 3.7.0 stable -You will also need to edit :file:`sitemap.xml` and :file:`versions.html` to include +You will also need to edit :file:`sitemap.xml` to include the newly released version. Now commit and push everything to GitHub :: git add * @@ -465,6 +474,8 @@ If you have access, clear the CloudFlare caches. It typically takes about 5-10 minutes for the website to process the push and update the live web page (remember to clear your browser cache). +Remember to :ref:`update the version switcher `! + .. _release_merge_up: Merge up changes to main diff --git a/doc/devel/tag_guidelines.rst b/doc/devel/tag_guidelines.rst index 2c80065982bc..2ff77d5279d5 100644 --- a/doc/devel/tag_guidelines.rst +++ b/doc/devel/tag_guidelines.rst @@ -61,7 +61,7 @@ Proposing new tags 1. Review existing tag list, looking out for similar entries (i.e. ``axes`` and ``axis``). 2. If a relevant tag or subcategory does not yet exist, propose it. Each tag is two parts: ``subcategory: tag``. Tags should be one or two words. -3. New tags should be be added when they are relevant to existing gallery entries too. +3. New tags should be added when they are relevant to existing gallery entries too. Avoid tags that will link to only a single gallery entry. 4. Tags can recreate other forms of organization. diff --git a/doc/install/dependencies.rst b/doc/install/dependencies.rst index f2fda95a5f77..4b006d9016e2 100644 --- a/doc/install/dependencies.rst +++ b/doc/install/dependencies.rst @@ -422,6 +422,8 @@ Python packages and must be installed separately. * a LaTeX distribution, e.g. `TeX Live `_ or `MikTeX `_ +.. _tex-dependencies: + LaTeX dependencies """""""""""""""""" @@ -441,7 +443,8 @@ will often automatically include these collections. | | `lm `_, | | | `txfonts `_ | +-----------------------------+--------------------------------------------------+ -| collection-latex | `geometry `_, | +| collection-latex | `fix-cm `_, | +| | `geometry `_, | | | `hyperref `_, | | | `latex `_, | | | latex-bin, | diff --git a/doc/project/history.rst b/doc/project/history.rst index 966b7a3caa38..7f148902898b 100644 --- a/doc/project/history.rst +++ b/doc/project/history.rst @@ -157,7 +157,7 @@ Matplotlib logo (2008 - 2015). def add_math_background(): - ax = fig.add_axes([0., 0., 1., 1.]) + ax = fig.add_axes((0., 0., 1., 1.)) text = [] text.append( @@ -187,7 +187,7 @@ Matplotlib logo (2008 - 2015). def add_polar_bar(): - ax = fig.add_axes([0.025, 0.075, 0.2, 0.85], projection='polar') + ax = fig.add_axes((0.025, 0.075, 0.2, 0.85), projection='polar') ax.patch.set_alpha(axalpha) ax.set_axisbelow(True) diff --git a/doc/users/faq.rst b/doc/users/faq.rst index b08bd75cee4e..c6bbc5ca8d87 100644 --- a/doc/users/faq.rst +++ b/doc/users/faq.rst @@ -281,8 +281,23 @@ locators as desired because the two axes are independent. Generate images without having a window appear ---------------------------------------------- -Simply do not call `~matplotlib.pyplot.show`, and directly save the figure to -the desired format:: +The recommended approach since matplotlib 3.1 is to explicitly create a Figure +instance:: + + from matplotlib.figure import Figure + fig = Figure() + ax = fig.subplots() + ax.plot([1, 2, 3]) + fig.savefig('myfig.png') + +This prevents any interaction with GUI frameworks and the window manager. + +It's alternatively still possible to use the pyplot interface. Instead of +calling `matplotlib.pyplot.show`, call `matplotlib.pyplot.savefig`. + +Additionally, you must ensure to close the figure after saving it. Not +closing the figure is a memory leak, because pyplot keeps references +to all not-yet-shown figures:: import matplotlib.pyplot as plt plt.plot([1, 2, 3]) diff --git a/doc/users/next_whats_new/grouped_bar.rst b/doc/users/next_whats_new/grouped_bar.rst new file mode 100644 index 000000000000..af57c71b8a3a --- /dev/null +++ b/doc/users/next_whats_new/grouped_bar.rst @@ -0,0 +1,26 @@ +Grouped bar charts +------------------ + +The new method `~.Axes.grouped_bar()` simplifies the creation of grouped bar charts +significantly. It supports different input data types (lists of datasets, dicts of +datasets, data in 2D arrays, pandas DataFrames), and allows for easy customization +of placement via controllable distances between bars and between bar groups. + +Example: + +.. plot:: + :include-source: true + :alt: Diagram of a grouped bar chart of 3 datasets with 2 categories. + + import matplotlib.pyplot as plt + + categories = ['A', 'B'] + datasets = { + 'dataset 0': [1, 11], + 'dataset 1': [3, 13], + 'dataset 2': [5, 15], + } + + fig, ax = plt.subplots() + ax.grouped_bar(datasets, tick_labels=categories) + ax.legend() diff --git a/doc/users/next_whats_new/new_rcparams_grid_options.rst b/doc/users/next_whats_new/new_rcparams_grid_options.rst new file mode 100644 index 000000000000..c2c0455eecbb --- /dev/null +++ b/doc/users/next_whats_new/new_rcparams_grid_options.rst @@ -0,0 +1,33 @@ +Separate styling options for major/minor grid line in rcParams +-------------------------------------------------------------- + +Using :rc:`grid.major.*` or :rc:`grid.minor.*` will overwrite the value in +:rc:`grid.*` for the major and minor gridlines, respectively. + +.. plot:: + :include-source: true + :alt: Modifying the gridlines using the new options `rcParams` + + import matplotlib as mpl + import matplotlib.pyplot as plt + + + # Set visibility for major and minor gridlines + mpl.rcParams["axes.grid"] = True + mpl.rcParams["ytick.minor.visible"] = True + mpl.rcParams["xtick.minor.visible"] = True + mpl.rcParams["axes.grid.which"] = "both" + + # Using old values to set both major and minor properties + mpl.rcParams["grid.color"] = "red" + mpl.rcParams["grid.linewidth"] = 1 + + # Overwrite some values for major and minor separately + mpl.rcParams["grid.major.color"] = "black" + mpl.rcParams["grid.major.linewidth"] = 2 + mpl.rcParams["grid.minor.linestyle"] = ":" + mpl.rcParams["grid.minor.alpha"] = 0.6 + + plt.plot([0, 1], [0, 1]) + + plt.show() diff --git a/doc/users/next_whats_new/six_and_eight_color_petroff_color_cycles.rst b/doc/users/next_whats_new/six_and_eight_color_petroff_color_cycles.rst new file mode 100644 index 000000000000..3b17b4f68868 --- /dev/null +++ b/doc/users/next_whats_new/six_and_eight_color_petroff_color_cycles.rst @@ -0,0 +1,21 @@ +Six and eight color Petroff color cycles +---------------------------------------- + +The six and eight color accessible Petroff color cycles are named 'petroff6' and +'petroff8'. +They compliment the existing 'petroff10' color cycle, added in `Matplotlib 3.10.0`_ + +For more details see +`Petroff, M. A.: "Accessible Color Sequences for Data Visualization" +`_. +To load the 'petroff6' color cycle in place of the default:: + + import matplotlib.pyplot as plt + plt.style.use('petroff6') + +or to load the 'petroff8' color cycle:: + + import matplotlib.pyplot as plt + plt.style.use('petroff8') + +.. _Matplotlib 3.10.0: https://matplotlib.org/stable/users/prev_whats_new/whats_new_3.10.0.html#new-more-accessible-color-cycle diff --git a/doc/users/next_whats_new/type1_subset.rst b/doc/users/next_whats_new/type1_subset.rst new file mode 100644 index 000000000000..b0ab0a4337e6 --- /dev/null +++ b/doc/users/next_whats_new/type1_subset.rst @@ -0,0 +1,9 @@ +PDF files created with usetex now embed subsets of Type 1 fonts +--------------------------------------------------------------- + +When using the PDF backend with the usetex feature, +Matplotlib calls TeX to render the text and formulas in the figure. +The fonts that get used are usually "Type 1" fonts. +They used to be embedded in full +but are now limited to the glyphs that are actually used in the figure. +This reduces the size of the resulting PDF files. diff --git a/doc/users/prev_whats_new/dflt_style_changes.rst b/doc/users/prev_whats_new/dflt_style_changes.rst index a833064b573b..808204383fb8 100644 --- a/doc/users/prev_whats_new/dflt_style_changes.rst +++ b/doc/users/prev_whats_new/dflt_style_changes.rst @@ -1005,7 +1005,7 @@ a cleaner separation between subplots. ax = fig.add_subplot(2, 2, j) ax.hist(np.random.beta(0.5, 0.5, 10000), 25, density=True) - ax.set_xlim([0, 1]) + ax.set_xlim(0, 1) ax.set_title(title) ax = fig.add_subplot(2, 2, j + 2) diff --git a/doc/users/prev_whats_new/whats_new_3.5.0.rst b/doc/users/prev_whats_new/whats_new_3.5.0.rst index e67573702218..fb156d0c68e8 100644 --- a/doc/users/prev_whats_new/whats_new_3.5.0.rst +++ b/doc/users/prev_whats_new/whats_new_3.5.0.rst @@ -274,9 +274,9 @@ of the text inside the Axes of the `.TextBox` widget. fig = plt.figure(figsize=(4, 3)) for i, alignment in enumerate(['left', 'center', 'right']): - box_input = fig.add_axes([0.1, 0.7 - i*0.3, 0.8, 0.2]) - text_box = TextBox(ax=box_input, initial=f'{alignment} alignment', - label='', textalignment=alignment) + box_input = fig.add_axes((0.1, 0.7 - i*0.3, 0.8, 0.2)) + text_box = TextBox(ax=box_input, initial=f'{alignment} alignment', + label='', textalignment=alignment) Simplifying the font setting for usetex mode -------------------------------------------- @@ -375,9 +375,9 @@ attribute. points = ax.scatter((3, 3), (1, 3), (1, 3), c='red', zorder=10, label='zorder=10') - ax.set_xlim((0, 5)) - ax.set_ylim((0, 5)) - ax.set_zlim((0, 2.5)) + ax.set_xlim(0, 5) + ax.set_ylim(0, 5) + ax.set_zlim(0, 2.5) plane = mpatches.Patch(facecolor='white', edgecolor='black', label='zorder=1') @@ -485,7 +485,7 @@ new styling parameters for the added handles. ax = ax_old valmin = 0 valinit = 0.5 - ax.set_xlim([0, 1]) + ax.set_xlim(0, 1) ax_old.axvspan(valmin, valinit, 0, 1) ax.axvline(valinit, 0, 1, color="r", lw=1) ax.set_xticks([]) diff --git a/doc/users/prev_whats_new/whats_new_3.8.0.rst b/doc/users/prev_whats_new/whats_new_3.8.0.rst index 88f987172adb..fe1d5f7a7952 100644 --- a/doc/users/prev_whats_new/whats_new_3.8.0.rst +++ b/doc/users/prev_whats_new/whats_new_3.8.0.rst @@ -359,7 +359,7 @@ The following delimiter names have been supported earlier, but can now be sized * ``\leftparen`` and ``\rightparen`` There are really no obvious advantages in using these. -Instead, they are are added for completeness. +Instead, they are added for completeness. ``mathtext`` documentation improvements --------------------------------------- @@ -513,7 +513,7 @@ Plot Directive now can make responsive images with "srcset" The plot sphinx directive (``matplotlib.sphinxext.plot_directive``, invoked in rst as ``.. plot::``) can be configured to automatically make higher res -figures and add these to the the built html docs. In ``conf.py``:: +figures and add these to the built html docs. In ``conf.py``:: extensions = [ ... diff --git a/doc/users/resources/index.rst b/doc/users/resources/index.rst index 7e2339ee8191..a31dbc83aa9d 100644 --- a/doc/users/resources/index.rst +++ b/doc/users/resources/index.rst @@ -82,6 +82,10 @@ Tutorials `_ by Stefanie Molin +* `Matplotlib Journey: Interactive Online Course + `_ + by Yan Holtz and Joseph Barbier + ========= Galleries ========= diff --git a/galleries/examples/animation/rain.py b/galleries/examples/animation/rain.py index eacfaecc59e2..a87eace6fe07 100644 --- a/galleries/examples/animation/rain.py +++ b/galleries/examples/animation/rain.py @@ -22,7 +22,7 @@ # Create new Figure and an Axes which fills it. fig = plt.figure(figsize=(7, 7)) -ax = fig.add_axes([0, 0, 1, 1], frameon=False) +ax = fig.add_axes((0, 0, 1, 1), frameon=False) ax.set_xlim(0, 1), ax.set_xticks([]) ax.set_ylim(0, 1), ax.set_yticks([]) diff --git a/galleries/examples/animation/simple_scatter.py b/galleries/examples/animation/simple_scatter.py index 3f8c285810a3..5afef75f6074 100644 --- a/galleries/examples/animation/simple_scatter.py +++ b/galleries/examples/animation/simple_scatter.py @@ -11,7 +11,7 @@ import matplotlib.animation as animation fig, ax = plt.subplots() -ax.set_xlim([0, 10]) +ax.set_xlim(0, 10) scat = ax.scatter(1, 0) x = np.linspace(0, 10) diff --git a/galleries/examples/axes_grid1/make_room_for_ylabel_using_axesgrid.py b/galleries/examples/axes_grid1/make_room_for_ylabel_using_axesgrid.py index f130ef4a6de2..7e914ff76b6b 100644 --- a/galleries/examples/axes_grid1/make_room_for_ylabel_using_axesgrid.py +++ b/galleries/examples/axes_grid1/make_room_for_ylabel_using_axesgrid.py @@ -10,7 +10,7 @@ from mpl_toolkits.axes_grid1.axes_divider import make_axes_area_auto_adjustable fig = plt.figure() -ax = fig.add_axes([0, 0, 1, 1]) +ax = fig.add_axes((0, 0, 1, 1)) ax.set_yticks([0.5], labels=["very long label"]) @@ -19,8 +19,8 @@ # %% fig = plt.figure() -ax1 = fig.add_axes([0, 0, 1, 0.5]) -ax2 = fig.add_axes([0, 0.5, 1, 0.5]) +ax1 = fig.add_axes((0, 0, 1, 0.5)) +ax2 = fig.add_axes((0, 0.5, 1, 0.5)) ax1.set_yticks([0.5], labels=["very long label"]) ax1.set_ylabel("Y label") @@ -33,7 +33,7 @@ # %% fig = plt.figure() -ax1 = fig.add_axes([0, 0, 1, 1]) +ax1 = fig.add_axes((0, 0, 1, 1)) divider = make_axes_locatable(ax1) ax2 = divider.append_axes("right", "100%", pad=0.3, sharey=ax1) diff --git a/galleries/examples/axisartist/demo_axis_direction.py b/galleries/examples/axisartist/demo_axis_direction.py index 8c57b6c5a351..9540599c6a7b 100644 --- a/galleries/examples/axisartist/demo_axis_direction.py +++ b/galleries/examples/axisartist/demo_axis_direction.py @@ -22,7 +22,7 @@ def setup_axes(fig, rect): grid_helper = GridHelperCurveLinear( ( Affine2D().scale(np.pi/180., 1.) + - PolarAxes.PolarTransform(apply_theta_transforms=False) + PolarAxes.PolarTransform() ), extreme_finder=angle_helper.ExtremeFinderCycle( 20, 20, diff --git a/galleries/examples/axisartist/demo_curvelinear_grid.py b/galleries/examples/axisartist/demo_curvelinear_grid.py index 40853dee12cb..fb1fbdd011ce 100644 --- a/galleries/examples/axisartist/demo_curvelinear_grid.py +++ b/galleries/examples/axisartist/demo_curvelinear_grid.py @@ -54,8 +54,7 @@ def curvelinear_test2(fig): # PolarAxes.PolarTransform takes radian. However, we want our coordinate # system in degree - tr = Affine2D().scale(np.pi/180, 1) + PolarAxes.PolarTransform( - apply_theta_transforms=False) + tr = Affine2D().scale(np.pi/180, 1) + PolarAxes.PolarTransform() # Polar projection, which involves cycle, and also has limits in # its coordinates, needs a special method to find the extremes # (min, max of the coordinate within the view). diff --git a/galleries/examples/axisartist/demo_floating_axes.py b/galleries/examples/axisartist/demo_floating_axes.py index 632f6d237aa6..add03e266d3e 100644 --- a/galleries/examples/axisartist/demo_floating_axes.py +++ b/galleries/examples/axisartist/demo_floating_axes.py @@ -54,7 +54,7 @@ def setup_axes2(fig, rect): With custom locator and formatter. Note that the extreme values are swapped. """ - tr = PolarAxes.PolarTransform(apply_theta_transforms=False) + tr = PolarAxes.PolarTransform() pi = np.pi angle_ticks = [(0, r"$0$"), @@ -99,8 +99,7 @@ def setup_axes3(fig, rect): # scale degree to radians tr_scale = Affine2D().scale(np.pi/180., 1.) - tr = tr_rotate + tr_scale + PolarAxes.PolarTransform( - apply_theta_transforms=False) + tr = tr_rotate + tr_scale + PolarAxes.PolarTransform() grid_locator1 = angle_helper.LocatorHMS(4) tick_formatter1 = angle_helper.FormatterHMS() diff --git a/galleries/examples/axisartist/demo_floating_axis.py b/galleries/examples/axisartist/demo_floating_axis.py index 5296b682367b..0894bf8f4ce1 100644 --- a/galleries/examples/axisartist/demo_floating_axis.py +++ b/galleries/examples/axisartist/demo_floating_axis.py @@ -22,8 +22,7 @@ def curvelinear_test2(fig): """Polar projection, but in a rectangular box.""" # see demo_curvelinear_grid.py for details - tr = Affine2D().scale(np.pi / 180., 1.) + PolarAxes.PolarTransform( - apply_theta_transforms=False) + tr = Affine2D().scale(np.pi / 180., 1.) + PolarAxes.PolarTransform() extreme_finder = angle_helper.ExtremeFinderCycle(20, 20, diff --git a/galleries/examples/axisartist/demo_parasite_axes.py b/galleries/examples/axisartist/demo_parasite_axes.py index 8565ef455c7e..800b9be32ac8 100644 --- a/galleries/examples/axisartist/demo_parasite_axes.py +++ b/galleries/examples/axisartist/demo_parasite_axes.py @@ -24,7 +24,7 @@ fig = plt.figure() -host = fig.add_axes([0.15, 0.1, 0.65, 0.8], axes_class=HostAxes) +host = fig.add_axes((0.15, 0.1, 0.65, 0.8), axes_class=HostAxes) par1 = host.get_aux_axes(viewlim_mode=None, sharex=host) par2 = host.get_aux_axes(viewlim_mode=None, sharex=host) diff --git a/galleries/examples/axisartist/simple_axis_pad.py b/galleries/examples/axisartist/simple_axis_pad.py index 95f30ce1ffbc..f40a1aa9f273 100644 --- a/galleries/examples/axisartist/simple_axis_pad.py +++ b/galleries/examples/axisartist/simple_axis_pad.py @@ -21,8 +21,7 @@ def setup_axes(fig, rect): """Polar projection, but in a rectangular box.""" # see demo_curvelinear_grid.py for details - tr = Affine2D().scale(np.pi/180., 1.) + PolarAxes.PolarTransform( - apply_theta_transforms=False) + tr = Affine2D().scale(np.pi/180., 1.) + PolarAxes.PolarTransform() extreme_finder = angle_helper.ExtremeFinderCycle(20, 20, lon_cycle=360, diff --git a/galleries/examples/color/color_sequences.py b/galleries/examples/color/color_sequences.py index 9a2fd04a53d0..4fc5571a0b69 100644 --- a/galleries/examples/color/color_sequences.py +++ b/galleries/examples/color/color_sequences.py @@ -38,7 +38,8 @@ def plot_color_sequences(names, ax): built_in_color_sequences = [ 'tab10', 'tab20', 'tab20b', 'tab20c', 'Pastel1', 'Pastel2', 'Paired', - 'Accent', 'Dark2', 'Set1', 'Set2', 'Set3', 'petroff10'] + 'Accent', 'Dark2', 'Set1', 'Set2', 'Set3', 'petroff6', 'petroff8', + 'petroff10'] fig, ax = plt.subplots(figsize=(6.4, 9.6), layout='constrained') diff --git a/galleries/examples/color/colorbar_histogram.py b/galleries/examples/color/colorbar_histogram.py new file mode 100644 index 000000000000..4a1a07e265a2 --- /dev/null +++ b/galleries/examples/color/colorbar_histogram.py @@ -0,0 +1,48 @@ +""" +===================== +Histogram as colorbar +===================== + +This example demonstrates how to use a colored histogram instead of a colorbar +to not only show the color-to-value mapping, but also visualize the +distribution of values. +""" + +import matplotlib.pyplot as plt +import numpy as np + +import matplotlib.colors as mcolors + +# surface data +delta = 0.025 +x = y = np.arange(-2.0, 2.0, delta) +X, Y = np.meshgrid(x, y) +Z1 = np.exp(-(((X + 1) * 1.3) ** 2) - ((Y + 1) * 1.3) ** 2) +Z2 = 2.5 * np.exp(-((X - 1) ** 2) - (Y - 1) ** 2) +Z = Z1**0.25 - Z2**0.5 + +# colormap & normalization +bins = 30 +cmap = plt.get_cmap("RdYlBu_r") +bin_edges = np.linspace(Z.min(), Z.max(), bins + 1) +norm = mcolors.BoundaryNorm(bin_edges, cmap.N) + +# main plot +fig, ax = plt.subplots(layout="constrained") +im = ax.imshow(Z, cmap=cmap, origin="lower", extent=[-3, 3, -3, 3], norm=norm) + +# inset histogram +cax = ax.inset_axes([1.18, 0.02, 0.25, 0.95]) # left, bottom, width, height + +# plot histogram +counts, _ = np.histogram(Z, bins=bin_edges) +midpoints = (bin_edges[:-1] + bin_edges[1:]) / 2 +distance = midpoints[1] - midpoints[0] +cax.barh(midpoints, counts, height=0.8 * distance, color=cmap(norm(midpoints))) + +# styling +cax.spines[:].set_visible(False) +cax.set_yticks(bin_edges) +cax.tick_params(axis="both", which="both", length=0) + +plt.show() diff --git a/galleries/examples/event_handling/poly_editor.py b/galleries/examples/event_handling/poly_editor.py index f6efd8bb8446..9cc2e5373ae5 100644 --- a/galleries/examples/event_handling/poly_editor.py +++ b/galleries/examples/event_handling/poly_editor.py @@ -203,6 +203,6 @@ def on_mouse_move(self, event): p = PolygonInteractor(ax, poly) ax.set_title('Click and drag a point to move it') - ax.set_xlim((-2, 2)) - ax.set_ylim((-2, 2)) + ax.set_xlim(-2, 2) + ax.set_ylim(-2, 2) plt.show() diff --git a/galleries/examples/event_handling/pong_sgskip.py b/galleries/examples/event_handling/pong_sgskip.py index 583e51eacdc5..2c4c35a7cb35 100644 --- a/galleries/examples/event_handling/pong_sgskip.py +++ b/galleries/examples/event_handling/pong_sgskip.py @@ -134,9 +134,9 @@ def __init__(self, ax): # create the initial line self.ax = ax ax.xaxis.set_visible(False) - ax.set_xlim([0, 7]) + ax.set_xlim(0, 7) ax.yaxis.set_visible(False) - ax.set_ylim([-1, 1]) + ax.set_ylim(-1, 1) pad_a_x = 0 pad_b_x = .50 pad_a_y = pad_b_y = .30 diff --git a/galleries/examples/images_contours_and_fields/barcode_demo.py b/galleries/examples/images_contours_and_fields/barcode_demo.py index bdf48ca22531..5df58535650d 100644 --- a/galleries/examples/images_contours_and_fields/barcode_demo.py +++ b/galleries/examples/images_contours_and_fields/barcode_demo.py @@ -30,7 +30,7 @@ dpi = 100 fig = plt.figure(figsize=(len(code) * pixel_per_bar / dpi, 2), dpi=dpi) -ax = fig.add_axes([0, 0, 1, 1]) # span the whole figure +ax = fig.add_axes((0, 0, 1, 1)) # span the whole figure ax.set_axis_off() ax.imshow(code.reshape(1, -1), cmap='binary', aspect='auto', interpolation='nearest') diff --git a/galleries/examples/images_contours_and_fields/image_antialiasing.py b/galleries/examples/images_contours_and_fields/image_antialiasing.py index 7f223f6998f2..10f563875767 100644 --- a/galleries/examples/images_contours_and_fields/image_antialiasing.py +++ b/galleries/examples/images_contours_and_fields/image_antialiasing.py @@ -245,7 +245,7 @@ # may serve a 100x100 version of the image, which will be downsampled.) fig = plt.figure(figsize=(2, 2)) -ax = fig.add_axes([0, 0, 1, 1]) +ax = fig.add_axes((0, 0, 1, 1)) ax.imshow(aa[:400, :400], cmap='RdBu_r', interpolation='nearest') plt.show() # %% diff --git a/galleries/examples/images_contours_and_fields/image_exact_placement.py b/galleries/examples/images_contours_and_fields/image_exact_placement.py index a3b314a7c7c3..7c667dfed1af 100644 --- a/galleries/examples/images_contours_and_fields/image_exact_placement.py +++ b/galleries/examples/images_contours_and_fields/image_exact_placement.py @@ -134,13 +134,13 @@ def annotate_rect(ax): fig = plt.figure(figsize=(fig_width / dpi, fig_height / dpi), facecolor='aliceblue') # the position posA must be normalized by the figure width and height: -ax = fig.add_axes([posA[0] / fig_width, posA[1] / fig_height, - posA[2] / fig_width, posA[3] / fig_height]) +ax = fig.add_axes((posA[0] / fig_width, posA[1] / fig_height, + posA[2] / fig_width, posA[3] / fig_height)) ax.imshow(A, vmin=-1, vmax=1) annotate_rect(ax) -ax = fig.add_axes([posB[0] / fig_width, posB[1] / fig_height, - posB[2] / fig_width, posB[3] / fig_height]) +ax = fig.add_axes((posB[0] / fig_width, posB[1] / fig_height, + posB[2] / fig_width, posB[3] / fig_height)) ax.imshow(B, vmin=-1, vmax=1) plt.show() # %% diff --git a/galleries/examples/images_contours_and_fields/multi_image.py b/galleries/examples/images_contours_and_fields/multi_image.py index 4e6f6cc54a79..11be73f3a267 100644 --- a/galleries/examples/images_contours_and_fields/multi_image.py +++ b/galleries/examples/images_contours_and_fields/multi_image.py @@ -11,15 +11,17 @@ value *x* in the image). If we want one colorbar to be representative for multiple images, we have -to explicitly ensure consistent data coloring by using the same data -normalization for all the images. We ensure this by explicitly creating a -``norm`` object that we pass to all the image plotting methods. +to explicitly ensure consistent data coloring by using the same +data-to-color pipeline for all the images. We ensure this by explicitly +creating a `matplotlib.colorizer.Colorizer` object that we pass to all +the image plotting methods. """ import matplotlib.pyplot as plt import numpy as np -from matplotlib import colors +import matplotlib.colorizer as mcolorizer +import matplotlib.colors as mcolors np.random.seed(19680801) @@ -31,12 +33,13 @@ fig, axs = plt.subplots(2, 2) fig.suptitle('Multiple images') -# create a single norm to be shared across all images -norm = colors.Normalize(vmin=np.min(datasets), vmax=np.max(datasets)) +# create a colorizer with a predefined norm to be shared across all images +norm = mcolors.Normalize(vmin=np.min(datasets), vmax=np.max(datasets)) +colorizer = mcolorizer.Colorizer(norm=norm) images = [] for ax, data in zip(axs.flat, datasets): - images.append(ax.imshow(data, norm=norm)) + images.append(ax.imshow(data, colorizer=colorizer)) fig.colorbar(images[0], ax=axs, orientation='horizontal', fraction=.1) @@ -45,30 +48,10 @@ # %% # The colors are now kept consistent across all images when changing the # scaling, e.g. through zooming in the colorbar or via the "edit axis, -# curves and images parameters" GUI of the Qt backend. This is sufficient -# for most practical use cases. -# -# Advanced: Additionally sync the colormap -# ---------------------------------------- -# -# Sharing a common norm object guarantees synchronized scaling because scale -# changes modify the norm object in-place and thus propagate to all images -# that use this norm. This approach does not help with synchronizing colormaps -# because changing the colormap of an image (e.g. through the "edit axis, -# curves and images parameters" GUI of the Qt backend) results in the image -# referencing the new colormap object. Thus, the other images are not updated. -# -# To update the other images, sync the -# colormaps using the following code:: -# -# def sync_cmaps(changed_image): -# for im in images: -# if changed_image.get_cmap() != im.get_cmap(): -# im.set_cmap(changed_image.get_cmap()) -# -# for im in images: -# im.callbacks.connect('changed', sync_cmaps) -# +# curves and images parameters" GUI of the Qt backend. Additionally, +# if the colormap of the colorizer is changed, (e.g. through the "edit +# axis, curves and images parameters" GUI of the Qt backend) this change +# propagates to the other plots and the colorbar. # # .. admonition:: References # @@ -77,6 +60,5 @@ # # - `matplotlib.axes.Axes.imshow` / `matplotlib.pyplot.imshow` # - `matplotlib.figure.Figure.colorbar` / `matplotlib.pyplot.colorbar` +# - `matplotlib.colorizer.Colorizer` # - `matplotlib.colors.Normalize` -# - `matplotlib.cm.ScalarMappable.set_cmap` -# - `matplotlib.cbook.CallbackRegistry.connect` diff --git a/galleries/examples/lines_bars_and_markers/barchart.py b/galleries/examples/lines_bars_and_markers/barchart.py index f2157a89c0cd..dbb0f5bbbadd 100644 --- a/galleries/examples/lines_bars_and_markers/barchart.py +++ b/galleries/examples/lines_bars_and_markers/barchart.py @@ -10,7 +10,6 @@ # data from https://allisonhorst.github.io/palmerpenguins/ import matplotlib.pyplot as plt -import numpy as np species = ("Adelie", "Chinstrap", "Gentoo") penguin_means = { @@ -19,22 +18,15 @@ 'Flipper Length': (189.95, 195.82, 217.19), } -x = np.arange(len(species)) # the label locations -width = 0.25 # the width of the bars -multiplier = 0 - fig, ax = plt.subplots(layout='constrained') -for attribute, measurement in penguin_means.items(): - offset = width * multiplier - rects = ax.bar(x + offset, measurement, width, label=attribute) - ax.bar_label(rects, padding=3) - multiplier += 1 +res = ax.grouped_bar(penguin_means, tick_labels=species, group_spacing=1) +for container in res.bar_containers: + ax.bar_label(container, padding=3) -# Add some text for labels, title and custom x-axis tick labels, etc. +# Add some text for labels, title, etc. ax.set_ylabel('Length (mm)') ax.set_title('Penguin attributes by species') -ax.set_xticks(x + width, species) ax.legend(loc='upper left', ncols=3) ax.set_ylim(0, 250) diff --git a/galleries/examples/lines_bars_and_markers/eventcollection_demo.py b/galleries/examples/lines_bars_and_markers/eventcollection_demo.py index 1aa2fa622812..6854a13e0974 100644 --- a/galleries/examples/lines_bars_and_markers/eventcollection_demo.py +++ b/galleries/examples/lines_bars_and_markers/eventcollection_demo.py @@ -53,8 +53,8 @@ ax.add_collection(yevents2) # set the limits -ax.set_xlim([0, 1]) -ax.set_ylim([0, 1]) +ax.set_xlim(0, 1) +ax.set_ylim(0, 1) ax.set_title('line plot with data points') diff --git a/galleries/examples/lines_bars_and_markers/markevery_demo.py b/galleries/examples/lines_bars_and_markers/markevery_demo.py index 919e12cde952..da4da0ecf9f1 100644 --- a/galleries/examples/lines_bars_and_markers/markevery_demo.py +++ b/galleries/examples/lines_bars_and_markers/markevery_demo.py @@ -79,8 +79,8 @@ for ax, markevery in zip(axs.flat, cases): ax.set_title(f'markevery={markevery}') ax.plot(x, y, 'o', ls='-', ms=4, markevery=markevery) - ax.set_xlim((6, 6.7)) - ax.set_ylim((1.1, 1.7)) + ax.set_xlim(6, 6.7) + ax.set_ylim(1.1, 1.7) # %% # markevery on polar plots diff --git a/galleries/examples/misc/svg_filter_line.py b/galleries/examples/misc/svg_filter_line.py index c6adec093bee..dd97dc975eda 100644 --- a/galleries/examples/misc/svg_filter_line.py +++ b/galleries/examples/misc/svg_filter_line.py @@ -17,7 +17,7 @@ import matplotlib.transforms as mtransforms fig1 = plt.figure() -ax = fig1.add_axes([0.1, 0.1, 0.8, 0.8]) +ax = fig1.add_axes((0.1, 0.1, 0.8, 0.8)) # draw lines l1, = ax.plot([0.1, 0.5, 0.9], [0.1, 0.9, 0.5], "bo-", diff --git a/galleries/examples/misc/svg_filter_pie.py b/galleries/examples/misc/svg_filter_pie.py index b823cc9670c9..b19867be9a2f 100644 --- a/galleries/examples/misc/svg_filter_pie.py +++ b/galleries/examples/misc/svg_filter_pie.py @@ -19,7 +19,7 @@ # make a square figure and Axes fig = plt.figure(figsize=(6, 6)) -ax = fig.add_axes([0.1, 0.1, 0.8, 0.8]) +ax = fig.add_axes((0.1, 0.1, 0.8, 0.8)) labels = 'Frogs', 'Hogs', 'Dogs', 'Logs' fracs = [15, 30, 45, 10] diff --git a/galleries/examples/mplot3d/box3d.py b/galleries/examples/mplot3d/box3d.py index 807e3d496ec6..4d75c8bc2809 100644 --- a/galleries/examples/mplot3d/box3d.py +++ b/galleries/examples/mplot3d/box3d.py @@ -51,7 +51,7 @@ xmin, xmax = X.min(), X.max() ymin, ymax = Y.min(), Y.max() zmin, zmax = Z.min(), Z.max() -ax.set(xlim=[xmin, xmax], ylim=[ymin, ymax], zlim=[zmin, zmax]) +ax.set(xlim=(xmin, xmax), ylim=(ymin, ymax), zlim=(zmin, zmax)) # Plot edges edges_kw = dict(color='0.4', linewidth=1, zorder=1e3) diff --git a/galleries/examples/pie_and_polar_charts/polar_demo.py b/galleries/examples/pie_and_polar_charts/polar_demo.py index e4967079d19d..909fea094be5 100644 --- a/galleries/examples/pie_and_polar_charts/polar_demo.py +++ b/galleries/examples/pie_and_polar_charts/polar_demo.py @@ -4,6 +4,11 @@ ========== Demo of a line plot on a polar axis. + +The second plot shows the same data, but with the radial axis starting at r=1 +and the angular axis starting at 0 degrees and ending at 225 degrees. Setting +the origin of the radial axis to 0 allows the radial ticks to be placed at the +same location as the first plot. """ import matplotlib.pyplot as plt import numpy as np @@ -11,14 +16,29 @@ r = np.arange(0, 2, 0.01) theta = 2 * np.pi * r -fig, ax = plt.subplots(subplot_kw={'projection': 'polar'}) +fig, axs = plt.subplots(2, 1, figsize=(5, 8), subplot_kw={'projection': 'polar'}, + layout='constrained') +ax = axs[0] ax.plot(theta, r) ax.set_rmax(2) -ax.set_rticks([0.5, 1, 1.5, 2]) # Less radial ticks +ax.set_rticks([0.5, 1, 1.5, 2]) # Fewer radial ticks ax.set_rlabel_position(-22.5) # Move radial labels away from plotted line ax.grid(True) ax.set_title("A line plot on a polar axis", va='bottom') + +ax = axs[1] +ax.plot(theta, r) +ax.set_rmax(2) +ax.set_rmin(1) # Change the radial axis to only go from 1 to 2 +ax.set_rorigin(0) # Set the origin of the radial axis to 0 +ax.set_thetamin(0) +ax.set_thetamax(225) +ax.set_rticks([1, 1.5, 2]) # Fewer radial ticks +ax.set_rlabel_position(-22.5) # Move radial labels away from plotted line + +ax.grid(True) +ax.set_title("Same plot, but with reduced axis limits", va='bottom') plt.show() # %% @@ -32,6 +52,8 @@ # - `matplotlib.projections.polar` # - `matplotlib.projections.polar.PolarAxes` # - `matplotlib.projections.polar.PolarAxes.set_rticks` +# - `matplotlib.projections.polar.PolarAxes.set_rmin` +# - `matplotlib.projections.polar.PolarAxes.set_rorigin` # - `matplotlib.projections.polar.PolarAxes.set_rmax` # - `matplotlib.projections.polar.PolarAxes.set_rlabel_position` # diff --git a/galleries/examples/scales/custom_scale.py b/galleries/examples/scales/custom_scale.py index 0eedb16ec5cf..1b6bdd6f3e09 100644 --- a/galleries/examples/scales/custom_scale.py +++ b/galleries/examples/scales/custom_scale.py @@ -22,7 +22,7 @@ * You want to override the default locators and formatters for the axis (``set_default_locators_and_formatters`` below). - * You want to limit the range of the the axis (``limit_range_for_scale`` below). + * You want to limit the range of the axis (``limit_range_for_scale`` below). """ diff --git a/galleries/examples/shapes_and_collections/hatch_demo.py b/galleries/examples/shapes_and_collections/hatch_demo.py index f2ca490c4e37..8d44dba5489b 100644 --- a/galleries/examples/shapes_and_collections/hatch_demo.py +++ b/galleries/examples/shapes_and_collections/hatch_demo.py @@ -41,8 +41,8 @@ hatch='*', facecolor='y')) axs['patches'].add_patch(Polygon([(10, 20), (30, 50), (50, 10)], hatch='\\/...', facecolor='g')) -axs['patches'].set_xlim([0, 40]) -axs['patches'].set_ylim([10, 60]) +axs['patches'].set_xlim(0, 40) +axs['patches'].set_ylim(10, 60) axs['patches'].set_aspect(1) plt.show() diff --git a/galleries/examples/showcase/anatomy.py b/galleries/examples/showcase/anatomy.py index b1fbde9c8d7b..798e4204cad3 100644 --- a/galleries/examples/showcase/anatomy.py +++ b/galleries/examples/showcase/anatomy.py @@ -27,7 +27,7 @@ Y3 = np.random.uniform(Y1, Y2, len(X)) fig = plt.figure(figsize=(7.5, 7.5)) -ax = fig.add_axes([0.2, 0.17, 0.68, 0.7], aspect=1) +ax = fig.add_axes((0.2, 0.17, 0.68, 0.7), aspect=1) ax.xaxis.set_major_locator(MultipleLocator(1.000)) ax.xaxis.set_minor_locator(AutoMinorLocator(4)) diff --git a/galleries/examples/showcase/firefox.py b/galleries/examples/showcase/firefox.py index 65682ccd7429..2026d253f6b6 100644 --- a/galleries/examples/showcase/firefox.py +++ b/galleries/examples/showcase/firefox.py @@ -48,7 +48,7 @@ def svg_parse(path): xmax, ymax = verts.max(axis=0) + 1 fig = plt.figure(figsize=(5, 5), facecolor="0.75") # gray background -ax = fig.add_axes([0, 0, 1, 1], frameon=False, aspect=1, +ax = fig.add_axes((0, 0, 1, 1), frameon=False, aspect=1, xlim=(xmin, xmax), # centering ylim=(ymax, ymin), # centering, upside down xticks=[], yticks=[]) # no ticks diff --git a/galleries/examples/showcase/mandelbrot.py b/galleries/examples/showcase/mandelbrot.py index ab40a061dc03..d8b7faf4c7b8 100644 --- a/galleries/examples/showcase/mandelbrot.py +++ b/galleries/examples/showcase/mandelbrot.py @@ -55,7 +55,7 @@ def mandelbrot_set(xmin, xmax, ymin, ymax, xn, yn, maxiter, horizon=2.0): width = 10 height = 10*yn/xn fig = plt.figure(figsize=(width, height), dpi=dpi) - ax = fig.add_axes([0, 0, 1, 1], frameon=False, aspect=1) + ax = fig.add_axes((0, 0, 1, 1), frameon=False, aspect=1) # Shaded rendering light = colors.LightSource(azdeg=315, altdeg=10) diff --git a/galleries/examples/showcase/xkcd.py b/galleries/examples/showcase/xkcd.py index 3d6d5418a13f..9b4de0a90f5b 100644 --- a/galleries/examples/showcase/xkcd.py +++ b/galleries/examples/showcase/xkcd.py @@ -19,7 +19,7 @@ ax.spines[['top', 'right']].set_visible(False) ax.set_xticks([]) ax.set_yticks([]) - ax.set_ylim([-30, 10]) + ax.set_ylim(-30, 10) data = np.ones(100) data[70:] -= np.arange(30) @@ -50,9 +50,9 @@ ax.xaxis.set_ticks_position('bottom') ax.set_xticks([0, 1]) ax.set_xticklabels(['CONFIRMED BY\nEXPERIMENT', 'REFUTED BY\nEXPERIMENT']) - ax.set_xlim([-0.5, 1.5]) + ax.set_xlim(-0.5, 1.5) ax.set_yticks([]) - ax.set_ylim([0, 110]) + ax.set_ylim(0, 110) ax.set_title("CLAIMS OF SUPERNATURAL POWERS") diff --git a/galleries/examples/specialty_plots/leftventricle_bullseye.py b/galleries/examples/specialty_plots/leftventricle_bullseye.py index 3ad02edbc630..285fcdaecc5e 100644 --- a/galleries/examples/specialty_plots/leftventricle_bullseye.py +++ b/galleries/examples/specialty_plots/leftventricle_bullseye.py @@ -55,7 +55,7 @@ def bullseye_plot(ax, data, seg_bold=None, cmap="viridis", norm=None): r = np.linspace(0.2, 1, 4) - ax.set(ylim=[0, 1], xticklabels=[], yticklabels=[]) + ax.set(ylim=(0, 1), xticklabels=[], yticklabels=[]) ax.grid(False) # Remove grid # Fill segments 1-6, 7-12, 13-16. diff --git a/galleries/examples/statistics/errorbar_limits.py b/galleries/examples/statistics/errorbar_limits.py index f1d26460d947..fde18327af83 100644 --- a/galleries/examples/statistics/errorbar_limits.py +++ b/galleries/examples/statistics/errorbar_limits.py @@ -71,7 +71,7 @@ linestyle='none') # tidy up the figure -ax.set_xlim((0, 5.5)) +ax.set_xlim(0, 5.5) ax.set_title('Errorbar upper and lower limits') plt.show() diff --git a/galleries/examples/style_sheets/petroff10.py b/galleries/examples/style_sheets/petroff10.py index f6293fd40a6b..5683a4df296c 100644 --- a/galleries/examples/style_sheets/petroff10.py +++ b/galleries/examples/style_sheets/petroff10.py @@ -1,13 +1,13 @@ """ -===================== -Petroff10 style sheet -===================== +==================== +Petroff style sheets +==================== -This example demonstrates the "petroff10" style, which implements the 10-color -sequence developed by Matthew A. Petroff [1]_ for accessible data visualization. -The style balances aesthetics with accessibility considerations, making it -suitable for various types of plots while ensuring readability and distinction -between data series. +This example demonstrates the "petroffN" styles, which implement the 6-, 8- and +10-color sequences developed by Matthew A. Petroff [1]_ for accessible data +visualization. The styles balance aesthetics with accessibility considerations, +making them suitable for various types of plots while ensuring readability and +distinction between data series. .. [1] https://arxiv.org/abs/2107.02270 @@ -35,9 +35,15 @@ def image_and_patch_example(ax): c = plt.Circle((5, 5), radius=5, label='patch') ax.add_patch(c) -plt.style.use('petroff10') -fig, (ax1, ax2) = plt.subplots(ncols=2, figsize=(12, 5)) -fig.suptitle("'petroff10' style sheet") -colored_lines_example(ax1) -image_and_patch_example(ax2) + +fig = plt.figure(figsize=(6.4, 9.6), layout='compressed') +sfigs = fig.subfigures(nrows=3) + +for style, sfig in zip(['petroff6', 'petroff8', 'petroff10'], sfigs): + sfig.suptitle(f"'{style}' style sheet") + with plt.style.context(style): + ax1, ax2 = sfig.subplots(ncols=2) + colored_lines_example(ax1) + image_and_patch_example(ax2) + plt.show() diff --git a/galleries/examples/subplots_axes_and_figures/axes_demo.py b/galleries/examples/subplots_axes_and_figures/axes_demo.py index 07f3ca2070c2..16db465449a4 100644 --- a/galleries/examples/subplots_axes_and_figures/axes_demo.py +++ b/galleries/examples/subplots_axes_and_figures/axes_demo.py @@ -33,12 +33,12 @@ main_ax.set_title('Gaussian colored noise') # this is an inset Axes over the main Axes -right_inset_ax = fig.add_axes([.65, .6, .2, .2], facecolor='k') +right_inset_ax = fig.add_axes((.65, .6, .2, .2), facecolor='k') right_inset_ax.hist(s, 400, density=True) right_inset_ax.set(title='Probability', xticks=[], yticks=[]) # this is another inset Axes over the main Axes -left_inset_ax = fig.add_axes([.2, .6, .2, .2], facecolor='k') +left_inset_ax = fig.add_axes((.2, .6, .2, .2), facecolor='k') left_inset_ax.plot(t[:len(r)], r) left_inset_ax.set(title='Impulse response', xlim=(0, .2), xticks=[], yticks=[]) diff --git a/galleries/examples/subplots_axes_and_figures/gridspec_nested.py b/galleries/examples/subplots_axes_and_figures/gridspec_nested.py index 025bdb1185a7..789cc0ae6b5b 100644 --- a/galleries/examples/subplots_axes_and_figures/gridspec_nested.py +++ b/galleries/examples/subplots_axes_and_figures/gridspec_nested.py @@ -1,4 +1,6 @@ """ +.. redirect-from:: /gallery/userdemo/demo_gridspec06 + ================ Nested Gridspecs ================ diff --git a/galleries/examples/text_labels_and_annotations/mathtext_examples.py b/galleries/examples/text_labels_and_annotations/mathtext_examples.py index f9f8e628e08b..cf395f0daf0e 100644 --- a/galleries/examples/text_labels_and_annotations/mathtext_examples.py +++ b/galleries/examples/text_labels_and_annotations/mathtext_examples.py @@ -61,7 +61,7 @@ def doall(): # Creating figure and axis. fig = plt.figure(figsize=(7, 7)) - ax = fig.add_axes([0.01, 0.01, 0.98, 0.90], + ax = fig.add_axes((0.01, 0.01, 0.98, 0.90), facecolor="white", frameon=True) ax.set_xlim(0, 1) ax.set_ylim(0, 1) diff --git a/galleries/examples/ticks/date_formatters_locators.py b/galleries/examples/ticks/date_formatters_locators.py index 39492168242f..8c3b24bb4c26 100644 --- a/galleries/examples/ticks/date_formatters_locators.py +++ b/galleries/examples/ticks/date_formatters_locators.py @@ -12,6 +12,7 @@ import matplotlib.pyplot as plt import numpy as np +# While these appear unused directly, they are used from eval'd strings. from matplotlib.dates import (FR, MO, MONTHLY, SA, SU, TH, TU, WE, AutoDateFormatter, AutoDateLocator, ConciseDateFormatter, DateFormatter, DayLocator, diff --git a/galleries/examples/ticks/fig_axes_customize_simple.py b/galleries/examples/ticks/fig_axes_customize_simple.py index 0dd85ec4bd93..07a569e3d31d 100644 --- a/galleries/examples/ticks/fig_axes_customize_simple.py +++ b/galleries/examples/ticks/fig_axes_customize_simple.py @@ -13,7 +13,7 @@ fig = plt.figure() fig.patch.set_facecolor('lightgoldenrodyellow') -ax1 = fig.add_axes([0.1, 0.3, 0.4, 0.4]) +ax1 = fig.add_axes((0.1, 0.3, 0.4, 0.4)) ax1.patch.set_facecolor('lightslategray') ax1.tick_params(axis='x', labelcolor='tab:red', labelrotation=45, labelsize=16) diff --git a/galleries/examples/user_interfaces/fourier_demo_wx_sgskip.py b/galleries/examples/user_interfaces/fourier_demo_wx_sgskip.py index f51917fda6b9..9e72b3745a40 100644 --- a/galleries/examples/user_interfaces/fourier_demo_wx_sgskip.py +++ b/galleries/examples/user_interfaces/fourier_demo_wx_sgskip.py @@ -194,10 +194,10 @@ def createPlots(self): self.subplot1.set_xlabel("frequency f", fontsize=8) self.subplot2.set_ylabel("Time Domain Waveform x(t)", fontsize=8) self.subplot2.set_xlabel("time t", fontsize=8) - self.subplot1.set_xlim([-6, 6]) - self.subplot1.set_ylim([0, 1]) - self.subplot2.set_xlim([-2, 2]) - self.subplot2.set_ylim([-2, 2]) + self.subplot1.set_xlim(-6, 6) + self.subplot1.set_ylim(0, 1) + self.subplot2.set_xlim(-2, 2) + self.subplot2.set_ylim(-2, 2) self.subplot1.text(0.05, .95, r'$X(f) = \mathcal{F}\{x(t)\}$', verticalalignment='top', diff --git a/galleries/examples/userdemo/README.txt b/galleries/examples/userdemo/README.txt deleted file mode 100644 index 7be351dc70dd..000000000000 --- a/galleries/examples/userdemo/README.txt +++ /dev/null @@ -1,4 +0,0 @@ -.. _userdemo: - -Userdemo -======== diff --git a/galleries/examples/userdemo/demo_gridspec06.py b/galleries/examples/userdemo/demo_gridspec06.py deleted file mode 100644 index c42224ce1e7b..000000000000 --- a/galleries/examples/userdemo/demo_gridspec06.py +++ /dev/null @@ -1,38 +0,0 @@ -r""" -================ -Nested GridSpecs -================ - -This example demonstrates the use of nested `.GridSpec`\s. -""" - -import matplotlib.pyplot as plt -import numpy as np - - -def squiggle_xy(a, b, c, d): - i = np.arange(0.0, 2*np.pi, 0.05) - return np.sin(i*a)*np.cos(i*b), np.sin(i*c)*np.cos(i*d) - - -fig = plt.figure(figsize=(8, 8)) -outer_grid = fig.add_gridspec(4, 4, wspace=0, hspace=0) - -for a in range(4): - for b in range(4): - # gridspec inside gridspec - inner_grid = outer_grid[a, b].subgridspec(3, 3, wspace=0, hspace=0) - axs = inner_grid.subplots() # Create all subplots for the inner grid. - for (c, d), ax in np.ndenumerate(axs): - ax.plot(*squiggle_xy(a + 1, b + 1, c + 1, d + 1)) - ax.set(xticks=[], yticks=[]) - -# show only the outside spines -for ax in fig.get_axes(): - ss = ax.get_subplotspec() - ax.spines.top.set_visible(ss.is_first_row()) - ax.spines.bottom.set_visible(ss.is_last_row()) - ax.spines.left.set_visible(ss.is_first_col()) - ax.spines.right.set_visible(ss.is_last_col()) - -plt.show() diff --git a/galleries/examples/widgets/buttons.py b/galleries/examples/widgets/buttons.py index 61249522c72c..2aef798399f4 100644 --- a/galleries/examples/widgets/buttons.py +++ b/galleries/examples/widgets/buttons.py @@ -41,8 +41,8 @@ def prev(self, event): plt.draw() callback = Index() -axprev = fig.add_axes([0.7, 0.05, 0.1, 0.075]) -axnext = fig.add_axes([0.81, 0.05, 0.1, 0.075]) +axprev = fig.add_axes((0.7, 0.05, 0.1, 0.075)) +axnext = fig.add_axes((0.81, 0.05, 0.1, 0.075)) bnext = Button(axnext, 'Next') bnext.on_clicked(callback.next) bprev = Button(axprev, 'Previous') diff --git a/galleries/examples/widgets/range_slider.py b/galleries/examples/widgets/range_slider.py index f1bed7431e39..d2f2d1554246 100644 --- a/galleries/examples/widgets/range_slider.py +++ b/galleries/examples/widgets/range_slider.py @@ -34,7 +34,7 @@ axs[1].set_title('Histogram of pixel intensities') # Create the RangeSlider -slider_ax = fig.add_axes([0.20, 0.1, 0.60, 0.03]) +slider_ax = fig.add_axes((0.20, 0.1, 0.60, 0.03)) slider = RangeSlider(slider_ax, "Threshold", img.min(), img.max()) # Create the Vertical lines on the histogram diff --git a/galleries/examples/widgets/slider_demo.py b/galleries/examples/widgets/slider_demo.py index 7dc47b9c7b6f..e56390c182a0 100644 --- a/galleries/examples/widgets/slider_demo.py +++ b/galleries/examples/widgets/slider_demo.py @@ -38,7 +38,7 @@ def f(t, amplitude, frequency): fig.subplots_adjust(left=0.25, bottom=0.25) # Make a horizontal slider to control the frequency. -axfreq = fig.add_axes([0.25, 0.1, 0.65, 0.03]) +axfreq = fig.add_axes((0.25, 0.1, 0.65, 0.03)) freq_slider = Slider( ax=axfreq, label='Frequency [Hz]', @@ -48,7 +48,7 @@ def f(t, amplitude, frequency): ) # Make a vertically oriented slider to control the amplitude -axamp = fig.add_axes([0.1, 0.25, 0.0225, 0.63]) +axamp = fig.add_axes((0.1, 0.25, 0.0225, 0.63)) amp_slider = Slider( ax=axamp, label="Amplitude", @@ -70,7 +70,7 @@ def update(val): amp_slider.on_changed(update) # Create a `matplotlib.widgets.Button` to reset the sliders to initial values. -resetax = fig.add_axes([0.8, 0.025, 0.1, 0.04]) +resetax = fig.add_axes((0.8, 0.025, 0.1, 0.04)) button = Button(resetax, 'Reset', hovercolor='0.975') diff --git a/galleries/examples/widgets/slider_snap_demo.py b/galleries/examples/widgets/slider_snap_demo.py index 953ffaf63672..5826be32fa07 100644 --- a/galleries/examples/widgets/slider_snap_demo.py +++ b/galleries/examples/widgets/slider_snap_demo.py @@ -30,8 +30,8 @@ fig.subplots_adjust(bottom=0.25) l, = ax.plot(t, s, lw=2) -ax_freq = fig.add_axes([0.25, 0.1, 0.65, 0.03]) -ax_amp = fig.add_axes([0.25, 0.15, 0.65, 0.03]) +ax_freq = fig.add_axes((0.25, 0.1, 0.65, 0.03)) +ax_amp = fig.add_axes((0.25, 0.15, 0.65, 0.03)) # define the values to use for snapping allowed_amplitudes = np.concatenate([np.linspace(.1, 5, 100), [6, 7, 8, 9]]) @@ -60,7 +60,7 @@ def update(val): sfreq.on_changed(update) samp.on_changed(update) -ax_reset = fig.add_axes([0.8, 0.025, 0.1, 0.04]) +ax_reset = fig.add_axes((0.8, 0.025, 0.1, 0.04)) button = Button(ax_reset, 'Reset', hovercolor='0.975') diff --git a/galleries/examples/widgets/textbox.py b/galleries/examples/widgets/textbox.py index d5f02b82a30b..2121ce8594ce 100644 --- a/galleries/examples/widgets/textbox.py +++ b/galleries/examples/widgets/textbox.py @@ -39,7 +39,7 @@ def submit(expression): plt.draw() -axbox = fig.add_axes([0.1, 0.05, 0.8, 0.075]) +axbox = fig.add_axes((0.1, 0.05, 0.8, 0.075)) text_box = TextBox(axbox, "Evaluate", textalignment="center") text_box.on_submit(submit) text_box.set_val("t ** 2") # Trigger `submit` with the initial string. diff --git a/galleries/plot_types/basic/scatter_plot.py b/galleries/plot_types/basic/scatter_plot.py index 07fa943b724f..738af15440db 100644 --- a/galleries/plot_types/basic/scatter_plot.py +++ b/galleries/plot_types/basic/scatter_plot.py @@ -2,7 +2,7 @@ ============= scatter(x, y) ============= -A scatter plot of y vs. x with varying marker size and/or color. +A scatter plot of y versus x with varying marker size and/or color. See `~matplotlib.axes.Axes.scatter`. """ diff --git a/galleries/tutorials/artists.py b/galleries/tutorials/artists.py index a258eb71d447..4f93f7c71a6e 100644 --- a/galleries/tutorials/artists.py +++ b/galleries/tutorials/artists.py @@ -70,7 +70,7 @@ class in the Matplotlib API, and the one you will be working with most coordinates:: fig2 = plt.figure() - ax2 = fig2.add_axes([0.15, 0.1, 0.7, 0.3]) + ax2 = fig2.add_axes((0.15, 0.1, 0.7, 0.3)) Continuing with our example:: @@ -134,7 +134,7 @@ class in the Matplotlib API, and the one you will be working with most # Fixing random state for reproducibility np.random.seed(19680801) -ax2 = fig.add_axes([0.15, 0.1, 0.7, 0.3]) +ax2 = fig.add_axes((0.15, 0.1, 0.7, 0.3)) n, bins, patches = ax2.hist(np.random.randn(1000), 50, facecolor='yellow', edgecolor='yellow') ax2.set_xlabel('Time [s]') @@ -295,7 +295,7 @@ class in the Matplotlib API, and the one you will be working with most # # In [157]: ax1 = fig.add_subplot(211) # -# In [158]: ax2 = fig.add_axes([0.1, 0.1, 0.7, 0.3]) +# In [158]: ax2 = fig.add_axes((0.1, 0.1, 0.7, 0.3)) # # In [159]: ax1 # Out[159]: @@ -669,7 +669,7 @@ class in the Matplotlib API, and the one you will be working with most rect = fig.patch # a rectangle instance rect.set_facecolor('lightgoldenrodyellow') -ax1 = fig.add_axes([0.1, 0.3, 0.4, 0.4]) +ax1 = fig.add_axes((0.1, 0.3, 0.4, 0.4)) rect = ax1.patch rect.set_facecolor('lightslategray') diff --git a/galleries/tutorials/images.py b/galleries/tutorials/images.py index 0867f7b6d672..a7c474dab40b 100644 --- a/galleries/tutorials/images.py +++ b/galleries/tutorials/images.py @@ -33,8 +33,8 @@ In [1]: %matplotlib inline -This turns on inline plotting, where plot graphics will appear in your -notebook. This has important implications for interactivity. For inline plotting, commands in +This turns on inline plotting, where plot graphics will appear in your notebook. This +has important implications for interactivity. For inline plotting, commands in cells below the cell that outputs a plot will not affect the plot. For example, changing the colormap is not possible from cells below the cell that creates a plot. However, for other backends, such as Qt, that open a separate window, diff --git a/galleries/tutorials/lifecycle.py b/galleries/tutorials/lifecycle.py index 4aae4d6c1dbc..4c009f802cf4 100644 --- a/galleries/tutorials/lifecycle.py +++ b/galleries/tutorials/lifecycle.py @@ -169,7 +169,7 @@ ax.barh(group_names, group_data) labels = ax.get_xticklabels() plt.setp(labels, rotation=45, horizontalalignment='right') -ax.set(xlim=[-10000, 140000], xlabel='Total Revenue', ylabel='Company', +ax.set(xlim=(-10000, 140000), xlabel='Total Revenue', ylabel='Company', title='Company Revenue') # %% @@ -187,7 +187,7 @@ ax.barh(group_names, group_data) labels = ax.get_xticklabels() plt.setp(labels, rotation=45, horizontalalignment='right') -ax.set(xlim=[-10000, 140000], xlabel='Total Revenue', ylabel='Company', +ax.set(xlim=(-10000, 140000), xlabel='Total Revenue', ylabel='Company', title='Company Revenue') # %% @@ -220,7 +220,7 @@ def currency(x, pos): labels = ax.get_xticklabels() plt.setp(labels, rotation=45, horizontalalignment='right') -ax.set(xlim=[-10000, 140000], xlabel='Total Revenue', ylabel='Company', +ax.set(xlim=(-10000, 140000), xlabel='Total Revenue', ylabel='Company', title='Company Revenue') ax.xaxis.set_major_formatter(currency) @@ -248,7 +248,7 @@ def currency(x, pos): # Now we move our title up since it's getting a little cramped ax.title.set(y=1.05) -ax.set(xlim=[-10000, 140000], xlabel='Total Revenue', ylabel='Company', +ax.set(xlim=(-10000, 140000), xlabel='Total Revenue', ylabel='Company', title='Company Revenue') ax.xaxis.set_major_formatter(currency) ax.set_xticks([0, 25e3, 50e3, 75e3, 100e3, 125e3]) diff --git a/galleries/users_explain/animations/animations.py b/galleries/users_explain/animations/animations.py index a0669956ab81..dca49fc5228e 100644 --- a/galleries/users_explain/animations/animations.py +++ b/galleries/users_explain/animations/animations.py @@ -111,7 +111,7 @@ scat = ax.scatter(t[0], z[0], c="b", s=5, label=f'v0 = {v0} m/s') line2 = ax.plot(t[0], z2[0], label=f'v0 = {v02} m/s')[0] -ax.set(xlim=[0, 3], ylim=[-4, 10], xlabel='Time [s]', ylabel='Z [m]') +ax.set(xlim=(0, 3), ylim=(-4, 10), xlabel='Time [s]', ylabel='Z [m]') ax.legend() diff --git a/galleries/users_explain/artists/transforms_tutorial.py b/galleries/users_explain/artists/transforms_tutorial.py index f8a3e98e8077..3920fe886c7f 100644 --- a/galleries/users_explain/artists/transforms_tutorial.py +++ b/galleries/users_explain/artists/transforms_tutorial.py @@ -401,7 +401,7 @@ fig, ax = plt.subplots() xdata, ydata = (0.2, 0.7), (0.5, 0.5) ax.plot(xdata, ydata, "o") -ax.set_xlim((0, 1)) +ax.set_xlim(0, 1) trans = (fig.dpi_scale_trans + transforms.ScaledTranslation(xdata[0], ydata[0], ax.transData)) diff --git a/galleries/users_explain/axes/arranging_axes.py b/galleries/users_explain/axes/arranging_axes.py index bc537e15c12c..64879d4a696d 100644 --- a/galleries/users_explain/axes/arranging_axes.py +++ b/galleries/users_explain/axes/arranging_axes.py @@ -103,8 +103,8 @@ w, h = 4, 3 margin = 0.5 fig = plt.figure(figsize=(w, h), facecolor='lightblue') -ax = fig.add_axes([margin / w, margin / h, (w - 2 * margin) / w, - (h - 2 * margin) / h]) +ax = fig.add_axes((margin / w, margin / h, + (w - 2 * margin) / w, (h - 2 * margin) / h)) # %% diff --git a/galleries/users_explain/axes/axes_intro.rst b/galleries/users_explain/axes/axes_intro.rst index 16738d929056..bb3094495026 100644 --- a/galleries/users_explain/axes/axes_intro.rst +++ b/galleries/users_explain/axes/axes_intro.rst @@ -52,8 +52,8 @@ Axes are added using methods on `~.Figure` objects, or via the `~.pyplot` interf There are a number of other methods for adding Axes to a Figure: -* `.Figure.add_axes`: manually position an Axes. ``fig.add_axes([0, 0, 1, - 1])`` makes an Axes that fills the whole figure. +* `.Figure.add_axes`: manually position an Axes. ``fig.add_axes((0, 0, 1, 1))`` makes an + Axes that fills the whole figure. * `.pyplot.subplots` and `.Figure.subplots`: add a grid of Axes as in the example above. The pyplot version returns both the Figure object and an array of Axes. Note that ``fig, ax = plt.subplots()`` adds a single Axes to a Figure. @@ -143,7 +143,7 @@ Other important methods set the extent on the axes (`~.axes.Axes.set_xlim`, `~.a x = 2**np.cumsum(np.random.randn(200)) linesx = ax.plot(t, x) ax.set_yscale('log') - ax.set_xlim([20, 180]) + ax.set_xlim(20, 180) The Axes class also has helpers to deal with Axis ticks and their labels. Most straight-forward is `~.axes.Axes.set_xticks` and `~.axes.Axes.set_yticks` which manually set the tick locations and optionally their labels. Minor ticks can be toggled with `~.axes.Axes.minorticks_on` or `~.axes.Axes.minorticks_off`. diff --git a/galleries/users_explain/axes/axes_scales.py b/galleries/users_explain/axes/axes_scales.py index 6b163835070c..f901c012974a 100644 --- a/galleries/users_explain/axes/axes_scales.py +++ b/galleries/users_explain/axes/axes_scales.py @@ -171,7 +171,7 @@ def inverse(a): ax.set_yscale('function', functions=(forward, inverse)) ax.set_title('function: Mercator') ax.grid(True) -ax.set_xlim([0, 180]) +ax.set_xlim(0, 180) ax.yaxis.set_minor_formatter(NullFormatter()) ax.yaxis.set_major_locator(FixedLocator(np.arange(0, 90, 10))) diff --git a/galleries/users_explain/axes/legend_guide.py b/galleries/users_explain/axes/legend_guide.py index 5da3ceafe387..ec0468fe172d 100644 --- a/galleries/users_explain/axes/legend_guide.py +++ b/galleries/users_explain/axes/legend_guide.py @@ -1,7 +1,7 @@ """ .. redirect-from:: /tutorials/intermediate/legend_guide -.. redirect-from:: /galleries/examples/userdemo/simple_legend01 -.. redirect-from:: /galleries/examples/userdemo/simple_legend02 +.. redirect-from:: /gallery/userdemo/simple_legend01 +.. redirect-from:: /gallery/userdemo/simple_legend02 .. _legend_guide: diff --git a/galleries/users_explain/colors/colorbar_only.py b/galleries/users_explain/colors/colorbar_only.py index a3f1d62042f4..b956fae43a1b 100644 --- a/galleries/users_explain/colors/colorbar_only.py +++ b/galleries/users_explain/colors/colorbar_only.py @@ -8,10 +8,12 @@ This tutorial shows how to build and customize standalone colorbars, i.e. without an attached plot. -A `~.Figure.colorbar` needs a "mappable" (`matplotlib.cm.ScalarMappable`) -object (typically, an image) which indicates the colormap and the norm to be -used. In order to create a colorbar without an attached image, one can instead -use a `.ScalarMappable` with no associated data. +A `~.Figure.colorbar` requires a `matplotlib.colorizer.ColorizingArtist` which +contains a `matplotlib.colorizer.Colorizer` that holds the data-to-color pipeline +(norm and colormap). To create a colorbar without an attached plot one can +directly instantiate the base class `.ColorizingArtist`, which has no associated +data. + """ import matplotlib.pyplot as plt @@ -23,9 +25,11 @@ # ------------------------- # Here, we create a basic continuous colorbar with ticks and labels. # -# The arguments to the `~.Figure.colorbar` call are the `.ScalarMappable` -# (constructed using the *norm* and *cmap* arguments), the axes where the -# colorbar should be drawn, and the colorbar's orientation. +# The arguments to the `~.Figure.colorbar` call are a `.ColorizingArtist`, +# the axes where the colorbar should be drawn, and the colorbar's orientation. +# To crate a `.ColorizingArtist` one must first make `.Colorizer` that holds the +# desired *norm* and *cmap*. +# # # For more information see the `~matplotlib.colorbar` API. @@ -33,7 +37,9 @@ norm = mpl.colors.Normalize(vmin=5, vmax=10) -fig.colorbar(mpl.cm.ScalarMappable(norm=norm, cmap="cool"), +colorizer = mpl.colorizer.Colorizer(norm=norm, cmap="cool") + +fig.colorbar(mpl.colorizer.ColorizingArtist(colorizer), cax=ax, orientation='horizontal', label='Some Units') # %% @@ -47,7 +53,9 @@ fig, ax = plt.subplots(layout='constrained') -fig.colorbar(mpl.cm.ScalarMappable(norm=mpl.colors.Normalize(0, 1), cmap='magma'), +colorizer = mpl.colorizer.Colorizer(norm=mpl.colors.Normalize(0, 1), cmap='magma') + +fig.colorbar(mpl.colorizer.ColorizingArtist(colorizer), ax=ax, orientation='vertical', label='a colorbar label') # %% @@ -65,7 +73,9 @@ bounds = [-1, 2, 5, 7, 12, 15] norm = mpl.colors.BoundaryNorm(bounds, cmap.N, extend='both') -fig.colorbar(mpl.cm.ScalarMappable(norm=norm, cmap="viridis"), +colorizer = mpl.colorizer.Colorizer(norm=norm, cmap='viridis') + +fig.colorbar(mpl.colorizer.ColorizingArtist(colorizer), cax=ax, orientation='horizontal', label="Discrete intervals with extend='both' keyword") @@ -94,8 +104,10 @@ bounds = [1, 2, 4, 7, 8] norm = mpl.colors.BoundaryNorm(bounds, cmap.N) +colorizer = mpl.colorizer.Colorizer(norm=norm, cmap=cmap) + fig.colorbar( - mpl.cm.ScalarMappable(cmap=cmap, norm=norm), + mpl.colorizer.ColorizingArtist(colorizer), cax=ax, orientation='horizontal', extend='both', spacing='proportional', @@ -116,8 +128,10 @@ bounds = [-1.0, -0.5, 0.0, 0.5, 1.0] norm = mpl.colors.BoundaryNorm(bounds, cmap.N) +colorizer = mpl.colorizer.Colorizer(norm=norm, cmap=cmap) + fig.colorbar( - mpl.cm.ScalarMappable(cmap=cmap, norm=norm), + mpl.colorizer.ColorizingArtist(colorizer), cax=ax, orientation='horizontal', extend='both', extendfrac='auto', spacing='uniform', diff --git a/galleries/users_explain/colors/colors.py b/galleries/users_explain/colors/colors.py index c91a5fcb0dbe..97a281bf1977 100644 --- a/galleries/users_explain/colors/colors.py +++ b/galleries/users_explain/colors/colors.py @@ -197,7 +197,7 @@ def demo(sty): if f'xkcd:{name}' in mcolors.XKCD_COLORS} fig = plt.figure(figsize=[9, 5]) -ax = fig.add_axes([0, 0, 1, 1]) +ax = fig.add_axes((0, 0, 1, 1)) n_groups = 3 n_rows = len(overlap) // n_groups + 1 diff --git a/galleries/users_explain/text/fonts.py b/galleries/users_explain/text/fonts.py index 7efb9a00aa09..067ed2f3932a 100644 --- a/galleries/users_explain/text/fonts.py +++ b/galleries/users_explain/text/fonts.py @@ -27,30 +27,35 @@ Matplotlib supports three font specifications (in addition to pdf 'core fonts', which are explained later in the guide): -.. list-table:: Type of Fonts - :header-rows: 1 - - * - Type 1 (PDF) - - Type 3 (PDF/PS) - - TrueType (PDF) - * - One of the oldest types, introduced by Adobe - - Similar to Type 1 in terms of introduction - - Newer than previous types, used commonly today, introduced by Apple - * - Restricted subset of PostScript, charstrings are in bytecode - - Full PostScript language, allows embedding arbitrary code - (in theory, even render fractals when rasterizing!) - - Include a virtual machine that can execute code! - * - These fonts support font hinting - - Do not support font hinting - - Hinting supported (virtual machine processes the "hints") - * - Non-subsetted through Matplotlib - - Subsetted via external module ttconv - - Subsetted via external module - `fontTools `__ +.. table:: Type of Fonts + + +--------------------------+----------------------------+----------------------------+ + | Type 1 (PDF with usetex) | Type 3 (PDF/PS) | TrueType (PDF) | + +==========================+============================+============================+ + | One of the oldest types, | Similar to Type 1 in | Newer than previous types, | + | introduced by Adobe | terms of introduction | used commonly today, | + | | | introduced by Apple | + +--------------------------+----------------------------+----------------------------+ + | Restricted subset of | Full PostScript language, | Includes a virtual machine | + | PostScript, charstrings | allows embedding arbitrary | that can execute code! | + | are in bytecode | code (in theory, even | | + | | render fractals when | | + | | rasterizing!) | | + +--------------------------+----------------------------+----------------------------+ + | Supports font | Does not support font | Supports font hinting | + | hinting | hinting | (virtual machine processes | + | | | the "hints") | + +--------------------------+----------------------------+----------------------------+ + | Subsetted by code in | Subsetted via external module | + | `matplotlib._type1font` | `fontTools `__ | + +--------------------------+----------------------------+----------------------------+ .. note:: Adobe disabled__ support for authoring with Type 1 fonts in January 2023. + Matplotlib uses Type 1 fonts for compatibility with TeX; when the usetex + feature is used with the PDF backend, Matplotlib reads the fonts used by + the TeX engine, which are usually Type 1. __ https://helpx.adobe.com/fonts/kb/postscript-type-1-fonts-end-of-support.html @@ -83,14 +88,12 @@ files, particularly with fonts with many glyphs such as those that support CJK (Chinese/Japanese/Korean). -The solution to this problem is to subset the fonts used in the document and -only embed the glyphs actually used. This gets both vector text and small -files sizes. Computing the subset of the font required and writing the new -(reduced) font are both complex problem and thus Matplotlib relies on -`fontTools `__ and a vendored fork -of ttconv. - -Currently Type 3, Type 42, and TrueType fonts are subsetted. Type 1 fonts are not. +To keep the output size reasonable while using vector fonts, +Matplotlib embeds only the glyphs that are actually used in the document. +This is known as font subsetting. +Computing the font subset and writing the reduced font are both complex problems, +which Matplotlib solves in most cases by using the +`fontTools `__ library. Core Fonts ^^^^^^^^^^ diff --git a/galleries/users_explain/text/pgf.py b/galleries/users_explain/text/pgf.py index fd7693cf55e3..c5fa16f35ce7 100644 --- a/galleries/users_explain/text/pgf.py +++ b/galleries/users_explain/text/pgf.py @@ -209,9 +209,10 @@ Troubleshooting =============== -* On Windows, the :envvar:`PATH` environment variable may need to be modified - to include the directories containing the latex, dvipng and ghostscript - executables. See :ref:`environment-variables` and +* Make sure LaTeX is working and on your :envvar:`PATH` (for raster output, + pdftocairo or ghostscript is also required). The :envvar:`PATH` environment + variable may need to be modified (in particular on Windows) to include the + directories containing the executable. See :ref:`environment-variables` and :ref:`setting-windows-environment-variables` for details. * Sometimes the font rendering in figures that are saved to png images is diff --git a/galleries/users_explain/text/text_props.py b/galleries/users_explain/text/text_props.py index c5ae22c02d38..fb67421fd880 100644 --- a/galleries/users_explain/text/text_props.py +++ b/galleries/users_explain/text/text_props.py @@ -75,7 +75,7 @@ top = bottom + height fig = plt.figure() -ax = fig.add_axes([0, 0, 1, 1]) +ax = fig.add_axes((0, 0, 1, 1)) # axes coordinates: (0, 0) is bottom left and (1, 1) is upper right p = patches.Rectangle( diff --git a/galleries/users_explain/text/usetex.py b/galleries/users_explain/text/usetex.py index f0c266819897..e687ec7af5bf 100644 --- a/galleries/users_explain/text/usetex.py +++ b/galleries/users_explain/text/usetex.py @@ -124,24 +124,6 @@ produces PostScript without rasterizing text, so it scales properly, can be edited in Adobe Illustrator, and searched text in pdf documents. -.. _usetex-hangups: - -Possible hangups -================ - -* On Windows, the :envvar:`PATH` environment variable may need to be modified - to include the directories containing the latex, dvipng and ghostscript - executables. See :ref:`environment-variables` and - :ref:`setting-windows-environment-variables` for details. - -* Using MiKTeX with Computer Modern fonts, if you get odd \*Agg and PNG - results, go to MiKTeX/Options and update your format files. - -* On Ubuntu and Gentoo, the base texlive install does not ship with - the type1cm package. You may need to install some of the extra - packages to get all the goodies that come bundled with other LaTeX - distributions. - .. _usetex-troubleshooting: Troubleshooting @@ -150,8 +132,11 @@ * Try deleting your :file:`.matplotlib/tex.cache` directory. If you don't know where to find :file:`.matplotlib`, see :ref:`locating-matplotlib-config-dir`. -* Make sure LaTeX, dvipng and Ghostscript are each working and on your - :envvar:`PATH`. +* Make sure LaTeX, dvipng, and Ghostscript are each working and on your + :envvar:`PATH`. The :envvar:`PATH` environment variable may need to + be modified (in particular on Windows) to include the directories + containing the executables. See :ref:`environment-variables` and + :ref:`setting-windows-environment-variables` for details. * Make sure what you are trying to do is possible in a LaTeX document, that your LaTeX syntax is valid and that you are using raw strings @@ -161,6 +146,12 @@ option provides lots of flexibility, and lots of ways to cause problems. Please disable this option before reporting problems. +* Using MiKTeX with Computer Modern fonts, if you get odd \*Agg and PNG + results, go to MiKTeX/Options and update your format files. + +* Some required LaTeX packages, such as type1cm, may be missing from minimalist + TeX installs. Required packages are listed at :ref:`tex-dependencies`. + * If you still need help, please see :ref:`reporting-problems`. .. _dvipng: http://www.nongnu.org/dvipng/ diff --git a/galleries/users_explain/toolkits/axisartist.rst b/galleries/users_explain/toolkits/axisartist.rst index eff2b575a63f..7ff0897f23d8 100644 --- a/galleries/users_explain/toolkits/axisartist.rst +++ b/galleries/users_explain/toolkits/axisartist.rst @@ -50,7 +50,7 @@ To create an Axes, :: import mpl_toolkits.axisartist as AA fig = plt.figure() - fig.add_axes([0.1, 0.1, 0.8, 0.8], axes_class=AA.Axes) + fig.add_axes((0.1, 0.1, 0.8, 0.8), axes_class=AA.Axes) or to create a subplot :: diff --git a/lib/matplotlib/__init__.py b/lib/matplotlib/__init__.py index ac71070e690a..e98e8ea07502 100644 --- a/lib/matplotlib/__init__.py +++ b/lib/matplotlib/__init__.py @@ -400,12 +400,15 @@ def impl(args, regex, min_ver=None, ignore_exit_code=False): try: output = subprocess.check_output( args, stderr=subprocess.STDOUT, - text=True, errors="replace") + text=True, errors="replace", timeout=30) except subprocess.CalledProcessError as _cpe: if ignore_exit_code: output = _cpe.output else: raise ExecutableNotFoundError(str(_cpe)) from _cpe + except subprocess.TimeoutExpired as _te: + msg = f"Timed out running {cbook._pformat_subprocess(args)}" + raise ExecutableNotFoundError(msg) from _te except OSError as _ose: raise ExecutableNotFoundError(str(_ose)) from _ose match = re.search(regex, output) @@ -799,13 +802,13 @@ def find_all(self, pattern): """ pattern_re = re.compile(pattern) - return RcParams((key, value) - for key, value in self.items() - if pattern_re.search(key)) + return self.__class__( + (key, value) for key, value in self.items() if pattern_re.search(key) + ) def copy(self): """Copy this RcParams instance.""" - rccopy = RcParams() + rccopy = self.__class__() for k in self: # Skip deprecations and revalidation. rccopy._set(k, self._get(k)) return rccopy @@ -995,9 +998,9 @@ def rc(group, **kwargs): The following aliases are available to save typing for interactive users: - ===== ================= + ====== ================= Alias Property - ===== ================= + ====== ================= 'lw' 'linewidth' 'ls' 'linestyle' 'c' 'color' @@ -1005,7 +1008,8 @@ def rc(group, **kwargs): 'ec' 'edgecolor' 'mew' 'markeredgewidth' 'aa' 'antialiased' - ===== ================= + 'sans' 'sans-serif' + ====== ================= Thus you could abbreviate the above call as:: @@ -1039,6 +1043,7 @@ def rc(group, **kwargs): 'ec': 'edgecolor', 'mew': 'markeredgewidth', 'aa': 'antialiased', + 'sans': 'sans-serif', } if isinstance(group, str): @@ -1307,11 +1312,18 @@ def is_interactive(): return rcParams['interactive'] -def _val_or_rc(val, rc_name): +def _val_or_rc(val, *rc_names): """ - If *val* is None, return ``mpl.rcParams[rc_name]``, otherwise return val. + If *val* is None, the first not-None value in ``mpl.rcParams[rc_names[i]]``. + If all are None returns ``mpl.rcParams[rc_names[-1]]``. """ - return val if val is not None else rcParams[rc_name] + if val is not None: + return val + + for rc_name in rc_names[:-1]: + if rcParams[rc_name] is not None: + return rcParams[rc_name] + return rcParams[rc_names[-1]] def _init_tests(): diff --git a/lib/matplotlib/__init__.pyi b/lib/matplotlib/__init__.pyi index 88058ffd7def..07019109f406 100644 --- a/lib/matplotlib/__init__.pyi +++ b/lib/matplotlib/__init__.pyi @@ -26,6 +26,8 @@ __all__ = [ "interactive", "is_interactive", "colormaps", + "multivar_colormaps", + "bivar_colormaps", "color_sequences", ] diff --git a/lib/matplotlib/_afm.py b/lib/matplotlib/_afm.py index 558efe16392f..9094206c2d7c 100644 --- a/lib/matplotlib/_afm.py +++ b/lib/matplotlib/_afm.py @@ -1,5 +1,5 @@ """ -A python interface to Adobe Font Metrics Files. +A Python interface to Adobe Font Metrics Files. Although a number of other Python implementations exist, and may be more complete than this, it was decided not to go with them because they were @@ -16,19 +16,11 @@ >>> from pathlib import Path >>> afm_path = Path(mpl.get_data_path(), 'fonts', 'afm', 'ptmr8a.afm') >>> ->>> from matplotlib.afm import AFM +>>> from matplotlib._afm import AFM >>> with afm_path.open('rb') as fh: ... afm = AFM(fh) ->>> afm.string_width_height('What the heck?') -(6220.0, 694) >>> afm.get_fontname() 'Times-Roman' ->>> afm.get_kern_dist('A', 'f') -0 ->>> afm.get_kern_dist('A', 'y') --92.0 ->>> afm.get_bbox_char('!') -[130, -9, 238, 676] As in the Adobe Font Metrics File Format Specification, all dimensions are given in units of 1/1000 of the scale factor (point size) of the font @@ -87,20 +79,23 @@ def _to_bool(s): def _parse_header(fh): """ - Read the font metrics header (up to the char metrics) and returns - a dictionary mapping *key* to *val*. *val* will be converted to the - appropriate python type as necessary; e.g.: + Read the font metrics header (up to the char metrics). - * 'False'->False - * '0'->0 - * '-168 -218 1000 898'-> [-168, -218, 1000, 898] + Returns + ------- + dict + A dictionary mapping *key* to *val*. Dictionary keys are: - Dictionary keys are + StartFontMetrics, FontName, FullName, FamilyName, Weight, ItalicAngle, + IsFixedPitch, FontBBox, UnderlinePosition, UnderlineThickness, Version, + Notice, EncodingScheme, CapHeight, XHeight, Ascender, Descender, + StartCharMetrics - StartFontMetrics, FontName, FullName, FamilyName, Weight, - ItalicAngle, IsFixedPitch, FontBBox, UnderlinePosition, - UnderlineThickness, Version, Notice, EncodingScheme, CapHeight, - XHeight, Ascender, Descender, StartCharMetrics + *val* will be converted to the appropriate Python type as necessary, e.g.,: + + * 'False' -> False + * '0' -> 0 + * '-168 -218 1000 898' -> [-168, -218, 1000, 898] """ header_converters = { b'StartFontMetrics': _to_float, @@ -185,11 +180,9 @@ def _parse_header(fh): def _parse_char_metrics(fh): """ - Parse the given filehandle for character metrics information and return - the information as dicts. + Parse the given filehandle for character metrics information. - It is assumed that the file cursor is on the line behind - 'StartCharMetrics'. + It is assumed that the file cursor is on the line behind 'StartCharMetrics'. Returns ------- @@ -239,14 +232,15 @@ def _parse_char_metrics(fh): def _parse_kern_pairs(fh): """ - Return a kern pairs dictionary; keys are (*char1*, *char2*) tuples and - values are the kern pair value. For example, a kern pairs line like - ``KPX A y -50`` - - will be represented as:: + Return a kern pairs dictionary. - d[ ('A', 'y') ] = -50 + Returns + ------- + dict + Keys are (*char1*, *char2*) tuples and values are the kern pair value. For + example, a kern pairs line like ``KPX A y -50`` will be represented as:: + d['A', 'y'] = -50 """ line = next(fh) @@ -279,8 +273,7 @@ def _parse_kern_pairs(fh): def _parse_composites(fh): """ - Parse the given filehandle for composites information return them as a - dict. + Parse the given filehandle for composites information. It is assumed that the file cursor is on the line behind 'StartComposites'. @@ -363,36 +356,6 @@ def __init__(self, fh): self._metrics, self._metrics_by_name = _parse_char_metrics(fh) self._kern, self._composite = _parse_optional(fh) - def get_bbox_char(self, c, isord=False): - if not isord: - c = ord(c) - return self._metrics[c].bbox - - def string_width_height(self, s): - """ - Return the string width (including kerning) and string height - as a (*w*, *h*) tuple. - """ - if not len(s): - return 0, 0 - total_width = 0 - namelast = None - miny = 1e9 - maxy = 0 - for c in s: - if c == '\n': - continue - wx, name, bbox = self._metrics[ord(c)] - - total_width += wx + self._kern.get((namelast, name), 0) - l, b, w, h = bbox - miny = min(miny, b) - maxy = max(maxy, b + h) - - namelast = name - - return total_width, maxy - miny - def get_str_bbox_and_descent(self, s): """Return the string bounding box and the maximal descent.""" if not len(s): @@ -423,45 +386,29 @@ def get_str_bbox_and_descent(self, s): return left, miny, total_width, maxy - miny, -miny - def get_str_bbox(self, s): - """Return the string bounding box.""" - return self.get_str_bbox_and_descent(s)[:4] - - def get_name_char(self, c, isord=False): - """Get the name of the character, i.e., ';' is 'semicolon'.""" - if not isord: - c = ord(c) - return self._metrics[c].name + def get_glyph_name(self, glyph_ind): # For consistency with FT2Font. + """Get the name of the glyph, i.e., ord(';') is 'semicolon'.""" + return self._metrics[glyph_ind].name - def get_width_char(self, c, isord=False): + def get_char_index(self, c): # For consistency with FT2Font. """ - Get the width of the character from the character metric WX field. + Return the glyph index corresponding to a character code point. + + Note, for AFM fonts, we treat the glyph index the same as the codepoint. """ - if not isord: - c = ord(c) + return c + + def get_width_char(self, c): + """Get the width of the character code from the character metric WX field.""" return self._metrics[c].width def get_width_from_char_name(self, name): """Get the width of the character from a type1 character name.""" return self._metrics_by_name[name].width - def get_height_char(self, c, isord=False): - """Get the bounding box (ink) height of character *c* (space is 0).""" - if not isord: - c = ord(c) - return self._metrics[c].bbox[-1] - - def get_kern_dist(self, c1, c2): - """ - Return the kerning pair distance (possibly 0) for chars *c1* and *c2*. - """ - name1, name2 = self.get_name_char(c1), self.get_name_char(c2) - return self.get_kern_dist_from_name(name1, name2) - def get_kern_dist_from_name(self, name1, name2): """ - Return the kerning pair distance (possibly 0) for chars - *name1* and *name2*. + Return the kerning pair distance (possibly 0) for chars *name1* and *name2*. """ return self._kern.get((name1, name2), 0) @@ -493,7 +440,7 @@ def get_familyname(self): return re.sub(extras, '', name) @property - def family_name(self): + def family_name(self): # For consistency with FT2Font. """The font family name, e.g., 'Times'.""" return self.get_familyname() @@ -516,17 +463,3 @@ def get_xheight(self): def get_underline_thickness(self): """Return the underline thickness as float.""" return self._header[b'UnderlineThickness'] - - def get_horizontal_stem_width(self): - """ - Return the standard horizontal stem width as float, or *None* if - not specified in AFM file. - """ - return self._header.get(b'StdHW', None) - - def get_vertical_stem_width(self): - """ - Return the standard vertical stem width as float, or *None* if - not specified in AFM file. - """ - return self._header.get(b'StdVW', None) diff --git a/lib/matplotlib/_cm.py b/lib/matplotlib/_cm.py index b942d1697934..d3f4632108a8 100644 --- a/lib/matplotlib/_cm.py +++ b/lib/matplotlib/_cm.py @@ -1365,6 +1365,29 @@ def _gist_yarg(x): return 1 - x (0.8509803921568627, 0.8509803921568627, 0.8509803921568627 ), # d9d9d9 ) +# Colorblind accessible palettes from +# Matthew A. Petroff, Accessible Color Sequences for Data Visualization +# https://arxiv.org/abs/2107.02270 + +_petroff6_data = ( + (0.3411764705882353, 0.5647058823529412, 0.9882352941176471), # 5790fc + (0.9725490196078431, 0.611764705882353, 0.12549019607843137), # f89c20 + (0.8941176470588236, 0.1450980392156863, 0.21176470588235294), # e42536 + (0.5882352941176471, 0.2901960784313726, 0.5450980392156862), # 964a8b + (0.611764705882353, 0.611764705882353, 0.6313725490196078), # 9c9ca1 + (0.47843137254901963, 0.12941176470588237, 0.8666666666666667), # 7a21dd +) + +_petroff8_data = ( + (0.09411764705882353, 0.27058823529411763, 0.984313725490196), # 1845fb + (1.0, 0.3686274509803922, 0.00784313725490196), # ff5e02 + (0.788235294117647, 0.12156862745098039, 0.08627450980392157), # c91f16 + (0.7843137254901961, 0.28627450980392155, 0.6627450980392157), # c849a9 + (0.6784313725490196, 0.6784313725490196, 0.49019607843137253), # adad7d + (0.5254901960784314, 0.7843137254901961, 0.8666666666666667), # 86c8dd + (0.3411764705882353, 0.5529411764705883, 1.0), # 578dff + (0.396078431372549, 0.38823529411764707, 0.39215686274509803), # 656364 +) _petroff10_data = ( (0.24705882352941178, 0.5647058823529412, 0.8549019607843137), # 3f90da diff --git a/lib/matplotlib/_constrained_layout.py b/lib/matplotlib/_constrained_layout.py index 5623e12a3c41..f5f23581bd9d 100644 --- a/lib/matplotlib/_constrained_layout.py +++ b/lib/matplotlib/_constrained_layout.py @@ -521,11 +521,13 @@ def match_submerged_margins(layoutgrids, fig): See test_constrained_layout::test_constrained_layout12 for an example. """ + axsdone = [] for sfig in fig.subfigs: - match_submerged_margins(layoutgrids, sfig) + axsdone += match_submerged_margins(layoutgrids, sfig) axs = [a for a in fig.get_axes() - if a.get_subplotspec() is not None and a.get_in_layout()] + if (a.get_subplotspec() is not None and a.get_in_layout() and + a not in axsdone)] for ax1 in axs: ss1 = ax1.get_subplotspec() @@ -592,6 +594,8 @@ def match_submerged_margins(layoutgrids, fig): for i in ss1.rowspan[:-1]: lg1.edit_margin_min('bottom', maxsubb, cell=i) + return axs + def get_cb_parent_spans(cbax): """ diff --git a/lib/matplotlib/_enums.py b/lib/matplotlib/_enums.py index 75a09b7b5d8c..d85c5c5f03db 100644 --- a/lib/matplotlib/_enums.py +++ b/lib/matplotlib/_enums.py @@ -151,7 +151,7 @@ def demo(): import matplotlib.pyplot as plt fig = plt.figure(figsize=(4, 1.2)) - ax = fig.add_axes([0, 0, 1, 0.8]) + ax = fig.add_axes((0, 0, 1, 0.8)) ax.set_title('Cap style') for x, style in enumerate(['butt', 'round', 'projecting']): diff --git a/lib/matplotlib/_enums.pyi b/lib/matplotlib/_enums.pyi index 714e6cfe03fa..3ff7e208c398 100644 --- a/lib/matplotlib/_enums.pyi +++ b/lib/matplotlib/_enums.pyi @@ -1,4 +1,3 @@ -from typing import cast from enum import Enum diff --git a/lib/matplotlib/_mathtext.py b/lib/matplotlib/_mathtext.py index 3739a517978b..19ddbb6d0883 100644 --- a/lib/matplotlib/_mathtext.py +++ b/lib/matplotlib/_mathtext.py @@ -9,6 +9,7 @@ import enum import functools import logging +import math import os import re import types @@ -19,6 +20,7 @@ from typing import NamedTuple import numpy as np +from numpy.typing import NDArray from pyparsing import ( Empty, Forward, Literal, Group, NotAny, OneOrMore, Optional, ParseBaseException, ParseException, ParseExpression, ParseFatalException, @@ -30,7 +32,7 @@ from ._mathtext_data import ( latex_to_bakoma, stix_glyph_fixes, stix_virtual_fonts, tex2uni) from .font_manager import FontProperties, findfont, get_font -from .ft2font import FT2Font, FT2Image, Kerning, LoadFlags +from .ft2font import FT2Font, Kerning, LoadFlags if T.TYPE_CHECKING: @@ -99,7 +101,7 @@ class RasterParse(NamedTuple): The offsets are always zero. width, height, depth : float The global metrics. - image : FT2Image + image : 2D array of uint8 A raster image. """ ox: float @@ -107,7 +109,7 @@ class RasterParse(NamedTuple): width: float height: float depth: float - image: FT2Image + image: NDArray[np.uint8] RasterParse.__module__ = "matplotlib.mathtext" @@ -148,7 +150,7 @@ def to_raster(self, *, antialiased: bool) -> RasterParse: w = xmax - xmin h = ymax - ymin - self.box.depth d = ymax - ymin - self.box.height - image = FT2Image(int(np.ceil(w)), int(np.ceil(h + max(d, 0)))) + image = np.zeros((math.ceil(h + max(d, 0)), math.ceil(w)), np.uint8) # Ideally, we could just use self.glyphs and self.rects here, shifting # their coordinates by (-xmin, -ymin), but this yields slightly @@ -167,7 +169,9 @@ def to_raster(self, *, antialiased: bool) -> RasterParse: y = int(center - (height + 1) / 2) else: y = int(y1) - image.draw_rect_filled(int(x1), y, int(np.ceil(x2)), y + height) + x1 = math.floor(x1) + x2 = math.ceil(x2) + image[y:y+height+1, x1:x2+1] = 0xff return RasterParse(0, 0, w, h + d, d, image) @@ -2520,10 +2524,10 @@ def subsuper(self, s: str, loc: int, toks: ParseResults) -> T.Any: if len(new_children): # remove last kern if (isinstance(new_children[-1], Kern) and - hasattr(new_children[-2], '_metrics')): + isinstance(new_children[-2], Char)): new_children = new_children[:-1] last_char = new_children[-1] - if hasattr(last_char, '_metrics'): + if isinstance(last_char, Char): last_char.width = last_char._metrics.advance # create new Hlist without kerning nucleus = Hlist(new_children, do_kern=False) @@ -2599,7 +2603,7 @@ def subsuper(self, s: str, loc: int, toks: ParseResults) -> T.Any: # Do we need to add a space after the nucleus? # To find out, check the flag set by operatorname - spaced_nucleus = [nucleus, x] + spaced_nucleus: list[Node] = [nucleus, x] if self._in_subscript_or_superscript: spaced_nucleus += [self._make_space(self._space_widths[r'\,'])] self._in_subscript_or_superscript = False diff --git a/lib/matplotlib/_type1font.py b/lib/matplotlib/_type1font.py index b3e08f52c035..33b22adbae73 100644 --- a/lib/matplotlib/_type1font.py +++ b/lib/matplotlib/_type1font.py @@ -3,7 +3,7 @@ This version reads pfa and pfb files and splits them for embedding in pdf files. It also supports SlantFont and ExtendFont transformations, -similarly to pdfTeX and friends. There is no support yet for subsetting. +similarly to pdfTeX and friends. Usage:: @@ -11,6 +11,7 @@ clear_part, encrypted_part, finale = font.parts slanted_font = font.transform({'slant': 0.167}) extended_font = font.transform({'extend': 1.2}) + subset_font = font.subset([ord(c) for c in 'Hello World']) Sources: @@ -25,6 +26,7 @@ import binascii import functools +import itertools import logging import re import string @@ -579,6 +581,16 @@ def _parse(self): extras = ('(?i)([ -](regular|plain|italic|oblique|(semi)?bold|' '(ultra)?light|extra|condensed))+$') prop['FamilyName'] = re.sub(extras, '', prop['FullName']) + + # Parse FontBBox + toks = [*_tokenize(prop['FontBBox'].encode('ascii'), True)] + if ([tok.kind for tok in toks] + != ['delimiter', 'number', 'number', 'number', 'number', 'delimiter'] + or toks[-1].raw != toks[0].opposite()): + raise RuntimeError( + f"FontBBox should be a size-4 array, was {prop['FontBBox']}") + prop['FontBBox'] = [tok.value() for tok in toks[1:-1]] + # Decrypt the encrypted parts ndiscard = prop.get('lenIV', 4) cs = prop['CharStrings'] @@ -627,8 +639,7 @@ def _parse_subrs(self, tokens, _data): return array, next(tokens).endpos() - @staticmethod - def _parse_charstrings(tokens, _data): + def _parse_charstrings(self, tokens, _data): count_token = next(tokens) if not count_token.is_number(): raise RuntimeError( @@ -650,7 +661,12 @@ def _parse_charstrings(tokens, _data): f"Token following /{glyphname} in CharStrings definition " f"must be a number, was {nbytes_token}" ) - next(tokens) # usually RD or |- + token = next(tokens) + if not token.is_keyword(self._abbr['RD']): + raise RuntimeError( + f"Token preceding charstring must be {self._abbr['RD']}, " + f"was {token}" + ) binary_token = tokens.send(1+nbytes_token.value()) charstrings[glyphname] = binary_token.value() @@ -681,8 +697,7 @@ def _parse_encoding(tokens, _data): continue encoding[index_token.value()] = name_token.value() - @staticmethod - def _parse_othersubrs(tokens, data): + def _parse_othersubrs(self, tokens, data): init_pos = None while True: token = next(tokens) @@ -690,7 +705,7 @@ def _parse_othersubrs(tokens, data): init_pos = token.pos if token.is_delim(): _expression(token, tokens, data) - elif token.is_keyword('def', 'ND', '|-'): + elif token.is_keyword('def', self._abbr['ND']): return data[init_pos:token.endpos()], token.endpos() def transform(self, effects): @@ -745,7 +760,7 @@ def transform(self, effects): fontmatrix = ( f"[{' '.join(_format_approx(x, 6) for x in array)}]" ) - replacements = ( + newparts = self._replace( [(x, f'/FontName/{fontname} def') for x in self._pos['FontName']] + [(x, f'/ItalicAngle {italicangle} def') @@ -755,11 +770,63 @@ def transform(self, effects): + [(x, '') for x in self._pos.get('UniqueID', [])] ) + return Type1Font(( + newparts[0], + self._encrypt(newparts[1], 'eexec'), + self.parts[2] + )) + + def with_encoding(self, encoding): + """ + Change the encoding of the font. + + Parameters + ---------- + encoding : dict + A dictionary mapping character codes to glyph names. + + Returns + ------- + `Type1Font` + """ + newparts = self._replace( + [(x, '') for x in self._pos.get('UniqueID', [])] + + [(self._pos['Encoding'][0], self._postscript_encoding(encoding))] + ) + return Type1Font(( + newparts[0], + self._encrypt(newparts[1], 'eexec'), + self.parts[2] + )) + + def _replace(self, replacements): + """ + Change the font according to `replacements` + + Parameters + ---------- + replacements : list of ((int, int), str) + Each element is ((pos0, pos1), replacement) where pos0 and + pos1 are indices to the original font data (parts[0] and the + decrypted part concatenated). The data in the interval + pos0:pos1 will be replaced by the replacement text. To + accommodate binary data, the replacement is taken to be in + Latin-1 encoding. + + The case where pos0 is inside parts[0] and pos1 inside + the decrypted part is not supported. + + Returns + ------- + (bytes, bytes) + The new parts[0] and decrypted part (which needs to be + encrypted in the transformed font). + """ data = bytearray(self.parts[0]) data.extend(self.decrypted) len0 = len(self.parts[0]) for (pos0, pos1), value in sorted(replacements, reverse=True): - data[pos0:pos1] = value.encode('ascii', 'replace') + data[pos0:pos1] = value.encode('latin-1') if pos0 < len(self.parts[0]): if pos1 >= len(self.parts[0]): raise RuntimeError( @@ -768,13 +835,275 @@ def transform(self, effects): ) len0 += len(value) - pos1 + pos0 - data = bytes(data) - return Type1Font(( - data[:len0], - self._encrypt(data[len0:], 'eexec'), + return bytes(data[:len0]), bytes(data[len0:]) + + def subset(self, characters, name_prefix): + """ + Return a new font that only defines the given characters. + + Parameters + ---------- + characters : sequence of bytes + The subset of characters to include. These are indices into the + font's encoding array. The encoding array of a Type-1 font can + only include 256 characters, but other glyphs may be accessed + via the seac operator. + name_prefix : str + Prefix to prepend to the font name. + + Returns + ------- + `Type1Font` + """ + characters = frozenset(characters) + if _log.isEnabledFor(logging.DEBUG): + _log.debug( + "Subsetting font %s to characters %s = %s", + self.prop['FontName'], + sorted(characters), + [self.prop['Encoding'].get(code) for code in sorted(characters)], + ) + encoding = {code: glyph + for code, glyph in self.prop['Encoding'].items() + if code in characters} + encoding[0] = '.notdef' + # todo and done include strings (glyph names) + todo = set(encoding.values()) + done = set() + seen_subrs = {0, 1, 2, 3} + while todo: + glyph = todo.pop() + called_glyphs, called_subrs = _CharstringSimulator(self).run(glyph) + todo.update(called_glyphs - done) + seen_subrs.update(called_subrs) + done.add(glyph) + + charstrings = self._subset_charstrings(done) + subrs = self._subset_subrs(seen_subrs) + newparts = self._replace( + [(x, f'/FontName /{name_prefix}{self.prop["FontName"]} def') + for x in self._pos['FontName']] + + [(self._pos['CharStrings'][0], charstrings), + (self._pos['Subrs'][0], subrs), + (self._pos['Encoding'][0], self._postscript_encoding(encoding)) + ] + [(x, '') for x in self._pos.get('UniqueID', [])] + ) + return type(self)(( + newparts[0], + self._encrypt(newparts[1], 'eexec'), self.parts[2] )) + @staticmethod + def _charstring_tokens(data): + """Parse a Type-1 charstring + + Yield opcode names and integer parameters. + """ + data = iter(data) + for byte in data: + if 32 <= byte <= 246: + yield byte - 139 + elif 247 <= byte <= 250: + byte2 = next(data) + yield (byte-247) * 256 + byte2 + 108 + elif 251 <= byte <= 254: + byte2 = next(data) + yield -(byte-251)*256 - byte2 - 108 + elif byte == 255: + bs = bytes(itertools.islice(data, 4)) + yield struct.unpack('>i', bs)[0] + elif byte == 12: + byte1 = next(data) + yield { + 0: 'dotsection', + 1: 'vstem3', + 2: 'hstem3', + 6: 'seac', + 7: 'sbw', + 12: 'div', + 16: 'callothersubr', + 17: 'pop', + 33: 'setcurrentpoint' + }[byte1] + else: + yield { + 1: 'hstem', + 3: 'vstem', + 4: 'vmoveto', + 5: 'rlineto', + 6: 'hlineto', + 7: 'vlineto', + 8: 'rrcurveto', + 9: 'closepath', + 10: 'callsubr', + 11: 'return', + 13: 'hsbw', + 14: 'endchar', + 21: 'rmoveto', + 22: 'hmoveto', + 30: 'vhcurveto', + 31: 'hvcurveto' + }[byte] + + def _postscript_encoding(self, encoding): + """Return a PostScript encoding array for the encoding.""" + return '\n'.join([ + '/Encoding 256 array\n0 1 255 { 1 index exch /.notdef put} for', + *( + f'dup {i} /{glyph} put' + for i, glyph in sorted(encoding.items()) + if glyph != '.notdef' + ), + 'readonly def\n', + ]) + + def _subset_charstrings(self, glyphs): + """Return a PostScript CharStrings array for the glyphs.""" + charstrings = self.prop['CharStrings'] + lenIV = self.prop.get('lenIV', 4) + ordered = sorted(glyphs) + encrypted = [ + self._encrypt(charstrings[glyph], 'charstring', lenIV).decode('latin-1') + for glyph in ordered + ] + RD, ND = self._abbr['RD'], self._abbr['ND'] + return '\n'.join([ + f'/CharStrings {len(ordered)} dict dup begin', + *( + f'/{glyph} {len(enc)} {RD} {enc} {ND}' + for glyph, enc in zip(ordered, encrypted) + ), + 'end\n', + ]) + + def _subset_subrs(self, indices): + """Return a PostScript Subrs array for the subroutines.""" + # we can't remove subroutines, we just replace unused ones with a stub + subrs = self.prop['Subrs'] + n_subrs = len(subrs) + lenIV = self.prop.get('lenIV', 4) + stub = self._encrypt(b'\x0b', 'charstring', lenIV).decode('latin-1') + encrypted = [ + self._encrypt(subrs[i], 'charstring', lenIV).decode('latin-1') + if i in indices + else stub + for i in range(n_subrs) + ] + RD, ND, NP = self._abbr['RD'], self._abbr['ND'], self._abbr['NP'] + return '\n'.join([ + f'/Subrs {n_subrs} array', + *( + f'dup {i} {len(enc)} {RD} {enc} {NP}' + for i, enc in enumerate(encrypted) + ), + ]) + + +class _CharstringSimulator: + __slots__ = ('font', 'buildchar_stack', 'postscript_stack', 'glyphs', 'subrs') + + def __init__(self, font): + self.font = font + self.buildchar_stack = [] + self.postscript_stack = [] + self.glyphs = set() + self.subrs = set() + + def run(self, glyph_or_subr): + """Run the charstring interpreter on a glyph or subroutine. + + This does not actually execute the code but simulates it to find out + which subroutines get called when executing the glyph or subroutine. + + Parameters + ---------- + glyph_or_subr : str or int + The name of the glyph or the index of the subroutine to simulate. + + Returns + ------- + glyphs : set[str] + The set of glyph names called by the glyph or subroutine. + subrs : set[int] + The set of subroutines called by the glyph or subroutine. + """ + if isinstance(glyph_or_subr, str): + program = self.font.prop['CharStrings'][glyph_or_subr] + self.glyphs.add(glyph_or_subr) + else: + program = self.font.prop['Subrs'][glyph_or_subr] + self.subrs.add(glyph_or_subr) + for opcode in self.font._charstring_tokens(program): + if opcode in ('return', 'endchar'): + return self.glyphs, self.subrs + self._step(opcode) + else: + font_name = self.font.prop.get('FontName', '(unknown)') + _log.info( + f"Glyph or subr {glyph_or_subr} in font {font_name} does not end " + "with return or endchar" + ) + return self.glyphs, self.subrs + + def _step(self, opcode): + """Run one step in the charstring interpreter.""" + match opcode: + case int(): + self.buildchar_stack.append(opcode) + case ( + 'hsbw' | 'sbw' | 'closepath' | 'hlineto' | 'hmoveto' | 'hcurveto' | + 'hvcurveto' | 'rlineto' | 'rmoveto' | 'rrcurveto' | 'vhcurveto' | + 'vlineto' | 'vmoveto' | 'dotsection' | 'hstem' | 'hstem3' | + 'vstem' | 'vstem3' | 'setcurrentpoint' + ): + self.buildchar_stack.clear() + case 'seac': # Standard Encoding Accented Character + codes = self.buildchar_stack[3:5] + self.glyphs.update(_StandardEncoding[int(x)] for x in codes) + self.buildchar_stack.clear() + case 'div': + num1, num2 = self.buildchar_stack[-2:] + if num2 == 0: + _log.warning( + f"Division by zero in font {self.font.prop['FontName']}" + ) + self.buildchar_stack[-2:] = [0] + else: + self.buildchar_stack[-2:] = [num1/num2] + case 'callothersubr': + n, othersubr = self.buildchar_stack[-2:] + if not isinstance(n, int): + _log.warning( + f"callothersubr {othersubr} with non-integer argument " + f"count in font {self.font.prop['FontName']}" + ) + n = int(n) + args = self.buildchar_stack[-2-n:-2] + if othersubr == 3: + self.postscript_stack.append(args[0]) + else: + self.postscript_stack.extend(args[::-1]) + self.buildchar_stack[-2-n:] = [] + case 'callsubr': + subr = self.buildchar_stack.pop() + if not isinstance(subr, int): + _log.warning( + f"callsubr with non-integer argument {subr} in font " + f"{self.font.prop['FontName']}" + ) + subr = int(subr) + self.run(subr) + case 'pop': + if not self.postscript_stack: + _log.warning( + f"pop with empty stack in font {self.font.prop['FontName']}" + ) + self.postscript_stack.append(0) + self.buildchar_stack.append(self.postscript_stack.pop()) + case _: + raise RuntimeError(f'opcode {opcode}') + _StandardEncoding = { **{ord(letter): letter for letter in string.ascii_letters}, diff --git a/lib/matplotlib/animation.py b/lib/matplotlib/animation.py index c6ff7702d992..8756cb0c1439 100644 --- a/lib/matplotlib/animation.py +++ b/lib/matplotlib/animation.py @@ -1,5 +1,6 @@ import abc import base64 +import collections import contextlib from io import BytesIO, TextIOWrapper import itertools @@ -1708,13 +1709,13 @@ def iter_frames(frames=frames): self._cache_frame_data = cache_frame_data # Needs to be initialized so the draw functions work without checking - self._save_seq = [] + self._save_seq = collections.deque([], self._save_count) super().__init__(fig, **kwargs) # Need to reset the saved seq, since right now it will contain data # for a single frame from init, which is not what we want. - self._save_seq = [] + self._save_seq.clear() def new_frame_seq(self): # Use the generating function to generate a new frame sequence @@ -1727,8 +1728,7 @@ def new_saved_frame_seq(self): if self._save_seq: # While iterating we are going to update _save_seq # so make a copy to safely iterate over - self._old_saved_seq = list(self._save_seq) - return iter(self._old_saved_seq) + return iter([*self._save_seq]) else: if self._save_count is None: frame_seq = self.new_frame_seq() @@ -1773,13 +1773,12 @@ def _init_draw(self): 'return a sequence of Artist objects.') for a in self._drawn_artists: a.set_animated(self._blit) - self._save_seq = [] + self._save_seq.clear() def _draw_frame(self, framedata): if self._cache_frame_data: # Save the data for potential saving of movies. self._save_seq.append(framedata) - self._save_seq = self._save_seq[-self._save_count:] # Call the func with framedata and args. If blitting is desired, # func needs to return a sequence of any artists that were modified. diff --git a/lib/matplotlib/artist.py b/lib/matplotlib/artist.py index 376eeb00b04d..50c9842ff010 100644 --- a/lib/matplotlib/artist.py +++ b/lib/matplotlib/artist.py @@ -321,39 +321,59 @@ def stale(self, val): def get_window_extent(self, renderer=None): """ - Get the artist's bounding box in display space. + Get the artist's bounding box in display space, ignoring clipping. - The bounding box' width and height are nonnegative. + The bounding box's width and height are non-negative. - Subclasses should override for inclusion in the bounding box - "tight" calculation. Default is to return an empty bounding - box at 0, 0. + Subclasses should override for inclusion in the bounding box "tight" + calculation. Default is to return an empty bounding box at 0, 0. - Be careful when using this function, the results will not update - if the artist window extent of the artist changes. The extent - can change due to any changes in the transform stack, such as - changing the Axes limits, the figure size, or the canvas used - (as is done when saving a figure). This can lead to unexpected - behavior where interactive figures will look fine on the screen, - but will save incorrectly. + .. warning:: + + The extent can change due to any changes in the transform stack, such + as changing the Axes limits, the figure size, the canvas used (as is + done when saving a figure), or the DPI. + + Relying on a once-retrieved window extent can lead to unexpected + behavior in various cases such as interactive figures being resized or + moved to a screen with different dpi, or figures that look fine on + screen render incorrectly when saved to file. + + To get accurate results you may need to manually call + `~.Figure.savefig` or `~.Figure.draw_without_rendering` to have + Matplotlib compute the rendered size. + + Parameters + ---------- + renderer : `~matplotlib.backend_bases.RendererBase`, optional + Renderer used to draw the figure (i.e. ``fig.canvas.get_renderer()``). + + See Also + -------- + `~.Artist.get_tightbbox` : + Get the artist bounding box, taking clipping into account. """ return Bbox([[0, 0], [0, 0]]) def get_tightbbox(self, renderer=None): """ - Like `.Artist.get_window_extent`, but includes any clipping. + Get the artist's bounding box in display space, taking clipping into account. Parameters ---------- - renderer : `~matplotlib.backend_bases.RendererBase` subclass, optional - renderer that will be used to draw the figures (i.e. - ``fig.canvas.get_renderer()``) + renderer : `~matplotlib.backend_bases.RendererBase`, optional + Renderer used to draw the figure (i.e. ``fig.canvas.get_renderer()``). Returns ------- `.Bbox` or None - The enclosing bounding box (in figure pixel coordinates). - Returns None if clipping results in no intersection. + The enclosing bounding box (in figure pixel coordinates), or None + if clipping results in no intersection. + + See Also + -------- + `~.Artist.get_window_extent` : + Get the artist bounding box, ignoring clipping. """ bbox = self.get_window_extent(renderer) if self.get_clip_on(): diff --git a/lib/matplotlib/axes/_axes.py b/lib/matplotlib/axes/_axes.py index 285eab153ecc..b4ed7ae22d35 100644 --- a/lib/matplotlib/axes/_axes.py +++ b/lib/matplotlib/axes/_axes.py @@ -64,6 +64,23 @@ def _make_axes_method(func): return func +class _GroupedBarReturn: + """ + A provisional result object for `.Axes.grouped_bar`. + + This is a placeholder for a future better return type. We try to build in + backward compatibility / migration possibilities. + + The only public interfaces are the ``bar_containers`` attribute and the + ``remove()`` method. + """ + def __init__(self, bar_containers): + self.bar_containers = bar_containers + + def remove(self): + [b.remove() for b in self.bar_containers] + + @_docstring.interpd class Axes(_AxesBase): """ @@ -2414,6 +2431,7 @@ def bar(self, x, height, width=0.8, bottom=None, *, align="center", See Also -------- barh : Plot a horizontal bar plot. + grouped_bar : Plot multiple datasets as grouped bar plot. Notes ----- @@ -3014,6 +3032,309 @@ def broken_barh(self, xranges, yrange, **kwargs): return col + @_docstring.interpd + def grouped_bar(self, heights, *, positions=None, group_spacing=1.5, bar_spacing=0, + tick_labels=None, labels=None, orientation="vertical", colors=None, + **kwargs): + """ + Make a grouped bar plot. + + .. versionadded:: 3.11 + + The API is still provisional. We may still fine-tune some aspects based on + user-feedback. + + Grouped bar charts visualize a collection of categorical datasets. Each value + in a dataset belongs to a distinct category and these categories are the same + across all datasets. The categories typically have string names, but could + also be dates or index keys. The values in each dataset are represented by a + sequence of bars of the same color. The bars of all datasets are grouped + together by their shared categories. The category names are drawn as the tick + labels for each bar group. Each dataset has a distinct bar color, and can + optionally get a label that is used for the legend. + + Example: + + .. code-block:: python + + grouped_bar([dataset_0, dataset_1, dataset_2], + tick_labels=['A', 'B'], + labels=['dataset 0', 'dataset 1', 'dataset 2']) + + .. plot:: _embedded_plots/grouped_bar.py + + Parameters + ---------- + heights : list of array-like or dict of array-like or 2D array \ +or pandas.DataFrame + The heights for all x and groups. One of: + + - list of array-like: A list of datasets, each dataset must have + the same number of elements. + + .. code-block:: none + + # category_A, category_B + dataset_0 = [value_0_A, value_0_B] + dataset_1 = [value_1_A, value_1_B] + dataset_2 = [value_2_A, value_2_B] + + Example call:: + + grouped_bar([dataset_0, dataset_1, dataset_2]) + + - dict of array-like: A mapping from names to datasets. Each dataset + (dict value) must have the same number of elements. + + Example call: + + .. code-block:: python + + data_dict = {'ds0': dataset_0, 'ds1': dataset_1, 'ds2': dataset_2} + grouped_bar(data_dict) + + The names are used as *labels*, i.e. this is equivalent to + + .. code-block:: python + + grouped_bar(data_dict.values(), labels=data_dict.keys()) + + When using a dict input, you must not pass *labels* explicitly. + + - a 2D array: The rows are the categories, the columns are the different + datasets. + + .. code-block:: none + + dataset_0 dataset_1 dataset_2 + category_A ds0_a ds1_a ds2_a + category_B ds0_b ds1_b ds2_b + + Example call: + + .. code-block:: python + + categories = ["A", "B"] + dataset_labels = ["dataset_0", "dataset_1", "dataset_2"] + array = np.random.random((2, 3)) + grouped_bar(array, tick_labels=categories, labels=dataset_labels) + + - a `pandas.DataFrame`. + + The index is used for the categories, the columns are used for the + datasets. + + .. code-block:: python + + df = pd.DataFrame( + np.random.random((2, 3)), + index=["A", "B"], + columns=["dataset_0", "dataset_1", "dataset_2"] + ) + grouped_bar(df) + + i.e. this is equivalent to + + .. code-block:: + + grouped_bar(df.to_numpy(), tick_labels=df.index, labels=df.columns) + + Note that ``grouped_bar(df)`` produces a structurally equivalent plot like + ``df.plot.bar()``. + + positions : array-like, optional + The center positions of the bar groups. The values have to be equidistant. + If not given, a sequence of integer positions 0, 1, 2, ... is used. + + tick_labels : list of str, optional + The category labels, which are placed on ticks at the center *positions* + of the bar groups. If not set, the axis ticks (positions and labels) are + left unchanged. + + labels : list of str, optional + The labels of the datasets, i.e. the bars within one group. + These will show up in the legend. + + group_spacing : float, default: 1.5 + The space between two bar groups as a multiple of bar width. + + The default value of 1.5 thus means that there's a gap of + 1.5 bar widths between bar groups. + + bar_spacing : float, default: 0 + The space between bars as a multiple of bar width. + + orientation : {"vertical", "horizontal"}, default: "vertical" + The direction of the bars. + + colors : list of :mpltype:`color`, optional + A sequence of colors to be cycled through and used to color bars + of the different datasets. The sequence need not be exactly the + same length as the number of provided y, in which case the colors + will repeat from the beginning. + + If not specified, the colors from the Axes property cycle will be used. + + **kwargs : `.Rectangle` properties + + %(Rectangle:kwdoc)s + + Returns + ------- + _GroupedBarReturn + + A provisional result object. This will be refined in the future. + For now, the guaranteed API on the returned object is limited to + + - the attribute ``bar_containers``, which is a list of + `.BarContainer`, i.e. the results of the individual `~.Axes.bar` + calls for each dataset. + + - a ``remove()`` method, that remove all bars from the Axes. + See also `.Artist.remove()`. + + See Also + -------- + bar : A lower-level API for bar plots, with more degrees of freedom like + individual bar sizes and colors. + + Notes + ----- + For a better understanding, we compare the `~.Axes.grouped_bar` API with + those of `~.Axes.bar` and `~.Axes.boxplot`. + + **Comparison to bar()** + + `~.Axes.grouped_bar` intentionally deviates from the `~.Axes.bar` API in some + aspects. ``bar(x, y)`` is a lower-level API and places bars with height *y* + at explicit positions *x*. It also allows to specify individual bar widths + and colors. This kind of detailed control and flexibility is difficult to + manage and often not needed when plotting multiple datasets as a grouped bar + plot. Therefore, ``grouped_bar`` focusses on the abstraction of bar plots + as visualization of categorical data. + + The following examples may help to transfer from ``bar`` to + ``grouped_bar``. + + Positions are de-emphasized due to categories, and default to integer values. + If you have used ``range(N)`` as positions, you can leave that value out:: + + bar(range(N), heights) + grouped_bar([heights]) + + If needed, positions can be passed as keyword arguments:: + + bar(x, heights) + grouped_bar([heights], positions=x) + + To place category labels in `~.Axes.bar` you could use the argument + *tick_label* or use a list of category names as *x*. + `~.Axes.grouped_bar` expects them in the argument *tick_labels*:: + + bar(range(N), heights, tick_label=["A", "B"]) + bar(["A", "B"], heights) + grouped_bar([heights], tick_labels=["A", "B"]) + + Dataset labels, which are shown in the legend, are still passed via the + *label* parameter:: + + bar(..., label="dataset") + grouped_bar(..., label=["dataset"]) + + **Comparison to boxplot()** + + Both, `~.Axes.grouped_bar` and `~.Axes.boxplot` visualize categorical data + from multiple datasets. The basic API on *tick_labels* and *positions* + is the same, so that you can easily switch between plotting all + individual values as `~.Axes.grouped_bar` or the statistical distribution + per category as `~.Axes.boxplot`:: + + grouped_bar(values, positions=..., tick_labels=...) + boxplot(values, positions=..., tick_labels=...) + + """ + if cbook._is_pandas_dataframe(heights): + if labels is None: + labels = heights.columns.tolist() + if tick_labels is None: + tick_labels = heights.index.tolist() + heights = heights.to_numpy().T + elif hasattr(heights, 'keys'): # dict + if labels is not None: + raise ValueError("'labels' cannot be used if 'heights' is a mapping") + labels = heights.keys() + heights = list(heights.values()) + elif hasattr(heights, 'shape'): # numpy array + heights = heights.T + + num_datasets = len(heights) + num_groups = len(next(iter(heights))) # inferred from first dataset + + # validate that all datasets have the same length, i.e. num_groups + # - can be skipped if heights is an array + if not hasattr(heights, 'shape'): + for i, dataset in enumerate(heights): + if len(dataset) != num_groups: + raise ValueError( + "'heights' contains datasets with different number of " + f"elements. dataset 0 has {num_groups} elements but " + f"dataset {i} has {len(dataset)} elements." + ) + + if positions is None: + group_centers = np.arange(num_groups) + group_distance = 1 + else: + group_centers = np.asanyarray(positions) + if len(group_centers) > 1: + d = np.diff(group_centers) + if not np.allclose(d, d.mean()): + raise ValueError("'positions' must be equidistant") + group_distance = d[0] + else: + group_distance = 1 + + _api.check_in_list(["vertical", "horizontal"], orientation=orientation) + + if colors is None: + colors = itertools.cycle([None]) + else: + # Note: This is equivalent to the behavior in stackplot + # TODO: do we want to be more restrictive and check lengths? + colors = itertools.cycle(colors) + + bar_width = (group_distance / + (num_datasets + (num_datasets - 1) * bar_spacing + group_spacing)) + bar_spacing_abs = bar_spacing * bar_width + margin_abs = 0.5 * group_spacing * bar_width + + if labels is None: + labels = [None] * num_datasets + else: + assert len(labels) == num_datasets + + # place the bars, but only use numerical positions, categorical tick labels + # are handled separately below + bar_containers = [] + for i, (hs, label, color) in enumerate(zip(heights, labels, colors)): + lefts = (group_centers - 0.5 * group_distance + margin_abs + + i * (bar_width + bar_spacing_abs)) + if orientation == "vertical": + bc = self.bar(lefts, hs, width=bar_width, align="edge", + label=label, color=color, **kwargs) + else: + bc = self.barh(lefts, hs, height=bar_width, align="edge", + label=label, color=color, **kwargs) + bar_containers.append(bc) + + if tick_labels is not None: + if orientation == "vertical": + self.xaxis.set_ticks(group_centers, labels=tick_labels) + else: + self.yaxis.set_ticks(group_centers, labels=tick_labels) + + return _GroupedBarReturn(bar_containers) + @_preprocess_data() def stem(self, *args, linefmt=None, markerfmt=None, basefmt=None, bottom=0, label=None, orientation='vertical'): @@ -3739,7 +4060,7 @@ def _upcast_err(err): 'zorder', 'rasterized'): if key in kwargs: eb_cap_style[key] = kwargs[key] - eb_cap_style['color'] = ecolor + eb_cap_style["markeredgecolor"] = ecolor barcols = [] caplines = {'x': [], 'y': []} diff --git a/lib/matplotlib/axes/_axes.pyi b/lib/matplotlib/axes/_axes.pyi index a23a0b27f01b..0008363b8220 100644 --- a/lib/matplotlib/axes/_axes.pyi +++ b/lib/matplotlib/axes/_axes.pyi @@ -2,7 +2,6 @@ from matplotlib.axes._base import _AxesBase from matplotlib.axes._secondary_axes import SecondaryAxis from matplotlib.artist import Artist -from matplotlib.backend_bases import RendererBase from matplotlib.collections import ( Collection, FillBetweenPolyCollection, @@ -32,13 +31,19 @@ import matplotlib.table as mtable import matplotlib.stackplot as mstack import matplotlib.streamplot as mstream -import datetime import PIL.Image from collections.abc import Callable, Iterable, Sequence from typing import Any, Literal, overload import numpy as np from numpy.typing import ArrayLike from matplotlib.typing import ColorType, MarkerType, LineStyleType +import pandas as pd + + +class _GroupedBarReturn: + bar_containers: list[BarContainer] + def __init__(self, bar_containers: list[BarContainer]) -> None: ... + def remove(self) -> None: ... class Axes(_AxesBase): def get_title(self, loc: Literal["left", "center", "right"] = ...) -> str: ... @@ -267,6 +272,19 @@ class Axes(_AxesBase): data=..., **kwargs ) -> PolyCollection: ... + def grouped_bar( + self, + heights: Sequence[ArrayLike] | dict[str, ArrayLike] | np.ndarray | pd.DataFrame, + *, + positions: ArrayLike | None = ..., + tick_labels: Sequence[str] | None = ..., + labels: Sequence[str] | None = ..., + group_spacing: float | None = ..., + bar_spacing: float | None = ..., + orientation: Literal["vertical", "horizontal"] = ..., + colors: Iterable[ColorType] | None = ..., + **kwargs + ) -> list[BarContainer]: ... def stem( self, *args: ArrayLike | str, diff --git a/lib/matplotlib/axes/_base.py b/lib/matplotlib/axes/_base.py index 15f8e97b449f..87d42b4d3014 100644 --- a/lib/matplotlib/axes/_base.py +++ b/lib/matplotlib/axes/_base.py @@ -2185,9 +2185,9 @@ def axis(self, arg=None, /, *, emit=True, **kwargs): xlim = self.get_xlim() ylim = self.get_ylim() edge_size = max(np.diff(xlim), np.diff(ylim))[0] - self.set_xlim([xlim[0], xlim[0] + edge_size], + self.set_xlim(xlim[0], xlim[0] + edge_size, emit=emit, auto=False) - self.set_ylim([ylim[0], ylim[0] + edge_size], + self.set_ylim(ylim[0], ylim[0] + edge_size, emit=emit, auto=False) else: raise ValueError(f"Unrecognized string {arg!r} to axis; " @@ -4749,14 +4749,25 @@ def label_outer(self, remove_inner_ticks=False): self._label_outer_yaxis(skip_non_rectangular_axes=False, remove_inner_ticks=remove_inner_ticks) + def _get_subplotspec_with_optional_colorbar(self): + """ + Return the subplotspec for this Axes, except that if this Axes has been + moved to a subgridspec to make room for a colorbar, then return the + subplotspec that encloses both this Axes and the colorbar Axes. + """ + ss = self.get_subplotspec() + if any(cax.get_subplotspec() for cax in self._colorbars): + ss = ss.get_gridspec()._subplot_spec + return ss + def _label_outer_xaxis(self, *, skip_non_rectangular_axes, remove_inner_ticks=False): # see documentation in label_outer. if skip_non_rectangular_axes and not isinstance(self.patch, mpl.patches.Rectangle): return - ss = self.get_subplotspec() - if not ss: + ss = self._get_subplotspec_with_optional_colorbar() + if ss is None: return label_position = self.xaxis.get_label_position() if not ss.is_first_row(): # Remove top label/ticklabels/offsettext. @@ -4782,8 +4793,8 @@ def _label_outer_yaxis(self, *, skip_non_rectangular_axes, if skip_non_rectangular_axes and not isinstance(self.patch, mpl.patches.Rectangle): return - ss = self.get_subplotspec() - if not ss: + ss = self._get_subplotspec_with_optional_colorbar() + if ss is None: return label_position = self.yaxis.get_label_position() if not ss.is_first_col(): # Remove left label/ticklabels/offsettext. diff --git a/lib/matplotlib/axis.py b/lib/matplotlib/axis.py index 19096fc29d3e..fafdf92017f2 100644 --- a/lib/matplotlib/axis.py +++ b/lib/matplotlib/axis.py @@ -125,16 +125,33 @@ def __init__( zorder = mlines.Line2D.zorder self._zorder = zorder - grid_color = mpl._val_or_rc(grid_color, "grid.color") - grid_linestyle = mpl._val_or_rc(grid_linestyle, "grid.linestyle") - grid_linewidth = mpl._val_or_rc(grid_linewidth, "grid.linewidth") + grid_color = mpl._val_or_rc( + grid_color, + f"grid.{major_minor}.color", + "grid.color", + ) + grid_linestyle = mpl._val_or_rc( + grid_linestyle, + f"grid.{major_minor}.linestyle", + "grid.linestyle", + ) + grid_linewidth = mpl._val_or_rc( + grid_linewidth, + f"grid.{major_minor}.linewidth", + "grid.linewidth", + ) if grid_alpha is None and not mcolors._has_alpha_channel(grid_color): # alpha precedence: kwarg > color alpha > rcParams['grid.alpha'] # Note: only resolve to rcParams if the color does not have alpha # otherwise `grid(color=(1, 1, 1, 0.5))` would work like # grid(color=(1, 1, 1, 0.5), alpha=rcParams['grid.alpha']) # so the that the rcParams default would override color alpha. - grid_alpha = mpl.rcParams["grid.alpha"] + grid_alpha = mpl._val_or_rc( + # grid_alpha is None so we can use the first key + mpl.rcParams[f"grid.{major_minor}.alpha"], + "grid.alpha", + ) + grid_kw = {k[5:]: v for k, v in kwargs.items() if k != "rotation_mode"} self.tick1line = mlines.Line2D( diff --git a/lib/matplotlib/backend_bases.py b/lib/matplotlib/backend_bases.py index 2158990f578a..626852f2aa34 100644 --- a/lib/matplotlib/backend_bases.py +++ b/lib/matplotlib/backend_bases.py @@ -1067,19 +1067,8 @@ def __del__(self): """Need to stop timer and possibly disconnect timer.""" self._timer_stop() - @_api.delete_parameter("3.9", "interval", alternative="timer.interval") - def start(self, interval=None): - """ - Start the timer object. - - Parameters - ---------- - interval : int, optional - Timer interval in milliseconds; overrides a previously set interval - if provided. - """ - if interval is not None: - self.interval = interval + def start(self): + """Start the timer.""" self._timer_start() def stop(self): @@ -1630,7 +1619,8 @@ def _allow_interrupt(prepare_notifier, handle_sigint): If SIGINT was indeed caught, after exiting the on_signal() function the interpreter reacts to the signal according to the handler function which had been set up by a signal.signal() call; here, we arrange to call the - backend-specific *handle_sigint* function. Finally, we call the old SIGINT + backend-specific *handle_sigint* function, passing the notifier object + as returned by prepare_notifier(). Finally, we call the old SIGINT handler with the same arguments that were given to our custom handler. We do this only if the old handler for SIGINT was not None, which means @@ -1640,7 +1630,7 @@ def _allow_interrupt(prepare_notifier, handle_sigint): Parameters ---------- prepare_notifier : Callable[[socket.socket], object] - handle_sigint : Callable[[], object] + handle_sigint : Callable[[object], object] """ old_sigint_handler = signal.getsignal(signal.SIGINT) @@ -1656,9 +1646,10 @@ def _allow_interrupt(prepare_notifier, handle_sigint): notifier = prepare_notifier(rsock) def save_args_and_handle_sigint(*args): - nonlocal handler_args + nonlocal handler_args, notifier handler_args = args - handle_sigint() + handle_sigint(notifier) + notifier = None signal.signal(signal.SIGINT, save_args_and_handle_sigint) try: @@ -3244,7 +3235,7 @@ def _update_view(self): def configure_subplots(self, *args): if hasattr(self, "subplot_tool"): self.subplot_tool.figure.canvas.manager.show() - return + return self.subplot_tool # This import needs to happen here due to circular imports. from matplotlib.figure import Figure with mpl.rc_context({"toolbar": "none"}): # No navbar for the toolfig. diff --git a/lib/matplotlib/backend_bases.pyi b/lib/matplotlib/backend_bases.pyi index 2d7b283bb4b8..24669bfb3aeb 100644 --- a/lib/matplotlib/backend_bases.pyi +++ b/lib/matplotlib/backend_bases.pyi @@ -186,7 +186,7 @@ class TimerBase: callbacks: list[tuple[Callable, tuple, dict[str, Any]]] | None = ..., ) -> None: ... def __del__(self) -> None: ... - def start(self, interval: int | None = ...) -> None: ... + def start(self) -> None: ... def stop(self) -> None: ... @property def interval(self) -> int: ... @@ -475,7 +475,7 @@ class NavigationToolbar2: def release_zoom(self, event: Event) -> None: ... def push_current(self) -> None: ... subplot_tool: widgets.SubplotTool - def configure_subplots(self, *args): ... + def configure_subplots(self, *args: Any) -> widgets.SubplotTool: ... def save_figure(self, *args) -> str | None | object: ... def update(self) -> None: ... def set_history_buttons(self) -> None: ... diff --git a/lib/matplotlib/backends/_backend_tk.py b/lib/matplotlib/backends/_backend_tk.py index 0bbff1379ffa..eaf868fd8bec 100644 --- a/lib/matplotlib/backends/_backend_tk.py +++ b/lib/matplotlib/backends/_backend_tk.py @@ -775,7 +775,7 @@ def _recolor_icon(image, color): image_data = np.asarray(image).copy() black_mask = (image_data[..., :3] == 0).all(axis=-1) image_data[black_mask, :3] = color - return Image.fromarray(image_data, mode="RGBA") + return Image.fromarray(image_data) # Use the high-resolution (48x48 px) icon if it exists and is needed with Image.open(path_large if (size > 24 and path_large.exists()) diff --git a/lib/matplotlib/backends/backend_pdf.py b/lib/matplotlib/backends/backend_pdf.py index 0ab5a65f0b75..a75a8a86eb92 100644 --- a/lib/matplotlib/backends/backend_pdf.py +++ b/lib/matplotlib/backends/backend_pdf.py @@ -721,9 +721,7 @@ def __init__(self, filename, metadata=None): self._internal_font_seq = (Name(f'F{i}') for i in itertools.count(1)) self._fontNames = {} # maps filenames to internal font names - self._dviFontInfo = {} # maps dvi font names to embedding information - # differently encoded Type-1 fonts may share the same descriptor - self._type1Descriptors = {} + self._dviFontInfo = {} # maps pdf names to dvifonts self._character_tracker = _backend_pdf_ps.CharacterTracker() self.alphaStates = {} # maps alpha values to graphics state objects @@ -766,9 +764,29 @@ def __init__(self, filename, metadata=None): self.writeObject(self.resourceObject, resources) fontNames = _api.deprecated("3.11")(property(lambda self: self._fontNames)) - dviFontNames = _api.deprecated("3.11")(property(lambda self: self._dviFontNames)) - type1Descriptors = _api.deprecated("3.11")( - property(lambda self: self._type1Descriptors)) + type1Descriptors = _api.deprecated("3.11")(property(lambda _: {})) + + @_api.deprecated("3.11") + @property + def dviFontInfo(self): + d = {} + tex_font_map = dviread.PsfontsMap(dviread.find_tex_file('pdftex.map')) + for pdfname, dvifont in self._dviFontInfo.items(): + psfont = tex_font_map[dvifont.texname] + if psfont.filename is None: + raise ValueError( + "No usable font file found for {} (TeX: {}); " + "the font may lack a Type-1 version" + .format(psfont.psname, dvifont.texname)) + d[dvifont.texname] = types.SimpleNamespace( + dvifont=dvifont, + pdfname=pdfname, + fontfile=psfont.filename, + basefont=psfont.psname, + encodingfile=psfont.encoding, + effects=psfont.effects, + ) + return d def newPage(self, width, height): self.endStream() @@ -808,7 +826,14 @@ def newTextnote(self, text, positionRect=[-100, -100, 0, 0]): } self.pageAnnotations.append(theNote) - def _get_subsetted_psname(self, ps_name, charmap): + @staticmethod + def _get_subset_prefix(charset): + """ + Get a prefix for a subsetted font name. + + The prefix is six uppercase letters followed by a plus sign; + see PDF reference section 5.5.3 Font Subsets. + """ def toStr(n, base): if n < base: return string.ascii_uppercase[n] @@ -818,11 +843,15 @@ def toStr(n, base): ) # encode to string using base 26 - hashed = hash(frozenset(charmap.keys())) % ((sys.maxsize + 1) * 2) + hashed = hash(charset) % ((sys.maxsize + 1) * 2) prefix = toStr(hashed, 26) # get first 6 characters from prefix - return prefix[:6] + "+" + ps_name + return prefix[:6] + "+" + + @staticmethod + def _get_subsetted_psname(ps_name, charmap): + return PdfFile._get_subset_prefix(frozenset(charmap.keys())) + ps_name def finalize(self): """Write out the various deferred objects and the pdf end matter.""" @@ -930,39 +959,19 @@ def fontName(self, fontprop): def dviFontName(self, dvifont): """ Given a dvi font object, return a name suitable for Op.selectfont. - This registers the font information internally (in ``_dviFontInfo``) if - not yet registered. - """ - - dvi_info = self._dviFontInfo.get(dvifont.texname) - if dvi_info is not None: - return dvi_info.pdfname - tex_font_map = dviread.PsfontsMap(dviread.find_tex_file('pdftex.map')) - psfont = tex_font_map[dvifont.texname] - if psfont.filename is None: - raise ValueError( - "No usable font file found for {} (TeX: {}); " - "the font may lack a Type-1 version" - .format(psfont.psname, dvifont.texname)) - - pdfname = next(self._internal_font_seq) + Register the font internally (in ``_dviFontInfo``) if not yet registered. + """ + pdfname = Name(f"F-{dvifont.texname.decode('ascii')}") _log.debug('Assigning font %s = %s (dvi)', pdfname, dvifont.texname) - self._dviFontInfo[dvifont.texname] = types.SimpleNamespace( - dvifont=dvifont, - pdfname=pdfname, - fontfile=psfont.filename, - basefont=psfont.psname, - encodingfile=psfont.encoding, - effects=psfont.effects) - return pdfname + self._dviFontInfo[pdfname] = dvifont + return Name(pdfname) def writeFonts(self): fonts = {} - for dviname, info in sorted(self._dviFontInfo.items()): - Fx = info.pdfname - _log.debug('Embedding Type-1 font %s from dvi.', dviname) - fonts[Fx] = self._embedTeXFont(info) + for pdfname, dvifont in sorted(self._dviFontInfo.items()): + _log.debug('Embedding Type-1 font %s from dvi.', dvifont.texname) + fonts[pdfname] = self._embedTeXFont(dvifont) for filename in sorted(self._fontNames): Fx = self._fontNames[filename] _log.debug('Embedding font %s.', filename) @@ -990,70 +999,68 @@ def _write_afm_font(self, filename): self.writeObject(fontdictObject, fontdict) return fontdictObject - def _embedTeXFont(self, fontinfo): - _log.debug('Embedding TeX font %s - fontinfo=%s', - fontinfo.dvifont.texname, fontinfo.__dict__) - - # Widths - widthsObject = self.reserveObject('font widths') - tfm = fontinfo.dvifont._tfm - # convert from TeX's 12.20 representation to 1/1000 text space units. - widths = [(1000 * metrics.tex_width) >> 20 - if (metrics := tfm.get_metrics(char)) else 0 - for char in range(max(tfm._glyph_metrics, default=-1) + 1)] - self.writeObject(widthsObject, widths) + def _embedTeXFont(self, dvifont): + tex_font_map = dviread.PsfontsMap(dviread.find_tex_file('pdftex.map')) + psfont = tex_font_map[dvifont.texname] + if psfont.filename is None: + raise ValueError( + "No usable font file found for {} (TeX: {}); " + "the font may lack a Type-1 version" + .format(psfont.psname, dvifont.texname)) - # Font dictionary + # The font dictionary is the top-level object describing a font fontdictObject = self.reserveObject('font dictionary') fontdict = { 'Type': Name('Font'), 'Subtype': Name('Type1'), - 'FirstChar': 0, - 'LastChar': len(widths) - 1, - 'Widths': widthsObject, - } + } - # Encoding (if needed) - if fontinfo.encodingfile is not None: - fontdict['Encoding'] = { - 'Type': Name('Encoding'), - 'Differences': [ - 0, *map(Name, dviread._parse_enc(fontinfo.encodingfile))], - } - - # If no file is specified, stop short - if fontinfo.fontfile is None: - _log.warning( - "Because of TeX configuration (pdftex.map, see updmap option " - "pdftexDownloadBase14) the font %s is not embedded. This is " - "deprecated as of PDF 1.5 and it may cause the consumer " - "application to show something that was not intended.", - fontinfo.basefont) - fontdict['BaseFont'] = Name(fontinfo.basefont) - self.writeObject(fontdictObject, fontdict) - return fontdictObject + # Read the font file and apply any encoding changes and effects + t1font = _type1font.Type1Font(psfont.filename) + if psfont.encoding is not None: + t1font = t1font.with_encoding( + {i: c for i, c in enumerate(dviread._parse_enc(psfont.encoding))} + ) + if psfont.effects: + t1font = t1font.transform(psfont.effects) - # We have a font file to embed - read it in and apply any effects - t1font = _type1font.Type1Font(fontinfo.fontfile) - if fontinfo.effects: - t1font = t1font.transform(fontinfo.effects) + # Reduce the font to only the glyphs used in the document, get the encoding + # for that subset, and compute various properties based on the encoding. + chars = frozenset(self._character_tracker.used[dvifont.fname]) + t1font = t1font.subset(chars, self._get_subset_prefix(chars)) fontdict['BaseFont'] = Name(t1font.prop['FontName']) - - # Font descriptors may be shared between differently encoded - # Type-1 fonts, so only create a new descriptor if there is no - # existing descriptor for this font. - effects = (fontinfo.effects.get('slant', 0.0), - fontinfo.effects.get('extend', 1.0)) - fontdesc = self._type1Descriptors.get((fontinfo.fontfile, effects)) - if fontdesc is None: - fontdesc = self.createType1Descriptor(t1font, fontinfo.fontfile) - self._type1Descriptors[(fontinfo.fontfile, effects)] = fontdesc - fontdict['FontDescriptor'] = fontdesc - + # createType1Descriptor writes the font data as a side effect + fontdict['FontDescriptor'] = self.createType1Descriptor(t1font) + encoding = t1font.prop['Encoding'] + fontdict['Encoding'] = self._generate_encoding(encoding) + fc = fontdict['FirstChar'] = min(encoding.keys(), default=0) + lc = fontdict['LastChar'] = max(encoding.keys(), default=255) + + # Convert glyph widths from TeX 12.20 fixed point to 1/1000 text space units + tfm = dvifont._tfm + widths = [(1000 * metrics.tex_width) >> 20 + if (metrics := tfm.get_metrics(char)) else 0 + for char in range(fc, lc + 1)] + fontdict['Widths'] = widthsObject = self.reserveObject('glyph widths') + self.writeObject(widthsObject, widths) self.writeObject(fontdictObject, fontdict) return fontdictObject - def createType1Descriptor(self, t1font, fontfile): + def _generate_encoding(self, encoding): + prev = -2 + result = [] + for code, name in sorted(encoding.items()): + if code != prev + 1: + result.append(code) + prev = code + result.append(Name(name)) + return { + 'Type': Name('Encoding'), + 'Differences': result + } + + @_api.delete_parameter("3.11", "fontfile") + def createType1Descriptor(self, t1font, fontfile=None): # Create and write the font descriptor and the font file # of a Type-1 font fontdescObject = self.reserveObject('font descriptor') @@ -1088,24 +1095,31 @@ def createType1Descriptor(self, t1font, fontfile): if 0: flags |= 1 << 18 - ft2font = get_font(fontfile) + encoding = t1font.prop['Encoding'] + charset = ''.join( + sorted( + f'/{c}' for c in encoding.values() + if c != '.notdef' + ) + ) descriptor = { 'Type': Name('FontDescriptor'), 'FontName': Name(t1font.prop['FontName']), 'Flags': flags, - 'FontBBox': ft2font.bbox, + 'FontBBox': t1font.prop['FontBBox'], 'ItalicAngle': italic_angle, - 'Ascent': ft2font.ascender, - 'Descent': ft2font.descender, + 'Ascent': t1font.prop['FontBBox'][3], + 'Descent': t1font.prop['FontBBox'][1], 'CapHeight': 1000, # TODO: find this out 'XHeight': 500, # TODO: this one too 'FontFile': fontfileObject, 'FontFamily': t1font.prop['FamilyName'], 'StemV': 50, # TODO + 'CharSet': charset, # (see also revision 3874; but not all TeX distros have AFM files!) # 'FontWeight': a number where 400 = Regular, 700 = Bold - } + } self.writeObject(fontdescObject, descriptor) @@ -1589,6 +1603,8 @@ def writeHatches(self): Op.setrgb_nonstroke, 0, 0, sidelen, sidelen, Op.rectangle, Op.fill) + self.output(stroke_rgb[0], stroke_rgb[1], stroke_rgb[2], + Op.setrgb_nonstroke) self.output(lw, Op.setlinewidth) @@ -1768,7 +1784,7 @@ def _writeImg(self, data, id, smask=None): data[:, :, 2]) indices = np.argsort(palette24).astype(np.uint8) rgb8 = indices[np.searchsorted(palette24, rgb24, sorter=indices)] - img = Image.fromarray(rgb8, mode='P') + img = Image.fromarray(rgb8).convert("P") img.putpalette(palette) png_data, bit_depth, palette = self._writePng(img) if bit_depth is None or palette is None: @@ -2280,6 +2296,7 @@ def draw_tex(self, gc, x, y, s, prop, angle, *, mtext=None): seq += [['font', pdfname, dvifont.size]] oldfont = dvifont seq += [['text', x1, y1, [bytes([glyph])], x1+width]] + self.file._character_tracker.track(dvifont, chr(glyph)) # Find consecutive text strings with constant y coordinate and # combine into a sequence of strings and kerns, or just one diff --git a/lib/matplotlib/backends/backend_ps.py b/lib/matplotlib/backends/backend_ps.py index 62952caa32e1..ea5868387918 100644 --- a/lib/matplotlib/backends/backend_ps.py +++ b/lib/matplotlib/backends/backend_ps.py @@ -24,7 +24,6 @@ import matplotlib as mpl from matplotlib import _api, cbook, _path, _text_helpers -from matplotlib._afm import AFM from matplotlib.backend_bases import ( _Backend, FigureCanvasBase, FigureManagerBase, RendererBase) from matplotlib.cbook import is_writable_file_like, file_requires_unicode @@ -407,7 +406,7 @@ class RendererPS(_backend_pdf_ps.RendererPDFPSBase): def __init__(self, width, height, pswriter, imagedpi=72): # Although postscript itself is dpi independent, we need to inform the # image code about a requested dpi to generate high resolution images - # and them scale them before embedding them. + # and then scale them before embedding them. super().__init__(width, height) self._pswriter = pswriter if mpl.rcParams['text.usetex']: @@ -787,7 +786,7 @@ def draw_text(self, gc, x, y, s, prop, angle, ismath=False, mtext=None): width = font.get_width_from_char_name(name) except KeyError: name = 'question' - width = font.get_width_char('?') + width = font.get_width_char(ord('?')) kern = font.get_kern_dist_from_name(last_name, name) last_name = name thisx += kern * scale @@ -835,9 +834,7 @@ def draw_mathtext(self, gc, x, y, s, prop, angle): lastfont = font.postscript_name, fontsize self._pswriter.write( f"/{font.postscript_name} {fontsize} selectfont\n") - glyph_name = ( - font.get_name_char(chr(num)) if isinstance(font, AFM) else - font.get_glyph_name(font.get_char_index(num))) + glyph_name = font.get_glyph_name(font.get_char_index(num)) self._pswriter.write( f"{ox:g} {oy:g} moveto\n" f"/{glyph_name} glyphshow\n") @@ -1362,15 +1359,6 @@ def xpdf_distill(tmpfile, eps=False, ptype='letter', bbox=None, rotated=False): pstoeps(tmpfile) -@_api.deprecated("3.9") -def get_bbox_header(lbrt, rotated=False): - """ - Return a postscript header string for the given bbox lbrt=(l, b, r, t). - Optionally, return rotate command. - """ - return _get_bbox_header(lbrt), (_get_rotate_command(lbrt) if rotated else "") - - def _get_bbox_header(lbrt): """Return a PostScript header string for bounding box *lbrt*=(l, b, r, t).""" l, b, r, t = lbrt diff --git a/lib/matplotlib/backends/backend_qt.py b/lib/matplotlib/backends/backend_qt.py index 5cde4866cad7..9089e982cea6 100644 --- a/lib/matplotlib/backends/backend_qt.py +++ b/lib/matplotlib/backends/backend_qt.py @@ -169,9 +169,14 @@ def _may_clear_sock(): # be forgiving about reading an empty socket. pass - return sn # Actually keep the notifier alive. - - def handle_sigint(): + # We return the QSocketNotifier so that the caller holds a reference, and we + # also explicitly clean it up in handle_sigint(). Without doing both, deletion + # of the socket notifier can happen prematurely or not at all. + return sn + + def handle_sigint(sn): + sn.deleteLater() + QtCore.QCoreApplication.sendPostedEvents(sn, QtCore.QEvent.Type.DeferredDelete) if hasattr(qapp_or_eventloop, 'closeAllWindows'): qapp_or_eventloop.closeAllWindows() qapp_or_eventloop.quit() diff --git a/lib/matplotlib/backends/web_backend/js/mpl.js b/lib/matplotlib/backends/web_backend/js/mpl.js index 2d1f383e9839..303260773a2f 100644 --- a/lib/matplotlib/backends/web_backend/js/mpl.js +++ b/lib/matplotlib/backends/web_backend/js/mpl.js @@ -575,7 +575,8 @@ mpl.figure.prototype._make_on_message_function = function (fig) { var callback = fig['handle_' + msg_type]; } catch (e) { console.log( - "No handler for the '" + msg_type + "' message type: ", + "No handler for the '%s' message type: ", + msg_type, msg ); return; @@ -583,11 +584,12 @@ mpl.figure.prototype._make_on_message_function = function (fig) { if (callback) { try { - // console.log("Handling '" + msg_type + "' message: ", msg); + // console.log("Handling '%s' message: ", msg_type, msg); callback(fig, msg); } catch (e) { console.log( - "Exception inside the 'handler_" + msg_type + "' callback:", + "Exception inside the 'handler_%s' callback:", + msg_type, e, e.stack, msg diff --git a/lib/matplotlib/backends/web_backend/nbagg_uat.ipynb b/lib/matplotlib/backends/web_backend/nbagg_uat.ipynb index e9fc62bc2883..0513fee2b54c 100644 --- a/lib/matplotlib/backends/web_backend/nbagg_uat.ipynb +++ b/lib/matplotlib/backends/web_backend/nbagg_uat.ipynb @@ -309,7 +309,7 @@ "metadata": {}, "outputs": [], "source": [ - "from matplotlib.backends.backend_nbagg import new_figure_manager,show\n", + "from matplotlib.backends.backend_nbagg import new_figure_manager\n", "\n", "manager = new_figure_manager(1000)\n", "fig = manager.canvas.figure\n", @@ -341,15 +341,18 @@ "x = np.arange(0, 2*np.pi, 0.01) # x-array\n", "line, = ax.plot(x, np.sin(x))\n", "\n", + "\n", "def animate(i):\n", " line.set_ydata(np.sin(x+i/10.0)) # update the data\n", " return line,\n", "\n", - "#Init only required for blitting to give a clean slate.\n", + "\n", + "# Init only required for blitting to give a clean slate.\n", "def init():\n", " line.set_ydata(np.ma.array(x, mask=True))\n", " return line,\n", "\n", + "\n", "ani = animation.FuncAnimation(fig, animate, np.arange(1, 200), init_func=init,\n", " interval=100., blit=True)\n", "plt.show()" @@ -405,6 +408,8 @@ "ln, = ax.plot(x,y)\n", "evt = []\n", "colors = iter(itertools.cycle(['r', 'g', 'b', 'k', 'c']))\n", + "\n", + "\n", "def on_event(event):\n", " if event.name.startswith('key'):\n", " fig.suptitle('%s: %s' % (event.name, event.key))\n", @@ -417,6 +422,7 @@ " fig.canvas.draw()\n", " fig.canvas.draw_idle()\n", "\n", + "\n", "fig.canvas.mpl_connect('button_press_event', on_event)\n", "fig.canvas.mpl_connect('button_release_event', on_event)\n", "fig.canvas.mpl_connect('scroll_event', on_event)\n", @@ -448,10 +454,12 @@ "fig, ax = plt.subplots()\n", "text = ax.text(0.5, 0.5, '', ha='center')\n", "\n", + "\n", "def update(text):\n", " text.set(text=time.ctime())\n", " text.axes.figure.canvas.draw()\n", - " \n", + "\n", + "\n", "timer = fig.canvas.new_timer(500, [(update, [text], {})])\n", "timer.start()\n", "plt.show()" @@ -471,7 +479,7 @@ "outputs": [], "source": [ "fig, ax = plt.subplots()\n", - "text = ax.text(0.5, 0.5, '', ha='center') \n", + "text = ax.text(0.5, 0.5, '', ha='center')\n", "timer = fig.canvas.new_timer(500, [(update, [text], {})])\n", "\n", "timer.single_shot = True\n", @@ -578,11 +586,12 @@ "cnt = itertools.count()\n", "bg = None\n", "\n", + "\n", "def onclick_handle(event):\n", " \"\"\"Should draw elevating green line on each mouse click\"\"\"\n", " global bg\n", " if bg is None:\n", - " bg = ax.figure.canvas.copy_from_bbox(ax.bbox) \n", + " bg = ax.figure.canvas.copy_from_bbox(ax.bbox)\n", " ax.figure.canvas.restore_region(bg)\n", "\n", " cur_y = (next(cnt) % 10) * 0.1\n", @@ -590,6 +599,7 @@ " ax.draw_artist(ln)\n", " ax.figure.canvas.blit(ax.bbox)\n", "\n", + "\n", "fig, ax = plt.subplots()\n", "ax.plot([0, 1], [0, 1], 'r')\n", "ln, = ax.plot([0, 1], [0, 0], 'g', animated=True)\n", @@ -598,13 +608,6 @@ "\n", "ax.figure.canvas.mpl_connect('button_press_event', onclick_handle)" ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [] } ], "metadata": { diff --git a/lib/matplotlib/bezier.py b/lib/matplotlib/bezier.py index 42a6b478d729..b9b67c9a72d6 100644 --- a/lib/matplotlib/bezier.py +++ b/lib/matplotlib/bezier.py @@ -190,6 +190,9 @@ class BezierSegment: """ A d-dimensional Bézier segment. + A BezierSegment can be called with an argument, either a scalar or an array-like + object, to evaluate the curve at that/those location(s). + Parameters ---------- control_points : (N, d) array @@ -223,6 +226,8 @@ def __call__(self, t): return (np.power.outer(1 - t, self._orders[::-1]) * np.power.outer(t, self._orders)) @ self._px + @_api.deprecated( + "3.11", alternative="Call the BezierSegment object with an argument.") def point_at_t(self, t): """ Evaluate the curve at a single point, returning a tuple of *d* floats. @@ -336,10 +341,9 @@ def split_bezier_intersecting_with_closedpath( """ bz = BezierSegment(bezier) - bezier_point_at_t = bz.point_at_t t0, t1 = find_bezier_t_intersecting_with_closedpath( - bezier_point_at_t, inside_closedpath, tolerance=tolerance) + lambda t: tuple(bz(t)), inside_closedpath, tolerance=tolerance) _left, _right = split_de_casteljau(bezier, (t0 + t1) / 2.) return _left, _right diff --git a/lib/matplotlib/cbook.py b/lib/matplotlib/cbook.py index 10048f1be782..a09780965b0c 100644 --- a/lib/matplotlib/cbook.py +++ b/lib/matplotlib/cbook.py @@ -2228,6 +2228,9 @@ def _g_sig_digits(value, delta): Return the number of significant digits to %g-format *value*, assuming that it is known with an error of *delta*. """ + # For inf or nan, the precision doesn't matter. + if not math.isfinite(value): + return 0 if delta == 0: if value == 0: # if both value and delta are 0, np.spacing below returns 5e-324 @@ -2241,11 +2244,10 @@ def _g_sig_digits(value, delta): # digits before the decimal point (floor(log10(45.67)) + 1 = 2): the total # is 4 significant digits. A value of 0 contributes 1 "digit" before the # decimal point. - # For inf or nan, the precision doesn't matter. return max( 0, (math.floor(math.log10(abs(value))) + 1 if value else 1) - - math.floor(math.log10(delta))) if math.isfinite(value) else 0 + - math.floor(math.log10(delta))) def _unikey_or_keysym_to_mplkey(unikey, keysym): @@ -2331,42 +2333,56 @@ def _picklable_class_constructor(mixin_class, fmt, attr_name, base_class): def _is_torch_array(x): - """Check if 'x' is a PyTorch Tensor.""" + """Return whether *x* is a PyTorch Tensor.""" try: - # we're intentionally not attempting to import torch. If somebody - # has created a torch array, torch should already be in sys.modules - return isinstance(x, sys.modules['torch'].Tensor) - except Exception: # TypeError, KeyError, AttributeError, maybe others? - # we're attempting to access attributes on imported modules which - # may have arbitrary user code, so we deliberately catch all exceptions - return False + # We're intentionally not attempting to import torch. If somebody + # has created a torch array, torch should already be in sys.modules. + tp = sys.modules.get("torch").Tensor + except AttributeError: + return False # Module not imported or a nonstandard module with no Tensor attr. + return (isinstance(tp, type) # Just in case it's a very nonstandard module. + and isinstance(x, tp)) def _is_jax_array(x): - """Check if 'x' is a JAX Array.""" + """Return whether *x* is a JAX Array.""" try: - # we're intentionally not attempting to import jax. If somebody - # has created a jax array, jax should already be in sys.modules - return isinstance(x, sys.modules['jax'].Array) - except Exception: # TypeError, KeyError, AttributeError, maybe others? - # we're attempting to access attributes on imported modules which - # may have arbitrary user code, so we deliberately catch all exceptions - return False + # We're intentionally not attempting to import jax. If somebody + # has created a jax array, jax should already be in sys.modules. + tp = sys.modules.get("jax").Array + except AttributeError: + return False # Module not imported or a nonstandard module with no Array attr. + return (isinstance(tp, type) # Just in case it's a very nonstandard module. + and isinstance(x, tp)) + + +def _is_pandas_dataframe(x): + """Check if *x* is a Pandas DataFrame.""" + try: + # We're intentionally not attempting to import Pandas. If somebody + # has created a Pandas DataFrame, Pandas should already be in sys.modules. + tp = sys.modules.get("pandas").DataFrame + except AttributeError: + return False # Module not imported or a nonstandard module with no Array attr. + return (isinstance(tp, type) # Just in case it's a very nonstandard module. + and isinstance(x, tp)) def _is_tensorflow_array(x): - """Check if 'x' is a TensorFlow Tensor or Variable.""" + """Return whether *x* is a TensorFlow Tensor or Variable.""" try: - # we're intentionally not attempting to import TensorFlow. If somebody - # has created a TensorFlow array, TensorFlow should already be in sys.modules - # we use `is_tensor` to not depend on the class structure of TensorFlow - # arrays, as `tf.Variables` are not instances of `tf.Tensor` - # (they both convert the same way) - return isinstance(x, sys.modules['tensorflow'].is_tensor(x)) - except Exception: # TypeError, KeyError, AttributeError, maybe others? - # we're attempting to access attributes on imported modules which - # may have arbitrary user code, so we deliberately catch all exceptions + # We're intentionally not attempting to import TensorFlow. If somebody + # has created a TensorFlow array, TensorFlow should already be in + # sys.modules we use `is_tensor` to not depend on the class structure + # of TensorFlow arrays, as `tf.Variables` are not instances of + # `tf.Tensor` (they both convert the same way). + is_tensor = sys.modules.get("tensorflow").is_tensor + except AttributeError: return False + try: + return is_tensor(x) + except Exception: + return False # Just in case it's a very nonstandard module. def _unpack_to_numpy(x): @@ -2421,15 +2437,3 @@ def _auto_format_str(fmt, value): return fmt % (value,) except (TypeError, ValueError): return fmt.format(value) - - -def _is_pandas_dataframe(x): - """Check if 'x' is a Pandas DataFrame.""" - try: - # we're intentionally not attempting to import Pandas. If somebody - # has created a Pandas DataFrame, Pandas should already be in sys.modules - return isinstance(x, sys.modules['pandas'].DataFrame) - except Exception: # TypeError, KeyError, AttributeError, maybe others? - # we're attempting to access attributes on imported modules which - # may have arbitrary user code, so we deliberately catch all exceptions - return False diff --git a/lib/matplotlib/cm.py b/lib/matplotlib/cm.py index 2697666b9573..ef5bf0719d3b 100644 --- a/lib/matplotlib/cm.py +++ b/lib/matplotlib/cm.py @@ -243,43 +243,6 @@ def get_cmap(self, cmap): _bivar_colormaps = ColormapRegistry(bivar_cmaps) -# This is an exact copy of pyplot.get_cmap(). It was removed in 3.9, but apparently -# caused more user trouble than expected. Re-added for 3.9.1 and extended the -# deprecation period for two additional minor releases. -@_api.deprecated( - '3.7', - removal='3.11', - alternative="``matplotlib.colormaps[name]`` or ``matplotlib.colormaps.get_cmap()``" - " or ``pyplot.get_cmap()``" - ) -def get_cmap(name=None, lut=None): - """ - Get a colormap instance, defaulting to rc values if *name* is None. - - Parameters - ---------- - name : `~matplotlib.colors.Colormap` or str or None, default: None - If a `.Colormap` instance, it will be returned. Otherwise, the name of - a colormap known to Matplotlib, which will be resampled by *lut*. The - default, None, means :rc:`image.cmap`. - lut : int or None, default: None - If *name* is not already a Colormap instance and *lut* is not None, the - colormap will be resampled to have *lut* entries in the lookup table. - - Returns - ------- - Colormap - """ - name = mpl._val_or_rc(name, 'image.cmap') - if isinstance(name, colors.Colormap): - return name - _api.check_in_list(sorted(_colormaps), name=name) - if lut is None: - return _colormaps[name] - else: - return _colormaps[name].resampled(lut) - - def _ensure_cmap(cmap): """ Ensure that we have a `.Colormap` object. diff --git a/lib/matplotlib/cm.pyi b/lib/matplotlib/cm.pyi index c3c62095684a..366b336fe04d 100644 --- a/lib/matplotlib/cm.pyi +++ b/lib/matplotlib/cm.pyi @@ -19,6 +19,4 @@ _colormaps: ColormapRegistry = ... _multivar_colormaps: ColormapRegistry = ... _bivar_colormaps: ColormapRegistry = ... -def get_cmap(name: str | colors.Colormap | None = ..., lut: int | None = ...) -> colors.Colormap: ... - ScalarMappable = _ScalarMappable diff --git a/lib/matplotlib/colorbar.py b/lib/matplotlib/colorbar.py index db33698c5514..4348f02cfc34 100644 --- a/lib/matplotlib/colorbar.py +++ b/lib/matplotlib/colorbar.py @@ -16,8 +16,9 @@ import numpy as np import matplotlib as mpl -from matplotlib import _api, cbook, collections, cm, colors, contour, ticker +from matplotlib import _api, cbook, collections, colors, contour, ticker import matplotlib.artist as martist +import matplotlib.colorizer as mcolorizer import matplotlib.patches as mpatches import matplotlib.path as mpath import matplotlib.spines as mspines @@ -199,12 +200,12 @@ class Colorbar: Draw a colorbar in an existing Axes. Typically, colorbars are created using `.Figure.colorbar` or - `.pyplot.colorbar` and associated with `.ScalarMappable`\s (such as an + `.pyplot.colorbar` and associated with `.ColorizingArtist`\s (such as an `.AxesImage` generated via `~.axes.Axes.imshow`). In order to draw a colorbar not associated with other elements in the figure, e.g. when showing a colormap by itself, one can create an empty - `.ScalarMappable`, or directly pass *cmap* and *norm* instead of *mappable* + `.ColorizingArtist`, or directly pass *cmap* and *norm* instead of *mappable* to `Colorbar`. Useful public methods are :meth:`set_label` and :meth:`add_lines`. @@ -244,7 +245,7 @@ def __init__( ax : `~matplotlib.axes.Axes` The `~.axes.Axes` instance in which the colorbar is drawn. - mappable : `.ScalarMappable` + mappable : `.ColorizingArtist` The mappable whose colormap and norm will be used. To show the colors versus index instead of on a 0-1 scale, set the @@ -288,7 +289,8 @@ def __init__( colorbar and at the right for a vertical. """ if mappable is None: - mappable = cm.ScalarMappable(norm=norm, cmap=cmap) + colorizer = mcolorizer.Colorizer(norm=norm, cmap=cmap) + mappable = mcolorizer.ColorizingArtist(colorizer) self.mappable = mappable cmap = mappable.cmap @@ -1453,8 +1455,7 @@ def make_axes(parents, location=None, orientation=None, fraction=0.15, cax = fig.add_axes(pbcb, label="") for a in parents: - # tell the parent it has a colorbar - a._colorbars += [cax] + a._colorbars.append(cax) # tell the parent it has a colorbar cax._colorbar_info = dict( parents=parents, location=location, @@ -1547,6 +1548,7 @@ def make_axes_gridspec(parent, *, location=None, orientation=None, fig = parent.get_figure() cax = fig.add_subplot(ss_cb, label="") + parent._colorbars.append(cax) # tell the parent it has a colorbar cax.set_anchor(anchor) cax.set_box_aspect(aspect) cax.set_aspect('auto') diff --git a/lib/matplotlib/colorizer.py b/lib/matplotlib/colorizer.py index b4223f389804..92a6e4ea4c4f 100644 --- a/lib/matplotlib/colorizer.py +++ b/lib/matplotlib/colorizer.py @@ -90,7 +90,7 @@ def norm(self): @norm.setter def norm(self, norm): - _api.check_isinstance((colors.Normalize, str, None), norm=norm) + _api.check_isinstance((colors.Norm, str, None), norm=norm) if norm is None: norm = colors.Normalize() elif isinstance(norm, str): diff --git a/lib/matplotlib/colorizer.pyi b/lib/matplotlib/colorizer.pyi index 8fcce3e5d63b..9a5a73415d83 100644 --- a/lib/matplotlib/colorizer.pyi +++ b/lib/matplotlib/colorizer.pyi @@ -1,6 +1,5 @@ from matplotlib import cbook, colorbar, colors, artist -from typing import overload import numpy as np from numpy.typing import ArrayLike @@ -11,12 +10,12 @@ class Colorizer: def __init__( self, cmap: str | colors.Colormap | None = ..., - norm: str | colors.Normalize | None = ..., + norm: str | colors.Norm | None = ..., ) -> None: ... @property - def norm(self) -> colors.Normalize: ... + def norm(self) -> colors.Norm: ... @norm.setter - def norm(self, norm: colors.Normalize | str | None) -> None: ... + def norm(self, norm: colors.Norm | str | None) -> None: ... def to_rgba( self, x: np.ndarray, @@ -64,10 +63,10 @@ class _ColorizerInterface: def get_cmap(self) -> colors.Colormap: ... def set_cmap(self, cmap: str | colors.Colormap) -> None: ... @property - def norm(self) -> colors.Normalize: ... + def norm(self) -> colors.Norm: ... @norm.setter - def norm(self, norm: colors.Normalize | str | None) -> None: ... - def set_norm(self, norm: colors.Normalize | str | None) -> None: ... + def norm(self, norm: colors.Norm | str | None) -> None: ... + def set_norm(self, norm: colors.Norm | str | None) -> None: ... def autoscale(self) -> None: ... def autoscale_None(self) -> None: ... @@ -75,7 +74,7 @@ class _ColorizerInterface: class _ScalarMappable(_ColorizerInterface): def __init__( self, - norm: colors.Normalize | None = ..., + norm: colors.Norm | None = ..., cmap: str | colors.Colormap | None = ..., *, colorizer: Colorizer | None = ..., diff --git a/lib/matplotlib/colors.py b/lib/matplotlib/colors.py index 9bd808074c1f..a09b4f3d4f5c 100644 --- a/lib/matplotlib/colors.py +++ b/lib/matplotlib/colors.py @@ -41,6 +41,7 @@ import base64 from collections.abc import Sequence, Mapping +from abc import ABC, abstractmethod import functools import importlib import inspect @@ -131,6 +132,8 @@ class ColorSequenceRegistry(Mapping): 'Set1': _cm._Set1_data, 'Set2': _cm._Set2_data, 'Set3': _cm._Set3_data, + 'petroff6': _cm._petroff6_data, + 'petroff8': _cm._petroff8_data, 'petroff10': _cm._petroff10_data, } @@ -947,7 +950,7 @@ def with_alpha(self, alpha): if not isinstance(alpha, Real): raise TypeError(f"'alpha' must be numeric or None, not {type(alpha)}") if not 0 <= alpha <= 1: - ValueError("'alpha' must be between 0 and 1, inclusive") + raise ValueError("'alpha' must be between 0 and 1, inclusive") new_cm = self.copy() if not new_cm._isinit: new_cm._init() @@ -2255,7 +2258,87 @@ def _init(self): self._isinit = True -class Normalize: +class Norm(ABC): + """ + Abstract base class for normalizations. + + Subclasses include `Normalize` which maps from a scalar to + a scalar. However, this class makes no such requirement, and subclasses may + support the normalization of multiple variates simultaneously, with + separate normalization for each variate. + """ + + def __init__(self): + self.callbacks = cbook.CallbackRegistry(signals=["changed"]) + + @property + @abstractmethod + def vmin(self): + """Lower limit of the input data interval; maps to 0.""" + pass + + @property + @abstractmethod + def vmax(self): + """Upper limit of the input data interval; maps to 1.""" + pass + + @property + @abstractmethod + def clip(self): + """ + Determines the behavior for mapping values outside the range ``[vmin, vmax]``. + + See the *clip* parameter in `.Normalize`. + """ + pass + + @abstractmethod + def __call__(self, value, clip=None): + """ + Normalize the data and return the normalized data. + + Parameters + ---------- + value + Data to normalize. + clip : bool, optional + See the description of the parameter *clip* in `.Normalize`. + + If ``None``, defaults to ``self.clip`` (which defaults to + ``False``). + + Notes + ----- + If not already initialized, ``self.vmin`` and ``self.vmax`` are + initialized using ``self.autoscale_None(value)``. + """ + pass + + @abstractmethod + def autoscale(self, A): + """Set *vmin*, *vmax* to min, max of *A*.""" + pass + + @abstractmethod + def autoscale_None(self, A): + """If *vmin* or *vmax* are not set, use the min/max of *A* to set them.""" + pass + + @abstractmethod + def scaled(self): + """Return whether *vmin* and *vmax* are both set.""" + pass + + def _changed(self): + """ + Call this whenever the norm is changed to notify all the + callback listeners to the 'changed' signal. + """ + self.callbacks.process('changed') + + +class Normalize(Norm): """ A class which, when called, maps values within the interval ``[vmin, vmax]`` linearly to the interval ``[0.0, 1.0]``. The mapping of @@ -2305,14 +2388,15 @@ def __init__(self, vmin=None, vmax=None, clip=False): ----- If ``vmin == vmax``, input data will be mapped to 0. """ + super().__init__() self._vmin = _sanitize_extrema(vmin) self._vmax = _sanitize_extrema(vmax) self._clip = clip self._scale = None - self.callbacks = cbook.CallbackRegistry(signals=["changed"]) @property def vmin(self): + # docstring inherited return self._vmin @vmin.setter @@ -2324,6 +2408,7 @@ def vmin(self, value): @property def vmax(self): + # docstring inherited return self._vmax @vmax.setter @@ -2335,6 +2420,7 @@ def vmax(self, value): @property def clip(self): + # docstring inherited return self._clip @clip.setter @@ -2343,13 +2429,6 @@ def clip(self, value): self._clip = value self._changed() - def _changed(self): - """ - Call this whenever the norm is changed to notify all the - callback listeners to the 'changed' signal. - """ - self.callbacks.process('changed') - @staticmethod def process_value(value): """ @@ -2391,24 +2470,7 @@ def process_value(value): return result, is_scalar def __call__(self, value, clip=None): - """ - Normalize the data and return the normalized data. - - Parameters - ---------- - value - Data to normalize. - clip : bool, optional - See the description of the parameter *clip* in `.Normalize`. - - If ``None``, defaults to ``self.clip`` (which defaults to - ``False``). - - Notes - ----- - If not already initialized, ``self.vmin`` and ``self.vmax`` are - initialized using ``self.autoscale_None(value)``. - """ + # docstring inherited if clip is None: clip = self.clip @@ -2459,7 +2521,7 @@ def inverse(self, value): return vmin + value * (vmax - vmin) def autoscale(self, A): - """Set *vmin*, *vmax* to min, max of *A*.""" + # docstring inherited with self.callbacks.blocked(): # Pause callbacks while we are updating so we only get # a single update signal at the end @@ -2468,7 +2530,7 @@ def autoscale(self, A): self._changed() def autoscale_None(self, A): - """If *vmin* or *vmax* are not set, use the min/max of *A* to set them.""" + # docstring inherited A = np.asanyarray(A) if isinstance(A, np.ma.MaskedArray): @@ -2482,7 +2544,7 @@ def autoscale_None(self, A): self.vmax = A.max() def scaled(self): - """Return whether *vmin* and *vmax* are both set.""" + # docstring inherited return self.vmin is not None and self.vmax is not None @@ -2766,7 +2828,7 @@ def _make_norm_from_scale( unlike to arbitrary lambdas. """ - class Norm(base_norm_cls): + class ScaleNorm(base_norm_cls): def __reduce__(self): cls = type(self) # If the class is toplevel-accessible, it is possible to directly @@ -2846,15 +2908,15 @@ def autoscale_None(self, A): return super().autoscale_None(in_trf_domain) if base_norm_cls is Normalize: - Norm.__name__ = f"{scale_cls.__name__}Norm" - Norm.__qualname__ = f"{scale_cls.__qualname__}Norm" + ScaleNorm.__name__ = f"{scale_cls.__name__}Norm" + ScaleNorm.__qualname__ = f"{scale_cls.__qualname__}Norm" else: - Norm.__name__ = base_norm_cls.__name__ - Norm.__qualname__ = base_norm_cls.__qualname__ - Norm.__module__ = base_norm_cls.__module__ - Norm.__doc__ = base_norm_cls.__doc__ + ScaleNorm.__name__ = base_norm_cls.__name__ + ScaleNorm.__qualname__ = base_norm_cls.__qualname__ + ScaleNorm.__module__ = base_norm_cls.__module__ + ScaleNorm.__doc__ = base_norm_cls.__doc__ - return Norm + return ScaleNorm def _create_empty_object_of_class(cls): diff --git a/lib/matplotlib/colors.pyi b/lib/matplotlib/colors.pyi index 3e761c949068..cdc6e5e7d89f 100644 --- a/lib/matplotlib/colors.pyi +++ b/lib/matplotlib/colors.pyi @@ -1,4 +1,5 @@ from collections.abc import Callable, Iterable, Iterator, Mapping, Sequence +from abc import ABC, abstractmethod from matplotlib import cbook, scale import re @@ -196,8 +197,8 @@ class BivarColormap: M: int n_variates: int def __init__( - self, N: int = ..., M: int | None = ..., shape: Literal['square', 'circle', 'ignore', 'circleignore'] = ..., - origin: Sequence[float] = ..., name: str = ... + self, N: int = ..., M: int | None = ..., shape: Literal['square', 'circle', 'ignore', 'circleignore'] = ..., + origin: Sequence[float] = ..., name: str = ... ) -> None: ... @overload def __call__( @@ -245,12 +246,33 @@ class SegmentedBivarColormap(BivarColormap): class BivarColormapFromImage(BivarColormap): def __init__( - self, lut: np.ndarray, shape: Literal['square', 'circle', 'ignore', 'circleignore'] = ..., - origin: Sequence[float] = ..., name: str = ... + self, lut: np.ndarray, shape: Literal['square', 'circle', 'ignore', 'circleignore'] = ..., + origin: Sequence[float] = ..., name: str = ... ) -> None: ... -class Normalize: +class Norm(ABC): callbacks: cbook.CallbackRegistry + def __init__(self) -> None: ... + @property + @abstractmethod + def vmin(self) -> float | tuple[float] | None: ... + @property + @abstractmethod + def vmax(self) -> float | tuple[float] | None: ... + @property + @abstractmethod + def clip(self) -> bool | tuple[bool]: ... + @abstractmethod + def __call__(self, value: np.ndarray, clip: bool | None = ...) -> ArrayLike: ... + @abstractmethod + def autoscale(self, A: ArrayLike) -> None: ... + @abstractmethod + def autoscale_None(self, A: ArrayLike) -> None: ... + @abstractmethod + def scaled(self) -> bool: ... + + +class Normalize(Norm): def __init__( self, vmin: float | None = ..., vmax: float | None = ..., clip: bool = ... ) -> None: ... diff --git a/lib/matplotlib/contour.pyi b/lib/matplotlib/contour.pyi index 7400fac50993..2a89d6016170 100644 --- a/lib/matplotlib/contour.pyi +++ b/lib/matplotlib/contour.pyi @@ -1,7 +1,7 @@ import matplotlib.cm as cm from matplotlib.artist import Artist from matplotlib.axes import Axes -from matplotlib.collections import Collection, PathCollection +from matplotlib.collections import Collection from matplotlib.colorizer import Colorizer, ColorizingArtist from matplotlib.colors import Colormap, Normalize from matplotlib.path import Path diff --git a/lib/matplotlib/dviread.py b/lib/matplotlib/dviread.py index a588979f5fad..9e8b6a5facf5 100644 --- a/lib/matplotlib/dviread.py +++ b/lib/matplotlib/dviread.py @@ -17,17 +17,17 @@ ... """ -from collections import namedtuple import dataclasses import enum -from functools import cache, lru_cache, partial, wraps import logging import os -from pathlib import Path import re import struct import subprocess import sys +from collections import namedtuple +from functools import cache, lru_cache, partial, wraps +from pathlib import Path import numpy as np @@ -583,6 +583,9 @@ class DviFont: Attributes ---------- texname : bytes + fname : str + Compatibility shim so that DviFont can be used with + ``_backend_pdf_ps.CharacterTracker``; not a real filename. size : float Size of the font in Adobe points, converted from the slightly smaller TeX points. @@ -602,6 +605,18 @@ def __init__(self, scale, tfm, texname, vf): (1000 * self._tfm.width.get(char, 0)) >> 20 for char in range(max(self._tfm.width, default=-1) + 1)])) + @property + def fname(self): + """A fake filename""" + return self.texname.decode('latin-1') + + def _get_fontmap(self, string): + """Get the mapping from characters to the font that includes them. + + Each value maps to self; there is no fallback mechanism for DviFont. + """ + return {char: self for char in string} + def __eq__(self, other): return (type(self) is type(other) and self.texname == other.texname and self.size == other.size) @@ -1161,8 +1176,8 @@ def _fontfile(cls, suffix, texname): if __name__ == '__main__': - from argparse import ArgumentParser import itertools + from argparse import ArgumentParser import fontTools.agl diff --git a/lib/matplotlib/dviread.pyi b/lib/matplotlib/dviread.pyi index 41799c083218..12a9215b5308 100644 --- a/lib/matplotlib/dviread.pyi +++ b/lib/matplotlib/dviread.pyi @@ -66,6 +66,8 @@ class DviFont: def __ne__(self, other: object) -> bool: ... @property def widths(self) -> list[int]: ... + @property + def fname(self) -> str: ... class Vf(Dvi): def __init__(self, filename: str | os.PathLike) -> None: ... diff --git a/lib/matplotlib/figure.py b/lib/matplotlib/figure.py index bf4e2253324f..03549dd53bc1 100644 --- a/lib/matplotlib/figure.py +++ b/lib/matplotlib/figure.py @@ -1200,17 +1200,18 @@ def colorbar( Parameters ---------- mappable - The `matplotlib.cm.ScalarMappable` (i.e., `.AxesImage`, + The `matplotlib.colorizer.ColorizingArtist` (i.e., `.AxesImage`, `.ContourSet`, etc.) described by this colorbar. This argument is mandatory for the `.Figure.colorbar` method but optional for the `.pyplot.colorbar` function, which sets the default to the current image. - Note that one can create a `.ScalarMappable` "on-the-fly" to - generate colorbars not attached to a previously drawn artist, e.g. + Note that one can create a `.colorizer.ColorizingArtist` "on-the-fly" + to generate colorbars not attached to a previously drawn artist, e.g. :: - fig.colorbar(cm.ScalarMappable(norm=norm, cmap=cmap), ax=ax) + cr = colorizer.Colorizer(norm=norm, cmap=cmap) + fig.colorbar(colorizer.ColorizingArtist(cr), ax=ax) cax : `~matplotlib.axes.Axes`, optional Axes into which the colorbar will be drawn. If `None`, then a new @@ -3679,7 +3680,7 @@ def figaspect(arg): w, h = figaspect(2.) fig = Figure(figsize=(w, h)) - ax = fig.add_axes([0.1, 0.1, 0.8, 0.8]) + ax = fig.add_axes((0.1, 0.1, 0.8, 0.8)) ax.imshow(A, **kwargs) Make a figure with the proper aspect for an array:: @@ -3687,7 +3688,7 @@ def figaspect(arg): A = rand(5, 3) w, h = figaspect(A) fig = Figure(figsize=(w, h)) - ax = fig.add_axes([0.1, 0.1, 0.8, 0.8]) + ax = fig.add_axes((0.1, 0.1, 0.8, 0.8)) ax.imshow(A, **kwargs) """ diff --git a/lib/matplotlib/figure.pyi b/lib/matplotlib/figure.pyi index e7c5175d8af9..61dc79619a80 100644 --- a/lib/matplotlib/figure.pyi +++ b/lib/matplotlib/figure.pyi @@ -89,19 +89,20 @@ class FigureBase(Artist): # TODO: docstring indicates SubplotSpec a valid arg, but none of the listed signatures appear to be that @overload - def add_subplot(self, *args, projection: Literal["3d"], **kwargs) -> Axes3D: ... + def add_subplot(self, *args: Any, projection: Literal["3d"], **kwargs: Any) -> Axes3D: ... @overload def add_subplot( - self, nrows: int, ncols: int, index: int | tuple[int, int], **kwargs + self, nrows: int, ncols: int, index: int | tuple[int, int], **kwargs: Any ) -> Axes: ... @overload - def add_subplot(self, pos: int, **kwargs) -> Axes: ... + def add_subplot(self, pos: int, **kwargs: Any) -> Axes: ... @overload - def add_subplot(self, ax: Axes, **kwargs) -> Axes: ... + def add_subplot(self, ax: Axes, **kwargs: Any) -> Axes: ... @overload - def add_subplot(self, ax: SubplotSpec, **kwargs) -> Axes: ... + def add_subplot(self, ax: SubplotSpec, **kwargs: Any) -> Axes: ... @overload - def add_subplot(self, **kwargs) -> Axes: ... + def add_subplot(self, **kwargs: Any) -> Axes: ... + @overload def subplots( self, diff --git a/lib/matplotlib/ft2font.pyi b/lib/matplotlib/ft2font.pyi index b12710afd801..a413cd3c1a76 100644 --- a/lib/matplotlib/ft2font.pyi +++ b/lib/matplotlib/ft2font.pyi @@ -198,7 +198,7 @@ class FT2Font(Buffer): def _get_fontmap(self, string: str) -> dict[str, FT2Font]: ... def clear(self) -> None: ... def draw_glyph_to_bitmap( - self, image: FT2Image, x: int, y: int, glyph: Glyph, antialiased: bool = ... + self, image: NDArray[np.uint8], x: int, y: int, glyph: Glyph, antialiased: bool = ... ) -> None: ... def draw_glyphs_to_bitmap(self, antialiased: bool = ...) -> None: ... def get_bitmap_offset(self) -> tuple[int, int]: ... diff --git a/lib/matplotlib/hatch.py b/lib/matplotlib/hatch.py index 6ce68a275b4e..5e0b6d761a98 100644 --- a/lib/matplotlib/hatch.py +++ b/lib/matplotlib/hatch.py @@ -182,6 +182,7 @@ def __init__(self, hatch, density): self.shape_codes = np.full(len(self.shape_vertices), Path.LINETO, dtype=Path.code_type) self.shape_codes[0] = Path.MOVETO + self.shape_codes[-1] = Path.CLOSEPOLY super().__init__(hatch, density) _hatch_types = [ diff --git a/lib/matplotlib/image.py b/lib/matplotlib/image.py index ad0c96f9a248..c1846f92608c 100644 --- a/lib/matplotlib/image.py +++ b/lib/matplotlib/image.py @@ -967,9 +967,9 @@ def set_extent(self, extent, **kwargs): self.sticky_edges.x[:] = [xmin, xmax] self.sticky_edges.y[:] = [ymin, ymax] if self.axes.get_autoscalex_on(): - self.axes.set_xlim((xmin, xmax), auto=None) + self.axes.set_xlim(xmin, xmax, auto=None) if self.axes.get_autoscaley_on(): - self.axes.set_ylim((ymin, ymax), auto=None) + self.axes.set_ylim(ymin, ymax, auto=None) self.stale = True def get_extent(self): @@ -1387,8 +1387,52 @@ def set_data(self, A): class BboxImage(_ImageBase): - """The Image class whose size is determined by the given bbox.""" + """ + The Image class whose size is determined by the given bbox. + + Parameters + ---------- + bbox : BboxBase or Callable[RendererBase, BboxBase] + The bbox or a function to generate the bbox + + .. warning :: + + If using `matplotlib.artist.Artist.get_window_extent` as the + callable ensure that the other artist is drawn first (lower zorder) + or you may need to renderer the figure twice to ensure that the + computed bbox is accurate. + cmap : str or `~matplotlib.colors.Colormap`, default: :rc:`image.cmap` + The Colormap instance or registered colormap name used to map scalar + data to colors. + norm : str or `~matplotlib.colors.Normalize` + Maps luminance to 0-1. + interpolation : str, default: :rc:`image.interpolation` + Supported values are 'none', 'auto', 'nearest', 'bilinear', + 'bicubic', 'spline16', 'spline36', 'hanning', 'hamming', 'hermite', + 'kaiser', 'quadric', 'catrom', 'gaussian', 'bessel', 'mitchell', + 'sinc', 'lanczos', 'blackman'. + origin : {'upper', 'lower'}, default: :rc:`image.origin` + Place the [0, 0] index of the array in the upper left or lower left + corner of the Axes. The convention 'upper' is typically used for + matrices and images. + filternorm : bool, default: True + A parameter for the antigrain image resize filter + (see the antigrain documentation). + If filternorm is set, the filter normalizes integer values and corrects + the rounding errors. It doesn't do anything with the source floating + point values, it corrects only integers according to the rule of 1.0 + which means that any sum of pixel weights must be equal to 1.0. So, + the filter function must produce a graph of the proper shape. + filterrad : float > 0, default: 4 + The filter radius for filters that have a radius parameter, i.e. when + interpolation is one of: 'sinc', 'lanczos' or 'blackman'. + resample : bool, default: False + When True, use a full resampling method. When False, only resample when + the output image is larger than the input image. + **kwargs : `~matplotlib.artist.Artist` properties + + """ def __init__(self, bbox, *, cmap=None, @@ -1401,12 +1445,7 @@ def __init__(self, bbox, resample=False, **kwargs ): - """ - cmap is a colors.Colormap instance - norm is a colors.Normalize instance to map luminance to 0-1 - kwargs are an optional list of Artist keyword args - """ super().__init__( None, cmap=cmap, @@ -1422,12 +1461,11 @@ def __init__(self, bbox, self.bbox = bbox def get_window_extent(self, renderer=None): - if renderer is None: - renderer = self.get_figure()._get_renderer() - if isinstance(self.bbox, BboxBase): return self.bbox elif callable(self.bbox): + if renderer is None: + renderer = self.get_figure()._get_renderer() return self.bbox(renderer) else: raise ValueError("Unknown type of bbox") @@ -1785,7 +1823,7 @@ def thumbnail(infile, thumbfile, scale=0.1, interpolation='bilinear', fig = Figure(figsize=(width, height), dpi=dpi) FigureCanvasBase(fig) - ax = fig.add_axes([0, 0, 1, 1], aspect='auto', + ax = fig.add_axes((0, 0, 1, 1), aspect='auto', frameon=False, xticks=[], yticks=[]) ax.imshow(im, aspect='auto', resample=True, interpolation=interpolation) fig.savefig(thumbfile, dpi=dpi) diff --git a/lib/matplotlib/legend.py b/lib/matplotlib/legend.py index d01a8dca0847..2fb14e52c58c 100644 --- a/lib/matplotlib/legend.py +++ b/lib/matplotlib/legend.py @@ -459,6 +459,7 @@ def __init__( labels = [*reversed(labels)] handles = [*reversed(handles)] + handles = list(handles) if len(handles) < 2: ncols = 1 self._ncols = ncols if ncols != 1 else ncol diff --git a/lib/matplotlib/mpl-data/matplotlibrc b/lib/matplotlib/mpl-data/matplotlibrc index 72117abf7317..ec649560ba3b 100644 --- a/lib/matplotlib/mpl-data/matplotlibrc +++ b/lib/matplotlib/mpl-data/matplotlibrc @@ -1,6 +1,6 @@ #### MATPLOTLIBRC FORMAT -## NOTE FOR END USERS: DO NOT EDIT THIS FILE! +## DO NOT EDIT THIS FILE, MAKE A COPY FIRST ## ## This is a sample Matplotlib configuration file - you can find a copy ## of it on your system in site-packages/matplotlib/mpl-data/matplotlibrc @@ -339,7 +339,7 @@ # become quite long. # The following packages are always loaded with usetex, # so beware of package collisions: - # geometry, inputenc, type1cm. + # color, fix-cm, geometry, graphicx, textcomp. # PostScript (PSNFSS) font packages may also be # loaded, depending on your font settings. @@ -543,6 +543,16 @@ #grid.linewidth: 0.8 # in points #grid.alpha: 1.0 # transparency, between 0.0 and 1.0 +#grid.major.color: None # If None defaults to grid.color +#grid.major.linestyle: None # If None defaults to grid.linestyle +#grid.major.linewidth: None # If None defaults to grid.linewidth +#grid.major.alpha: None # If None defaults to grid.alpha + +#grid.minor.color: None # If None defaults to grid.color +#grid.minor.linestyle: None # If None defaults to grid.linestyle +#grid.minor.linewidth: None # If None defaults to grid.linewidth +#grid.minor.alpha: None # If None defaults to grid.alpha + ## *************************************************************************** ## * LEGEND * diff --git a/lib/matplotlib/mpl-data/stylelib/classic.mplstyle b/lib/matplotlib/mpl-data/stylelib/classic.mplstyle index 6cba66076ac7..92624503f99e 100644 --- a/lib/matplotlib/mpl-data/stylelib/classic.mplstyle +++ b/lib/matplotlib/mpl-data/stylelib/classic.mplstyle @@ -122,8 +122,8 @@ text.latex.preamble : # IMPROPER USE OF THIS FEATURE WILL LEAD TO LATEX FAILURE # Note that it has to be put on a single line, which may # become quite long. # The following packages are always loaded with usetex, so - # beware of package collisions: color, geometry, graphicx, - # type1cm, textcomp. + # beware of package collisions: + # color, fix-cm, geometry, graphicx, textcomp. # Adobe Postscript (PSSNFS) font packages may also be # loaded, depending on your font settings. diff --git a/lib/matplotlib/mpl-data/stylelib/petroff6.mplstyle b/lib/matplotlib/mpl-data/stylelib/petroff6.mplstyle new file mode 100644 index 000000000000..ff227eba45ba --- /dev/null +++ b/lib/matplotlib/mpl-data/stylelib/petroff6.mplstyle @@ -0,0 +1,5 @@ +# Color cycle survey palette from Petroff (2021): +# https://arxiv.org/abs/2107.02270 +# https://github.com/mpetroff/accessible-color-cycles +axes.prop_cycle: cycler('color', ['5790fc', 'f89c20', 'e42536', '964a8b', '9c9ca1', '7a21dd']) +patch.facecolor: 5790fc diff --git a/lib/matplotlib/mpl-data/stylelib/petroff8.mplstyle b/lib/matplotlib/mpl-data/stylelib/petroff8.mplstyle new file mode 100644 index 000000000000..0228f736ddea --- /dev/null +++ b/lib/matplotlib/mpl-data/stylelib/petroff8.mplstyle @@ -0,0 +1,5 @@ +# Color cycle survey palette from Petroff (2021): +# https://arxiv.org/abs/2107.02270 +# https://github.com/mpetroff/accessible-color-cycles +axes.prop_cycle: cycler('color', ['1845fb', 'ff5e02', 'c91f16', 'c849a9', 'adad7d', '86c8dd', '578dff', '656364']) +patch.facecolor: 1845fb diff --git a/lib/matplotlib/offsetbox.py b/lib/matplotlib/offsetbox.py index 6a3a122fc3e7..974cc4f2db05 100644 --- a/lib/matplotlib/offsetbox.py +++ b/lib/matplotlib/offsetbox.py @@ -201,7 +201,7 @@ def _get_aligned_offsets(yspans, height, align="baseline"): class OffsetBox(martist.Artist): """ - The OffsetBox is a simple container artist. + A simple container artist. The child artists are meant to be drawn at a relative position to its parent. @@ -826,17 +826,18 @@ def draw(self, renderer): class AuxTransformBox(OffsetBox): """ - Offset Box with the aux_transform. Its children will be - transformed with the aux_transform first then will be - offsetted. The absolute coordinate of the aux_transform is meaning - as it will be automatically adjust so that the left-lower corner - of the bounding box of children will be set to (0, 0) before the - offset transform. - - It is similar to drawing area, except that the extent of the box - is not predetermined but calculated from the window extent of its - children. Furthermore, the extent of the children will be - calculated in the transformed coordinate. + An OffsetBox with an auxiliary transform. + + All child artists are first transformed with *aux_transform*, then + translated with an offset (the same for all children) so the bounding + box of the children matches the drawn box. (In other words, adding an + arbitrary translation to *aux_transform* has no effect as it will be + cancelled out by the later offsetting.) + + `AuxTransformBox` is similar to `.DrawingArea`, except that the extent of + the box is not predetermined but calculated from the window extent of its + children, and the extent of the children will be calculated in the + transformed coordinate. """ def __init__(self, aux_transform): self.aux_transform = aux_transform @@ -853,10 +854,7 @@ def add_artist(self, a): self.stale = True def get_transform(self): - """ - Return the :class:`~matplotlib.transforms.Transform` applied - to the children - """ + """Return the `.Transform` applied to the children.""" return (self.aux_transform + self.ref_offset_transform + self.offset_transform) @@ -908,7 +906,7 @@ def draw(self, renderer): class AnchoredOffsetbox(OffsetBox): """ - An offset box placed according to location *loc*. + An OffsetBox placed according to location *loc*. AnchoredOffsetbox has a single child. When multiple children are needed, use an extra OffsetBox to enclose them. By default, the offset box is @@ -1504,7 +1502,9 @@ def __init__(self, ref_artist, use_blit=False): @staticmethod def _picker(artist, mouseevent): # A custom picker to prevent dragging on mouse scroll events - return (artist.contains(mouseevent) and mouseevent.name != "scroll_event"), {} + if mouseevent.name == "scroll_event": + return False, {} + return artist.contains(mouseevent) # A property, not an attribute, to maintain picklability. canvas = property(lambda self: self.ref_artist.get_figure(root=True).canvas) diff --git a/lib/matplotlib/patches.py b/lib/matplotlib/patches.py index 63453d416b99..477eee9f5a7a 100644 --- a/lib/matplotlib/patches.py +++ b/lib/matplotlib/patches.py @@ -1538,7 +1538,7 @@ def _make_verts(self): length = distance else: length = distance + head_length - if not length: + if np.size(length) == 0: self.verts = np.empty([0, 2]) # display nothing if empty else: # start by drawing horizontal arrow, point at (0, 0) diff --git a/lib/matplotlib/path.py b/lib/matplotlib/path.py index a021706fb1e5..f65ade669167 100644 --- a/lib/matplotlib/path.py +++ b/lib/matplotlib/path.py @@ -275,17 +275,37 @@ def copy(self): """ return copy.copy(self) - def __deepcopy__(self, memo=None): + def __deepcopy__(self, memo): """ Return a deepcopy of the `Path`. The `Path` will not be readonly, even if the source `Path` is. """ # Deepcopying arrays (vertices, codes) strips the writeable=False flag. - p = copy.deepcopy(super(), memo) + cls = type(self) + memo[id(self)] = p = cls.__new__(cls) + + for k, v in self.__dict__.items(): + setattr(p, k, copy.deepcopy(v, memo)) + p._readonly = False return p - deepcopy = __deepcopy__ + def deepcopy(self, memo=None): + """ + Return a deep copy of the `Path`. The `Path` will not be readonly, + even if the source `Path` is. + + Parameters + ---------- + memo : dict, optional + A dictionary to use for memoizing, passed to `copy.deepcopy`. + + Returns + ------- + Path + A deep copy of the `Path`, but not readonly. + """ + return copy.deepcopy(self, memo) @classmethod def make_compound_path_from_polys(cls, XY): diff --git a/lib/matplotlib/path.pyi b/lib/matplotlib/path.pyi index 464fc6d9a912..8a5a5c03792e 100644 --- a/lib/matplotlib/path.pyi +++ b/lib/matplotlib/path.pyi @@ -44,8 +44,8 @@ class Path: @property def readonly(self) -> bool: ... def copy(self) -> Path: ... - def __deepcopy__(self, memo: dict[int, Any] | None = ...) -> Path: ... - deepcopy = __deepcopy__ + def __deepcopy__(self, memo: dict[int, Any]) -> Path: ... + def deepcopy(self, memo: dict[int, Any] | None = None) -> Path: ... @classmethod def make_compound_path_from_polys(cls, XY: ArrayLike) -> Path: ... diff --git a/lib/matplotlib/projections/polar.py b/lib/matplotlib/projections/polar.py index 71224fb3affe..8fdb31b4256e 100644 --- a/lib/matplotlib/projections/polar.py +++ b/lib/matplotlib/projections/polar.py @@ -15,20 +15,6 @@ from matplotlib.spines import Spine -def _apply_theta_transforms_warn(): - _api.warn_deprecated( - "3.9", - message=( - "Passing `apply_theta_transforms=True` (the default) " - "is deprecated since Matplotlib %(since)s. " - "Support for this will be removed in Matplotlib in %(removal)s. " - "To prevent this warning, set `apply_theta_transforms=False`, " - "and make sure to shift theta values before being passed to " - "this transform." - ) - ) - - class PolarTransform(mtransforms.Transform): r""" The base polar transform. @@ -48,8 +34,7 @@ class PolarTransform(mtransforms.Transform): input_dims = output_dims = 2 - def __init__(self, axis=None, use_rmin=True, *, - apply_theta_transforms=True, scale_transform=None): + def __init__(self, axis=None, use_rmin=True, *, scale_transform=None): """ Parameters ---------- @@ -64,15 +49,12 @@ def __init__(self, axis=None, use_rmin=True, *, super().__init__() self._axis = axis self._use_rmin = use_rmin - self._apply_theta_transforms = apply_theta_transforms self._scale_transform = scale_transform - if apply_theta_transforms: - _apply_theta_transforms_warn() __str__ = mtransforms._make_str_method( "_axis", - use_rmin="_use_rmin", - apply_theta_transforms="_apply_theta_transforms") + use_rmin="_use_rmin" + ) def _get_rorigin(self): # Get lower r limit after being scaled by the radial scale transform @@ -82,11 +64,6 @@ def _get_rorigin(self): def transform_non_affine(self, values): # docstring inherited theta, r = np.transpose(values) - # PolarAxes does not use the theta transforms here, but apply them for - # backwards-compatibility if not being used by it. - if self._apply_theta_transforms and self._axis is not None: - theta *= self._axis.get_theta_direction() - theta += self._axis.get_theta_offset() if self._use_rmin and self._axis is not None: r = (r - self._get_rorigin()) * self._axis.get_rsign() r = np.where(r >= 0, r, np.nan) @@ -148,10 +125,7 @@ def transform_path_non_affine(self, path): def inverted(self): # docstring inherited - return PolarAxes.InvertedPolarTransform( - self._axis, self._use_rmin, - apply_theta_transforms=self._apply_theta_transforms - ) + return PolarAxes.InvertedPolarTransform(self._axis, self._use_rmin) class PolarAffine(mtransforms.Affine2DBase): @@ -209,8 +183,7 @@ class InvertedPolarTransform(mtransforms.Transform): """ input_dims = output_dims = 2 - def __init__(self, axis=None, use_rmin=True, - *, apply_theta_transforms=True): + def __init__(self, axis=None, use_rmin=True): """ Parameters ---------- @@ -225,26 +198,16 @@ def __init__(self, axis=None, use_rmin=True, super().__init__() self._axis = axis self._use_rmin = use_rmin - self._apply_theta_transforms = apply_theta_transforms - if apply_theta_transforms: - _apply_theta_transforms_warn() __str__ = mtransforms._make_str_method( "_axis", - use_rmin="_use_rmin", - apply_theta_transforms="_apply_theta_transforms") + use_rmin="_use_rmin") def transform_non_affine(self, values): # docstring inherited x, y = values.T r = np.hypot(x, y) theta = (np.arctan2(y, x) + 2 * np.pi) % (2 * np.pi) - # PolarAxes does not use the theta transforms here, but apply them for - # backwards-compatibility if not being used by it. - if self._apply_theta_transforms and self._axis is not None: - theta -= self._axis.get_theta_offset() - theta *= self._axis.get_theta_direction() - theta %= 2 * np.pi if self._use_rmin and self._axis is not None: r += self._axis.get_rorigin() r *= self._axis.get_rsign() @@ -252,10 +215,7 @@ def transform_non_affine(self, values): def inverted(self): # docstring inherited - return PolarAxes.PolarTransform( - self._axis, self._use_rmin, - apply_theta_transforms=self._apply_theta_transforms - ) + return PolarAxes.PolarTransform(self._axis, self._use_rmin) class ThetaFormatter(mticker.Formatter): @@ -857,6 +817,10 @@ def _init_axis(self): self.xaxis = ThetaAxis(self, clear=False) self.yaxis = RadialAxis(self, clear=False) self.spines['polar'].register_axis(self.yaxis) + inner_spine = self.spines.get('inner', None) + if inner_spine is not None: + # Subclasses may not have inner spine. + inner_spine.register_axis(self.yaxis) def _set_lim_and_transforms(self): # A view limit where the minimum radius can be locked if the user @@ -895,7 +859,6 @@ def _set_lim_and_transforms(self): # data. This one is aware of rmin self.transProjection = self.PolarTransform( self, - apply_theta_transforms=False, scale_transform=self.transScale ) # Add dependency on rorigin. @@ -1002,7 +965,9 @@ def draw(self, renderer): thetamin, thetamax = np.rad2deg(self._realViewLim.intervalx) if thetamin > thetamax: thetamin, thetamax = thetamax, thetamin - rmin, rmax = ((self._realViewLim.intervaly - self.get_rorigin()) * + rscale_tr = self.yaxis.get_transform() + rmin, rmax = ((rscale_tr.transform(self._realViewLim.intervaly) - + rscale_tr.transform(self.get_rorigin())) * self.get_rsign()) if isinstance(self.patch, mpatches.Wedge): # Backwards-compatibility: Any subclassed Axes might override the diff --git a/lib/matplotlib/projections/polar.pyi b/lib/matplotlib/projections/polar.pyi index de1cbc293900..fc1d508579b5 100644 --- a/lib/matplotlib/projections/polar.pyi +++ b/lib/matplotlib/projections/polar.pyi @@ -18,7 +18,6 @@ class PolarTransform(mtransforms.Transform): axis: PolarAxes | None = ..., use_rmin: bool = ..., *, - apply_theta_transforms: bool = ..., scale_transform: mtransforms.Transform | None = ..., ) -> None: ... def inverted(self) -> InvertedPolarTransform: ... @@ -35,8 +34,6 @@ class InvertedPolarTransform(mtransforms.Transform): self, axis: PolarAxes | None = ..., use_rmin: bool = ..., - *, - apply_theta_transforms: bool = ..., ) -> None: ... def inverted(self) -> PolarTransform: ... diff --git a/lib/matplotlib/pyplot.py b/lib/matplotlib/pyplot.py index 78fc962d9c5c..8c9d1e1e5a29 100644 --- a/lib/matplotlib/pyplot.py +++ b/lib/matplotlib/pyplot.py @@ -50,7 +50,7 @@ import sys import threading import time -from typing import TYPE_CHECKING, cast, overload +from typing import IO, TYPE_CHECKING, cast, overload from cycler import cycler # noqa: F401 import matplotlib @@ -93,6 +93,7 @@ import PIL.Image from numpy.typing import ArrayLike + import pandas as pd import matplotlib.axes import matplotlib.artist @@ -337,8 +338,8 @@ def uninstall_repl_displayhook() -> None: # Ensure this appears in the pyplot docs. @_copy_docstring_and_deprecators(matplotlib.set_loglevel) -def set_loglevel(*args, **kwargs) -> None: - return matplotlib.set_loglevel(*args, **kwargs) +def set_loglevel(level: str) -> None: + return matplotlib.set_loglevel(level) @_copy_docstring_and_deprecators(Artist.findobj) @@ -568,6 +569,14 @@ def draw_if_interactive(*args, **kwargs): return _get_backend_mod().draw_if_interactive(*args, **kwargs) +@overload +def show(*, block: bool, **kwargs) -> None: ... + + +@overload +def show(*args: Any, **kwargs: Any) -> None: ... + + # This function's signature is rewritten upon backend-load by switch_backend. def show(*args, **kwargs) -> None: """ @@ -1250,11 +1259,11 @@ def draw() -> None: @_copy_docstring_and_deprecators(Figure.savefig) -def savefig(*args, **kwargs) -> None: +def savefig(fname: str | os.PathLike | IO, **kwargs) -> None: fig = gcf() # savefig default implementation has no return, so mypy is unhappy # presumably this is here because subclasses can return? - res = fig.savefig(*args, **kwargs) # type: ignore[func-returns-value] + res = fig.savefig(fname, **kwargs) # type: ignore[func-returns-value] fig.canvas.draw_idle() # Need this if 'transparent=True', to reset colors. return res @@ -1392,6 +1401,18 @@ def cla() -> None: ## More ways of creating Axes ## +@overload +def subplot(nrows: int, ncols: int, index: int, /, **kwargs): ... + + +@overload +def subplot(pos: int | SubplotSpec, /, **kwargs): ... + + +@overload +def subplot(**kwargs): ... + + @_docstring.interpd def subplot(*args, **kwargs) -> Axes: """ @@ -1405,7 +1426,6 @@ def subplot(*args, **kwargs) -> Axes: subplot(nrows, ncols, index, **kwargs) subplot(pos, **kwargs) subplot(**kwargs) - subplot(ax) Parameters ---------- @@ -2095,6 +2115,24 @@ def box(on: bool | None = None) -> None: ## Axis ## +@overload +def xlim() -> tuple[float, float]: + ... + + +@overload +def xlim( + left: float | tuple[float, float] | None = None, + right: float | None = None, + *, + emit: bool = True, + auto: bool | None = False, + xmin: float | None = None, + xmax: float | None = None, +) -> tuple[float, float]: + ... + + def xlim(*args, **kwargs) -> tuple[float, float]: """ Get or set the x limits of the current Axes. @@ -2132,6 +2170,24 @@ def xlim(*args, **kwargs) -> tuple[float, float]: return ret +@overload +def ylim() -> tuple[float, float]: + ... + + +@overload +def ylim( + bottom: float | tuple[float, float] | None = None, + top: float | None = None, + *, + emit: bool = True, + auto: bool | None = False, + ymin: float | None = None, + ymax: float | None = None, +) -> tuple[float, float]: + ... + + def ylim(*args, **kwargs) -> tuple[float, float]: """ Get or set the y-limits of the current Axes. @@ -3404,6 +3460,33 @@ def grid( gca().grid(visible=visible, which=which, axis=axis, **kwargs) +# Autogenerated by boilerplate.py. Do not edit as changes will be lost. +@_copy_docstring_and_deprecators(Axes.grouped_bar) +def grouped_bar( + heights: Sequence[ArrayLike] | dict[str, ArrayLike] | np.ndarray | pd.DataFrame, + *, + positions: ArrayLike | None = None, + group_spacing: float | None = 1.5, + bar_spacing: float | None = 0, + tick_labels: Sequence[str] | None = None, + labels: Sequence[str] | None = None, + orientation: Literal["vertical", "horizontal"] = "vertical", + colors: Iterable[ColorType] | None = None, + **kwargs, +) -> list[BarContainer]: + return gca().grouped_bar( + heights, + positions=positions, + group_spacing=group_spacing, + bar_spacing=bar_spacing, + tick_labels=tick_labels, + labels=labels, + orientation=orientation, + colors=colors, + **kwargs, + ) + + # Autogenerated by boilerplate.py. Do not edit as changes will be lost. @_copy_docstring_and_deprecators(Axes.hexbin) def hexbin( diff --git a/lib/matplotlib/rcsetup.py b/lib/matplotlib/rcsetup.py index ce29c5076100..80d25659888e 100644 --- a/lib/matplotlib/rcsetup.py +++ b/lib/matplotlib/rcsetup.py @@ -24,7 +24,7 @@ import matplotlib as mpl from matplotlib import _api, cbook -from matplotlib.backends import BackendFilter, backend_registry +from matplotlib.backends import backend_registry from matplotlib.cbook import ls_mapper from matplotlib.colors import Colormap, is_color_like from matplotlib._fontconfig_pattern import parse_fontconfig_pattern @@ -34,32 +34,6 @@ from cycler import Cycler, cycler as ccycler -@_api.caching_module_getattr -class __getattr__: - @_api.deprecated( - "3.9", - alternative="``matplotlib.backends.backend_registry.list_builtin" - "(matplotlib.backends.BackendFilter.INTERACTIVE)``") - @property - def interactive_bk(self): - return backend_registry.list_builtin(BackendFilter.INTERACTIVE) - - @_api.deprecated( - "3.9", - alternative="``matplotlib.backends.backend_registry.list_builtin" - "(matplotlib.backends.BackendFilter.NON_INTERACTIVE)``") - @property - def non_interactive_bk(self): - return backend_registry.list_builtin(BackendFilter.NON_INTERACTIVE) - - @_api.deprecated( - "3.9", - alternative="``matplotlib.backends.backend_registry.list_builtin()``") - @property - def all_backends(self): - return backend_registry.list_builtin() - - class ValidateInStrings: def __init__(self, key, valid, ignorecase=False, *, _deprecated_since=None): @@ -387,6 +361,12 @@ def validate_color(s): raise ValueError(f'{s!r} does not look like a color arg') +def _validate_color_or_None(s): + if s is None or cbook._str_equal(s, "None"): + return None + return validate_color(s) + + validate_colorlist = _listify_validator( validate_color, allow_stringlist=True, doc='return a list of colorspecs') @@ -541,6 +521,13 @@ def _is_iterable_not_string_like(x): raise ValueError(f"linestyle {ls!r} is not a valid on-off ink sequence.") +def _validate_linestyle_or_None(s): + if s is None or cbook._str_equal(s, "None"): + return None + + return _validate_linestyle(s) + + validate_fillstyle = ValidateInStrings( 'markers.fillstyle', ['full', 'left', 'right', 'bottom', 'top', 'none']) @@ -1268,6 +1255,16 @@ def _convert_validator_spec(key, conv): "grid.linewidth": validate_float, # in points "grid.alpha": validate_float, + "grid.major.color": _validate_color_or_None, # grid color + "grid.major.linestyle": _validate_linestyle_or_None, # solid + "grid.major.linewidth": validate_float_or_None, # in points + "grid.major.alpha": validate_float_or_None, + + "grid.minor.color": _validate_color_or_None, # grid color + "grid.minor.linestyle": _validate_linestyle_or_None, # solid + "grid.minor.linewidth": validate_float_or_None, # in points + "grid.minor.alpha": validate_float_or_None, + ## figure props # figure title "figure.titlesize": validate_fontsize, diff --git a/lib/matplotlib/rcsetup.pyi b/lib/matplotlib/rcsetup.pyi index 79538511c0e4..c6611845723d 100644 --- a/lib/matplotlib/rcsetup.pyi +++ b/lib/matplotlib/rcsetup.pyi @@ -4,9 +4,6 @@ from collections.abc import Callable, Iterable from typing import Any, Literal, TypeVar from matplotlib.typing import ColorType, LineStyleType, MarkEveryType -interactive_bk: list[str] -non_interactive_bk: list[str] -all_backends: list[str] _T = TypeVar("_T") @@ -51,6 +48,7 @@ def validate_color_or_auto(s: Any) -> ColorType | Literal["auto"]: ... def _validate_color_or_edge(s: Any) -> ColorType | Literal["edge"]: ... def validate_color_for_prop_cycle(s: Any) -> ColorType: ... def validate_color(s: Any) -> ColorType: ... +def _validate_color_or_None(s: Any) -> ColorType | None: ... def validate_colorlist(s: Any) -> list[ColorType]: ... def _validate_color_or_linecolor( s: Any, @@ -140,6 +138,7 @@ def validate_fillstylelist( ) -> list[Literal["full", "left", "right", "bottom", "top", "none"]]: ... def validate_markevery(s: Any) -> MarkEveryType: ... def _validate_linestyle(s: Any) -> LineStyleType: ... +def _validate_linestyle_or_None(s: Any) -> LineStyleType | None: ... def validate_markeverylist(s: Any) -> list[MarkEveryType]: ... def validate_bbox(s: Any) -> Literal["tight", "standard"] | None: ... def validate_sketch(s: Any) -> None | tuple[float, float, float]: ... diff --git a/lib/matplotlib/scale.py b/lib/matplotlib/scale.py index 44fbe5209c4d..4517b8946b03 100644 --- a/lib/matplotlib/scale.py +++ b/lib/matplotlib/scale.py @@ -31,6 +31,7 @@ import inspect import textwrap +from functools import wraps import numpy as np @@ -103,6 +104,53 @@ def limit_range_for_scale(self, vmin, vmax, minpos): return vmin, vmax +def _make_axis_parameter_optional(init_func): + """ + Decorator to allow leaving out the *axis* parameter in scale constructors. + + This decorator ensures backward compatibility for scale classes that + previously required an *axis* parameter. It allows constructors to be + callerd with or without the *axis* parameter. + + For simplicity, this does not handle the case when *axis* + is passed as a keyword. However, + scanning GitHub, there's no evidence that that is used anywhere. + + Parameters + ---------- + init_func : callable + The original __init__ method of a scale class. + + Returns + ------- + callable + A wrapped version of *init_func* that handles the optional *axis*. + + Notes + ----- + If the wrapped constructor defines *axis* as its first argument, the + parameter is preserved when present. Otherwise, the value `None` is injected + as the first argument. + + Examples + -------- + >>> from matplotlib.scale import ScaleBase + >>> class CustomScale(ScaleBase): + ... @_make_axis_parameter_optional + ... def __init__(self, axis, custom_param=1): + ... self.custom_param = custom_param + """ + @wraps(init_func) + def wrapper(self, *args, **kwargs): + if args and isinstance(args[0], mpl.axis.Axis): + return init_func(self, *args, **kwargs) + else: + # Remove 'axis' from kwargs to avoid double assignment + axis = kwargs.pop('axis', None) + return init_func(self, axis, *args, **kwargs) + return wrapper + + class LinearScale(ScaleBase): """ The default linear scale. @@ -110,6 +158,7 @@ class LinearScale(ScaleBase): name = 'linear' + @_make_axis_parameter_optional def __init__(self, axis): # This method is present only to prevent inheritance of the base class' # constructor docstring, which would otherwise end up interpolated into @@ -180,6 +229,7 @@ class FuncScale(ScaleBase): name = 'function' + @_make_axis_parameter_optional def __init__(self, axis, functions): """ Parameters @@ -279,7 +329,8 @@ class LogScale(ScaleBase): """ name = 'log' - def __init__(self, axis, *, base=10, subs=None, nonpositive="clip"): + @_make_axis_parameter_optional + def __init__(self, axis=None, *, base=10, subs=None, nonpositive="clip"): """ Parameters ---------- @@ -330,6 +381,7 @@ class FuncScaleLog(LogScale): name = 'functionlog' + @_make_axis_parameter_optional def __init__(self, axis, functions, base=10): """ Parameters @@ -455,7 +507,8 @@ class SymmetricalLogScale(ScaleBase): """ name = 'symlog' - def __init__(self, axis, *, base=10, linthresh=2, subs=None, linscale=1): + @_make_axis_parameter_optional + def __init__(self, axis=None, *, base=10, linthresh=2, subs=None, linscale=1): self._transform = SymmetricalLogTransform(base, linthresh, linscale) self.subs = subs @@ -547,7 +600,8 @@ class AsinhScale(ScaleBase): 1024: (256, 512) } - def __init__(self, axis, *, linear_width=1.0, + @_make_axis_parameter_optional + def __init__(self, axis=None, *, linear_width=1.0, base=10, subs='auto', **kwargs): """ Parameters @@ -645,7 +699,8 @@ class LogitScale(ScaleBase): """ name = 'logit' - def __init__(self, axis, nonpositive='mask', *, + @_make_axis_parameter_optional + def __init__(self, axis=None, nonpositive='mask', *, one_half=r"\frac{1}{2}", use_overline=False): r""" Parameters diff --git a/lib/matplotlib/scale.pyi b/lib/matplotlib/scale.pyi index 7fec8e68cc5a..ba9f269b8c78 100644 --- a/lib/matplotlib/scale.pyi +++ b/lib/matplotlib/scale.pyi @@ -15,6 +15,10 @@ class ScaleBase: class LinearScale(ScaleBase): name: str + def __init__( + self, + axis: Axis | None, + ) -> None: ... class FuncTransform(Transform): input_dims: int @@ -57,7 +61,7 @@ class LogScale(ScaleBase): subs: Iterable[int] | None def __init__( self, - axis: Axis | None, + axis: Axis | None = ..., *, base: float = ..., subs: Iterable[int] | None = ..., @@ -104,7 +108,7 @@ class SymmetricalLogScale(ScaleBase): subs: Iterable[int] | None def __init__( self, - axis: Axis | None, + axis: Axis | None = ..., *, base: float = ..., linthresh: float = ..., @@ -138,7 +142,7 @@ class AsinhScale(ScaleBase): auto_tick_multipliers: dict[int, tuple[int, ...]] def __init__( self, - axis: Axis | None, + axis: Axis | None = ..., *, linear_width: float = ..., base: float = ..., @@ -165,7 +169,7 @@ class LogitScale(ScaleBase): name: str def __init__( self, - axis: Axis | None, + axis: Axis | None = ..., nonpositive: Literal["mask", "clip"] = ..., *, one_half: str = ..., @@ -176,3 +180,4 @@ class LogitScale(ScaleBase): def get_scale_names() -> list[str]: ... def scale_factory(scale: str, axis: Axis, **kwargs) -> ScaleBase: ... def register_scale(scale_class: type[ScaleBase]) -> None: ... +def _make_axis_parameter_optional(init_func: Callable[..., None]) -> Callable[..., None]: ... diff --git a/lib/matplotlib/sphinxext/plot_directive.py b/lib/matplotlib/sphinxext/plot_directive.py index af858e344afa..b5f10d851182 100644 --- a/lib/matplotlib/sphinxext/plot_directive.py +++ b/lib/matplotlib/sphinxext/plot_directive.py @@ -47,6 +47,12 @@ The ``.. plot::`` directive supports the following options: +``:filename-prefix:`` : str + The base name (without the extension) of the outputted image and script + files. The default is to use the same name as the input script, or the + name of the RST document if no script is provided. The filename-prefix for + each plot directive must be unique. + ``:format:`` : {'python', 'doctest'} The format of the input. If unset, the format is auto-detected. @@ -163,8 +169,10 @@ be customized by changing the *plot_template*. See the source of :doc:`/api/sphinxext_plot_directive_api` for the templates defined in *TEMPLATE* and *TEMPLATE_SRCSET*. + """ +from collections import defaultdict import contextlib import doctest from io import StringIO @@ -182,6 +190,7 @@ from docutils.parsers.rst.directives.images import Image import jinja2 # Sphinx dependency. +from sphinx.environment.collectors import EnvironmentCollector from sphinx.errors import ExtensionError import matplotlib @@ -265,6 +274,7 @@ class PlotDirective(Directive): 'scale': directives.nonnegative_int, 'align': Image.align, 'class': directives.class_option, + 'filename-prefix': directives.unchanged, 'include-source': _option_boolean, 'show-source-link': _option_boolean, 'format': _option_format, @@ -312,9 +322,35 @@ def setup(app): app.connect('build-finished', _copy_css_file) metadata = {'parallel_read_safe': True, 'parallel_write_safe': True, 'version': matplotlib.__version__} + app.connect('builder-inited', init_filename_registry) + app.add_env_collector(_FilenameCollector) return metadata +# ----------------------------------------------------------------------------- +# Handle Duplicate Filenames +# ----------------------------------------------------------------------------- + +def init_filename_registry(app): + env = app.builder.env + if not hasattr(env, 'mpl_plot_image_basenames'): + env.mpl_plot_image_basenames = defaultdict(set) + + +class _FilenameCollector(EnvironmentCollector): + def process_doc(self, app, doctree): + pass + + def clear_doc(self, app, env, docname): + if docname in env.mpl_plot_image_basenames: + del env.mpl_plot_image_basenames[docname] + + def merge_other(self, app, env, docnames, other): + for docname in other.mpl_plot_image_basenames: + env.mpl_plot_image_basenames[docname].update( + other.mpl_plot_image_basenames[docname]) + + # ----------------------------------------------------------------------------- # Doctest handling # ----------------------------------------------------------------------------- @@ -600,6 +636,25 @@ def _parse_srcset(entries): return srcset +def check_output_base_name(env, output_base): + docname = env.docname + + if '.' in output_base or '/' in output_base or '\\' in output_base: + raise PlotError( + f"The filename-prefix '{output_base}' is invalid. " + f"It must not contain dots or slashes.") + + for d in env.mpl_plot_image_basenames: + if output_base in env.mpl_plot_image_basenames[d]: + if d == docname: + raise PlotError( + f"The filename-prefix {output_base!r} is used multiple times.") + raise PlotError(f"The filename-prefix {output_base!r} is used multiple" + f"times (it is also used in {env.doc2path(d)}).") + + env.mpl_plot_image_basenames[docname].add(output_base) + + def render_figures(code, code_path, output_dir, output_base, context, function_name, config, context_reset=False, close_figs=False, @@ -722,7 +777,8 @@ def render_figures(code, code_path, output_dir, output_base, context, def run(arguments, content, options, state_machine, state, lineno): document = state_machine.document - config = document.settings.env.config + env = document.settings.env + config = env.config nofigs = 'nofigs' in options if config.plot_srcset and setup.app.builder.name == 'singlehtml': @@ -734,6 +790,7 @@ def run(arguments, content, options, state_machine, state, lineno): options.setdefault('include-source', config.plot_include_source) options.setdefault('show-source-link', config.plot_html_show_source_link) + options.setdefault('filename-prefix', None) if 'class' in options: # classes are parsed into a list of string, and output by simply @@ -775,14 +832,22 @@ def run(arguments, content, options, state_machine, state, lineno): function_name = None code = Path(source_file_name).read_text(encoding='utf-8') - output_base = os.path.basename(source_file_name) + if options['filename-prefix']: + output_base = options['filename-prefix'] + check_output_base_name(env, output_base) + else: + output_base = os.path.basename(source_file_name) else: source_file_name = rst_file code = textwrap.dedent("\n".join(map(str, content))) - counter = document.attributes.get('_plot_counter', 0) + 1 - document.attributes['_plot_counter'] = counter - base, ext = os.path.splitext(os.path.basename(source_file_name)) - output_base = '%s-%d.py' % (base, counter) + if options['filename-prefix']: + output_base = options['filename-prefix'] + check_output_base_name(env, output_base) + else: + base, ext = os.path.splitext(os.path.basename(source_file_name)) + counter = document.attributes.get('_plot_counter', 0) + 1 + document.attributes['_plot_counter'] = counter + output_base = '%s-%d.py' % (base, counter) function_name = None caption = options.get('caption', '') @@ -846,7 +911,7 @@ def run(arguments, content, options, state_machine, state, lineno): # save script (if necessary) if options['show-source-link']: - Path(build_dir, output_base + source_ext).write_text( + Path(build_dir, output_base + (source_ext or '.py')).write_text( doctest.script_from_examples(code) if source_file_name == rst_file and is_doctest else code, @@ -906,7 +971,7 @@ def run(arguments, content, options, state_machine, state, lineno): # Not-None src_name signals the need for a source download in the # generated html if j == 0 and options['show-source-link']: - src_name = output_base + source_ext + src_name = output_base + (source_ext or '.py') else: src_name = None if config.plot_srcset: diff --git a/lib/matplotlib/spines.py b/lib/matplotlib/spines.py index 7e77a393f2a2..9732a2f3347a 100644 --- a/lib/matplotlib/spines.py +++ b/lib/matplotlib/spines.py @@ -265,11 +265,17 @@ def _adjust_location(self): self._path = mpath.Path.arc(np.rad2deg(low), np.rad2deg(high)) if self.spine_type == 'bottom': - rmin, rmax = self.axes.viewLim.intervaly + if self.axis is None: + tr = mtransforms.IdentityTransform() + else: + tr = self.axis.get_transform() + rmin, rmax = tr.transform(self.axes.viewLim.intervaly) try: rorigin = self.axes.get_rorigin() except AttributeError: rorigin = rmin + else: + rorigin = tr.transform(rorigin) scaled_diameter = (rmin - rorigin) / (rmax - rorigin) self._height = scaled_diameter self._width = scaled_diameter diff --git a/lib/matplotlib/style/__init__.py b/lib/matplotlib/style/__init__.py index 488c6d6ae1ec..a202cfe08b20 100644 --- a/lib/matplotlib/style/__init__.py +++ b/lib/matplotlib/style/__init__.py @@ -1,4 +1,252 @@ -from .core import available, context, library, reload_library, use +""" +Core functions and attributes for the matplotlib style library: +``use`` + Select style sheet to override the current matplotlib settings. +``context`` + Context manager to use a style sheet temporarily. +``available`` + List available style sheets. +``library`` + A dictionary of style names and matplotlib settings. +""" -__all__ = ["available", "context", "library", "reload_library", "use"] +import contextlib +import importlib.resources +import logging +import os +from pathlib import Path +import warnings + +import matplotlib as mpl +from matplotlib import _api, _docstring, rc_params_from_file, rcParamsDefault + +_log = logging.getLogger(__name__) + +__all__ = ['use', 'context', 'available', 'library', 'reload_library'] + + +_BASE_LIBRARY_PATH = os.path.join(mpl.get_data_path(), 'stylelib') +# Users may want multiple library paths, so store a list of paths. +USER_LIBRARY_PATHS = [os.path.join(mpl.get_configdir(), 'stylelib')] +_STYLE_EXTENSION = 'mplstyle' +# A list of rcParams that should not be applied from styles +_STYLE_BLACKLIST = { + 'interactive', 'backend', 'webagg.port', 'webagg.address', + 'webagg.port_retries', 'webagg.open_in_browser', 'backend_fallback', + 'toolbar', 'timezone', 'figure.max_open_warning', + 'figure.raise_window', 'savefig.directory', 'tk.window_focus', + 'docstring.hardcopy', 'date.epoch'} + + +@_docstring.Substitution( + "\n".join(map("- {}".format, sorted(_STYLE_BLACKLIST, key=str.lower))) +) +def use(style): + """ + Use Matplotlib style settings from a style specification. + + The style name of 'default' is reserved for reverting back to + the default style settings. + + .. note:: + + This updates the `.rcParams` with the settings from the style. + `.rcParams` not defined in the style are kept. + + Parameters + ---------- + style : str, dict, Path or list + + A style specification. Valid options are: + + str + - One of the style names in `.style.available` (a builtin style or + a style installed in the user library path). + + - A dotted name of the form "package.style_name"; in that case, + "package" should be an importable Python package name, e.g. at + ``/path/to/package/__init__.py``; the loaded style file is + ``/path/to/package/style_name.mplstyle``. (Style files in + subpackages are likewise supported.) + + - The path or URL to a style file, which gets loaded by + `.rc_params_from_file`. + + dict + A mapping of key/value pairs for `matplotlib.rcParams`. + + Path + The path to a style file, which gets loaded by + `.rc_params_from_file`. + + list + A list of style specifiers (str, Path or dict), which are applied + from first to last in the list. + + Notes + ----- + The following `.rcParams` are not related to style and will be ignored if + found in a style specification: + + %s + """ + if isinstance(style, (str, Path)) or hasattr(style, 'keys'): + # If name is a single str, Path or dict, make it a single element list. + styles = [style] + else: + styles = style + + style_alias = {'mpl20': 'default', 'mpl15': 'classic'} + + for style in styles: + if isinstance(style, str): + style = style_alias.get(style, style) + if style == "default": + # Deprecation warnings were already handled when creating + # rcParamsDefault, no need to reemit them here. + with _api.suppress_matplotlib_deprecation_warning(): + # don't trigger RcParams.__getitem__('backend') + style = {k: rcParamsDefault[k] for k in rcParamsDefault + if k not in _STYLE_BLACKLIST} + elif style in library: + style = library[style] + elif "." in style: + pkg, _, name = style.rpartition(".") + try: + path = importlib.resources.files(pkg) / f"{name}.{_STYLE_EXTENSION}" + style = rc_params_from_file(path, use_default_template=False) + except (ModuleNotFoundError, OSError, TypeError) as exc: + # There is an ambiguity whether a dotted name refers to a + # package.style_name or to a dotted file path. Currently, + # we silently try the first form and then the second one; + # in the future, we may consider forcing file paths to + # either use Path objects or be prepended with "./" and use + # the slash as marker for file paths. + pass + if isinstance(style, (str, Path)): + try: + style = rc_params_from_file(style, use_default_template=False) + except OSError as err: + raise OSError( + f"{style!r} is not a valid package style, path of style " + f"file, URL of style file, or library style name (library " + f"styles are listed in `style.available`)") from err + filtered = {} + for k in style: # don't trigger RcParams.__getitem__('backend') + if k in _STYLE_BLACKLIST: + _api.warn_external( + f"Style includes a parameter, {k!r}, that is not " + f"related to style. Ignoring this parameter.") + else: + filtered[k] = style[k] + mpl.rcParams.update(filtered) + + +@contextlib.contextmanager +def context(style, after_reset=False): + """ + Context manager for using style settings temporarily. + + Parameters + ---------- + style : str, dict, Path or list + A style specification. Valid options are: + + str + - One of the style names in `.style.available` (a builtin style or + a style installed in the user library path). + + - A dotted name of the form "package.style_name"; in that case, + "package" should be an importable Python package name, e.g. at + ``/path/to/package/__init__.py``; the loaded style file is + ``/path/to/package/style_name.mplstyle``. (Style files in + subpackages are likewise supported.) + + - The path or URL to a style file, which gets loaded by + `.rc_params_from_file`. + dict + A mapping of key/value pairs for `matplotlib.rcParams`. + + Path + The path to a style file, which gets loaded by + `.rc_params_from_file`. + + list + A list of style specifiers (str, Path or dict), which are applied + from first to last in the list. + + after_reset : bool + If True, apply style after resetting settings to their defaults; + otherwise, apply style on top of the current settings. + """ + with mpl.rc_context(): + if after_reset: + mpl.rcdefaults() + use(style) + yield + + +def _update_user_library(library): + """Update style library with user-defined rc files.""" + for stylelib_path in map(os.path.expanduser, USER_LIBRARY_PATHS): + styles = _read_style_directory(stylelib_path) + _update_nested_dict(library, styles) + return library + + +@_api.deprecated("3.11") +def update_user_library(library): + return _update_user_library(library) + + +def _read_style_directory(style_dir): + """Return dictionary of styles defined in *style_dir*.""" + styles = dict() + for path in Path(style_dir).glob(f"*.{_STYLE_EXTENSION}"): + with warnings.catch_warnings(record=True) as warns: + styles[path.stem] = rc_params_from_file(path, use_default_template=False) + for w in warns: + _log.warning('In %s: %s', path, w.message) + return styles + + +@_api.deprecated("3.11") +def read_style_directory(style_dir): + return _read_style_directory(style_dir) + + +def _update_nested_dict(main_dict, new_dict): + """ + Update nested dict (only level of nesting) with new values. + + Unlike `dict.update`, this assumes that the values of the parent dict are + dicts (or dict-like), so you shouldn't replace the nested dict if it + already exists. Instead you should update the sub-dict. + """ + # update named styles specified by user + for name, rc_dict in new_dict.items(): + main_dict.setdefault(name, {}).update(rc_dict) + return main_dict + + +@_api.deprecated("3.11") +def update_nested_dict(main_dict, new_dict): + return _update_nested_dict(main_dict, new_dict) + + +# Load style library +# ================== +_base_library = _read_style_directory(_BASE_LIBRARY_PATH) +library = {} +available = [] + + +def reload_library(): + """Reload the style library.""" + library.clear() + library.update(_update_user_library(_base_library)) + available[:] = sorted(library.keys()) + + +reload_library() diff --git a/lib/matplotlib/style/__init__.pyi b/lib/matplotlib/style/__init__.pyi new file mode 100644 index 000000000000..c93b504fe6bd --- /dev/null +++ b/lib/matplotlib/style/__init__.pyi @@ -0,0 +1,20 @@ +from collections.abc import Generator +import contextlib + +from matplotlib import RcParams +from matplotlib.typing import RcStyleType + +USER_LIBRARY_PATHS: list[str] = ... + +def use(style: RcStyleType) -> None: ... +@contextlib.contextmanager +def context( + style: RcStyleType, after_reset: bool = ... +) -> Generator[None, None, None]: ... + +library: dict[str, RcParams] +available: list[str] + +def reload_library() -> None: ... + +__all__ = ['use', 'context', 'available', 'library', 'reload_library'] diff --git a/lib/matplotlib/style/core.py b/lib/matplotlib/style/core.py index e36c3c37a882..c377bc64077a 100644 --- a/lib/matplotlib/style/core.py +++ b/lib/matplotlib/style/core.py @@ -11,227 +11,17 @@ A dictionary of style names and matplotlib settings. """ -import contextlib -import importlib.resources -import logging -import os -from pathlib import Path -import warnings - -import matplotlib as mpl -from matplotlib import _api, _docstring, _rc_params_in_file, rcParamsDefault - -_log = logging.getLogger(__name__) - -__all__ = ['use', 'context', 'available', 'library', 'reload_library'] - - -BASE_LIBRARY_PATH = os.path.join(mpl.get_data_path(), 'stylelib') -# Users may want multiple library paths, so store a list of paths. -USER_LIBRARY_PATHS = [os.path.join(mpl.get_configdir(), 'stylelib')] -STYLE_EXTENSION = 'mplstyle' -# A list of rcParams that should not be applied from styles -STYLE_BLACKLIST = { - 'interactive', 'backend', 'webagg.port', 'webagg.address', - 'webagg.port_retries', 'webagg.open_in_browser', 'backend_fallback', - 'toolbar', 'timezone', 'figure.max_open_warning', - 'figure.raise_window', 'savefig.directory', 'tk.window_focus', - 'docstring.hardcopy', 'date.epoch'} - - -@_docstring.Substitution( - "\n".join(map("- {}".format, sorted(STYLE_BLACKLIST, key=str.lower))) +from .. import _api +from . import ( + use, context, available, library, reload_library, USER_LIBRARY_PATHS, + _BASE_LIBRARY_PATH as BASE_LIBRARY_PATH, + _STYLE_EXTENSION as STYLE_EXTENSION, + _STYLE_BLACKLIST as STYLE_BLACKLIST, ) -def use(style): - """ - Use Matplotlib style settings from a style specification. - - The style name of 'default' is reserved for reverting back to - the default style settings. - - .. note:: - - This updates the `.rcParams` with the settings from the style. - `.rcParams` not defined in the style are kept. - - Parameters - ---------- - style : str, dict, Path or list - - A style specification. Valid options are: - - str - - One of the style names in `.style.available` (a builtin style or - a style installed in the user library path). - - - A dotted name of the form "package.style_name"; in that case, - "package" should be an importable Python package name, e.g. at - ``/path/to/package/__init__.py``; the loaded style file is - ``/path/to/package/style_name.mplstyle``. (Style files in - subpackages are likewise supported.) - - - The path or URL to a style file, which gets loaded by - `.rc_params_from_file`. - - dict - A mapping of key/value pairs for `matplotlib.rcParams`. - - Path - The path to a style file, which gets loaded by - `.rc_params_from_file`. - - list - A list of style specifiers (str, Path or dict), which are applied - from first to last in the list. - - Notes - ----- - The following `.rcParams` are not related to style and will be ignored if - found in a style specification: - - %s - """ - if isinstance(style, (str, Path)) or hasattr(style, 'keys'): - # If name is a single str, Path or dict, make it a single element list. - styles = [style] - else: - styles = style - - style_alias = {'mpl20': 'default', 'mpl15': 'classic'} - - for style in styles: - if isinstance(style, str): - style = style_alias.get(style, style) - if style == "default": - # Deprecation warnings were already handled when creating - # rcParamsDefault, no need to reemit them here. - with _api.suppress_matplotlib_deprecation_warning(): - # don't trigger RcParams.__getitem__('backend') - style = {k: rcParamsDefault[k] for k in rcParamsDefault - if k not in STYLE_BLACKLIST} - elif style in library: - style = library[style] - elif "." in style: - pkg, _, name = style.rpartition(".") - try: - path = importlib.resources.files(pkg) / f"{name}.{STYLE_EXTENSION}" - style = _rc_params_in_file(path) - except (ModuleNotFoundError, OSError, TypeError) as exc: - # There is an ambiguity whether a dotted name refers to a - # package.style_name or to a dotted file path. Currently, - # we silently try the first form and then the second one; - # in the future, we may consider forcing file paths to - # either use Path objects or be prepended with "./" and use - # the slash as marker for file paths. - pass - if isinstance(style, (str, Path)): - try: - style = _rc_params_in_file(style) - except OSError as err: - raise OSError( - f"{style!r} is not a valid package style, path of style " - f"file, URL of style file, or library style name (library " - f"styles are listed in `style.available`)") from err - filtered = {} - for k in style: # don't trigger RcParams.__getitem__('backend') - if k in STYLE_BLACKLIST: - _api.warn_external( - f"Style includes a parameter, {k!r}, that is not " - f"related to style. Ignoring this parameter.") - else: - filtered[k] = style[k] - mpl.rcParams.update(filtered) - - -@contextlib.contextmanager -def context(style, after_reset=False): - """ - Context manager for using style settings temporarily. - - Parameters - ---------- - style : str, dict, Path or list - A style specification. Valid options are: - - str - - One of the style names in `.style.available` (a builtin style or - a style installed in the user library path). - - - A dotted name of the form "package.style_name"; in that case, - "package" should be an importable Python package name, e.g. at - ``/path/to/package/__init__.py``; the loaded style file is - ``/path/to/package/style_name.mplstyle``. (Style files in - subpackages are likewise supported.) - - - The path or URL to a style file, which gets loaded by - `.rc_params_from_file`. - dict - A mapping of key/value pairs for `matplotlib.rcParams`. - - Path - The path to a style file, which gets loaded by - `.rc_params_from_file`. - - list - A list of style specifiers (str, Path or dict), which are applied - from first to last in the list. - - after_reset : bool - If True, apply style after resetting settings to their defaults; - otherwise, apply style on top of the current settings. - """ - with mpl.rc_context(): - if after_reset: - mpl.rcdefaults() - use(style) - yield - - -def update_user_library(library): - """Update style library with user-defined rc files.""" - for stylelib_path in map(os.path.expanduser, USER_LIBRARY_PATHS): - styles = read_style_directory(stylelib_path) - update_nested_dict(library, styles) - return library - - -def read_style_directory(style_dir): - """Return dictionary of styles defined in *style_dir*.""" - styles = dict() - for path in Path(style_dir).glob(f"*.{STYLE_EXTENSION}"): - with warnings.catch_warnings(record=True) as warns: - styles[path.stem] = _rc_params_in_file(path) - for w in warns: - _log.warning('In %s: %s', path, w.message) - return styles - - -def update_nested_dict(main_dict, new_dict): - """ - Update nested dict (only level of nesting) with new values. - - Unlike `dict.update`, this assumes that the values of the parent dict are - dicts (or dict-like), so you shouldn't replace the nested dict if it - already exists. Instead you should update the sub-dict. - """ - # update named styles specified by user - for name, rc_dict in new_dict.items(): - main_dict.setdefault(name, {}).update(rc_dict) - return main_dict - - -# Load style library -# ================== -_base_library = read_style_directory(BASE_LIBRARY_PATH) -library = {} -available = [] - - -def reload_library(): - """Reload the style library.""" - library.clear() - library.update(update_user_library(_base_library)) - available[:] = sorted(library.keys()) +__all__ = [ + "use", "context", "available", "library", "reload_library", + "USER_LIBRARY_PATHS", "BASE_LIBRARY_PATH", "STYLE_EXTENSION", "STYLE_BLACKLIST", +] -reload_library() +_api.warn_deprecated("3.11", name=__name__, obj_type="module") diff --git a/lib/matplotlib/style/core.pyi b/lib/matplotlib/style/core.pyi index 5734b017f7c4..ee21d2f41ef5 100644 --- a/lib/matplotlib/style/core.pyi +++ b/lib/matplotlib/style/core.pyi @@ -5,7 +5,9 @@ from matplotlib import RcParams from matplotlib.typing import RcStyleType USER_LIBRARY_PATHS: list[str] = ... +BASE_LIBRARY_PATH: str = ... STYLE_EXTENSION: str = ... +STYLE_BLACKLIST: set[str] = ... def use(style: RcStyleType) -> None: ... @contextlib.contextmanager @@ -18,4 +20,7 @@ available: list[str] def reload_library() -> None: ... -__all__ = ['use', 'context', 'available', 'library', 'reload_library'] +__all__ = [ + "use", "context", "available", "library", "reload_library", + "USER_LIBRARY_PATHS", "BASE_LIBRARY_PATH", "STYLE_EXTENSION", "STYLE_BLACKLIST", +] diff --git a/lib/matplotlib/style/meson.build b/lib/matplotlib/style/meson.build index 03e7972132bb..e7a183c8581c 100644 --- a/lib/matplotlib/style/meson.build +++ b/lib/matplotlib/style/meson.build @@ -4,6 +4,7 @@ python_sources = [ ] typing_sources = [ + '__init__.pyi', 'core.pyi', ] diff --git a/lib/matplotlib/table.pyi b/lib/matplotlib/table.pyi index 07d2427f66dc..167d98d3c4cb 100644 --- a/lib/matplotlib/table.pyi +++ b/lib/matplotlib/table.pyi @@ -8,7 +8,7 @@ from .transforms import Bbox from .typing import ColorType from collections.abc import Sequence -from typing import Any, Literal, TYPE_CHECKING +from typing import Any, Literal from pandas import DataFrame diff --git a/lib/matplotlib/testing/compare.py b/lib/matplotlib/testing/compare.py index 67897e76edcb..fa5cd89481b5 100644 --- a/lib/matplotlib/testing/compare.py +++ b/lib/matplotlib/testing/compare.py @@ -19,7 +19,7 @@ from PIL import Image import matplotlib as mpl -from matplotlib import cbook +from matplotlib import cbook, _image from matplotlib.testing.exceptions import ImageComparisonFailure _log = logging.getLogger(__name__) @@ -412,7 +412,7 @@ def compare_images(expected, actual, tol, in_decorator=False): The two given filenames may point to files which are convertible to PNG via the `!converter` dictionary. The underlying RMS is calculated - with the `.calculate_rms` function. + in a similar way to the `.calculate_rms` function. Parameters ---------- @@ -483,17 +483,12 @@ def compare_images(expected, actual, tol, in_decorator=False): if np.array_equal(expected_image, actual_image): return None - # convert to signed integers, so that the images can be subtracted without - # overflow - expected_image = expected_image.astype(np.int16) - actual_image = actual_image.astype(np.int16) - - rms = calculate_rms(expected_image, actual_image) + rms, abs_diff = _image.calculate_rms_and_diff(expected_image, actual_image) if rms <= tol: return None - save_diff_image(expected, actual, diff_image) + Image.fromarray(abs_diff).save(diff_image, format="png") results = dict(rms=rms, expected=str(expected), actual=str(actual), diff=str(diff_image), tol=tol) diff --git a/lib/matplotlib/tests/baseline_images/test_artist/clip_path_clipping.pdf b/lib/matplotlib/tests/baseline_images/test_artist/clip_path_clipping.pdf index 054fe8d8264f..cac3b8f7751e 100644 Binary files a/lib/matplotlib/tests/baseline_images/test_artist/clip_path_clipping.pdf and b/lib/matplotlib/tests/baseline_images/test_artist/clip_path_clipping.pdf differ diff --git a/lib/matplotlib/tests/baseline_images/test_artist/clip_path_clipping.png b/lib/matplotlib/tests/baseline_images/test_artist/clip_path_clipping.png index cf2ebc38391d..1846832dc3f3 100644 Binary files a/lib/matplotlib/tests/baseline_images/test_artist/clip_path_clipping.png and b/lib/matplotlib/tests/baseline_images/test_artist/clip_path_clipping.png differ diff --git a/lib/matplotlib/tests/baseline_images/test_artist/clip_path_clipping.svg b/lib/matplotlib/tests/baseline_images/test_artist/clip_path_clipping.svg index e6743bd2a79b..eb2fc6501453 100644 --- a/lib/matplotlib/tests/baseline_images/test_artist/clip_path_clipping.svg +++ b/lib/matplotlib/tests/baseline_images/test_artist/clip_path_clipping.svg @@ -1,12 +1,23 @@ - - + + + + + + 2025-05-18T15:59:59.749730 + image/svg+xml + + + Matplotlib v3.11.0.dev842+g991ee94077, https://matplotlib.org/ + + + + + - + @@ -15,7 +26,7 @@ L 576 432 L 576 0 L 0 0 z -" style="fill:#ffffff;"/> +" style="fill: #ffffff"/> @@ -24,10 +35,10 @@ L 274.909091 388.8 L 274.909091 43.2 L 72 43.2 z -" style="fill:#ffffff;"/> +" style="fill: #ffffff"/> - +" clip-path="url(#p4234805953)" style="fill: url(#h8da01be9d9); fill-opacity: 0.7; stroke: #0000ff; stroke-opacity: 0.7; stroke-width: 5"/> +" style="fill: none; stroke: #000000; stroke-linejoin: miter; stroke-linecap: square"/> +" style="fill: none; stroke: #000000; stroke-linejoin: miter; stroke-linecap: square"/> +" style="fill: none; stroke: #000000; stroke-linejoin: miter; stroke-linecap: square"/> +" style="fill: none; stroke: #000000; stroke-linejoin: miter; stroke-linecap: square"/> - +" style="stroke: #000000; stroke-width: 0.5"/> - + - +" style="stroke: #000000; stroke-width: 0.5"/> - + - + - + - + - + - + - + - + - + - + - + - + - + @@ -162,94 +173,94 @@ L 0 4 - +" style="stroke: #000000; stroke-width: 0.5"/> - + - +" style="stroke: #000000; stroke-width: 0.5"/> - + - + - + - + - + - + - + - + - + - + - + - + - + @@ -262,10 +273,10 @@ L 518.4 388.8 L 518.4 43.2 L 315.490909 43.2 z -" style="fill:#ffffff;"/> +" style="fill: #ffffff"/> - +" clip-path="url(#p1824667f16)" style="fill: url(#h8da01be9d9); opacity: 0.7; stroke: #0000ff; stroke-width: 5; stroke-linejoin: miter"/> +" style="fill: none; stroke: #000000; stroke-linejoin: miter; stroke-linecap: square"/> +" style="fill: none; stroke: #000000; stroke-linejoin: miter; stroke-linecap: square"/> +" style="fill: none; stroke: #000000; stroke-linejoin: miter; stroke-linecap: square"/> +" style="fill: none; stroke: #000000; stroke-linejoin: miter; stroke-linecap: square"/> - + - + - + - + - + - + - + - + - + - + - + - + - + - + @@ -390,84 +401,84 @@ L 518.4 43.2 - + - + - + - + - + - + - + - + - + - + - + - + - + - + @@ -475,7 +486,7 @@ L 518.4 43.2 - + - + - + + +z +" style="fill: #0000ff; stroke: #0000ff; stroke-width: 1.0; stroke-linecap: butt; stroke-linejoin: miter; stroke-opacity: 0.7"/> diff --git a/lib/matplotlib/tests/baseline_images/test_artist/hatching.pdf b/lib/matplotlib/tests/baseline_images/test_artist/hatching.pdf index c812f811812a..df8dcbeed8e6 100644 Binary files a/lib/matplotlib/tests/baseline_images/test_artist/hatching.pdf and b/lib/matplotlib/tests/baseline_images/test_artist/hatching.pdf differ diff --git a/lib/matplotlib/tests/baseline_images/test_axes/contour_hatching.pdf b/lib/matplotlib/tests/baseline_images/test_axes/contour_hatching.pdf index ac6f579cdafc..6ad6ca0de11f 100644 Binary files a/lib/matplotlib/tests/baseline_images/test_axes/contour_hatching.pdf and b/lib/matplotlib/tests/baseline_images/test_axes/contour_hatching.pdf differ diff --git a/lib/matplotlib/tests/baseline_images/test_axes/grouped_bar.png b/lib/matplotlib/tests/baseline_images/test_axes/grouped_bar.png new file mode 100644 index 000000000000..19d676a6b662 Binary files /dev/null and b/lib/matplotlib/tests/baseline_images/test_axes/grouped_bar.png differ diff --git a/lib/matplotlib/tests/baseline_images/test_backend_pdf/font-bitstream-charter.pdf b/lib/matplotlib/tests/baseline_images/test_backend_pdf/font-bitstream-charter.pdf new file mode 100644 index 000000000000..c8f9411fb3d9 Binary files /dev/null and b/lib/matplotlib/tests/baseline_images/test_backend_pdf/font-bitstream-charter.pdf differ diff --git a/lib/matplotlib/tests/baseline_images/test_backend_pdf/font-dejavusans.pdf b/lib/matplotlib/tests/baseline_images/test_backend_pdf/font-dejavusans.pdf new file mode 100644 index 000000000000..fd907dee6687 Binary files /dev/null and b/lib/matplotlib/tests/baseline_images/test_backend_pdf/font-dejavusans.pdf differ diff --git a/lib/matplotlib/tests/baseline_images/test_backend_pdf/font-heuristica.pdf b/lib/matplotlib/tests/baseline_images/test_backend_pdf/font-heuristica.pdf new file mode 100644 index 000000000000..ca9b38d09b89 Binary files /dev/null and b/lib/matplotlib/tests/baseline_images/test_backend_pdf/font-heuristica.pdf differ diff --git a/lib/matplotlib/tests/baseline_images/test_backend_pdf/hatching_legend.pdf b/lib/matplotlib/tests/baseline_images/test_backend_pdf/hatching_legend.pdf index 18ba1d830a5b..57fc311ee81b 100644 Binary files a/lib/matplotlib/tests/baseline_images/test_backend_pdf/hatching_legend.pdf and b/lib/matplotlib/tests/baseline_images/test_backend_pdf/hatching_legend.pdf differ diff --git a/lib/matplotlib/tests/baseline_images/test_colors/test_norm_abc.png b/lib/matplotlib/tests/baseline_images/test_colors/test_norm_abc.png new file mode 100644 index 000000000000..077365674ac2 Binary files /dev/null and b/lib/matplotlib/tests/baseline_images/test_colors/test_norm_abc.png differ diff --git a/lib/matplotlib/tests/baseline_images/test_image/imshow_masked_interpolation.pdf b/lib/matplotlib/tests/baseline_images/test_image/imshow_masked_interpolation.pdf index 1d14a9d2f60c..0342a2baa4b2 100644 Binary files a/lib/matplotlib/tests/baseline_images/test_image/imshow_masked_interpolation.pdf and b/lib/matplotlib/tests/baseline_images/test_image/imshow_masked_interpolation.pdf differ diff --git a/lib/matplotlib/tests/baseline_images/test_image/log_scale_image.pdf b/lib/matplotlib/tests/baseline_images/test_image/log_scale_image.pdf index b338fce6ee5a..c26419850251 100644 Binary files a/lib/matplotlib/tests/baseline_images/test_image/log_scale_image.pdf and b/lib/matplotlib/tests/baseline_images/test_image/log_scale_image.pdf differ diff --git a/lib/matplotlib/tests/baseline_images/test_image/log_scale_image.png b/lib/matplotlib/tests/baseline_images/test_image/log_scale_image.png index 9d93c8fb00bf..1df80c1b2045 100644 Binary files a/lib/matplotlib/tests/baseline_images/test_image/log_scale_image.png and b/lib/matplotlib/tests/baseline_images/test_image/log_scale_image.png differ diff --git a/lib/matplotlib/tests/baseline_images/test_image/log_scale_image.svg b/lib/matplotlib/tests/baseline_images/test_image/log_scale_image.svg index 6c958cc79592..259eb2c9c7f3 100644 --- a/lib/matplotlib/tests/baseline_images/test_image/log_scale_image.svg +++ b/lib/matplotlib/tests/baseline_images/test_image/log_scale_image.svg @@ -1,12 +1,23 @@ - - + + + + + + 2025-05-14T18:02:41.587512 + image/svg+xml + + + Matplotlib v3.11.0.dev832+gc5ea66e278, https://matplotlib.org/ + + + + + - + @@ -15,7 +26,7 @@ L 576 432 L 576 0 L 0 0 z -" style="fill:#ffffff;"/> +" style="fill: #ffffff"/> @@ -24,100 +35,100 @@ L 518.4 388.8 L 518.4 43.2 L 72 43.2 z -" style="fill:#ffffff;"/> +" style="fill: #ffffff"/> - - + + +" style="fill: none; stroke: #000000; stroke-linejoin: miter; stroke-linecap: square"/> +" style="fill: none; stroke: #000000; stroke-linejoin: miter; stroke-linecap: square"/> +" style="fill: none; stroke: #000000; stroke-linejoin: miter; stroke-linecap: square"/> +" style="fill: none; stroke: #000000; stroke-linejoin: miter; stroke-linecap: square"/> - +" style="stroke: #000000; stroke-width: 0.5"/> - + - +" style="stroke: #000000; stroke-width: 0.5"/> - + - + - + - + - + - + - + - + - + @@ -126,248 +137,248 @@ L 0 4 - +" style="stroke: #000000; stroke-width: 0.5"/> - + - +" style="stroke: #000000; stroke-width: 0.5"/> - + - + - + - + - + - +" style="stroke: #000000; stroke-width: 0.5"/> - + - +" style="stroke: #000000; stroke-width: 0.5"/> - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + @@ -375,8 +386,8 @@ L -2 0 - - + + diff --git a/lib/matplotlib/tests/baseline_images/test_legend/hatching.pdf b/lib/matplotlib/tests/baseline_images/test_legend/hatching.pdf index e345dbff7ce5..5d752d52634d 100644 Binary files a/lib/matplotlib/tests/baseline_images/test_legend/hatching.pdf and b/lib/matplotlib/tests/baseline_images/test_legend/hatching.pdf differ diff --git a/lib/matplotlib/tests/baseline_images/test_patches/multi_color_hatch.pdf b/lib/matplotlib/tests/baseline_images/test_patches/multi_color_hatch.pdf index e956cbdf248d..a9db7c30998e 100644 Binary files a/lib/matplotlib/tests/baseline_images/test_patches/multi_color_hatch.pdf and b/lib/matplotlib/tests/baseline_images/test_patches/multi_color_hatch.pdf differ diff --git a/lib/matplotlib/tests/baseline_images/test_patheffects/patheffect3.pdf b/lib/matplotlib/tests/baseline_images/test_patheffects/patheffect3.pdf index ad98ff7223cc..402ec545847d 100644 Binary files a/lib/matplotlib/tests/baseline_images/test_patheffects/patheffect3.pdf and b/lib/matplotlib/tests/baseline_images/test_patheffects/patheffect3.pdf differ diff --git a/lib/matplotlib/tests/Courier10PitchBT-Bold.pfb b/lib/matplotlib/tests/data/Courier10PitchBT-Bold.pfb similarity index 100% rename from lib/matplotlib/tests/Courier10PitchBT-Bold.pfb rename to lib/matplotlib/tests/data/Courier10PitchBT-Bold.pfb diff --git a/lib/matplotlib/tests/cmr10.pfb b/lib/matplotlib/tests/data/cmr10.pfb similarity index 100% rename from lib/matplotlib/tests/cmr10.pfb rename to lib/matplotlib/tests/data/cmr10.pfb diff --git a/lib/matplotlib/tests/mpltest.ttf b/lib/matplotlib/tests/data/mpltest.ttf similarity index 100% rename from lib/matplotlib/tests/mpltest.ttf rename to lib/matplotlib/tests/data/mpltest.ttf diff --git a/lib/matplotlib/tests/test_inline_01.ipynb b/lib/matplotlib/tests/data/test_inline_01.ipynb similarity index 100% rename from lib/matplotlib/tests/test_inline_01.ipynb rename to lib/matplotlib/tests/data/test_inline_01.ipynb diff --git a/lib/matplotlib/tests/test_nbagg_01.ipynb b/lib/matplotlib/tests/data/test_nbagg_01.ipynb similarity index 100% rename from lib/matplotlib/tests/test_nbagg_01.ipynb rename to lib/matplotlib/tests/data/test_nbagg_01.ipynb diff --git a/lib/matplotlib/tests/data/tinypages/.gitignore b/lib/matplotlib/tests/data/tinypages/.gitignore new file mode 100644 index 000000000000..739e1d9ce65d --- /dev/null +++ b/lib/matplotlib/tests/data/tinypages/.gitignore @@ -0,0 +1,3 @@ +_build/ +doctrees/ +plot_directive/ diff --git a/lib/matplotlib/tests/tinypages/README.md b/lib/matplotlib/tests/data/tinypages/README.md similarity index 100% rename from lib/matplotlib/tests/tinypages/README.md rename to lib/matplotlib/tests/data/tinypages/README.md diff --git a/lib/matplotlib/tests/tinypages/_static/.gitignore b/lib/matplotlib/tests/data/tinypages/_static/.gitignore similarity index 100% rename from lib/matplotlib/tests/tinypages/_static/.gitignore rename to lib/matplotlib/tests/data/tinypages/_static/.gitignore diff --git a/lib/matplotlib/tests/tinypages/_static/README.txt b/lib/matplotlib/tests/data/tinypages/_static/README.txt similarity index 100% rename from lib/matplotlib/tests/tinypages/_static/README.txt rename to lib/matplotlib/tests/data/tinypages/_static/README.txt diff --git a/lib/matplotlib/tests/tinypages/conf.py b/lib/matplotlib/tests/data/tinypages/conf.py similarity index 100% rename from lib/matplotlib/tests/tinypages/conf.py rename to lib/matplotlib/tests/data/tinypages/conf.py diff --git a/lib/matplotlib/tests/tinypages/included_plot_21.rst b/lib/matplotlib/tests/data/tinypages/included_plot_21.rst similarity index 100% rename from lib/matplotlib/tests/tinypages/included_plot_21.rst rename to lib/matplotlib/tests/data/tinypages/included_plot_21.rst diff --git a/lib/matplotlib/tests/tinypages/index.rst b/lib/matplotlib/tests/data/tinypages/index.rst similarity index 100% rename from lib/matplotlib/tests/tinypages/index.rst rename to lib/matplotlib/tests/data/tinypages/index.rst diff --git a/lib/matplotlib/tests/tinypages/nestedpage/index.rst b/lib/matplotlib/tests/data/tinypages/nestedpage/index.rst similarity index 100% rename from lib/matplotlib/tests/tinypages/nestedpage/index.rst rename to lib/matplotlib/tests/data/tinypages/nestedpage/index.rst diff --git a/lib/matplotlib/tests/tinypages/nestedpage2/index.rst b/lib/matplotlib/tests/data/tinypages/nestedpage2/index.rst similarity index 100% rename from lib/matplotlib/tests/tinypages/nestedpage2/index.rst rename to lib/matplotlib/tests/data/tinypages/nestedpage2/index.rst diff --git a/lib/matplotlib/tests/tinypages/range4.py b/lib/matplotlib/tests/data/tinypages/range4.py similarity index 100% rename from lib/matplotlib/tests/tinypages/range4.py rename to lib/matplotlib/tests/data/tinypages/range4.py diff --git a/lib/matplotlib/tests/tinypages/range6.py b/lib/matplotlib/tests/data/tinypages/range6.py similarity index 100% rename from lib/matplotlib/tests/tinypages/range6.py rename to lib/matplotlib/tests/data/tinypages/range6.py diff --git a/lib/matplotlib/tests/tinypages/some_plots.rst b/lib/matplotlib/tests/data/tinypages/some_plots.rst similarity index 91% rename from lib/matplotlib/tests/tinypages/some_plots.rst rename to lib/matplotlib/tests/data/tinypages/some_plots.rst index cb56c5b3b8d5..17de8f1d742e 100644 --- a/lib/matplotlib/tests/tinypages/some_plots.rst +++ b/lib/matplotlib/tests/data/tinypages/some_plots.rst @@ -179,3 +179,22 @@ Plot 21 is generated via an include directive: Plot 22 uses a different specific function in a file with plot commands: .. plot:: range6.py range10 + +Plots 23--25 use filename-prefix. + +.. plot:: + :filename-prefix: custom-basename-6 + + plt.plot(range(6)) + +.. plot:: range4.py + :filename-prefix: custom-basename-4 + +.. plot:: + :filename-prefix: custom-basename-4-6 + + plt.figure() + plt.plot(range(4)) + + plt.figure() + plt.plot(range(6)) diff --git a/lib/matplotlib/tests/meson.build b/lib/matplotlib/tests/meson.build index 05336496969f..48b97a1d4b3d 100644 --- a/lib/matplotlib/tests/meson.build +++ b/lib/matplotlib/tests/meson.build @@ -99,11 +99,6 @@ py3.install_sources(python_sources, install_data( 'README', - 'Courier10PitchBT-Bold.pfb', - 'cmr10.pfb', - 'mpltest.ttf', - 'test_nbagg_01.ipynb', - 'test_inline_01.ipynb', install_tag: 'tests', install_dir: py3.get_install_dir(subdir: 'matplotlib/tests/')) @@ -112,6 +107,6 @@ install_subdir( install_tag: 'tests', install_dir: py3.get_install_dir(subdir: 'matplotlib/tests')) install_subdir( - 'tinypages', + 'data', install_tag: 'tests', - install_dir: py3.get_install_dir(subdir: 'matplotlib/tests')) + install_dir: py3.get_install_dir(subdir: 'matplotlib/tests/')) diff --git a/lib/matplotlib/tests/test_afm.py b/lib/matplotlib/tests/test_afm.py index e5c6a83937cd..80cf8ac60feb 100644 --- a/lib/matplotlib/tests/test_afm.py +++ b/lib/matplotlib/tests/test_afm.py @@ -135,3 +135,11 @@ def test_malformed_header(afm_data, caplog): _afm._parse_header(fh) assert len(caplog.records) == 1 + + +def test_afm_kerning(): + fn = fm.findfont("Helvetica", fontext="afm") + with open(fn, 'rb') as fh: + afm = _afm.AFM(fh) + assert afm.get_kern_dist_from_name('A', 'V') == -70.0 + assert afm.get_kern_dist_from_name('V', 'A') == -80.0 diff --git a/lib/matplotlib/tests/test_arrow_patches.py b/lib/matplotlib/tests/test_arrow_patches.py index c2b6d4fa8086..e26c806c9ea4 100644 --- a/lib/matplotlib/tests/test_arrow_patches.py +++ b/lib/matplotlib/tests/test_arrow_patches.py @@ -59,8 +59,8 @@ def __prepare_fancyarrow_dpi_cor_test(): """ fig2 = plt.figure("fancyarrow_dpi_cor_test", figsize=(4, 3), dpi=50) ax = fig2.add_subplot() - ax.set_xlim([0, 1]) - ax.set_ylim([0, 1]) + ax.set_xlim(0, 1) + ax.set_ylim(0, 1) ax.add_patch(mpatches.FancyArrowPatch(posA=(0.3, 0.4), posB=(0.8, 0.6), lw=3, arrowstyle='->', mutation_scale=100)) diff --git a/lib/matplotlib/tests/test_artist.py b/lib/matplotlib/tests/test_artist.py index 5c8141e40741..1367701ffe3e 100644 --- a/lib/matplotlib/tests/test_artist.py +++ b/lib/matplotlib/tests/test_artist.py @@ -120,15 +120,15 @@ def test_clipping(): patch.set_clip_path(clip_path, ax2.transData) ax2.add_patch(patch) - ax1.set_xlim([-3, 3]) - ax1.set_ylim([-3, 3]) + ax1.set_xlim(-3, 3) + ax1.set_ylim(-3, 3) @check_figures_equal() def test_clipping_zoom(fig_test, fig_ref): # This test places the Axes and sets its limits such that the clip path is # outside the figure entirely. This should not break the clip path. - ax_test = fig_test.add_axes([0, 0, 1, 1]) + ax_test = fig_test.add_axes((0, 0, 1, 1)) l, = ax_test.plot([-3, 3], [-3, 3]) # Explicit Path instead of a Rectangle uses clip path processing, instead # of a clip box optimization. @@ -136,7 +136,7 @@ def test_clipping_zoom(fig_test, fig_ref): p = mpatches.PathPatch(p, transform=ax_test.transData) l.set_clip_path(p) - ax_ref = fig_ref.add_axes([0, 0, 1, 1]) + ax_ref = fig_ref.add_axes((0, 0, 1, 1)) ax_ref.plot([-3, 3], [-3, 3]) ax_ref.set(xlim=(0.5, 0.75), ylim=(0.5, 0.75)) @@ -226,8 +226,8 @@ def test_default_edges(): np.arange(10) + 1, np.arange(10), 'o') ax2.bar(np.arange(10), np.arange(10), align='edge') ax3.text(0, 0, "BOX", size=24, bbox=dict(boxstyle='sawtooth')) - ax3.set_xlim((-1, 1)) - ax3.set_ylim((-1, 1)) + ax3.set_xlim(-1, 1) + ax3.set_ylim(-1, 1) pp1 = mpatches.PathPatch( mpath.Path([(0, 0), (1, 0), (1, 1), (0, 0)], [mpath.Path.MOVETO, mpath.Path.CURVE3, diff --git a/lib/matplotlib/tests/test_axes.py b/lib/matplotlib/tests/test_axes.py index 5d11964cb613..db0629de99b5 100644 --- a/lib/matplotlib/tests/test_axes.py +++ b/lib/matplotlib/tests/test_axes.py @@ -23,6 +23,7 @@ from matplotlib import rc_context, patheffects import matplotlib.colors as mcolors import matplotlib.dates as mdates +from matplotlib.container import BarContainer from matplotlib.figure import Figure from matplotlib.axes import Axes from matplotlib.lines import Line2D @@ -2166,6 +2167,105 @@ def test_bar_datetime_start(): assert isinstance(ax.xaxis.get_major_formatter(), mdates.AutoDateFormatter) +@image_comparison(["grouped_bar.png"], style="mpl20") +def test_grouped_bar(): + data = { + 'data1': [1, 2, 3], + 'data2': [1.2, 2.2, 3.2], + 'data3': [1.4, 2.4, 3.4], + } + + fig, ax = plt.subplots() + ax.grouped_bar(data, tick_labels=['A', 'B', 'C'], + group_spacing=0.5, bar_spacing=0.1, + colors=['#1f77b4', '#58a1cf', '#abd0e6']) + ax.set_yticks([]) + + +@check_figures_equal() +def test_grouped_bar_list_of_datasets(fig_test, fig_ref): + categories = ['A', 'B'] + data1 = [1, 1.2] + data2 = [2, 2.4] + data3 = [3, 3.6] + + ax = fig_test.subplots() + ax.grouped_bar([data1, data2, data3], tick_labels=categories, + labels=["data1", "data2", "data3"]) + ax.legend() + + ax = fig_ref.subplots() + label_pos = np.array([0, 1]) + bar_width = 1 / (3 + 1.5) # 3 bars + 1.5 group_spacing + data_shift = -1 * bar_width + np.array([0, bar_width, 2 * bar_width]) + ax.bar(label_pos + data_shift[0], data1, width=bar_width, label="data1") + ax.bar(label_pos + data_shift[1], data2, width=bar_width, label="data2") + ax.bar(label_pos + data_shift[2], data3, width=bar_width, label="data3") + ax.set_xticks(label_pos, categories) + ax.legend() + + +@check_figures_equal() +def test_grouped_bar_dict_of_datasets(fig_test, fig_ref): + categories = ['A', 'B'] + data_dict = dict(data1=[1, 1.2], data2=[2, 2.4], data3=[3, 3.6]) + + ax = fig_test.subplots() + ax.grouped_bar(data_dict, tick_labels=categories) + ax.legend() + + ax = fig_ref.subplots() + ax.grouped_bar(data_dict.values(), tick_labels=categories, labels=data_dict.keys()) + ax.legend() + + +@check_figures_equal() +def test_grouped_bar_array(fig_test, fig_ref): + categories = ['A', 'B'] + array = np.array([[1, 2, 3], [1.2, 2.4, 3.6]]) + labels = ['data1', 'data2', 'data3'] + + ax = fig_test.subplots() + ax.grouped_bar(array, tick_labels=categories, labels=labels) + ax.legend() + + ax = fig_ref.subplots() + list_of_datasets = [column for column in array.T] + ax.grouped_bar(list_of_datasets, tick_labels=categories, labels=labels) + ax.legend() + + +@check_figures_equal() +def test_grouped_bar_dataframe(fig_test, fig_ref, pd): + categories = ['A', 'B'] + labels = ['data1', 'data2', 'data3'] + df = pd.DataFrame([[1, 2, 3], [1.2, 2.4, 3.6]], + index=categories, columns=labels) + + ax = fig_test.subplots() + ax.grouped_bar(df) + ax.legend() + + ax = fig_ref.subplots() + list_of_datasets = [df[col].to_numpy() for col in df.columns] + ax.grouped_bar(list_of_datasets, tick_labels=categories, labels=labels) + ax.legend() + + +def test_grouped_bar_return_value(): + fig, ax = plt.subplots() + ret = ax.grouped_bar([[1, 2, 3], [11, 12, 13]], tick_labels=['A', 'B', 'C']) + + assert len(ret.bar_containers) == 2 + for bc in ret.bar_containers: + assert isinstance(bc, BarContainer) + assert bc in ax.containers + + ret.remove() + for bc in ret.bar_containers: + assert bc not in ax.containers + + def test_boxplot_dates_pandas(pd): # smoke test for boxplot and dates in pandas data = np.random.rand(5, 2) @@ -3262,16 +3362,16 @@ def test_stackplot(): y3 = 3.0 * x + 2 ax = fig.add_subplot(1, 1, 1) ax.stackplot(x, y1, y2, y3) - ax.set_xlim((0, 10)) - ax.set_ylim((0, 70)) + ax.set_xlim(0, 10) + ax.set_ylim(0, 70) # Reuse testcase from above for a test with labeled data and with colours # from the Axes property cycle. data = {"x": x, "y1": y1, "y2": y2, "y3": y3} fig, ax = plt.subplots() ax.stackplot("x", "y1", "y2", "y3", data=data, colors=["C0", "C1", "C2"]) - ax.set_xlim((0, 10)) - ax.set_ylim((0, 70)) + ax.set_xlim(0, 10) + ax.set_ylim(0, 70) @image_comparison(['stackplot_test_baseline.png'], remove_text=True) @@ -3308,16 +3408,16 @@ def test_stackplot_hatching(fig_ref, fig_test): # stackplot with different hatching styles (issue #27146) ax_test = fig_test.subplots() ax_test.stackplot(x, y1, y2, y3, hatch=["x", "//", "\\\\"], colors=["white"]) - ax_test.set_xlim((0, 10)) - ax_test.set_ylim((0, 70)) + ax_test.set_xlim(0, 10) + ax_test.set_ylim(0, 70) # compare with result from hatching each layer individually stack_baseline = np.zeros(len(x)) ax_ref = fig_ref.subplots() ax_ref.fill_between(x, stack_baseline, y1, hatch="x", facecolor="white") ax_ref.fill_between(x, y1, y1+y2, hatch="//", facecolor="white") ax_ref.fill_between(x, y1+y2, y1+y2+y3, hatch="\\\\", facecolor="white") - ax_ref.set_xlim((0, 10)) - ax_ref.set_ylim((0, 70)) + ax_ref.set_xlim(0, 10) + ax_ref.set_ylim(0, 70) def _bxp_test_helper( @@ -3594,13 +3694,13 @@ def test_boxplot(): fig, ax = plt.subplots() ax.boxplot([x, x], bootstrap=10000, notch=1) - ax.set_ylim((-30, 30)) + ax.set_ylim(-30, 30) # Reuse testcase from above for a labeled data test data = {"x": [x, x]} fig, ax = plt.subplots() ax.boxplot("x", bootstrap=10000, notch=1, data=data) - ax.set_ylim((-30, 30)) + ax.set_ylim(-30, 30) @check_figures_equal() @@ -3638,10 +3738,10 @@ def test_boxplot_sym2(): fig, [ax1, ax2] = plt.subplots(1, 2) ax1.boxplot([x, x], bootstrap=10000, sym='^') - ax1.set_ylim((-30, 30)) + ax1.set_ylim(-30, 30) ax2.boxplot([x, x], bootstrap=10000, sym='g') - ax2.set_ylim((-30, 30)) + ax2.set_ylim(-30, 30) @image_comparison(['boxplot_sym.png'], @@ -3654,7 +3754,7 @@ def test_boxplot_sym(): fig, ax = plt.subplots() ax.boxplot([x, x], sym='gs') - ax.set_ylim((-30, 30)) + ax.set_ylim(-30, 30) @image_comparison(['boxplot_autorange_false_whiskers.png', @@ -3669,11 +3769,11 @@ def test_boxplot_autorange_whiskers(): fig1, ax1 = plt.subplots() ax1.boxplot([x, x], bootstrap=10000, notch=1) - ax1.set_ylim((-5, 5)) + ax1.set_ylim(-5, 5) fig2, ax2 = plt.subplots() ax2.boxplot([x, x], bootstrap=10000, notch=1, autorange=True) - ax2.set_ylim((-5, 5)) + ax2.set_ylim(-5, 5) def _rc_test_bxp_helper(ax, rc_dict): @@ -3763,7 +3863,7 @@ def test_boxplot_with_CIarray(): # another with manual values ax.boxplot([x, x], bootstrap=10000, usermedians=[None, 1.0], conf_intervals=CIs, notch=1) - ax.set_ylim((-30, 30)) + ax.set_ylim(-30, 30) @image_comparison(['boxplot_no_inverted_whisker.png'], @@ -4352,7 +4452,7 @@ def test_errorbar_limits(): xlolims=xlolims, xuplims=xuplims, uplims=uplims, lolims=lolims, ls='none', mec='blue', capsize=0, color='cyan') - ax.set_xlim((0, 5.5)) + ax.set_xlim(0, 5.5) ax.set_title('Errorbar upper and lower limits') @@ -5288,8 +5388,8 @@ def test_vertex_markers(): fig, ax = plt.subplots() ax.plot(data, linestyle='', marker=marker_as_tuple, mfc='k') ax.plot(data[::-1], linestyle='', marker=marker_as_list, mfc='b') - ax.set_xlim([-1, 10]) - ax.set_ylim([-1, 10]) + ax.set_xlim(-1, 10) + ax.set_ylim(-1, 10) @image_comparison(['vline_hline_zorder.png', 'errorbar_zorder.png'], @@ -5558,8 +5658,8 @@ def test_step_linestyle(): ax.step(x, y, lw=5, linestyle=ls, where='pre') ax.step(x, y + 1, lw=5, linestyle=ls, where='mid') ax.step(x, y + 2, lw=5, linestyle=ls, where='post') - ax.set_xlim([-1, 5]) - ax.set_ylim([-1, 7]) + ax.set_xlim(-1, 5) + ax.set_ylim(-1, 7) # Reuse testcase from above for a labeled data test data = {"X": x, "Y0": y, "Y1": y+1, "Y2": y+2} @@ -5570,8 +5670,8 @@ def test_step_linestyle(): ax.step("X", "Y0", lw=5, linestyle=ls, where='pre', data=data) ax.step("X", "Y1", lw=5, linestyle=ls, where='mid', data=data) ax.step("X", "Y2", lw=5, linestyle=ls, where='post', data=data) - ax.set_xlim([-1, 5]) - ax.set_ylim([-1, 7]) + ax.set_xlim(-1, 5) + ax.set_ylim(-1, 7) @image_comparison(['mixed_collection'], remove_text=True) @@ -7132,7 +7232,7 @@ def shared_axes_generator(request): ax = ax_lst[0][0] elif request.param == 'add_axes': fig = plt.figure() - ax = fig.add_axes([.1, .1, .8, .8]) + ax = fig.add_axes((.1, .1, .8, .8)) return fig, ax @@ -7466,7 +7566,7 @@ def test_title_no_move_off_page(): # make sure that the automatic title repositioning does not get done. mpl.rcParams['axes.titley'] = None fig = plt.figure() - ax = fig.add_axes([0.1, -0.5, 0.8, 0.2]) + ax = fig.add_axes((0.1, -0.5, 0.8, 0.2)) ax.tick_params(axis="x", bottom=True, top=True, labelbottom=True, labeltop=True) tt = ax.set_title('Boo') @@ -7808,8 +7908,8 @@ def test_zoom_inset(): axin1 = ax.inset_axes([0.7, 0.7, 0.35, 0.35]) # redraw the data in the inset axes... axin1.pcolormesh(x, y, z[:-1, :-1]) - axin1.set_xlim([1.5, 2.15]) - axin1.set_ylim([2, 2.5]) + axin1.set_xlim(1.5, 2.15) + axin1.set_ylim(2, 2.5) axin1.set_aspect(ax.get_aspect()) with pytest.warns(mpl.MatplotlibDeprecationWarning): @@ -8405,7 +8505,7 @@ def test_aspect_nonlinear_adjustable_box(): def test_aspect_nonlinear_adjustable_datalim(): fig = plt.figure(figsize=(10, 10)) # Square. - ax = fig.add_axes([.1, .1, .8, .8]) # Square. + ax = fig.add_axes((.1, .1, .8, .8)) # Square. ax.plot([.4, .6], [.4, .6]) # Set minpos to keep logit happy. ax.set(xscale="log", xlim=(1, 100), yscale="logit", ylim=(1 / 101, 1 / 11), @@ -8629,7 +8729,7 @@ def test_multiplot_autoscale(): def test_sharing_does_not_link_positions(): fig = plt.figure() ax0 = fig.add_subplot(221) - ax1 = fig.add_axes([.6, .6, .3, .3], sharex=ax0) + ax1 = fig.add_axes((.6, .6, .3, .3), sharex=ax0) init_pos = ax1.get_position() fig.subplots_adjust(left=0) assert (ax1.get_position().get_points() == init_pos.get_points()).all() @@ -9728,7 +9828,7 @@ def test_axes_set_position_external_bbox_unchanged(fig_test, fig_ref): ax_test = fig_test.add_axes(bbox) ax_test.set_position([0.25, 0.25, 0.5, 0.5]) assert (bbox.x0, bbox.y0, bbox.width, bbox.height) == (0.0, 0.0, 1.0, 1.0) - ax_ref = fig_ref.add_axes([0.25, 0.25, 0.5, 0.5]) + ax_ref = fig_ref.add_axes((0.25, 0.25, 0.5, 0.5)) def test_bar_shape_mismatch(): @@ -9741,6 +9841,34 @@ def test_bar_shape_mismatch(): plt.bar(x, height) +def test_caps_color(): + + # Creates a simple plot with error bars and a specified ecolor + x = np.linspace(0, 10, 10) + mpl.rcParams['lines.markeredgecolor'] = 'green' + ecolor = 'red' + + fig, ax = plt.subplots() + errorbars = ax.errorbar(x, np.sin(x), yerr=0.1, ecolor=ecolor) + + # Tests if the caps have the specified color + for cap in errorbars[2]: + assert mcolors.same_color(cap.get_edgecolor(), ecolor) + + +def test_caps_no_ecolor(): + + # Creates a simple plot with error bars without specifying ecolor + x = np.linspace(0, 10, 10) + mpl.rcParams['lines.markeredgecolor'] = 'green' + fig, ax = plt.subplots() + errorbars = ax.errorbar(x, np.sin(x), yerr=0.1) + + # Tests if the caps have the default color (blue) + for cap in errorbars[2]: + assert mcolors.same_color(cap.get_edgecolor(), "blue") + + def test_pie_non_finite_values(): fig, ax = plt.subplots() df = [5, float('nan'), float('inf')] diff --git a/lib/matplotlib/tests/test_axis.py b/lib/matplotlib/tests/test_axis.py index e33656ea9c17..97884a33208f 100644 --- a/lib/matplotlib/tests/test_axis.py +++ b/lib/matplotlib/tests/test_axis.py @@ -15,9 +15,9 @@ def test_axis_not_in_layout(): fig2, (ax2_left, ax2_right) = plt.subplots(ncols=2, layout='constrained') # 100 label overlapping the end of the axis - ax1_left.set_xlim([0, 100]) + ax1_left.set_xlim(0, 100) # 100 label not overlapping the end of the axis - ax2_left.set_xlim([0, 120]) + ax2_left.set_xlim(0, 120) for ax in ax1_left, ax2_left: ax.set_xticks([0, 100]) @@ -67,3 +67,31 @@ def test_get_tick_position_tick_params(): right=True, labelright=True, left=False, labelleft=False) assert ax.xaxis.get_ticks_position() == "top" assert ax.yaxis.get_ticks_position() == "right" + + +def test_grid_rcparams(): + """Tests that `grid.major/minor.*` overwrites `grid.*` in rcParams.""" + plt.rcParams.update({ + "axes.grid": True, "axes.grid.which": "both", + "ytick.minor.visible": True, "xtick.minor.visible": True, + }) + def_linewidth = plt.rcParams["grid.linewidth"] + def_linestyle = plt.rcParams["grid.linestyle"] + def_alpha = plt.rcParams["grid.alpha"] + + plt.rcParams.update({ + "grid.color": "gray", "grid.minor.color": "red", + "grid.major.linestyle": ":", "grid.major.linewidth": 2, + "grid.minor.alpha": 0.6, + }) + _, ax = plt.subplots() + ax.plot([0, 1]) + + assert ax.xaxis.get_major_ticks()[0].gridline.get_color() == "gray" + assert ax.xaxis.get_minor_ticks()[0].gridline.get_color() == "red" + assert ax.xaxis.get_major_ticks()[0].gridline.get_linewidth() == 2 + assert ax.xaxis.get_minor_ticks()[0].gridline.get_linewidth() == def_linewidth + assert ax.xaxis.get_major_ticks()[0].gridline.get_linestyle() == ":" + assert ax.xaxis.get_minor_ticks()[0].gridline.get_linestyle() == def_linestyle + assert ax.xaxis.get_major_ticks()[0].gridline.get_alpha() == def_alpha + assert ax.xaxis.get_minor_ticks()[0].gridline.get_alpha() == 0.6 diff --git a/lib/matplotlib/tests/test_backend_inline.py b/lib/matplotlib/tests/test_backend_inline.py index 6f0d67d51756..997e1e7186b1 100644 --- a/lib/matplotlib/tests/test_backend_inline.py +++ b/lib/matplotlib/tests/test_backend_inline.py @@ -13,7 +13,7 @@ def test_ipynb(): - nb_path = Path(__file__).parent / 'test_inline_01.ipynb' + nb_path = Path(__file__).parent / 'data/test_inline_01.ipynb' with TemporaryDirectory() as tmpdir: out_path = Path(tmpdir, "out.ipynb") diff --git a/lib/matplotlib/tests/test_backend_nbagg.py b/lib/matplotlib/tests/test_backend_nbagg.py index 23af88d95086..ccf74df20aab 100644 --- a/lib/matplotlib/tests/test_backend_nbagg.py +++ b/lib/matplotlib/tests/test_backend_nbagg.py @@ -14,7 +14,7 @@ def test_ipynb(): - nb_path = Path(__file__).parent / 'test_nbagg_01.ipynb' + nb_path = Path(__file__).parent / 'data/test_nbagg_01.ipynb' with TemporaryDirectory() as tmpdir: out_path = Path(tmpdir, "out.ipynb") diff --git a/lib/matplotlib/tests/test_backend_pdf.py b/lib/matplotlib/tests/test_backend_pdf.py index 60169a38c972..f126fb543e78 100644 --- a/lib/matplotlib/tests/test_backend_pdf.py +++ b/lib/matplotlib/tests/test_backend_pdf.py @@ -16,7 +16,7 @@ from matplotlib.backends._backend_pdf_ps import get_glyphs_subset, font_as_file from matplotlib.backends.backend_pdf import PdfPages from matplotlib.patches import Rectangle -from matplotlib.testing import _gen_multi_font_text +from matplotlib.testing import _gen_multi_font_text, _has_tex_package from matplotlib.testing.decorators import check_figures_equal, image_comparison from matplotlib.testing._markers import needs_usetex @@ -425,6 +425,56 @@ def test_truetype_conversion(recwarn): mpl.rcParams['pdf.fonttype'] = 3 fig, ax = plt.subplots() ax.text(0, 0, "ABCDE", - font=Path(__file__).with_name("mpltest.ttf"), fontsize=80) + font=Path(__file__).parent / "data/mpltest.ttf", fontsize=80) + ax.set_xticks([]) + ax.set_yticks([]) + + +@pytest.mark.skipif(not _has_tex_package("heuristica"), + reason="LaTeX lacks heuristica package") +@image_comparison(["font-heuristica.pdf"]) +def test_font_heuristica(): + # Heuristica uses the callothersubr operator for some glyphs + mpl.rcParams['text.latex.preamble'] = '\n'.join(( + r'\usepackage{heuristica}', + r'\usepackage[T1]{fontenc}', + r'\usepackage[utf8]{inputenc}' + )) + fig, ax = plt.subplots() + ax.text(0.1, 0.1, r"BHTem fi ffl 1234", usetex=True, fontsize=50) + ax.set_xticks([]) + ax.set_yticks([]) + + +@pytest.mark.skipif(not _has_tex_package("DejaVuSans"), + reason="LaTeX lacks DejaVuSans package") +@image_comparison(["font-dejavusans.pdf"]) +def test_font_dejavusans(): + # DejaVuSans uses the seac operator to compose characters with diacritics + mpl.rcParams['text.latex.preamble'] = '\n'.join(( + r'\usepackage{DejaVuSans}', + r'\usepackage[T1]{fontenc}', + r'\usepackage[utf8]{inputenc}' + )) + + fig, ax = plt.subplots() + ax.text(0.1, 0.1, r"\textsf{ñäö ABCDabcd}", usetex=True, fontsize=50) + ax.text(0.1, 0.3, r"\textsf{fi ffl 1234}", usetex=True, fontsize=50) + ax.set_xticks([]) + ax.set_yticks([]) + + +@pytest.mark.skipif(not _has_tex_package("charter"), + reason="LaTeX lacks charter package") +@image_comparison(["font-bitstream-charter.pdf"]) +def test_font_bitstream_charter(): + mpl.rcParams['text.latex.preamble'] = '\n'.join(( + r'\usepackage{charter}', + r'\usepackage[T1]{fontenc}', + r'\usepackage[utf8]{inputenc}' + )) + fig, ax = plt.subplots() + ax.text(0.1, 0.1, r"åüš ABCDabcd", usetex=True, fontsize=50) + ax.text(0.1, 0.3, r"fi ffl 1234", usetex=True, fontsize=50) ax.set_xticks([]) ax.set_yticks([]) diff --git a/lib/matplotlib/tests/test_backend_qt.py b/lib/matplotlib/tests/test_backend_qt.py index 60f8a4f49bb8..a17e98d70484 100644 --- a/lib/matplotlib/tests/test_backend_qt.py +++ b/lib/matplotlib/tests/test_backend_qt.py @@ -213,7 +213,9 @@ def set_device_pixel_ratio(ratio): def test_subplottool(): fig, ax = plt.subplots() with mock.patch("matplotlib.backends.qt_compat._exec", lambda obj: None): - fig.canvas.manager.toolbar.configure_subplots() + tool = fig.canvas.manager.toolbar.configure_subplots() + assert tool is not None + assert tool == fig.canvas.manager.toolbar.configure_subplots() @pytest.mark.backend('QtAgg', skip_on_importerror=True) diff --git a/lib/matplotlib/tests/test_backend_registry.py b/lib/matplotlib/tests/test_backend_registry.py index 80c2ce4fc51a..2bd8e161bd6b 100644 --- a/lib/matplotlib/tests/test_backend_registry.py +++ b/lib/matplotlib/tests/test_backend_registry.py @@ -3,7 +3,6 @@ import pytest -import matplotlib as mpl from matplotlib.backends import BackendFilter, backend_registry @@ -95,16 +94,6 @@ def test_backend_normalization(backend, normalized): assert backend_registry._backend_module_name(backend) == normalized -def test_deprecated_rcsetup_attributes(): - match = "was deprecated in Matplotlib 3.9" - with pytest.warns(mpl.MatplotlibDeprecationWarning, match=match): - mpl.rcsetup.interactive_bk - with pytest.warns(mpl.MatplotlibDeprecationWarning, match=match): - mpl.rcsetup.non_interactive_bk - with pytest.warns(mpl.MatplotlibDeprecationWarning, match=match): - mpl.rcsetup.all_backends - - def test_entry_points_inline(): pytest.importorskip('matplotlib_inline') backends = backend_registry.list_all() diff --git a/lib/matplotlib/tests/test_backend_svg.py b/lib/matplotlib/tests/test_backend_svg.py index d2d4042870a1..2c64b7c24b3e 100644 --- a/lib/matplotlib/tests/test_backend_svg.py +++ b/lib/matplotlib/tests/test_backend_svg.py @@ -216,7 +216,7 @@ def test_unicode_won(): tree = xml.etree.ElementTree.fromstring(buf) ns = 'http://www.w3.org/2000/svg' - won_id = 'SFSS3583-8e' + won_id = 'SFSS1728-8e' assert len(tree.findall(f'.//{{{ns}}}path[@d][@id="{won_id}"]')) == 1 assert f'#{won_id}' in tree.find(f'.//{{{ns}}}use').attrib.values() diff --git a/lib/matplotlib/tests/test_backend_tk.py b/lib/matplotlib/tests/test_backend_tk.py index 1210c8c9993e..1f96ad1308cb 100644 --- a/lib/matplotlib/tests/test_backend_tk.py +++ b/lib/matplotlib/tests/test_backend_tk.py @@ -168,7 +168,9 @@ def test_never_update(): plt.show(block=False) plt.draw() # Test FigureCanvasTkAgg. - fig.canvas.toolbar.configure_subplots() # Test NavigationToolbar2Tk. + tool = fig.canvas.toolbar.configure_subplots() # Test NavigationToolbar2Tk. + assert tool is not None + assert tool == fig.canvas.toolbar.configure_subplots() # Tool is reused internally. # Test FigureCanvasTk filter_destroy callback fig.canvas.get_tk_widget().after(100, plt.close, fig) diff --git a/lib/matplotlib/tests/test_cbook.py b/lib/matplotlib/tests/test_cbook.py index 7cb057cf4723..9b97d8e7e231 100644 --- a/lib/matplotlib/tests/test_cbook.py +++ b/lib/matplotlib/tests/test_cbook.py @@ -1000,6 +1000,7 @@ def __array__(self): torch_tensor = torch.Tensor(data) result = cbook._unpack_to_numpy(torch_tensor) + assert isinstance(result, np.ndarray) # compare results, do not check for identity: the latter would fail # if not mocked, and the implementation does not guarantee it # is the same Python object, just the same values. @@ -1028,6 +1029,7 @@ def __array__(self): jax_array = jax.Array(data) result = cbook._unpack_to_numpy(jax_array) + assert isinstance(result, np.ndarray) # compare results, do not check for identity: the latter would fail # if not mocked, and the implementation does not guarantee it # is the same Python object, just the same values. @@ -1057,6 +1059,7 @@ def __array__(self): tf_tensor = tensorflow.Tensor(data) result = cbook._unpack_to_numpy(tf_tensor) + assert isinstance(result, np.ndarray) # compare results, do not check for identity: the latter would fail # if not mocked, and the implementation does not guarantee it # is the same Python object, just the same values. diff --git a/lib/matplotlib/tests/test_collections.py b/lib/matplotlib/tests/test_collections.py index 27ce8b5d69bc..642e5829a7b5 100644 --- a/lib/matplotlib/tests/test_collections.py +++ b/lib/matplotlib/tests/test_collections.py @@ -492,6 +492,28 @@ def test_polycollection_close(): ax.set_ylim3d(0, 4) +@check_figures_equal(extensions=["png"]) +def test_scalarmap_change_cmap(fig_test, fig_ref): + # Ensure that changing the colormap of a 3D scatter after draw updates the colors. + + x, y, z = np.array(list(itertools.product( + np.arange(0, 5, 1), + np.arange(0, 5, 1), + np.arange(0, 5, 1) + ))).T + c = x + y + + # test + ax_test = fig_test.add_subplot(111, projection='3d') + sc_test = ax_test.scatter(x, y, z, c=c, s=40, cmap='jet') + fig_test.canvas.draw() + sc_test.set_cmap('viridis') + + # ref + ax_ref = fig_ref.add_subplot(111, projection='3d') + ax_ref.scatter(x, y, z, c=c, s=40, cmap='viridis') + + @image_comparison(['regularpolycollection_rotate.png'], remove_text=True) def test_regularpolycollection_rotate(): xx, yy = np.mgrid[:10, :10] diff --git a/lib/matplotlib/tests/test_colorbar.py b/lib/matplotlib/tests/test_colorbar.py index f95f131e3bf6..ba20f325f4d7 100644 --- a/lib/matplotlib/tests/test_colorbar.py +++ b/lib/matplotlib/tests/test_colorbar.py @@ -332,11 +332,11 @@ def test_colorbar_closed_patch(): plt.rcParams['pcolormesh.snap'] = False fig = plt.figure(figsize=(8, 6)) - ax1 = fig.add_axes([0.05, 0.85, 0.9, 0.1]) - ax2 = fig.add_axes([0.1, 0.65, 0.75, 0.1]) - ax3 = fig.add_axes([0.05, 0.45, 0.9, 0.1]) - ax4 = fig.add_axes([0.05, 0.25, 0.9, 0.1]) - ax5 = fig.add_axes([0.05, 0.05, 0.9, 0.1]) + ax1 = fig.add_axes((0.05, 0.85, 0.9, 0.1)) + ax2 = fig.add_axes((0.1, 0.65, 0.75, 0.1)) + ax3 = fig.add_axes((0.05, 0.45, 0.9, 0.1)) + ax4 = fig.add_axes((0.05, 0.25, 0.9, 0.1)) + ax5 = fig.add_axes((0.05, 0.05, 0.9, 0.1)) cmap = mpl.colormaps["RdBu"].resampled(5) @@ -845,7 +845,7 @@ def test_colorbar_change_lim_scale(): pc = ax[1].pcolormesh(np.arange(100).reshape(10, 10)+1) cb = fig.colorbar(pc, ax=ax[1], extend='both') - cb.ax.set_ylim([20, 90]) + cb.ax.set_ylim(20, 90) @check_figures_equal() @@ -854,7 +854,7 @@ def test_axes_handles_same_functions(fig_ref, fig_test): for nn, fig in enumerate([fig_ref, fig_test]): ax = fig.add_subplot() pc = ax.pcolormesh(np.ones(300).reshape(10, 30)) - cax = fig.add_axes([0.9, 0.1, 0.03, 0.8]) + cax = fig.add_axes((0.9, 0.1, 0.03, 0.8)) cb = fig.colorbar(pc, cax=cax) if nn == 0: caxx = cax diff --git a/lib/matplotlib/tests/test_colors.py b/lib/matplotlib/tests/test_colors.py index 8d0f3467f045..73de50408401 100644 --- a/lib/matplotlib/tests/test_colors.py +++ b/lib/matplotlib/tests/test_colors.py @@ -7,6 +7,7 @@ from PIL import Image import pytest import base64 +import platform from numpy.testing import assert_array_equal, assert_array_almost_equal @@ -857,9 +858,9 @@ def test_boundarynorm_and_colorbarbase(): # Make a figure and axes with dimensions as desired. fig = plt.figure() - ax1 = fig.add_axes([0.05, 0.80, 0.9, 0.15]) - ax2 = fig.add_axes([0.05, 0.475, 0.9, 0.15]) - ax3 = fig.add_axes([0.05, 0.15, 0.9, 0.15]) + ax1 = fig.add_axes((0.05, 0.80, 0.9, 0.15)) + ax2 = fig.add_axes((0.05, 0.475, 0.9, 0.15)) + ax3 = fig.add_axes((0.05, 0.15, 0.9, 0.15)) # Set the colormap and bounds bounds = [-1, 2, 5, 7, 12, 15] @@ -1704,7 +1705,8 @@ def test_color_sequences(): assert plt.color_sequences is matplotlib.color_sequences # same registry assert list(plt.color_sequences) == [ 'tab10', 'tab20', 'tab20b', 'tab20c', 'Pastel1', 'Pastel2', 'Paired', - 'Accent', 'Dark2', 'Set1', 'Set2', 'Set3', 'petroff10'] + 'Accent', 'Dark2', 'Set1', 'Set2', 'Set3', 'petroff6', 'petroff8', + 'petroff10'] assert len(plt.color_sequences['tab10']) == 10 assert len(plt.color_sequences['tab20']) == 20 @@ -1828,3 +1830,49 @@ def test_LinearSegmentedColormap_from_list_value_color_tuple(): cmap([value for value, _ in value_color_tuples]), to_rgba_array([color for _, color in value_color_tuples]), ) + + +@image_comparison(['test_norm_abc.png'], remove_text=True, + tol=0 if platform.machine() == 'x86_64' else 0.05) +def test_norm_abc(): + + class CustomHalfNorm(mcolors.Norm): + def __init__(self): + super().__init__() + + @property + def vmin(self): + return 0 + + @property + def vmax(self): + return 1 + + @property + def clip(self): + return False + + def __call__(self, value, clip=None): + return value / 2 + + def inverse(self, value): + return 2 * value + + def autoscale(self, A): + pass + + def autoscale_None(self, A): + pass + + def scaled(self): + return True + + fig, axes = plt.subplots(2,2) + + r = np.linspace(-1, 3, 16*16).reshape((16,16)) + norm = CustomHalfNorm() + colorizer = mpl.colorizer.Colorizer(cmap='viridis', norm=norm) + c = axes[0,0].imshow(r, colorizer=colorizer) + axes[0,1].pcolor(r, colorizer=colorizer) + axes[1,0].contour(r, colorizer=colorizer) + axes[1,1].contourf(r, colorizer=colorizer) diff --git a/lib/matplotlib/tests/test_compare_images.py b/lib/matplotlib/tests/test_compare_images.py index 6023f3d05468..96b76f790ccd 100644 --- a/lib/matplotlib/tests/test_compare_images.py +++ b/lib/matplotlib/tests/test_compare_images.py @@ -1,11 +1,14 @@ from pathlib import Path import shutil +import numpy as np import pytest from pytest import approx +from matplotlib import _image from matplotlib.testing.compare import compare_images from matplotlib.testing.decorators import _image_directories +from matplotlib.testing.exceptions import ImageComparisonFailure # Tests of the image comparison algorithm. @@ -71,3 +74,27 @@ def test_image_comparison_expect_rms(im1, im2, tol, expect_rms, tmp_path, else: assert results is not None assert results['rms'] == approx(expect_rms, abs=1e-4) + + +def test_invalid_input(): + img = np.zeros((16, 16, 4), dtype=np.uint8) + + with pytest.raises(ImageComparisonFailure, + match='must be 3-dimensional, but is 2-dimensional'): + _image.calculate_rms_and_diff(img[:, :, 0], img) + with pytest.raises(ImageComparisonFailure, + match='must be 3-dimensional, but is 5-dimensional'): + _image.calculate_rms_and_diff(img, img[:, :, :, np.newaxis, np.newaxis]) + with pytest.raises(ImageComparisonFailure, + match='must be RGB or RGBA but has depth 2'): + _image.calculate_rms_and_diff(img[:, :, :2], img) + + with pytest.raises(ImageComparisonFailure, + match=r'expected size: \(16, 16, 4\) actual size \(8, 16, 4\)'): + _image.calculate_rms_and_diff(img, img[:8, :, :]) + with pytest.raises(ImageComparisonFailure, + match=r'expected size: \(16, 16, 4\) actual size \(16, 6, 4\)'): + _image.calculate_rms_and_diff(img, img[:, :6, :]) + with pytest.raises(ImageComparisonFailure, + match=r'expected size: \(16, 16, 4\) actual size \(16, 16, 3\)'): + _image.calculate_rms_and_diff(img, img[:, :, :3]) diff --git a/lib/matplotlib/tests/test_constrainedlayout.py b/lib/matplotlib/tests/test_constrainedlayout.py index 7c7dd43a3115..a2fa5efe780f 100644 --- a/lib/matplotlib/tests/test_constrainedlayout.py +++ b/lib/matplotlib/tests/test_constrainedlayout.py @@ -309,7 +309,7 @@ def test_constrained_layout16(): """Test ax.set_position.""" fig, ax = plt.subplots(layout="constrained") example_plot(ax, fontsize=12) - ax2 = fig.add_axes([0.2, 0.2, 0.4, 0.4]) + ax2 = fig.add_axes((0.2, 0.2, 0.4, 0.4)) @image_comparison(['constrained_layout17.png'], style='mpl20') @@ -357,7 +357,7 @@ def test_constrained_layout20(): img = np.hypot(gx, gx[:, None]) fig = plt.figure() - ax = fig.add_axes([0, 0, 1, 1]) + ax = fig.add_axes((0, 0, 1, 1)) mesh = ax.pcolormesh(gx, gx, img[:-1, :-1]) fig.colorbar(mesh) @@ -721,3 +721,23 @@ def test_layout_leak(): gc.collect() assert not any(isinstance(obj, mpl._layoutgrid.LayoutGrid) for obj in gc.get_objects()) + + +def test_submerged_subfig(): + """ + Test that the submerged margin logic does not get called multiple times + on same axes if it is already in a subfigure + """ + fig = plt.figure(figsize=(4, 5), layout='constrained') + figures = fig.subfigures(3, 1) + axs = [] + for f in figures.flatten(): + gs = f.add_gridspec(2, 2) + for i in range(2): + axs += [f.add_subplot(gs[i, 0])] + axs[-1].plot() + f.add_subplot(gs[:, 1]).plot() + fig.draw_without_rendering() + for ax in axs[1:]: + assert np.allclose(ax.get_position().bounds[-1], + axs[0].get_position().bounds[-1], atol=1e-6) diff --git a/lib/matplotlib/tests/test_dates.py b/lib/matplotlib/tests/test_dates.py index 73f10cec52aa..8ee12131fdbe 100644 --- a/lib/matplotlib/tests/test_dates.py +++ b/lib/matplotlib/tests/test_dates.py @@ -199,7 +199,7 @@ def test_too_many_date_ticks(caplog): tf = datetime.datetime(2000, 1, 20) fig, ax = plt.subplots() with pytest.warns(UserWarning) as rec: - ax.set_xlim((t0, tf), auto=True) + ax.set_xlim(t0, tf, auto=True) assert len(rec) == 1 assert ('Attempting to set identical low and high xlims' in str(rec[0].message)) diff --git a/lib/matplotlib/tests/test_figure.py b/lib/matplotlib/tests/test_figure.py index 496ac0a071ec..c5890a2963b3 100644 --- a/lib/matplotlib/tests/test_figure.py +++ b/lib/matplotlib/tests/test_figure.py @@ -241,7 +241,7 @@ def test_gca(): fig = plt.figure() # test that gca() picks up Axes created via add_axes() - ax0 = fig.add_axes([0, 0, 1, 1]) + ax0 = fig.add_axes((0, 0, 1, 1)) assert fig.gca() is ax0 # test that gca() picks up Axes created via add_subplot() @@ -546,7 +546,7 @@ def test_invalid_figure_add_axes(): fig.add_axes((.1, .1, .5, np.nan)) with pytest.raises(TypeError, match="multiple values for argument 'rect'"): - fig.add_axes([0, 0, 1, 1], rect=[0, 0, 1, 1]) + fig.add_axes((0, 0, 1, 1), rect=[0, 0, 1, 1]) fig2, ax = plt.subplots() with pytest.raises(ValueError, @@ -559,7 +559,7 @@ def test_invalid_figure_add_axes(): fig2.add_axes(ax, "extra positional argument") with pytest.raises(TypeError, match=r"add_axes\(\) takes 1 positional arguments"): - fig.add_axes([0, 0, 1, 1], "extra positional argument") + fig.add_axes((0, 0, 1, 1), "extra positional argument") def test_subplots_shareax_loglabels(): @@ -1583,22 +1583,22 @@ def test_add_subplot_kwargs(): def test_add_axes_kwargs(): # fig.add_axes() always creates new axes, even if axes kwargs differ. fig = plt.figure() - ax = fig.add_axes([0, 0, 1, 1]) - ax1 = fig.add_axes([0, 0, 1, 1]) + ax = fig.add_axes((0, 0, 1, 1)) + ax1 = fig.add_axes((0, 0, 1, 1)) assert ax is not None assert ax1 is not ax plt.close() fig = plt.figure() - ax = fig.add_axes([0, 0, 1, 1], projection='polar') - ax1 = fig.add_axes([0, 0, 1, 1], projection='polar') + ax = fig.add_axes((0, 0, 1, 1), projection='polar') + ax1 = fig.add_axes((0, 0, 1, 1), projection='polar') assert ax is not None assert ax1 is not ax plt.close() fig = plt.figure() - ax = fig.add_axes([0, 0, 1, 1], projection='polar') - ax1 = fig.add_axes([0, 0, 1, 1]) + ax = fig.add_axes((0, 0, 1, 1), projection='polar') + ax1 = fig.add_axes((0, 0, 1, 1)) assert ax is not None assert ax1.name == 'rectilinear' assert ax1 is not ax diff --git a/lib/matplotlib/tests/test_font_manager.py b/lib/matplotlib/tests/test_font_manager.py index d15b892b3eea..97ee8672b1d4 100644 --- a/lib/matplotlib/tests/test_font_manager.py +++ b/lib/matplotlib/tests/test_font_manager.py @@ -164,7 +164,7 @@ def test_user_fonts_linux(tmpdir, monkeypatch): # Prepare a temporary user font directory user_fonts_dir = tmpdir.join('fonts') user_fonts_dir.ensure(dir=True) - shutil.copyfile(Path(__file__).parent / font_test_file, + shutil.copyfile(Path(__file__).parent / 'data' / font_test_file, user_fonts_dir.join(font_test_file)) with monkeypatch.context() as m: @@ -181,7 +181,7 @@ def test_user_fonts_linux(tmpdir, monkeypatch): def test_addfont_as_path(): """Smoke test that addfont() accepts pathlib.Path.""" font_test_file = 'mpltest.ttf' - path = Path(__file__).parent / font_test_file + path = Path(__file__).parent / 'data' / font_test_file try: fontManager.addfont(path) added, = (font for font in fontManager.ttflist @@ -215,7 +215,7 @@ def test_user_fonts_win32(): os.makedirs(user_fonts_dir) # Copy the test font to the user font directory - shutil.copy(Path(__file__).parent / font_test_file, user_fonts_dir) + shutil.copy(Path(__file__).parent / 'data' / font_test_file, user_fonts_dir) # Now, the font should be available fonts = findSystemFonts() diff --git a/lib/matplotlib/tests/test_ft2font.py b/lib/matplotlib/tests/test_ft2font.py index a9f2a56658aa..8b448e17b7fd 100644 --- a/lib/matplotlib/tests/test_ft2font.py +++ b/lib/matplotlib/tests/test_ft2font.py @@ -18,7 +18,8 @@ def test_ft2image_draw_rect_filled(): width = 23 height = 42 for x0, y0, x1, y1 in itertools.product([1, 100], [2, 200], [4, 400], [8, 800]): - im = ft2font.FT2Image(width, height) + with pytest.warns(mpl.MatplotlibDeprecationWarning): + im = ft2font.FT2Image(width, height) im.draw_rect_filled(x0, y0, x1, y1) a = np.asarray(im) assert a.dtype == np.uint8 @@ -823,7 +824,7 @@ def test_ft2font_drawing(): np.testing.assert_array_equal(image, expected) font = ft2font.FT2Font(file, hinting_factor=1, _kerning_factor=0) glyph = font.load_char(ord('M')) - image = ft2font.FT2Image(expected.shape[1], expected.shape[0]) + image = np.zeros(expected.shape, np.uint8) font.draw_glyph_to_bitmap(image, -1, 1, glyph, antialiased=False) np.testing.assert_array_equal(image, expected) diff --git a/lib/matplotlib/tests/test_image.py b/lib/matplotlib/tests/test_image.py index cededdb1b83c..00c223c59362 100644 --- a/lib/matplotlib/tests/test_image.py +++ b/lib/matplotlib/tests/test_image.py @@ -9,7 +9,7 @@ import urllib.request import numpy as np -from numpy.testing import assert_array_equal +from numpy.testing import assert_allclose, assert_array_equal from PIL import Image import matplotlib as mpl @@ -18,7 +18,7 @@ from matplotlib.image import (AxesImage, BboxImage, FigureImage, NonUniformImage, PcolorImage) from matplotlib.testing.decorators import check_figures_equal, image_comparison -from matplotlib.transforms import Bbox, Affine2D, TransformedBbox +from matplotlib.transforms import Bbox, Affine2D, Transform, TransformedBbox import matplotlib.ticker as mticker import pytest @@ -114,12 +114,12 @@ def test_imshow_zoom(fig_test, fig_ref): fig.set_size_inches(2.9, 2.9) ax = fig_test.subplots() ax.imshow(A, interpolation='auto') - ax.set_xlim([10, 20]) - ax.set_ylim([10, 20]) + ax.set_xlim(10, 20) + ax.set_ylim(10, 20) ax = fig_ref.subplots() ax.imshow(A, interpolation='nearest') - ax.set_xlim([10, 20]) - ax.set_ylim([10, 20]) + ax.set_xlim(10, 20) + ax.set_ylim(10, 20) @check_figures_equal() @@ -526,7 +526,7 @@ def test_image_shift(): def test_image_edges(): fig = plt.figure(figsize=[1, 1]) - ax = fig.add_axes([0, 0, 1, 1], frameon=False) + ax = fig.add_axes((0, 0, 1, 1), frameon=False) data = np.tile(np.arange(12), 15).reshape(20, 9) @@ -534,8 +534,8 @@ def test_image_edges(): interpolation='none', cmap='gray') x = y = 2 - ax.set_xlim([-x, x]) - ax.set_ylim([-y, y]) + ax.set_xlim(-x, x) + ax.set_ylim(-y, y) ax.set_xticks([]) ax.set_yticks([]) @@ -560,7 +560,7 @@ def test_image_composite_background(): ax.imshow(arr, extent=[0, 2, 15, 0]) ax.imshow(arr, extent=[4, 6, 15, 0]) ax.set_facecolor((1, 0, 0, 0.5)) - ax.set_xlim([0, 12]) + ax.set_xlim(0, 12) @image_comparison(['image_composite_alpha'], remove_text=True, tol=0.07) @@ -586,8 +586,8 @@ def test_image_composite_alpha(): ax.imshow(arr2, extent=[0, 5, 2, 3], alpha=0.6) ax.imshow(arr2, extent=[0, 5, 3, 4], alpha=0.3) ax.set_facecolor((0, 0.5, 0, 1)) - ax.set_xlim([0, 5]) - ax.set_ylim([5, 0]) + ax.set_xlim(0, 5) + ax.set_ylim(5, 0) @check_figures_equal(extensions=["pdf"]) @@ -1214,7 +1214,7 @@ def test_exact_vmin(): # make the image exactly 190 pixels wide fig = plt.figure(figsize=(1.9, 0.1), dpi=100) - ax = fig.add_axes([0, 0, 1, 1]) + ax = fig.add_axes((0, 0, 1, 1)) data = np.array( [[-1, -1, -1, 0, 0, 0, 0, 43, 79, 95, 66, 1, -1, -1, -1, 0, 0, 0, 34]], @@ -1491,8 +1491,8 @@ def test_rgba_antialias(): axs = axs.flatten() # zoom in axs[0].imshow(aa, interpolation='nearest', cmap=cmap, vmin=-1.2, vmax=1.2) - axs[0].set_xlim([N/2-25, N/2+25]) - axs[0].set_ylim([N/2+50, N/2-10]) + axs[0].set_xlim(N/2-25, N/2+25) + axs[0].set_ylim(N/2+50, N/2-10) # no anti-alias axs[1].imshow(aa, interpolation='nearest', cmap=cmap, vmin=-1.2, vmax=1.2) @@ -1641,6 +1641,40 @@ def test__resample_valid_output(): resample(np.zeros((9, 9)), out) +@pytest.mark.parametrize("data, interpolation, expected", + [(np.array([[0.1, 0.3, 0.2]]), mimage.NEAREST, + np.array([[0.1, 0.1, 0.1, 0.3, 0.3, 0.3, 0.3, 0.2, 0.2, 0.2]])), + (np.array([[0.1, 0.3, 0.2]]), mimage.BILINEAR, + np.array([[0.1, 0.1, 0.15078125, 0.21096191, 0.27033691, + 0.28476562, 0.2546875, 0.22460938, 0.20002441, 0.20002441]])), + ] +) +def test_resample_nonaffine(data, interpolation, expected): + # Test that equivalent affine and nonaffine transforms resample the same + + # Create a simple affine transform for scaling the input array + affine_transform = Affine2D().scale(sx=expected.shape[1] / data.shape[1], sy=1) + + affine_result = np.empty_like(expected) + mimage.resample(data, affine_result, affine_transform, interpolation=interpolation) + assert_allclose(affine_result, expected) + + # Create a nonaffine version of the same transform + # by compositing with a nonaffine identity transform + class NonAffineIdentityTransform(Transform): + input_dims = 2 + output_dims = 2 + + def inverted(self): + return self + nonaffine_transform = NonAffineIdentityTransform() + affine_transform + + nonaffine_result = np.empty_like(expected) + mimage.resample(data, nonaffine_result, nonaffine_transform, + interpolation=interpolation) + assert_allclose(nonaffine_result, expected, atol=5e-3) + + def test_axesimage_get_shape(): # generate dummy image to test get_shape method ax = plt.gca() diff --git a/lib/matplotlib/tests/test_legend.py b/lib/matplotlib/tests/test_legend.py index 9c708598e27c..9b100037cc41 100644 --- a/lib/matplotlib/tests/test_legend.py +++ b/lib/matplotlib/tests/test_legend.py @@ -42,6 +42,18 @@ def test_legend_ordereddict(): loc='center left', bbox_to_anchor=(1, .5)) +def test_legend_generator(): + # smoketest that generator inputs work + fig, ax = plt.subplots() + ax.plot([0, 1]) + ax.plot([0, 2]) + + handles = (line for line in ax.get_lines()) + labels = (label for label in ['spam', 'eggs']) + + ax.legend(handles, labels, loc='upper left') + + @image_comparison(['legend_auto1.png'], remove_text=True) def test_legend_auto1(): """Test automatic legend placement""" @@ -518,8 +530,8 @@ def test_legend_stackplot(): y2 = 2.0 * x + 1 y3 = 3.0 * x + 2 ax.stackplot(x, y1, y2, y3, labels=['y1', 'y2', 'y3']) - ax.set_xlim((0, 10)) - ax.set_ylim((0, 70)) + ax.set_xlim(0, 10) + ax.set_ylim(0, 70) ax.legend(loc='best') diff --git a/lib/matplotlib/tests/test_lines.py b/lib/matplotlib/tests/test_lines.py index 68ee1ff8a9a6..fe92547c5963 100644 --- a/lib/matplotlib/tests/test_lines.py +++ b/lib/matplotlib/tests/test_lines.py @@ -219,8 +219,8 @@ def test_marker_fill_styles(): markeredgecolor=color, markeredgewidth=2) - ax.set_ylim([0, 7.5]) - ax.set_xlim([-5, 155]) + ax.set_ylim(0, 7.5) + ax.set_xlim(-5, 155) def test_markerfacecolor_fillstyle(): diff --git a/lib/matplotlib/tests/test_marker.py b/lib/matplotlib/tests/test_marker.py index f6e20c148897..a1e71f1f6533 100644 --- a/lib/matplotlib/tests/test_marker.py +++ b/lib/matplotlib/tests/test_marker.py @@ -181,9 +181,9 @@ def test_marker_clipping(fig_ref, fig_test): width = 2 * marker_size * ncol height = 2 * marker_size * nrow * 2 fig_ref.set_size_inches((width / fig_ref.dpi, height / fig_ref.dpi)) - ax_ref = fig_ref.add_axes([0, 0, 1, 1]) + ax_ref = fig_ref.add_axes((0, 0, 1, 1)) fig_test.set_size_inches((width / fig_test.dpi, height / fig_ref.dpi)) - ax_test = fig_test.add_axes([0, 0, 1, 1]) + ax_test = fig_test.add_axes((0, 0, 1, 1)) for i, marker in enumerate(markers.MarkerStyle.markers): x = i % ncol diff --git a/lib/matplotlib/tests/test_mathtext.py b/lib/matplotlib/tests/test_mathtext.py index 198e640ad286..39c28dc9228c 100644 --- a/lib/matplotlib/tests/test_mathtext.py +++ b/lib/matplotlib/tests/test_mathtext.py @@ -437,7 +437,7 @@ def test_mathtext_fallback_invalid(): ("stix", ['DejaVu Sans', 'mpltest', 'STIXGeneral', 'STIXGeneral', 'STIXGeneral'])]) def test_mathtext_fallback(fallback, fontlist): mpl.font_manager.fontManager.addfont( - str(Path(__file__).resolve().parent / 'mpltest.ttf')) + (Path(__file__).resolve().parent / 'data/mpltest.ttf')) mpl.rcParams["svg.fonttype"] = 'none' mpl.rcParams['mathtext.fontset'] = 'custom' mpl.rcParams['mathtext.rm'] = 'mpltest' diff --git a/lib/matplotlib/tests/test_matplotlib.py b/lib/matplotlib/tests/test_matplotlib.py index 37b41fafdb78..d0a3f8c617e1 100644 --- a/lib/matplotlib/tests/test_matplotlib.py +++ b/lib/matplotlib/tests/test_matplotlib.py @@ -1,6 +1,7 @@ import os import subprocess import sys +from unittest.mock import patch import pytest @@ -80,3 +81,16 @@ def test_importable_with__OO(): [sys.executable, "-OO", "-c", program], env={**os.environ, "MPLBACKEND": ""}, check=True ) + + +@patch('matplotlib.subprocess.check_output') +def test_get_executable_info_timeout(mock_check_output): + """ + Test that _get_executable_info raises ExecutableNotFoundError if the + command times out. + """ + + mock_check_output.side_effect = subprocess.TimeoutExpired(cmd=['mock'], timeout=30) + + with pytest.raises(matplotlib.ExecutableNotFoundError, match='Timed out'): + matplotlib._get_executable_info.__wrapped__('inkscape') diff --git a/lib/matplotlib/tests/test_offsetbox.py b/lib/matplotlib/tests/test_offsetbox.py index f18fa7c777d1..bd353ffc719b 100644 --- a/lib/matplotlib/tests/test_offsetbox.py +++ b/lib/matplotlib/tests/test_offsetbox.py @@ -48,8 +48,8 @@ def test_offsetbox_clipping(): da.add_artist(bg) da.add_artist(line) ax.add_artist(anchored_box) - ax.set_xlim((0, 1)) - ax.set_ylim((0, 1)) + ax.set_xlim(0, 1) + ax.set_ylim(0, 1) def test_offsetbox_clip_children(): @@ -455,8 +455,18 @@ def test_remove_draggable(): def test_draggable_in_subfigure(): fig = plt.figure() # Put annotation at lower left corner to make it easily pickable below. - ann = fig.subfigures().add_axes([0, 0, 1, 1]).annotate("foo", (0, 0)) + ann = fig.subfigures().add_axes((0, 0, 1, 1)).annotate("foo", (0, 0)) ann.draggable(True) fig.canvas.draw() # Texts are non-pickable until the first draw. MouseEvent("button_press_event", fig.canvas, 1, 1)._process() assert ann._draggable.got_artist + # Stop dragging the annotation. + MouseEvent("button_release_event", fig.canvas, 1, 1)._process() + assert not ann._draggable.got_artist + # A scroll event should not initiate a drag. + MouseEvent("scroll_event", fig.canvas, 1, 1)._process() + assert not ann._draggable.got_artist + # An event outside the annotation should not initiate a drag. + bbox = ann.get_window_extent() + MouseEvent("button_press_event", fig.canvas, bbox.x1+2, bbox.y1+2)._process() + assert not ann._draggable.got_artist diff --git a/lib/matplotlib/tests/test_patches.py b/lib/matplotlib/tests/test_patches.py index 4ed9222eb95e..d69a9dad4337 100644 --- a/lib/matplotlib/tests/test_patches.py +++ b/lib/matplotlib/tests/test_patches.py @@ -1093,3 +1093,9 @@ def test_facecolor_none_edgecolor_force_edgecolor(): rcParams['patch.edgecolor'] = 'red' rect = Rectangle((0, 0), 1, 1, facecolor="none") assert mcolors.same_color(rect.get_edgecolor(), rcParams['patch.edgecolor']) + + +def test_empty_fancyarrow(): + fig, ax = plt.subplots() + arrow = ax.arrow([], [], [], []) + assert arrow is not None diff --git a/lib/matplotlib/tests/test_path.py b/lib/matplotlib/tests/test_path.py index 21f4c33794af..a61f01c0d48a 100644 --- a/lib/matplotlib/tests/test_path.py +++ b/lib/matplotlib/tests/test_path.py @@ -155,8 +155,8 @@ def test_nonlinear_containment(): def test_arrow_contains_point(): # fix bug (#8384) fig, ax = plt.subplots() - ax.set_xlim((0, 2)) - ax.set_ylim((0, 2)) + ax.set_xlim(0, 2) + ax.set_ylim(0, 2) # create an arrow with Curve style arrow = patches.FancyArrowPatch((0.5, 0.25), (1.5, 0.75), @@ -355,15 +355,49 @@ def test_path_deepcopy(): # Should not raise any error verts = [[0, 0], [1, 1]] codes = [Path.MOVETO, Path.LINETO] - path1 = Path(verts) - path2 = Path(verts, codes) + path1 = Path(verts, readonly=True) + path2 = Path(verts, codes, readonly=True) path1_copy = path1.deepcopy() path2_copy = path2.deepcopy() assert path1 is not path1_copy assert path1.vertices is not path1_copy.vertices + assert_array_equal(path1.vertices, path1_copy.vertices) + assert path1.readonly + assert not path1_copy.readonly assert path2 is not path2_copy assert path2.vertices is not path2_copy.vertices + assert_array_equal(path2.vertices, path2_copy.vertices) assert path2.codes is not path2_copy.codes + assert_array_equal(path2.codes, path2_copy.codes) + assert path2.readonly + assert not path2_copy.readonly + + +def test_path_deepcopy_cycle(): + class PathWithCycle(Path): + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.x = self + + p = PathWithCycle([[0, 0], [1, 1]], readonly=True) + p_copy = p.deepcopy() + assert p_copy is not p + assert p.readonly + assert not p_copy.readonly + assert p_copy.x is p_copy + + class PathWithCycle2(Path): + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.x = [self] * 2 + + p2 = PathWithCycle2([[0, 0], [1, 1]], readonly=True) + p2_copy = p2.deepcopy() + assert p2_copy is not p2 + assert p2.readonly + assert not p2_copy.readonly + assert p2_copy.x[0] is p2_copy + assert p2_copy.x[1] is p2_copy def test_path_shallowcopy(): diff --git a/lib/matplotlib/tests/test_polar.py b/lib/matplotlib/tests/test_polar.py index a0969df5de90..c0bf72b89eb0 100644 --- a/lib/matplotlib/tests/test_polar.py +++ b/lib/matplotlib/tests/test_polar.py @@ -150,7 +150,7 @@ def test_polar_rmin(): theta = 2*np.pi*r fig = plt.figure() - ax = fig.add_axes([0.1, 0.1, 0.8, 0.8], polar=True) + ax = fig.add_axes((0.1, 0.1, 0.8, 0.8), polar=True) ax.plot(theta, r) ax.set_rmax(2.0) ax.set_rmin(0.5) @@ -162,7 +162,7 @@ def test_polar_negative_rmin(): theta = 2*np.pi*r fig = plt.figure() - ax = fig.add_axes([0.1, 0.1, 0.8, 0.8], polar=True) + ax = fig.add_axes((0.1, 0.1, 0.8, 0.8), polar=True) ax.plot(theta, r) ax.set_rmax(0.0) ax.set_rmin(-3.0) @@ -174,7 +174,7 @@ def test_polar_rorigin(): theta = 2*np.pi*r fig = plt.figure() - ax = fig.add_axes([0.1, 0.1, 0.8, 0.8], polar=True) + ax = fig.add_axes((0.1, 0.1, 0.8, 0.8), polar=True) ax.plot(theta, r) ax.set_rmax(2.0) ax.set_rmin(0.5) @@ -184,14 +184,14 @@ def test_polar_rorigin(): @image_comparison(['polar_invertedylim.png'], style='default') def test_polar_invertedylim(): fig = plt.figure() - ax = fig.add_axes([0.1, 0.1, 0.8, 0.8], polar=True) + ax = fig.add_axes((0.1, 0.1, 0.8, 0.8), polar=True) ax.set_ylim(2, 0) @image_comparison(['polar_invertedylim_rorigin.png'], style='default') def test_polar_invertedylim_rorigin(): fig = plt.figure() - ax = fig.add_axes([0.1, 0.1, 0.8, 0.8], polar=True) + ax = fig.add_axes((0.1, 0.1, 0.8, 0.8), polar=True) ax.yaxis.set_inverted(True) # Set the rlims to inverted (2, 0) without calling set_rlim, to check that # viewlims are correctly unstaled before draw()ing. @@ -206,7 +206,7 @@ def test_polar_theta_position(): theta = 2*np.pi*r fig = plt.figure() - ax = fig.add_axes([0.1, 0.1, 0.8, 0.8], polar=True) + ax = fig.add_axes((0.1, 0.1, 0.8, 0.8), polar=True) ax.plot(theta, r) ax.set_theta_zero_location("NW", 30) ax.set_theta_direction('clockwise') @@ -482,6 +482,26 @@ def test_polar_log(): ax.plot(np.linspace(0, 2 * np.pi, n), np.logspace(0, 2, n)) +@check_figures_equal() +def test_polar_log_rorigin(fig_ref, fig_test): + # Test that equivalent linear and log radial settings give the same axes patch + # and spines. + ax_ref = fig_ref.add_subplot(projection='polar', facecolor='red') + ax_ref.set_rlim(0, 2) + ax_ref.set_rorigin(-3) + ax_ref.set_rticks(np.linspace(0, 2, 5)) + + ax_test = fig_test.add_subplot(projection='polar', facecolor='red') + ax_test.set_rscale('log') + ax_test.set_rlim(1, 100) + ax_test.set_rorigin(10**-3) + ax_test.set_rticks(np.logspace(0, 2, 5)) + + for ax in ax_ref, ax_test: + # Radial tick labels should be the only difference, so turn them off. + ax.tick_params(labelleft=False) + + def test_polar_neg_theta_lims(): fig = plt.figure() ax = fig.add_subplot(projection='polar') diff --git a/lib/matplotlib/tests/test_pyplot.py b/lib/matplotlib/tests/test_pyplot.py index ab713707bace..55f7c33cb52e 100644 --- a/lib/matplotlib/tests/test_pyplot.py +++ b/lib/matplotlib/tests/test_pyplot.py @@ -1,4 +1,5 @@ import difflib +import inspect import numpy as np import sys @@ -484,3 +485,26 @@ def test_matshow(): # Smoke test that matshow does not ask for a new figsize on the existing figure plt.matshow(arr, fignum=fig.number) + + +def assert_same_signature(func1, func2): + """ + Assert that `func1` and `func2` have the same arguments, + i.e. same parameter count, names and kinds. + + :param func1: First function to check + :param func2: Second function to check + """ + params1 = inspect.signature(func1).parameters + params2 = inspect.signature(func2).parameters + + assert len(params1) == len(params2) + assert all([ + params1[p].name == params2[p].name and + params1[p].kind == params2[p].kind + for p in params1 + ]) + + +def test_setloglevel_signature(): + assert_same_signature(plt.set_loglevel, mpl.set_loglevel) diff --git a/lib/matplotlib/tests/test_rcparams.py b/lib/matplotlib/tests/test_rcparams.py index 1bc148a83a7e..2235f98b720f 100644 --- a/lib/matplotlib/tests/test_rcparams.py +++ b/lib/matplotlib/tests/test_rcparams.py @@ -654,3 +654,21 @@ def test_rcparams_path_sketch_from_file(tmp_path, value): rc_path.write_text(f"path.sketch: {value}") with mpl.rc_context(fname=rc_path): assert mpl.rcParams["path.sketch"] == (1, 2, 3) + + +@pytest.mark.parametrize('group, option, alias, value', [ + ('lines', 'linewidth', 'lw', 3), + ('lines', 'linestyle', 'ls', 'dashed'), + ('lines', 'color', 'c', 'white'), + ('axes', 'facecolor', 'fc', 'black'), + ('figure', 'edgecolor', 'ec', 'magenta'), + ('lines', 'markeredgewidth', 'mew', 1.5), + ('patch', 'antialiased', 'aa', False), + ('font', 'sans-serif', 'sans', ["Verdana"]) +]) +def test_rc_aliases(group, option, alias, value): + rc_kwargs = {alias: value,} + mpl.rc(group, **rc_kwargs) + + rcParams_key = f"{group}.{option}" + assert mpl.rcParams[rcParams_key] == value diff --git a/lib/matplotlib/tests/test_simplification.py b/lib/matplotlib/tests/test_simplification.py index bc9b46b14db2..41d01addd622 100644 --- a/lib/matplotlib/tests/test_simplification.py +++ b/lib/matplotlib/tests/test_simplification.py @@ -25,7 +25,7 @@ def test_clipping(): fig, ax = plt.subplots() ax.plot(t, s, linewidth=1.0) - ax.set_ylim((-0.20, -0.28)) + ax.set_ylim(-0.20, -0.28) @image_comparison(['overflow'], remove_text=True, @@ -244,8 +244,8 @@ def test_simplify_curve(): fig, ax = plt.subplots() ax.add_patch(pp1) - ax.set_xlim((0, 2)) - ax.set_ylim((0, 2)) + ax.set_xlim(0, 2) + ax.set_ylim(0, 2) @check_figures_equal(extensions=['png', 'pdf', 'svg']) @@ -401,8 +401,8 @@ def test_closed_path_clipping(fig_test, fig_ref): def test_hatch(): fig, ax = plt.subplots() ax.add_patch(plt.Rectangle((0, 0), 1, 1, fill=False, hatch="/")) - ax.set_xlim((0.45, 0.55)) - ax.set_ylim((0.45, 0.55)) + ax.set_xlim(0.45, 0.55) + ax.set_ylim(0.45, 0.55) @image_comparison(['fft_peaks'], remove_text=True) diff --git a/lib/matplotlib/tests/test_sphinxext.py b/lib/matplotlib/tests/test_sphinxext.py index 1aaa6baca47c..ede3166a2e1b 100644 --- a/lib/matplotlib/tests/test_sphinxext.py +++ b/lib/matplotlib/tests/test_sphinxext.py @@ -13,14 +13,21 @@ pytest.importorskip('sphinx', minversion='4.1.3') +tinypages = Path(__file__).parent / 'data/tinypages' + + def build_sphinx_html(source_dir, doctree_dir, html_dir, extra_args=None): # Build the pages with warnings turned into errors extra_args = [] if extra_args is None else extra_args cmd = [sys.executable, '-msphinx', '-W', '-b', 'html', '-d', str(doctree_dir), str(source_dir), str(html_dir), *extra_args] + # On CI, gcov emits warnings (due to agg headers being included with the + # same name in multiple extension modules -- but we don't care about their + # coverage anyways); hide them using GCOV_ERROR_FILE. proc = subprocess_run_for_testing( cmd, capture_output=True, text=True, - env={**os.environ, "MPLBACKEND": ""}) + env={**os.environ, "MPLBACKEND": "", "GCOV_ERROR_FILE": os.devnull} + ) out = proc.stdout err = proc.stderr @@ -33,24 +40,12 @@ def build_sphinx_html(source_dir, doctree_dir, html_dir, extra_args=None): def test_tinypages(tmp_path): - shutil.copytree(Path(__file__).parent / 'tinypages', tmp_path, - dirs_exist_ok=True) + shutil.copytree(tinypages, tmp_path, dirs_exist_ok=True, + ignore=shutil.ignore_patterns('_build', 'doctrees', + 'plot_directive')) html_dir = tmp_path / '_build' / 'html' img_dir = html_dir / '_images' doctree_dir = tmp_path / 'doctrees' - # Build the pages with warnings turned into errors - cmd = [sys.executable, '-msphinx', '-W', '-b', 'html', - '-d', str(doctree_dir), - str(Path(__file__).parent / 'tinypages'), str(html_dir)] - # On CI, gcov emits warnings (due to agg headers being included with the - # same name in multiple extension modules -- but we don't care about their - # coverage anyways); hide them using GCOV_ERROR_FILE. - proc = subprocess_run_for_testing( - cmd, capture_output=True, text=True, - env={**os.environ, "MPLBACKEND": "", "GCOV_ERROR_FILE": os.devnull} - ) - out = proc.stdout - err = proc.stderr # Build the pages with warnings turned into errors build_sphinx_html(tmp_path, doctree_dir, html_dir) @@ -99,6 +94,11 @@ def plot_directive_file(num): assert filecmp.cmp(range_6, plot_file(17)) # plot 22 is from the range6.py file again, but a different function assert filecmp.cmp(range_10, img_dir / 'range6_range10.png') + # plots 23--25 use a custom basename + assert filecmp.cmp(range_6, img_dir / 'custom-basename-6.png') + assert filecmp.cmp(range_4, img_dir / 'custom-basename-4.png') + assert filecmp.cmp(range_4, img_dir / 'custom-basename-4-6_00.png') + assert filecmp.cmp(range_6, img_dir / 'custom-basename-4-6_01.png') # Modify the included plot contents = (tmp_path / 'included_plot_21.rst').read_bytes() @@ -125,9 +125,8 @@ def plot_directive_file(num): def test_plot_html_show_source_link(tmp_path): - parent = Path(__file__).parent - shutil.copyfile(parent / 'tinypages/conf.py', tmp_path / 'conf.py') - shutil.copytree(parent / 'tinypages/_static', tmp_path / '_static') + shutil.copyfile(tinypages / 'conf.py', tmp_path / 'conf.py') + shutil.copytree(tinypages / '_static', tmp_path / '_static') doctree_dir = tmp_path / 'doctrees' (tmp_path / 'index.rst').write_text(""" .. plot:: @@ -150,9 +149,8 @@ def test_plot_html_show_source_link(tmp_path): def test_show_source_link_true(tmp_path, plot_html_show_source_link): # Test that a source link is generated if :show-source-link: is true, # whether or not plot_html_show_source_link is true. - parent = Path(__file__).parent - shutil.copyfile(parent / 'tinypages/conf.py', tmp_path / 'conf.py') - shutil.copytree(parent / 'tinypages/_static', tmp_path / '_static') + shutil.copyfile(tinypages / 'conf.py', tmp_path / 'conf.py') + shutil.copytree(tinypages / '_static', tmp_path / '_static') doctree_dir = tmp_path / 'doctrees' (tmp_path / 'index.rst').write_text(""" .. plot:: @@ -170,9 +168,8 @@ def test_show_source_link_true(tmp_path, plot_html_show_source_link): def test_show_source_link_false(tmp_path, plot_html_show_source_link): # Test that a source link is NOT generated if :show-source-link: is false, # whether or not plot_html_show_source_link is true. - parent = Path(__file__).parent - shutil.copyfile(parent / 'tinypages/conf.py', tmp_path / 'conf.py') - shutil.copytree(parent / 'tinypages/_static', tmp_path / '_static') + shutil.copyfile(tinypages / 'conf.py', tmp_path / 'conf.py') + shutil.copytree(tinypages / '_static', tmp_path / '_static') doctree_dir = tmp_path / 'doctrees' (tmp_path / 'index.rst').write_text(""" .. plot:: @@ -186,15 +183,38 @@ def test_show_source_link_false(tmp_path, plot_html_show_source_link): assert len(list(html_dir.glob("**/index-1.py"))) == 0 +def test_plot_html_show_source_link_custom_basename(tmp_path): + # Test that source link filename includes .py extension when using custom basename + shutil.copyfile(tinypages / 'conf.py', tmp_path / 'conf.py') + shutil.copytree(tinypages / '_static', tmp_path / '_static') + doctree_dir = tmp_path / 'doctrees' + (tmp_path / 'index.rst').write_text(""" +.. plot:: + :filename-prefix: custom-name + + plt.plot(range(2)) +""") + html_dir = tmp_path / '_build' / 'html' + build_sphinx_html(tmp_path, doctree_dir, html_dir) + + # Check that source file with .py extension is generated + assert len(list(html_dir.glob("**/custom-name.py"))) == 1 + + # Check that the HTML contains the correct link with .py extension + html_content = (html_dir / 'index.html').read_text() + assert 'custom-name.py' in html_content + + def test_srcset_version(tmp_path): - shutil.copytree(Path(__file__).parent / 'tinypages', tmp_path, - dirs_exist_ok=True) + shutil.copytree(tinypages, tmp_path, dirs_exist_ok=True, + ignore=shutil.ignore_patterns('_build', 'doctrees', + 'plot_directive')) html_dir = tmp_path / '_build' / 'html' img_dir = html_dir / '_images' doctree_dir = tmp_path / 'doctrees' - build_sphinx_html(tmp_path, doctree_dir, html_dir, extra_args=[ - '-D', 'plot_srcset=2x']) + build_sphinx_html(tmp_path, doctree_dir, html_dir, + extra_args=['-D', 'plot_srcset=2x']) def plot_file(num, suff=''): return img_dir / f'some_plots-{num}{suff}.png' diff --git a/lib/matplotlib/tests/test_spines.py b/lib/matplotlib/tests/test_spines.py index 353aede00298..d6ddcabb6878 100644 --- a/lib/matplotlib/tests/test_spines.py +++ b/lib/matplotlib/tests/test_spines.py @@ -154,3 +154,15 @@ def test_spines_black_axes(): ax.set_xticks([]) ax.set_yticks([]) ax.set_facecolor((0, 0, 0)) + + +def test_arc_spine_inner_no_axis(): + # Backcompat: smoke test that inner arc spine does not need a registered + # axis in order to be drawn + fig = plt.figure() + ax = fig.add_subplot(projection="polar") + inner_spine = ax.spines["inner"] + inner_spine.register_axis(None) + assert ax.spines["inner"].axis is None + + fig.draw_without_rendering() diff --git a/lib/matplotlib/tests/test_style.py b/lib/matplotlib/tests/test_style.py index be038965e33d..4d76a4ecfa8b 100644 --- a/lib/matplotlib/tests/test_style.py +++ b/lib/matplotlib/tests/test_style.py @@ -8,7 +8,6 @@ import matplotlib as mpl from matplotlib import pyplot as plt, style -from matplotlib.style.core import USER_LIBRARY_PATHS, STYLE_EXTENSION PARAM = 'image.cmap' @@ -21,7 +20,7 @@ def temp_style(style_name, settings=None): """Context manager to create a style sheet in a temporary directory.""" if not settings: settings = DUMMY_SETTINGS - temp_file = f'{style_name}.{STYLE_EXTENSION}' + temp_file = f'{style_name}.mplstyle' try: with TemporaryDirectory() as tmpdir: # Write style settings to file in the tmpdir. @@ -29,7 +28,7 @@ def temp_style(style_name, settings=None): "\n".join(f"{k}: {v}" for k, v in settings.items()), encoding="utf-8") # Add tmpdir to style path and reload so we can access this style. - USER_LIBRARY_PATHS.append(tmpdir) + style.USER_LIBRARY_PATHS.append(tmpdir) style.reload_library() yield finally: @@ -71,7 +70,7 @@ def test_use_url(tmp_path): def test_single_path(tmp_path): mpl.rcParams[PARAM] = 'gray' - path = tmp_path / f'text.{STYLE_EXTENSION}' + path = tmp_path / 'text.mplstyle' path.write_text(f'{PARAM} : {VALUE}', encoding='utf-8') with style.context(path): assert mpl.rcParams[PARAM] == VALUE diff --git a/lib/matplotlib/tests/test_subplots.py b/lib/matplotlib/tests/test_subplots.py index a899110ac77a..0f00a88aa72d 100644 --- a/lib/matplotlib/tests/test_subplots.py +++ b/lib/matplotlib/tests/test_subplots.py @@ -4,6 +4,7 @@ import numpy as np import pytest +import matplotlib as mpl from matplotlib.axes import Axes, SubplotBase import matplotlib.pyplot as plt from matplotlib.testing.decorators import check_figures_equal, image_comparison @@ -111,10 +112,15 @@ def test_shared(): @pytest.mark.parametrize('remove_ticks', [True, False]) -def test_label_outer(remove_ticks): - f, axs = plt.subplots(2, 2, sharex=True, sharey=True) +@pytest.mark.parametrize('layout_engine', ['none', 'tight', 'constrained']) +@pytest.mark.parametrize('with_colorbar', [True, False]) +def test_label_outer(remove_ticks, layout_engine, with_colorbar): + fig = plt.figure(layout=layout_engine) + axs = fig.subplots(2, 2, sharex=True, sharey=True) for ax in axs.flat: ax.set(xlabel="foo", ylabel="bar") + if with_colorbar: + fig.colorbar(mpl.cm.ScalarMappable(), ax=ax) ax.label_outer(remove_inner_ticks=remove_ticks) check_ticklabel_visible( axs.flat, [False, False, True, True], [True, False, True, False]) diff --git a/lib/matplotlib/tests/test_text.py b/lib/matplotlib/tests/test_text.py index 79a9e2d66c46..02cecea1c6c6 100644 --- a/lib/matplotlib/tests/test_text.py +++ b/lib/matplotlib/tests/test_text.py @@ -144,8 +144,8 @@ def test_multiline2(): fig, ax = plt.subplots() - ax.set_xlim([0, 1.4]) - ax.set_ylim([0, 2]) + ax.set_xlim(0, 1.4) + ax.set_ylim(0, 2) ax.axhline(0.5, color='C2', linewidth=0.3) sts = ['Line', '2 Lineg\n 2 Lg', '$\\sum_i x $', 'hi $\\sum_i x $\ntest', 'test\n $\\sum_i x $', '$\\sum_i x $\n $\\sum_i x $'] @@ -208,13 +208,6 @@ def test_antialiasing(): mpl.rcParams['text.antialiased'] = False # Should not affect existing text. -def test_afm_kerning(): - fn = mpl.font_manager.findfont("Helvetica", fontext="afm") - with open(fn, 'rb') as fh: - afm = mpl._afm.AFM(fh) - assert afm.string_width_height('VAVAVAVAVAVA') == (7174.0, 718) - - @image_comparison(['text_contains.png']) def test_contains(): fig = plt.figure() diff --git a/lib/matplotlib/tests/test_tightlayout.py b/lib/matplotlib/tests/test_tightlayout.py index f6b6d8f644cc..98fd5e70cdb9 100644 --- a/lib/matplotlib/tests/test_tightlayout.py +++ b/lib/matplotlib/tests/test_tightlayout.py @@ -331,8 +331,8 @@ def test_collapsed(): # zero (i.e. margins add up to more than the available width) that a call # to tight_layout will not get applied: fig, ax = plt.subplots(tight_layout=True) - ax.set_xlim([0, 1]) - ax.set_ylim([0, 1]) + ax.set_xlim(0, 1) + ax.set_ylim(0, 1) ax.annotate('BIG LONG STRING', xy=(1.25, 2), xytext=(10.5, 1.75), annotation_clip=False) diff --git a/lib/matplotlib/tests/test_transforms.py b/lib/matplotlib/tests/test_transforms.py index 99647e99bbde..b4db34db5a91 100644 --- a/lib/matplotlib/tests/test_transforms.py +++ b/lib/matplotlib/tests/test_transforms.py @@ -891,8 +891,7 @@ def test_str_transform(): Affine2D().scale(1.0))), PolarTransform( PolarAxes(0.125,0.1;0.775x0.8), - use_rmin=True, - apply_theta_transforms=False)), + use_rmin=True)), CompositeGenericTransform( CompositeGenericTransform( PolarAffine( diff --git a/lib/matplotlib/tests/test_type1font.py b/lib/matplotlib/tests/test_type1font.py index 9b8a2d1f07c6..b2f93ef28a26 100644 --- a/lib/matplotlib/tests/test_type1font.py +++ b/lib/matplotlib/tests/test_type1font.py @@ -5,7 +5,7 @@ def test_Type1Font(): - filename = os.path.join(os.path.dirname(__file__), 'cmr10.pfb') + filename = os.path.join(os.path.dirname(__file__), 'data', 'cmr10.pfb') font = t1f.Type1Font(filename) slanted = font.transform({'slant': 1}) condensed = font.transform({'extend': 0.5}) @@ -78,7 +78,7 @@ def test_Type1Font(): def test_Type1Font_2(): - filename = os.path.join(os.path.dirname(__file__), + filename = os.path.join(os.path.dirname(__file__), 'data', 'Courier10PitchBT-Bold.pfb') font = t1f.Type1Font(filename) assert font.prop['Weight'] == 'Bold' @@ -137,7 +137,7 @@ def test_tokenize_errors(): def test_overprecision(): # We used to output too many digits in FontMatrix entries and # ItalicAngle, which could make Type-1 parsers unhappy. - filename = os.path.join(os.path.dirname(__file__), 'cmr10.pfb') + filename = os.path.join(os.path.dirname(__file__), 'data', 'cmr10.pfb') font = t1f.Type1Font(filename) slanted = font.transform({'slant': .167}) lines = slanted.parts[0].decode('ascii').splitlines() diff --git a/lib/matplotlib/tests/test_usetex.py b/lib/matplotlib/tests/test_usetex.py index c7658c4f42ac..cd9f2597361b 100644 --- a/lib/matplotlib/tests/test_usetex.py +++ b/lib/matplotlib/tests/test_usetex.py @@ -1,3 +1,4 @@ +import re from tempfile import TemporaryFile import numpy as np @@ -156,6 +157,69 @@ def test_missing_psfont(fmt, monkeypatch): fig.savefig(tmpfile, format=fmt) +def test_pdf_type1_font_subsetting(): + """Test that fonts in PDF output are properly subset.""" + pikepdf = pytest.importorskip("pikepdf") + + mpl.rcParams["text.usetex"] = True + mpl.rcParams["text.latex.preamble"] = r"\usepackage{amssymb}" + fig, ax = plt.subplots() + ax.text(0.2, 0.7, r"$\int_{-\infty}^{\aleph}\sqrt{\alpha\beta\gamma}\mathrm{d}x$") + ax.text(0.2, 0.5, r"$\mathfrak{x}\circledcirc\mathfrak{y}\in\mathbb{R}$") + + with TemporaryFile() as tmpfile: + fig.savefig(tmpfile, format="pdf") + tmpfile.seek(0) + pdf = pikepdf.Pdf.open(tmpfile) + + length = {} + page = pdf.pages[0] + for font_name, font in page.Resources.Font.items(): + assert font.Subtype == "/Type1", ( + f"Font {font_name}={font} is not a Type 1 font" + ) + + # Subsetted font names have a 6-character tag followed by a '+' + base_font = str(font["/BaseFont"]).removeprefix("/") + assert re.match(r"^[A-Z]{6}\+", base_font), ( + f"Font {font_name}={base_font} lacks a subset indicator tag" + ) + assert "/FontFile" in font.FontDescriptor, ( + f"Type 1 font {font_name}={base_font} is not embedded" + ) + _, original_name = base_font.split("+", 1) + length[original_name] = len(bytes(font["/FontDescriptor"]["/FontFile"])) + + print("Embedded font stream lengths:", length) + # We should have several fonts, each much smaller than the original. + # I get under 10kB on my system for each font, but allow 15kB in case + # of differences in the font files. + assert { + 'CMEX10', + 'CMMI12', + 'CMR12', + 'CMSY10', + 'CMSY8', + 'EUFM10', + 'MSAM10', + 'MSBM10', + }.issubset(length), "Missing expected fonts in the PDF" + for font_name, length in length.items(): + assert length < 15_000, ( + f"Font {font_name}={length} is larger than expected" + ) + + # For comparison, lengths without subsetting on my system: + # 'CMEX10': 29686 + # 'CMMI12': 36176 + # 'CMR12': 32157 + # 'CMSY10': 32004 + # 'CMSY8': 32061 + # 'EUFM10': 20546 + # 'MSAM10': 31199 + # 'MSBM10': 34129 + + try: _old_gs_version = mpl._get_executable_info('gs').version < parse_version('9.55') except mpl.ExecutableNotFoundError: @@ -168,8 +232,8 @@ def test_rotation(): mpl.rcParams['text.usetex'] = True fig = plt.figure() - ax = fig.add_axes([0, 0, 1, 1]) - ax.set(xlim=[-0.5, 5], xticks=[], ylim=[-0.5, 3], yticks=[], frame_on=False) + ax = fig.add_axes((0, 0, 1, 1)) + ax.set(xlim=(-0.5, 5), xticks=[], ylim=(-0.5, 3), yticks=[], frame_on=False) text = {val: val[0] for val in ['top', 'center', 'bottom', 'left', 'right']} text['baseline'] = 'B' @@ -185,3 +249,10 @@ def test_rotation(): # 'My' checks full height letters plus descenders. ax.text(x, y, f"$\\mathrm{{My {text[ha]}{text[va]} {angle}}}$", rotation=angle, horizontalalignment=ha, verticalalignment=va) + + +def test_unicode_sizing(): + tp = mpl.textpath.TextToPath() + scale1 = tp.get_glyphs_tex(mpl.font_manager.FontProperties(), "W")[0][0][3] + scale2 = tp.get_glyphs_tex(mpl.font_manager.FontProperties(), r"\textwon")[0][0][3] + assert scale1 == scale2 diff --git a/lib/matplotlib/tests/tinypages/.gitignore b/lib/matplotlib/tests/tinypages/.gitignore deleted file mode 100644 index 69fa449dd96e..000000000000 --- a/lib/matplotlib/tests/tinypages/.gitignore +++ /dev/null @@ -1 +0,0 @@ -_build/ diff --git a/lib/matplotlib/texmanager.py b/lib/matplotlib/texmanager.py index 94fc94e9e840..020a26e31cbe 100644 --- a/lib/matplotlib/texmanager.py +++ b/lib/matplotlib/texmanager.py @@ -67,6 +67,13 @@ class TexManager: _grey_arrayd = {} _font_families = ('serif', 'sans-serif', 'cursive', 'monospace') + # Check for the cm-super package (which registers unicode computer modern + # support just by being installed) without actually loading any package + # (because we already load the incompatible fix-cm). + _check_cmsuper_installed = ( + r'\IfFileExists{type1ec.sty}{}{\PackageError{matplotlib-support}{' + r'Missing cm-super package, required by Matplotlib}{}}' + ) _font_preambles = { 'new century schoolbook': r'\renewcommand{\rmdefault}{pnc}', 'bookman': r'\renewcommand{\rmdefault}{pbk}', @@ -80,13 +87,10 @@ class TexManager: 'helvetica': r'\usepackage{helvet}', 'avant garde': r'\usepackage{avant}', 'courier': r'\usepackage{courier}', - # Loading the type1ec package ensures that cm-super is installed, which - # is necessary for Unicode computer modern. (It also allows the use of - # computer modern at arbitrary sizes, but that's just a side effect.) - 'monospace': r'\usepackage{type1ec}', - 'computer modern roman': r'\usepackage{type1ec}', - 'computer modern sans serif': r'\usepackage{type1ec}', - 'computer modern typewriter': r'\usepackage{type1ec}', + 'monospace': _check_cmsuper_installed, + 'computer modern roman': _check_cmsuper_installed, + 'computer modern sans serif': _check_cmsuper_installed, + 'computer modern typewriter': _check_cmsuper_installed, } _font_types = { 'new century schoolbook': 'serif', @@ -200,6 +204,7 @@ def _get_tex_source(cls, tex, fontsize): font_preamble, fontcmd = cls._get_font_preamble_and_command() baselineskip = 1.25 * fontsize return "\n".join([ + r"\RequirePackage{fix-cm}", r"\documentclass{article}", r"% Pass-through \mathdefault, which is used in non-usetex mode", r"% to use the default text font but was historically suppressed", diff --git a/lib/matplotlib/text.py b/lib/matplotlib/text.py index 3b0de58814d9..acde4fb179a2 100644 --- a/lib/matplotlib/text.py +++ b/lib/matplotlib/text.py @@ -1553,9 +1553,7 @@ def _get_xy_transform(self, renderer, coords): return self.axes.transData elif coords == 'polar': from matplotlib.projections import PolarAxes - tr = PolarAxes.PolarTransform(apply_theta_transforms=False) - trans = tr + self.axes.transData - return trans + return PolarAxes.PolarTransform() + self.axes.transData try: bbox_name, unit = coords.split() diff --git a/lib/matplotlib/text.pyi b/lib/matplotlib/text.pyi index 9cdfd9596a7d..41c7b761ae32 100644 --- a/lib/matplotlib/text.pyi +++ b/lib/matplotlib/text.pyi @@ -14,7 +14,7 @@ from .transforms import ( Transform, ) -from collections.abc import Callable, Iterable +from collections.abc import Iterable from typing import Any, Literal from .typing import ColorType, CoordsType diff --git a/lib/matplotlib/transforms.py b/lib/matplotlib/transforms.py index 2cca56f04457..350113c56170 100644 --- a/lib/matplotlib/transforms.py +++ b/lib/matplotlib/transforms.py @@ -35,7 +35,6 @@ # `np.minimum` instead of the builtin `min`, and likewise for `max`. This is # done so that `nan`s are propagated, instead of being silently dropped. -import copy import functools import itertools import textwrap @@ -98,7 +97,6 @@ class TransformNode: # Some metadata about the transform, used to determine whether an # invalidation is affine-only is_affine = False - is_bbox = _api.deprecated("3.9")(_api.classproperty(lambda cls: False)) pass_through = False """ @@ -140,7 +138,9 @@ def __setstate__(self, data_dict): for k, v in self._parents.items() if v is not None} def __copy__(self): - other = copy.copy(super()) + cls = type(self) + other = cls.__new__(cls) + other.__dict__.update(self.__dict__) # If `c = a + b; a1 = copy(a)`, then modifications to `a1` do not # propagate back to `c`, i.e. we need to clear the parents of `a1`. other._parents = {} @@ -216,7 +216,6 @@ class BboxBase(TransformNode): and height, but these are not stored explicitly. """ - is_bbox = _api.deprecated("3.9")(_api.classproperty(lambda cls: True)) is_affine = True if DEBUG: @@ -2627,27 +2626,6 @@ def get_matrix(self): return self._mtx -@_api.deprecated("3.9") -class BboxTransformToMaxOnly(BboxTransformTo): - """ - `BboxTransformToMaxOnly` is a transformation that linearly transforms points from - the unit bounding box to a given `Bbox` with a fixed upper left of (0, 0). - """ - def get_matrix(self): - # docstring inherited - if self._invalid: - xmax, ymax = self._boxout.max - if DEBUG and (xmax == 0 or ymax == 0): - raise ValueError("Transforming to a singular bounding box.") - self._mtx = np.array([[xmax, 0.0, 0.0], - [ 0.0, ymax, 0.0], - [ 0.0, 0.0, 1.0]], - float) - self._inverted = None - self._invalid = 0 - return self._mtx - - class BboxTransformFrom(Affine2DBase): """ `BboxTransformFrom` linearly transforms points from a given `Bbox` to the diff --git a/lib/matplotlib/transforms.pyi b/lib/matplotlib/transforms.pyi index 551487a11c60..07d299be297c 100644 --- a/lib/matplotlib/transforms.pyi +++ b/lib/matplotlib/transforms.pyi @@ -12,7 +12,6 @@ class TransformNode: INVALID_NON_AFFINE: int INVALID_AFFINE: int INVALID: int - is_bbox: bool # Implemented as a standard attr in base class, but functionally readonly and some subclasses implement as such @property def is_affine(self) -> bool: ... @@ -24,7 +23,6 @@ class TransformNode: def frozen(self) -> TransformNode: ... class BboxBase(TransformNode): - is_bbox: bool is_affine: bool def frozen(self) -> Bbox: ... def __array__(self, *args, **kwargs): ... @@ -295,8 +293,6 @@ class BboxTransform(Affine2DBase): class BboxTransformTo(Affine2DBase): def __init__(self, boxout: BboxBase, **kwargs) -> None: ... -class BboxTransformToMaxOnly(BboxTransformTo): ... - class BboxTransformFrom(Affine2DBase): def __init__(self, boxin: BboxBase, **kwargs) -> None: ... diff --git a/lib/matplotlib/widgets.py b/lib/matplotlib/widgets.py index 6b196571814d..9ded7c61ce2d 100644 --- a/lib/matplotlib/widgets.py +++ b/lib/matplotlib/widgets.py @@ -273,10 +273,10 @@ def __init__(self, ax, orientation, closedmin, closedmax, self.valfmt = valfmt if orientation == "vertical": - ax.set_ylim((valmin, valmax)) + ax.set_ylim(valmin, valmax) axis = ax.yaxis else: - ax.set_xlim((valmin, valmax)) + ax.set_xlim(valmin, valmax) axis = ax.xaxis self._fmt = axis.get_major_formatter() @@ -1841,7 +1841,7 @@ def __init__(self, targetfig, toolfig): self.sliderbottom.slidermax = self.slidertop self.slidertop.slidermin = self.sliderbottom - bax = toolfig.add_axes([0.8, 0.05, 0.15, 0.075]) + bax = toolfig.add_axes((0.8, 0.05, 0.15, 0.075)) self.buttonreset = Button(bax, 'Reset') self.buttonreset.on_clicked(self._on_reset) diff --git a/lib/mpl_toolkits/axes_grid1/parasite_axes.py b/lib/mpl_toolkits/axes_grid1/parasite_axes.py index f7bc2df6d7e0..fbc6e8141272 100644 --- a/lib/mpl_toolkits/axes_grid1/parasite_axes.py +++ b/lib/mpl_toolkits/axes_grid1/parasite_axes.py @@ -25,6 +25,9 @@ def clear(self): self._parent_axes.callbacks._connect_picklable( "ylim_changed", self._sync_lims) + def get_axes_locator(self): + return self._parent_axes.get_axes_locator() + def pick(self, mouseevent): # This most likely goes to Artist.pick (depending on axes_class given # to the factory), which only handles pick events registered on the diff --git a/lib/mpl_toolkits/axes_grid1/tests/test_axes_grid1.py b/lib/mpl_toolkits/axes_grid1/tests/test_axes_grid1.py index 496ce74d72c0..b6d72e408a52 100644 --- a/lib/mpl_toolkits/axes_grid1/tests/test_axes_grid1.py +++ b/lib/mpl_toolkits/axes_grid1/tests/test_axes_grid1.py @@ -9,7 +9,7 @@ from matplotlib.backend_bases import MouseEvent from matplotlib.colors import LogNorm from matplotlib.patches import Circle, Ellipse -from matplotlib.transforms import Bbox, TransformedBbox +from matplotlib.transforms import Affine2D, Bbox, TransformedBbox from matplotlib.testing.decorators import ( check_figures_equal, image_comparison, remove_ticks_and_titles) @@ -26,6 +26,7 @@ from mpl_toolkits.axes_grid1.axes_rgb import RGBAxes from mpl_toolkits.axes_grid1.inset_locator import ( zoomed_inset_axes, mark_inset, inset_axes, BboxConnectorPatch) +from mpl_toolkits.axes_grid1.parasite_axes import HostAxes import mpl_toolkits.axes_grid1.mpl_axes import pytest @@ -467,6 +468,26 @@ def test_gettightbbox(): [-17.7, -13.9, 7.2, 5.4]) +def test_gettightbbox_parasite(): + fig = plt.figure() + + y0 = 0.3 + horiz = [Size.Scaled(1.0)] + vert = [Size.Scaled(1.0)] + ax0_div = Divider(fig, [0.1, y0, 0.8, 0.2], horiz, vert) + ax1_div = Divider(fig, [0.1, 0.5, 0.8, 0.4], horiz, vert) + + ax0 = fig.add_subplot( + xticks=[], yticks=[], axes_locator=ax0_div.new_locator(nx=0, ny=0)) + ax1 = fig.add_subplot( + axes_class=HostAxes, axes_locator=ax1_div.new_locator(nx=0, ny=0)) + aux_ax = ax1.get_aux_axes(Affine2D()) + + fig.canvas.draw() + rdr = fig.canvas.get_renderer() + assert rdr.get_canvas_width_height()[1] * y0 / fig.dpi == fig.get_tightbbox(rdr).y0 + + @pytest.mark.parametrize("click_on", ["big", "small"]) @pytest.mark.parametrize("big_on_axes,small_on_axes", [ ("gca", "gca"), @@ -678,7 +699,7 @@ def test_mark_inset_unstales_viewlim(fig_test, fig_ref): def test_auto_adjustable(): fig = plt.figure() - ax = fig.add_axes([0, 0, 1, 1]) + ax = fig.add_axes((0, 0, 1, 1)) pad = 0.1 make_axes_area_auto_adjustable(ax, pad=pad) fig.canvas.draw() diff --git a/lib/mpl_toolkits/axisartist/tests/test_axislines.py b/lib/mpl_toolkits/axisartist/tests/test_axislines.py index 8bc3707421b6..a1485d4f436b 100644 --- a/lib/mpl_toolkits/axisartist/tests/test_axislines.py +++ b/lib/mpl_toolkits/axisartist/tests/test_axislines.py @@ -83,8 +83,8 @@ def test_ParasiteAxesAuxTrans(): getattr(ax2, name)(xx, yy, data[:-1, :-1]) else: getattr(ax2, name)(xx, yy, data) - ax1.set_xlim((0, 5)) - ax1.set_ylim((0, 5)) + ax1.set_xlim(0, 5) + ax1.set_ylim(0, 5) ax2.contour(xx, yy, data, colors='k') diff --git a/lib/mpl_toolkits/axisartist/tests/test_floating_axes.py b/lib/mpl_toolkits/axisartist/tests/test_floating_axes.py index 362384221bdd..feb667af013e 100644 --- a/lib/mpl_toolkits/axisartist/tests/test_floating_axes.py +++ b/lib/mpl_toolkits/axisartist/tests/test_floating_axes.py @@ -26,7 +26,7 @@ def test_curvelinear3(): fig = plt.figure(figsize=(5, 5)) tr = (mtransforms.Affine2D().scale(np.pi / 180, 1) + - mprojections.PolarAxes.PolarTransform(apply_theta_transforms=False)) + mprojections.PolarAxes.PolarTransform()) grid_helper = GridHelperCurveLinear( tr, extremes=(0, 360, 10, 3), @@ -75,7 +75,7 @@ def test_curvelinear4(): fig = plt.figure(figsize=(5, 5)) tr = (mtransforms.Affine2D().scale(np.pi / 180, 1) + - mprojections.PolarAxes.PolarTransform(apply_theta_transforms=False)) + mprojections.PolarAxes.PolarTransform()) grid_helper = GridHelperCurveLinear( tr, extremes=(120, 30, 10, 0), diff --git a/lib/mpl_toolkits/axisartist/tests/test_grid_helper_curvelinear.py b/lib/mpl_toolkits/axisartist/tests/test_grid_helper_curvelinear.py index 1b266044bdd0..7d6554782fe6 100644 --- a/lib/mpl_toolkits/axisartist/tests/test_grid_helper_curvelinear.py +++ b/lib/mpl_toolkits/axisartist/tests/test_grid_helper_curvelinear.py @@ -82,8 +82,7 @@ def test_polar_box(): # PolarAxes.PolarTransform takes radian. However, we want our coordinate # system in degree - tr = (Affine2D().scale(np.pi / 180., 1.) + - PolarAxes.PolarTransform(apply_theta_transforms=False)) + tr = Affine2D().scale(np.pi / 180., 1.) + PolarAxes.PolarTransform() # polar projection, which involves cycle, and also has limits in # its coordinates, needs a special method to find the extremes @@ -145,8 +144,7 @@ def test_axis_direction(): # PolarAxes.PolarTransform takes radian. However, we want our coordinate # system in degree - tr = (Affine2D().scale(np.pi / 180., 1.) + - PolarAxes.PolarTransform(apply_theta_transforms=False)) + tr = Affine2D().scale(np.pi / 180., 1.) + PolarAxes.PolarTransform() # polar projection, which involves cycle, and also has limits in # its coordinates, needs a special method to find the extremes diff --git a/lib/mpl_toolkits/mplot3d/tests/test_axes3d.py b/lib/mpl_toolkits/mplot3d/tests/test_axes3d.py index 79c7baba9bd1..cd45c8e33a6f 100644 --- a/lib/mpl_toolkits/mplot3d/tests/test_axes3d.py +++ b/lib/mpl_toolkits/mplot3d/tests/test_axes3d.py @@ -2227,9 +2227,9 @@ def test_computed_zorder(): # plot some points ax.scatter((3, 3), (1, 3), (1, 3), c='red', zorder=10) - ax.set_xlim((0, 5.0)) - ax.set_ylim((0, 5.0)) - ax.set_zlim((0, 2.5)) + ax.set_xlim(0, 5.0) + ax.set_ylim(0, 5.0) + ax.set_zlim(0, 2.5) ax3 = fig.add_subplot(223, projection='3d') ax4 = fig.add_subplot(224, projection='3d') diff --git a/pyproject.toml b/pyproject.toml index dc951375bba2..b580feff930e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -46,7 +46,7 @@ requires-python = ">=3.11" [project.optional-dependencies] # Should be a copy of the build dependencies below. dev = [ - "meson-python>=0.13.1,<0.17.0", + "meson-python>=0.13.1,!=0.17.*", "pybind11>=2.13.2,!=2.13.3", "setuptools_scm>=7", # Not required by us but setuptools_scm without a version, cso _if_ @@ -70,7 +70,9 @@ dev = [ build-backend = "mesonpy" # Also keep in sync with optional dependencies above. requires = [ - "meson-python>=0.13.1,<0.17.0", + # meson-python 0.17.x breaks symlinks in sdists. You can remove this pin if + # you really need it and aren't using an sdist. + "meson-python>=0.13.1,!=0.17.*", "pybind11>=2.13.2,!=2.13.3", "setuptools_scm>=7", ] @@ -91,21 +93,13 @@ sections = "FUTURE,STDLIB,THIRDPARTY,PYDATA,FIRSTPARTY,LOCALFOLDER" force_sort_within_sections = true [tool.ruff] -exclude = [ - ".git", +extend-exclude = [ "build", "doc/gallery", "doc/tutorials", "tools/gh_api.py", - ".tox", - ".eggs", - # TODO: fix .pyi files - "*.pyi", - # TODO: fix .ipynb files - "*.ipynb" ] line-length = 88 -target-version = "py311" [tool.ruff.lint] ignore = [ @@ -131,9 +125,7 @@ ignore = [ "D404", "D413", "D415", - "D416", "D417", - "E24", "E266", "E305", "E306", @@ -173,15 +165,14 @@ external = [ convention = "numpy" [tool.ruff.lint.per-file-ignores] +"*.pyi" = ["E501"] +"*.ipynb" = ["E402"] "doc/conf.py" = ["E402"] -"galleries/examples/animation/frame_grabbing_sgskip.py" = ["E402"] "galleries/examples/images_contours_and_fields/tricontour_demo.py" = ["E201"] "galleries/examples/images_contours_and_fields/tripcolor_demo.py" = ["E201"] "galleries/examples/images_contours_and_fields/triplot_demo.py" = ["E201"] "galleries/examples/lines_bars_and_markers/marker_reference.py" = ["E402"] -"galleries/examples/misc/print_stdout_sgskip.py" = ["E402"] "galleries/examples/misc/table_demo.py" = ["E201"] -"galleries/examples/style_sheets/bmh.py" = ["E501"] "galleries/examples/subplots_axes_and_figures/demo_constrained_layout.py" = ["E402"] "galleries/examples/text_labels_and_annotations/custom_legends.py" = ["E402"] "galleries/examples/ticks/date_concise_formatter.py" = ["E402"] @@ -195,7 +186,6 @@ convention = "numpy" "galleries/examples/user_interfaces/mpl_with_glade3_sgskip.py" = ["E402"] "galleries/examples/user_interfaces/pylab_with_gtk3_sgskip.py" = ["E402"] "galleries/examples/user_interfaces/pylab_with_gtk4_sgskip.py" = ["E402"] -"galleries/examples/userdemo/pgf_preamble_sgskip.py" = ["E402"] "lib/matplotlib/__init__.py" = ["F822"] "lib/matplotlib/_cm.py" = ["E202", "E203", "E302"] @@ -211,21 +201,17 @@ convention = "numpy" "lib/mpl_toolkits/axisartist/angle_helper.py" = ["E221"] "lib/mpl_toolkits/mplot3d/proj3d.py" = ["E201"] -"galleries/users_explain/artists/paths.py" = ["E402"] "galleries/users_explain/quick_start.py" = ["E402"] "galleries/users_explain/artists/patheffects_guide.py" = ["E402"] -"galleries/users_explain/artists/transforms_tutorial.py" = ["E402", "E501"] -"galleries/users_explain/colors/colormaps.py" = ["E501"] +"galleries/users_explain/artists/transforms_tutorial.py" = ["E402"] "galleries/users_explain/colors/colors.py" = ["E402"] "galleries/tutorials/artists.py" = ["E402"] "galleries/users_explain/axes/constrainedlayout_guide.py" = ["E402"] "galleries/users_explain/axes/legend_guide.py" = ["E402"] "galleries/users_explain/axes/tight_layout_guide.py" = ["E402"] "galleries/users_explain/animations/animations.py" = ["E501"] -"galleries/tutorials/images.py" = ["E501"] "galleries/tutorials/pyplot.py" = ["E402", "E501"] "galleries/users_explain/text/annotations.py" = ["E402", "E501"] -"galleries/users_explain/text/mathtext.py" = ["E501"] "galleries/users_explain/text/text_intro.py" = ["E402"] "galleries/users_explain/text/text_props.py" = ["E501"] @@ -236,18 +222,12 @@ enable_error_code = [ "redundant-expr", "truthy-bool", ] -enable_incomplete_feature = [ - "Unpack", -] exclude = [ #stubtest ".*/matplotlib/(sphinxext|backends|pylab|testing/jpl_units)", #mypy precommit "galleries/", "doc/", - "lib/matplotlib/backends/", - "lib/matplotlib/sphinxext", - "lib/matplotlib/testing/jpl_units", "lib/mpl_toolkits/", #removing tests causes errors in backends "lib/matplotlib/tests/", diff --git a/src/_backend_agg.h b/src/_backend_agg.h index 6ecbcba1df18..1ac3d4c06b13 100644 --- a/src/_backend_agg.h +++ b/src/_backend_agg.h @@ -11,6 +11,8 @@ #include #include #include +#include +#include #include "agg_alpha_mask_u8.h" #include "agg_conv_curve.h" @@ -102,8 +104,6 @@ class BufferRegion int stride; }; -#define MARKER_CACHE_SIZE 512 - // the renderer class RendererAgg { @@ -124,9 +124,6 @@ class RendererAgg typedef agg::renderer_base renderer_base_alpha_mask_type; typedef agg::renderer_scanline_aa_solid renderer_alpha_mask_type; - /* TODO: Remove facepair_t */ - typedef std::pair facepair_t; - RendererAgg(unsigned int width, unsigned int height, double dpi); virtual ~RendererAgg(); @@ -249,7 +246,7 @@ class RendererAgg bool render_clippath(mpl::PathIterator &clippath, const agg::trans_affine &clippath_trans, e_snap_mode snap_mode); template - void _draw_path(PathIteratorType &path, bool has_clippath, const facepair_t &face, GCAgg &gc); + void _draw_path(PathIteratorType &path, bool has_clippath, const std::optional &face, GCAgg &gc); template inline void -RendererAgg::_draw_path(path_t &path, bool has_clippath, const facepair_t &face, GCAgg &gc) +RendererAgg::_draw_path(path_t &path, bool has_clippath, const std::optional &face, GCAgg &gc) { typedef agg::conv_stroke stroke_t; typedef agg::conv_dash dash_t; @@ -307,7 +304,7 @@ RendererAgg::_draw_path(path_t &path, bool has_clippath, const facepair_t &face, typedef agg::renderer_scanline_bin_solid amask_bin_renderer_type; // Render face - if (face.first) { + if (face) { theRasterizer.add_path(path); if (gc.isaa) { @@ -315,10 +312,10 @@ RendererAgg::_draw_path(path_t &path, bool has_clippath, const facepair_t &face, pixfmt_amask_type pfa(pixFmt, alphaMask); amask_ren_type r(pfa); amask_aa_renderer_type ren(r); - ren.color(face.second); + ren.color(*face); agg::render_scanlines(theRasterizer, scanlineAlphaMask, ren); } else { - rendererAA.color(face.second); + rendererAA.color(*face); agg::render_scanlines(theRasterizer, slineP8, rendererAA); } } else { @@ -326,10 +323,10 @@ RendererAgg::_draw_path(path_t &path, bool has_clippath, const facepair_t &face, pixfmt_amask_type pfa(pixFmt, alphaMask); amask_ren_type r(pfa); amask_bin_renderer_type ren(r); - ren.color(face.second); + ren.color(*face); agg::render_scanlines(theRasterizer, scanlineAlphaMask, ren); } else { - rendererBin.color(face.second); + rendererBin.color(*face); agg::render_scanlines(theRasterizer, slineP8, rendererBin); } } @@ -459,7 +456,10 @@ RendererAgg::draw_path(GCAgg &gc, PathIterator &path, agg::trans_affine &trans, typedef agg::conv_curve curve_t; typedef Sketch sketch_t; - facepair_t face(color.a != 0.0, color); + std::optional face; + if (color.a != 0.0) { + face = color; + } theRasterizer.reset_clipping(); rendererBase.reset_clipping(true); @@ -468,7 +468,7 @@ RendererAgg::draw_path(GCAgg &gc, PathIterator &path, agg::trans_affine &trans, trans *= agg::trans_affine_scaling(1.0, -1.0); trans *= agg::trans_affine_translation(0.0, (double)height); - bool clip = !face.first && !gc.has_hatchpath(); + bool clip = !face && !gc.has_hatchpath(); bool simplify = path.should_simplify() && clip; double snapping_linewidth = points_to_pixels(gc.linewidth); if (gc.color.a == 0.0) { @@ -530,7 +530,10 @@ inline void RendererAgg::draw_markers(GCAgg &gc, curve_t path_curve(path_snapped); path_curve.rewind(0); - facepair_t face(color.a != 0.0, color); + std::optional face; + if (color.a != 0.0) { + face = color; + } // maxim's suggestions for cached scanlines agg::scanline_storage_aa8 scanlines; @@ -539,22 +542,14 @@ inline void RendererAgg::draw_markers(GCAgg &gc, rendererBase.reset_clipping(true); agg::rect_i marker_size(0x7FFFFFFF, 0x7FFFFFFF, -0x7FFFFFFF, -0x7FFFFFFF); - agg::int8u staticFillCache[MARKER_CACHE_SIZE]; - agg::int8u staticStrokeCache[MARKER_CACHE_SIZE]; - agg::int8u *fillCache = staticFillCache; - agg::int8u *strokeCache = staticStrokeCache; - try { - unsigned fillSize = 0; - if (face.first) { + std::vector fillBuffer; + if (face) { theRasterizer.add_path(marker_path_curve); agg::render_scanlines(theRasterizer, slineP8, scanlines); - fillSize = scanlines.byte_size(); - if (fillSize >= MARKER_CACHE_SIZE) { - fillCache = new agg::int8u[fillSize]; - } - scanlines.serialize(fillCache); + fillBuffer.resize(scanlines.byte_size()); + scanlines.serialize(fillBuffer.data()); marker_size = agg::rect_i(scanlines.min_x(), scanlines.min_y(), scanlines.max_x(), @@ -569,11 +564,8 @@ inline void RendererAgg::draw_markers(GCAgg &gc, theRasterizer.reset(); theRasterizer.add_path(stroke); agg::render_scanlines(theRasterizer, slineP8, scanlines); - unsigned strokeSize = scanlines.byte_size(); - if (strokeSize >= MARKER_CACHE_SIZE) { - strokeCache = new agg::int8u[strokeSize]; - } - scanlines.serialize(strokeCache); + std::vector strokeBuffer(scanlines.byte_size()); + scanlines.serialize(strokeBuffer.data()); marker_size = agg::rect_i(std::min(marker_size.x1, scanlines.min_x()), std::min(marker_size.y1, scanlines.min_y()), std::max(marker_size.x2, scanlines.max_x()), @@ -617,13 +609,13 @@ inline void RendererAgg::draw_markers(GCAgg &gc, amask_ren_type r(pfa); amask_aa_renderer_type ren(r); - if (face.first) { - ren.color(face.second); - sa.init(fillCache, fillSize, x, y); + if (face) { + ren.color(*face); + sa.init(fillBuffer.data(), fillBuffer.size(), x, y); agg::render_scanlines(sa, sl, ren); } ren.color(gc.color); - sa.init(strokeCache, strokeSize, x, y); + sa.init(strokeBuffer.data(), strokeBuffer.size(), x, y); agg::render_scanlines(sa, sl, ren); } } else { @@ -645,34 +637,25 @@ inline void RendererAgg::draw_markers(GCAgg &gc, continue; } - if (face.first) { - rendererAA.color(face.second); - sa.init(fillCache, fillSize, x, y); + if (face) { + rendererAA.color(*face); + sa.init(fillBuffer.data(), fillBuffer.size(), x, y); agg::render_scanlines(sa, sl, rendererAA); } rendererAA.color(gc.color); - sa.init(strokeCache, strokeSize, x, y); + sa.init(strokeBuffer.data(), strokeBuffer.size(), x, y); agg::render_scanlines(sa, sl, rendererAA); } } } catch (...) { - if (fillCache != staticFillCache) - delete[] fillCache; - if (strokeCache != staticStrokeCache) - delete[] strokeCache; theRasterizer.reset_clipping(); rendererBase.reset_clipping(true); throw; } - if (fillCache != staticFillCache) - delete[] fillCache; - if (strokeCache != staticStrokeCache) - delete[] strokeCache; - theRasterizer.reset_clipping(); rendererBase.reset_clipping(true); } @@ -957,10 +940,9 @@ inline void RendererAgg::_draw_path_collection_generic(GCAgg &gc, // Set some defaults, assuming no face or edge gc.linewidth = 0.0; - facepair_t face; - face.first = Nfacecolors != 0; + std::optional face; agg::trans_affine trans; - bool do_clip = !face.first && !gc.has_hatchpath(); + bool do_clip = Nfacecolors == 0 && !gc.has_hatchpath(); for (int i = 0; i < (int)N; ++i) { typename PathGenerator::path_iterator path = path_generator(i); @@ -991,7 +973,7 @@ inline void RendererAgg::_draw_path_collection_generic(GCAgg &gc, if (Nfacecolors) { int ic = i % Nfacecolors; - face.second = agg::rgba(facecolors(ic, 0), facecolors(ic, 1), facecolors(ic, 2), facecolors(ic, 3)); + face.emplace(facecolors(ic, 0), facecolors(ic, 1), facecolors(ic, 2), facecolors(ic, 3)); } if (Nedgecolors) { diff --git a/src/_image_wrapper.cpp b/src/_image_wrapper.cpp index 0f7b0da88de8..6528c4a9270c 100644 --- a/src/_image_wrapper.cpp +++ b/src/_image_wrapper.cpp @@ -1,6 +1,8 @@ #include #include +#include + #include "_image_resample.h" #include "py_converters.h" @@ -54,7 +56,7 @@ _get_transform_mesh(const py::object& transform, const py::ssize_t *dims) /* TODO: Could we get away with float, rather than double, arrays here? */ /* Given a non-affine transform object, create a mesh that maps - every pixel in the output image to the input image. This is used + every pixel center in the output image to the input image. This is used as a lookup table during the actual resampling. */ // If attribute doesn't exist, raises Python AttributeError @@ -66,8 +68,10 @@ _get_transform_mesh(const py::object& transform, const py::ssize_t *dims) for (auto y = 0; y < dims[0]; ++y) { for (auto x = 0; x < dims[1]; ++x) { - *p++ = (double)x; - *p++ = (double)y; + // The convention for the supplied transform is that pixel centers + // are at 0.5, 1.5, 2.5, etc. + *p++ = (double)x + 0.5; + *p++ = (double)y + 0.5; } } @@ -200,6 +204,80 @@ image_resample(py::array input_array, } +// This is used by matplotlib.testing.compare to calculate RMS and a difference image. +static py::tuple +calculate_rms_and_diff(py::array_t expected_image, + py::array_t actual_image) +{ + for (const auto & [image, name] : {std::pair{expected_image, "Expected"}, + std::pair{actual_image, "Actual"}}) + { + if (image.ndim() != 3) { + auto exceptions = py::module_::import("matplotlib.testing.exceptions"); + auto ImageComparisonFailure = exceptions.attr("ImageComparisonFailure"); + py::set_error( + ImageComparisonFailure, + "{name} image must be 3-dimensional, but is {ndim}-dimensional"_s.format( + "name"_a=name, "ndim"_a=image.ndim())); + throw py::error_already_set(); + } + } + + auto height = expected_image.shape(0); + auto width = expected_image.shape(1); + auto depth = expected_image.shape(2); + + if (depth != 3 && depth != 4) { + auto exceptions = py::module_::import("matplotlib.testing.exceptions"); + auto ImageComparisonFailure = exceptions.attr("ImageComparisonFailure"); + py::set_error( + ImageComparisonFailure, + "Image must be RGB or RGBA but has depth {depth}"_s.format( + "depth"_a=depth)); + throw py::error_already_set(); + } + + if (height != actual_image.shape(0) || width != actual_image.shape(1) || + depth != actual_image.shape(2)) { + auto exceptions = py::module_::import("matplotlib.testing.exceptions"); + auto ImageComparisonFailure = exceptions.attr("ImageComparisonFailure"); + py::set_error( + ImageComparisonFailure, + "Image sizes do not match expected size: {expected_image.shape} "_s + "actual size {actual_image.shape}"_s.format( + "expected_image"_a=expected_image, "actual_image"_a=actual_image)); + throw py::error_already_set(); + } + auto expected = expected_image.unchecked<3>(); + auto actual = actual_image.unchecked<3>(); + + py::ssize_t diff_dims[3] = {height, width, 3}; + py::array_t diff_image(diff_dims); + auto diff = diff_image.mutable_unchecked<3>(); + + double total = 0.0; + for (auto i = 0; i < height; i++) { + for (auto j = 0; j < width; j++) { + for (auto k = 0; k < depth; k++) { + auto pixel_diff = static_cast(expected(i, j, k)) - + static_cast(actual(i, j, k)); + + total += pixel_diff*pixel_diff; + + if (k != 3) { // Hard-code a fully solid alpha channel by omitting it. + diff(i, j, k) = static_cast(std::clamp( + abs(pixel_diff) * 10, // Expand differences in luminance domain. + 0.0, 255.0)); + } + } + } + } + total = total / (width * height * depth); + + return py::make_tuple(sqrt(total), diff_image); +} + + PYBIND11_MODULE(_image, m, py::mod_gil_not_used()) { py::enum_(m, "_InterpolationType") @@ -232,4 +310,7 @@ PYBIND11_MODULE(_image, m, py::mod_gil_not_used()) "norm"_a = false, "radius"_a = 1, image_resample__doc__); + + m.def("calculate_rms_and_diff", &calculate_rms_and_diff, + "expected_image"_a, "actual_image"_a); } diff --git a/src/_macosx.m b/src/_macosx.m index aa2a6e68cda5..1372157bc80d 100755 --- a/src/_macosx.m +++ b/src/_macosx.m @@ -258,7 +258,7 @@ static void lazy_init(void) { } static PyObject* -stop(PyObject* self) +stop(PyObject* self, PyObject* _ /* ignored */) { stopWithEvent(); Py_RETURN_NONE; @@ -1863,7 +1863,7 @@ - (void)flagsChanged:(NSEvent *)event "written on the file descriptor given as argument.")}, {"stop", (PyCFunction)stop, - METH_NOARGS, + METH_VARARGS, PyDoc_STR("Stop the NSApp.")}, {"show", (PyCFunction)show, diff --git a/src/ft2font.cpp b/src/ft2font.cpp index 94c554cf9f63..da1bd19dca57 100644 --- a/src/ft2font.cpp +++ b/src/ft2font.cpp @@ -1,5 +1,8 @@ /* -*- mode: c++; c-basic-offset: 4 -*- */ +#include "ft2font.h" +#include "mplutils.h" + #include #include #include @@ -9,9 +12,6 @@ #include #include -#include "ft2font.h" -#include "mplutils.h" - #ifndef M_PI #define M_PI 3.14159265358979323846264338328 #endif @@ -43,71 +43,23 @@ FT_Library _ft2Library; -// FreeType error codes; loaded as per fterror.h. -static char const* ft_error_string(FT_Error error) { -#undef __FTERRORS_H__ -#define FT_ERROR_START_LIST switch (error) { -#define FT_ERRORDEF( e, v, s ) case v: return s; -#define FT_ERROR_END_LIST default: return NULL; } -#include FT_ERRORS_H -} - -void throw_ft_error(std::string message, FT_Error error) { - char const* s = ft_error_string(error); - std::ostringstream os(""); - if (s) { - os << message << " (" << s << "; error code 0x" << std::hex << error << ")"; - } else { // Should not occur, but don't add another error from failed lookup. - os << message << " (error code 0x" << std::hex << error << ")"; - } - throw std::runtime_error(os.str()); -} - -FT2Image::FT2Image() : m_buffer(nullptr), m_width(0), m_height(0) -{ -} - FT2Image::FT2Image(unsigned long width, unsigned long height) - : m_buffer(nullptr), m_width(0), m_height(0) + : m_buffer((unsigned char *)calloc(width * height, 1)), m_width(width), m_height(height) { - resize(width, height); } FT2Image::~FT2Image() { - delete[] m_buffer; + free(m_buffer); } -void FT2Image::resize(long width, long height) +void draw_bitmap( + py::array_t im, FT_Bitmap *bitmap, FT_Int x, FT_Int y) { - if (width <= 0) { - width = 1; - } - if (height <= 0) { - height = 1; - } - size_t numBytes = width * height; - - if ((unsigned long)width != m_width || (unsigned long)height != m_height) { - if (numBytes > m_width * m_height) { - delete[] m_buffer; - m_buffer = nullptr; - m_buffer = new unsigned char[numBytes]; - } - - m_width = (unsigned long)width; - m_height = (unsigned long)height; - } - - if (numBytes && m_buffer) { - memset(m_buffer, 0, numBytes); - } -} + auto buf = im.mutable_data(0); -void FT2Image::draw_bitmap(FT_Bitmap *bitmap, FT_Int x, FT_Int y) -{ - FT_Int image_width = (FT_Int)m_width; - FT_Int image_height = (FT_Int)m_height; + FT_Int image_width = (FT_Int)im.shape(1); + FT_Int image_height = (FT_Int)im.shape(0); FT_Int char_width = bitmap->width; FT_Int char_height = bitmap->rows; @@ -121,14 +73,14 @@ void FT2Image::draw_bitmap(FT_Bitmap *bitmap, FT_Int x, FT_Int y) if (bitmap->pixel_mode == FT_PIXEL_MODE_GRAY) { for (FT_Int i = y1; i < y2; ++i) { - unsigned char *dst = m_buffer + (i * image_width + x1); + unsigned char *dst = buf + (i * image_width + x1); unsigned char *src = bitmap->buffer + (((i - y_offset) * bitmap->pitch) + x_start); for (FT_Int j = x1; j < x2; ++j, ++dst, ++src) *dst |= *src; } } else if (bitmap->pixel_mode == FT_PIXEL_MODE_MONO) { for (FT_Int i = y1; i < y2; ++i) { - unsigned char *dst = m_buffer + (i * image_width + x1); + unsigned char *dst = buf + (i * image_width + x1); unsigned char *src = bitmap->buffer + ((i - y_offset) * bitmap->pitch); for (FT_Int j = x1; j < x2; ++j, ++dst) { int x = (j - x1 + x_start); @@ -259,32 +211,22 @@ FT2Font::FT2Font(FT_Open_Args &open_args, long hinting_factor_, std::vector &fallback_list, FT2Font::WarnFunc warn, bool warn_if_used) - : ft_glyph_warn(warn), warn_if_used(warn_if_used), image(), face(nullptr), + : ft_glyph_warn(warn), warn_if_used(warn_if_used), image({1, 1}), face(nullptr), hinting_factor(hinting_factor_), // set default kerning factor to 0, i.e., no kerning manipulation kerning_factor(0) { clear(); - - FT_Error error = FT_Open_Face(_ft2Library, &open_args, 0, &face); - if (error) { - throw_ft_error("Can not load face", error); - } - - // set a default fontsize 12 pt at 72dpi - error = FT_Set_Char_Size(face, 12 * 64, 0, 72 * (unsigned int)hinting_factor, 72); - if (error) { - FT_Done_Face(face); - throw_ft_error("Could not set the fontsize", error); - } - + FT_CHECK(FT_Open_Face, _ft2Library, &open_args, 0, &face); if (open_args.stream != nullptr) { face->face_flags |= FT_FACE_FLAG_EXTERNAL_STREAM; } - - FT_Matrix transform = { 65536 / hinting_factor, 0, 0, 65536 }; - FT_Set_Transform(face, &transform, nullptr); - + try { + set_size(12., 72.); // Set a default fontsize 12 pt at 72dpi. + } catch (...) { + FT_Done_Face(face); + throw; + } // Set fallbacks std::copy(fallback_list.begin(), fallback_list.end(), std::back_inserter(fallbacks)); } @@ -321,11 +263,9 @@ void FT2Font::clear() void FT2Font::set_size(double ptsize, double dpi) { - FT_Error error = FT_Set_Char_Size( + FT_CHECK( + FT_Set_Char_Size, face, (FT_F26Dot6)(ptsize * 64), 0, (FT_UInt)(dpi * hinting_factor), (FT_UInt)dpi); - if (error) { - throw_ft_error("Could not set the fontsize", error); - } FT_Matrix transform = { 65536 / hinting_factor, 0, 0, 65536 }; FT_Set_Transform(face, &transform, nullptr); @@ -339,17 +279,12 @@ void FT2Font::set_charmap(int i) if (i >= face->num_charmaps) { throw std::runtime_error("i exceeds the available number of char maps"); } - FT_CharMap charmap = face->charmaps[i]; - if (FT_Error error = FT_Set_Charmap(face, charmap)) { - throw_ft_error("Could not set the charmap", error); - } + FT_CHECK(FT_Set_Charmap, face, face->charmaps[i]); } void FT2Font::select_charmap(unsigned long i) { - if (FT_Error error = FT_Select_Charmap(face, (FT_Encoding)i)) { - throw_ft_error("Could not set the charmap", error); - } + FT_CHECK(FT_Select_Charmap, face, (FT_Encoding)i); } int FT2Font::get_kerning(FT_UInt left, FT_UInt right, FT_Kerning_Mode mode, @@ -505,10 +440,10 @@ void FT2Font::load_char(long charcode, FT_Int32 flags, FT2Font *&ft_object, bool if (!was_found) { ft_glyph_warn(charcode, glyph_seen_fonts); if (charcode_error) { - throw_ft_error("Could not load charcode", charcode_error); + THROW_FT_ERROR("charcode loading", charcode_error); } else if (glyph_error) { - throw_ft_error("Could not load charcode", glyph_error); + THROW_FT_ERROR("charcode loading", glyph_error); } } else if (ft_object_with_glyph->warn_if_used) { ft_glyph_warn(charcode, glyph_seen_fonts); @@ -522,13 +457,9 @@ void FT2Font::load_char(long charcode, FT_Int32 flags, FT2Font *&ft_object, bool glyph_seen_fonts.insert((face != nullptr)?face->family_name: nullptr); ft_glyph_warn((FT_ULong)charcode, glyph_seen_fonts); } - if (FT_Error error = FT_Load_Glyph(face, glyph_index, flags)) { - throw_ft_error("Could not load charcode", error); - } + FT_CHECK(FT_Load_Glyph, face, glyph_index, flags); FT_Glyph thisGlyph; - if (FT_Error error = FT_Get_Glyph(face->glyph, &thisGlyph)) { - throw_ft_error("Could not get glyph", error); - } + FT_CHECK(FT_Get_Glyph, face->glyph, &thisGlyph); glyphs.push_back(thisGlyph); } } @@ -628,13 +559,9 @@ void FT2Font::load_glyph(FT_UInt glyph_index, void FT2Font::load_glyph(FT_UInt glyph_index, FT_Int32 flags) { - if (FT_Error error = FT_Load_Glyph(face, glyph_index, flags)) { - throw_ft_error("Could not load glyph", error); - } + FT_CHECK(FT_Load_Glyph, face, glyph_index, flags); FT_Glyph thisGlyph; - if (FT_Error error = FT_Get_Glyph(face->glyph, &thisGlyph)) { - throw_ft_error("Could not get glyph", error); - } + FT_CHECK(FT_Get_Glyph, face->glyph, &thisGlyph); glyphs.push_back(thisGlyph); } @@ -676,15 +603,13 @@ void FT2Font::draw_glyphs_to_bitmap(bool antialiased) long width = (bbox.xMax - bbox.xMin) / 64 + 2; long height = (bbox.yMax - bbox.yMin) / 64 + 2; - image.resize(width, height); + image = py::array_t{{height, width}}; + std::memset(image.mutable_data(0), 0, image.nbytes()); - for (auto & glyph : glyphs) { - FT_Error error = FT_Glyph_To_Bitmap( + for (auto & glyph: glyphs) { + FT_CHECK( + FT_Glyph_To_Bitmap, &glyph, antialiased ? FT_RENDER_MODE_NORMAL : FT_RENDER_MODE_MONO, nullptr, 1); - if (error) { - throw_ft_error("Could not convert glyph to bitmap", error); - } - FT_BitmapGlyph bitmap = (FT_BitmapGlyph)glyph; // now, draw to our target surface (convert position) @@ -692,11 +617,13 @@ void FT2Font::draw_glyphs_to_bitmap(bool antialiased) FT_Int x = (FT_Int)(bitmap->left - (bbox.xMin * (1. / 64.))); FT_Int y = (FT_Int)((bbox.yMax * (1. / 64.)) - bitmap->top + 1); - image.draw_bitmap(&bitmap->bitmap, x, y); + draw_bitmap(image, &bitmap->bitmap, x, y); } } -void FT2Font::draw_glyph_to_bitmap(FT2Image &im, int x, int y, size_t glyphInd, bool antialiased) +void FT2Font::draw_glyph_to_bitmap( + py::array_t im, + int x, int y, size_t glyphInd, bool antialiased) { FT_Vector sub_offset; sub_offset.x = 0; // int((xd - (double)x) * 64.0); @@ -706,19 +633,15 @@ void FT2Font::draw_glyph_to_bitmap(FT2Image &im, int x, int y, size_t glyphInd, throw std::runtime_error("glyph num is out of range"); } - FT_Error error = FT_Glyph_To_Bitmap( - &glyphs[glyphInd], - antialiased ? FT_RENDER_MODE_NORMAL : FT_RENDER_MODE_MONO, - &sub_offset, // additional translation - 1 // destroy image - ); - if (error) { - throw_ft_error("Could not convert glyph to bitmap", error); - } - + FT_CHECK( + FT_Glyph_To_Bitmap, + &glyphs[glyphInd], + antialiased ? FT_RENDER_MODE_NORMAL : FT_RENDER_MODE_MONO, + &sub_offset, // additional translation + 1); // destroy image FT_BitmapGlyph bitmap = (FT_BitmapGlyph)glyphs[glyphInd]; - im.draw_bitmap(&bitmap->bitmap, x + bitmap->left, y); + draw_bitmap(im, &bitmap->bitmap, x + bitmap->left, y); } void FT2Font::get_glyph_name(unsigned int glyph_number, std::string &buffer, @@ -740,9 +663,7 @@ void FT2Font::get_glyph_name(unsigned int glyph_number, std::string &buffer, throw std::runtime_error("Failed to convert glyph to standard name"); } } else { - if (FT_Error error = FT_Get_Glyph_Name(face, glyph_number, buffer.data(), buffer.size())) { - throw_ft_error("Could not get glyph names", error); - } + FT_CHECK(FT_Get_Glyph_Name, face, glyph_number, buffer.data(), buffer.size()); auto len = buffer.find('\0'); if (len != buffer.npos) { buffer.resize(len); diff --git a/src/ft2font.h b/src/ft2font.h index cb38e337157a..6676a7dd4818 100644 --- a/src/ft2font.h +++ b/src/ft2font.h @@ -6,6 +6,9 @@ #ifndef MPL_FT2FONT_H #define MPL_FT2FONT_H +#include +#include + #include #include #include @@ -22,17 +25,43 @@ extern "C" { #include FT_TRUETYPE_TABLES_H } -/* - By definition, FT_FIXED as 2 16bit values stored in a single long. - */ +namespace py = pybind11; + +// By definition, FT_FIXED as 2 16bit values stored in a single long. #define FIXED_MAJOR(val) (signed short)((val & 0xffff0000) >> 16) #define FIXED_MINOR(val) (unsigned short)(val & 0xffff) +// Error handling (error codes are loaded as described in fterror.h). +inline char const* ft_error_string(FT_Error error) { +#undef __FTERRORS_H__ +#define FT_ERROR_START_LIST switch (error) { +#define FT_ERRORDEF( e, v, s ) case v: return s; +#define FT_ERROR_END_LIST default: return NULL; } +#include FT_ERRORS_H +} + +// No more than 16 hex digits + "0x" + null byte for a 64-bit int error. +#define THROW_FT_ERROR(name, err) { \ + std::string path{__FILE__}; \ + char buf[20] = {0}; \ + snprintf(buf, sizeof buf, "%#04x", err); \ + throw std::runtime_error{ \ + name " (" \ + + path.substr(path.find_last_of("/\\") + 1) \ + + " line " + std::to_string(__LINE__) + ") failed with error " \ + + std::string{buf} + ": " + std::string{ft_error_string(err)}}; \ +} (void)0 + +#define FT_CHECK(func, ...) { \ + if (auto const& error_ = func(__VA_ARGS__)) { \ + THROW_FT_ERROR(#func, error_); \ + } \ +} (void)0 + // the FreeType string rendered into a width, height buffer class FT2Image { public: - FT2Image(); FT2Image(unsigned long width, unsigned long height); virtual ~FT2Image(); @@ -101,7 +130,9 @@ class FT2Font void get_bitmap_offset(long *x, long *y); long get_descent(); void draw_glyphs_to_bitmap(bool antialiased); - void draw_glyph_to_bitmap(FT2Image &im, int x, int y, size_t glyphInd, bool antialiased); + void draw_glyph_to_bitmap( + py::array_t im, + int x, int y, size_t glyphInd, bool antialiased); void get_glyph_name(unsigned int glyph_number, std::string &buffer, bool fallback); long get_name_index(char *name); FT_UInt get_char_index(FT_ULong charcode, bool fallback); @@ -113,7 +144,7 @@ class FT2Font return face; } - FT2Image &get_image() + py::array_t &get_image() { return image; } @@ -141,7 +172,7 @@ class FT2Font private: WarnFunc ft_glyph_warn; bool warn_if_used; - FT2Image image; + py::array_t image; FT_Face face; FT_Vector pen; /* untransformed origin */ std::vector glyphs; diff --git a/src/ft2font_wrapper.cpp b/src/ft2font_wrapper.cpp index 18f26ad4e76b..ca2db6aa0e5b 100644 --- a/src/ft2font_wrapper.cpp +++ b/src/ft2font_wrapper.cpp @@ -968,7 +968,7 @@ const char *PyFT2Font_draw_glyph_to_bitmap__doc__ = R"""( Parameters ---------- - image : FT2Image + image : 2d array of uint8 The image buffer on which to draw the glyph. x, y : int The pixel location at which to draw the glyph. @@ -983,14 +983,16 @@ const char *PyFT2Font_draw_glyph_to_bitmap__doc__ = R"""( )"""; static void -PyFT2Font_draw_glyph_to_bitmap(PyFT2Font *self, FT2Image &image, +PyFT2Font_draw_glyph_to_bitmap(PyFT2Font *self, py::buffer &image, double_or_ vxd, double_or_ vyd, PyGlyph *glyph, bool antialiased = true) { auto xd = _double_to_("x", vxd); auto yd = _double_to_("y", vyd); - self->x->draw_glyph_to_bitmap(image, xd, yd, glyph->glyphInd, antialiased); + self->x->draw_glyph_to_bitmap( + py::array_t{image}, + xd, yd, glyph->glyphInd, antialiased); } const char *PyFT2Font_get_glyph_name__doc__ = R"""( @@ -1440,12 +1442,7 @@ const char *PyFT2Font_get_image__doc__ = R"""( static py::array PyFT2Font_get_image(PyFT2Font *self) { - FT2Image &im = self->x->get_image(); - py::ssize_t dims[] = { - static_cast(im.get_height()), - static_cast(im.get_width()) - }; - return py::array_t(dims, im.get_buffer()); + return self->x->get_image(); } const char *PyFT2Font__get_type1_encoding_vector__doc__ = R"""( @@ -1565,6 +1562,10 @@ PYBIND11_MODULE(ft2font, m, py::mod_gil_not_used()) PyFT2Image__doc__) .def(py::init( [](double_or_ width, double_or_ height) { + auto warn = + py::module_::import("matplotlib._api").attr("warn_deprecated"); + warn("since"_a="3.11", "name"_a="FT2Image", "obj_type"_a="class", + "alternative"_a="a 2D uint8 ndarray"); return new FT2Image( _double_to_("width", width), _double_to_("height", height) @@ -1604,8 +1605,8 @@ PYBIND11_MODULE(ft2font, m, py::mod_gil_not_used()) .def_property_readonly("bbox", &PyGlyph_get_bbox, "The control box of the glyph."); - py::class_(m, "FT2Font", py::is_final(), py::buffer_protocol(), - PyFT2Font__doc__) + auto cls = py::class_(m, "FT2Font", py::is_final(), py::buffer_protocol(), + PyFT2Font__doc__) .def(py::init(&PyFT2Font_init), "filename"_a, "hinting_factor"_a=8, py::kw_only(), "_fallback_list"_a=py::none(), "_kerning_factor"_a=0, @@ -1639,10 +1640,20 @@ PYBIND11_MODULE(ft2font, m, py::mod_gil_not_used()) .def("get_descent", &PyFT2Font_get_descent, PyFT2Font_get_descent__doc__) .def("draw_glyphs_to_bitmap", &PyFT2Font_draw_glyphs_to_bitmap, py::kw_only(), "antialiased"_a=true, - PyFT2Font_draw_glyphs_to_bitmap__doc__) - .def("draw_glyph_to_bitmap", &PyFT2Font_draw_glyph_to_bitmap, - "image"_a, "x"_a, "y"_a, "glyph"_a, py::kw_only(), "antialiased"_a=true, - PyFT2Font_draw_glyph_to_bitmap__doc__) + PyFT2Font_draw_glyphs_to_bitmap__doc__); + // The generated docstring uses an unqualified "Buffer" as type hint, + // which causes an error in sphinx. This is fixed as of pybind11 + // master (since #5566) which now uses "collections.abc.Buffer"; + // restore the signature once that version is released. + { + py::options options{}; + options.disable_function_signatures(); + cls + .def("draw_glyph_to_bitmap", &PyFT2Font_draw_glyph_to_bitmap, + "image"_a, "x"_a, "y"_a, "glyph"_a, py::kw_only(), "antialiased"_a=true, + PyFT2Font_draw_glyph_to_bitmap__doc__); + } + cls .def("get_glyph_name", &PyFT2Font_get_glyph_name, "index"_a, PyFT2Font_get_glyph_name__doc__) .def("get_charmap", &PyFT2Font_get_charmap, PyFT2Font_get_charmap__doc__) @@ -1760,10 +1771,7 @@ PYBIND11_MODULE(ft2font, m, py::mod_gil_not_used()) "The original filename for this object.") .def_buffer([](PyFT2Font &self) -> py::buffer_info { - FT2Image &im = self.x->get_image(); - std::vector shape { im.get_height(), im.get_width() }; - std::vector strides { im.get_width(), 1 }; - return py::buffer_info(im.get_buffer(), shape, strides); + return self.x->get_image().request(); }); m.attr("__freetype_version__") = version_string; diff --git a/src/meson.build b/src/meson.build index a7018f0db094..d479a8b84aa2 100644 --- a/src/meson.build +++ b/src/meson.build @@ -37,7 +37,7 @@ extension_data = { '_backend_agg.cpp', '_backend_agg_wrapper.cpp', ), - 'dependencies': [agg_dep, freetype_dep, pybind11_dep], + 'dependencies': [agg_dep, pybind11_dep], }, '_c_internal_utils': { 'subdir': 'matplotlib', diff --git a/tools/boilerplate.py b/tools/boilerplate.py index f018dfc887c8..11ec15ac1c44 100644 --- a/tools/boilerplate.py +++ b/tools/boilerplate.py @@ -238,6 +238,7 @@ def boilerplate_gen(): 'fill_between', 'fill_betweenx', 'grid', + 'grouped_bar', 'hexbin', 'hist', 'stairs', diff --git a/tools/make_icons.py b/tools/make_icons.py index f09d40e92256..b253c0517c43 100755 --- a/tools/make_icons.py +++ b/tools/make_icons.py @@ -64,7 +64,7 @@ def make_icon(font_path, ccode): def make_matplotlib_icon(): fig = plt.figure(figsize=(1, 1)) fig.patch.set_alpha(0.0) - ax = fig.add_axes([0.025, 0.025, 0.95, 0.95], projection='polar') + ax = fig.add_axes((0.025, 0.025, 0.95, 0.95), projection='polar') ax.set_axisbelow(True) N = 7